系列文章目录
- pthreads并行编程(上)
- pthreads并行编程(中)
- pthreads并行编程(下)
- 使用OpenMP进行共享内存编程
文章目录
- 系列文章目录
- 前言
- 一、临界区
- 1.1 `pi`值估计的例子
- 1.2 找到问题
- 竞争条件
- 临界区
- 二、忙等待
- 三、互斥量
- 3.1 定义和初始化互斥锁
- 3.2 销毁。
- 3.3 获得临界区的访问权(上锁)
- 3.4 退出临界区(解锁)
- 3.5 小节
- 3.6 改进`pi`值估计的例子
- 四、忙等待 vs 互斥量
- 总结
- 参考
前言
在C++实现高性能并行计算——1.pthreads并行编程(上)一文中介绍了pthreads的基本编程框架,但是不是随便什么程序都像上一文中轻松多线程编程,会遇到许多问题,涉及到许多底层逻辑。本篇文章就是在讲其底层逻辑。
一、临界区
1.1 pi
值估计的例子
并行化该例子:
// pth_pi_wrong.c
#include <stdlib.h>
#include <stdio.h>
#include <pthread.h>
#define n 100000000
int num_thread;
double sum = 0;
void* thread_sum(void* rank);
int main(int argc, char* argv[]){
long thread;
pthread_t* thread_handles;
double pi;
num_thread = strtol(argv[1], NULL, 10);
thread_handles = (pthread_t *)malloc(num_thread * sizeof(pthread_t *));
for (thread = 0; thread < num_thread; thread++){
pthread_create(&thread_handles[thread], NULL, thread_sum, (void *)thread);
}
for (thread = 0; thread < num_thread; thread++){
pthread_join(thread_handles[thread], NULL);
}
pi = 4 *sum;
printf("Result is %lf\n", pi);
free(thread_handles);
return 0;
}
void* thread_sum(void *rank){
long my_rank = (long)rank;
double factor;
long long i;
long long my_n = n / num_thread; //每个线程所要计算的个数,这里理想情况,可以被整除
long long my_first_i = my_n * my_rank;
long long my_last_i = my_first_i + my_n;
if (my_first_i % 2 == 0){
factor = 1.0;
}else{
factor = -1.0;
}
for (i = my_first_i; i < my_last_i; i++, factor = -factor){
sum += factor / (2 * i + 1);
}
return NULL;
}
运行结果:
1.2 找到问题
竞争条件
当多个线程都要访问共享变量或共享文件这样的共享资源时,如果至少其中一个访问是更新操作,那么这些访问就可能会导致某种错误,称之为竞争条件。
临界区
临界区就是一个更新共享资源的代码段,一次只允许一个线程执行该代码段。
二、忙等待
如何进行更新操作,又要保证结果的正确性?——忙等待
使用标志变量flag
,主线程将其初始化为0
y = compute(my_rank);
while (flag != my_rank); // 忙等待,要一直等待它的flag等于其rank才会执行下面的操作
x += y; //就是临界区
flag++;
在忙等待中,线程不停地测试某个条件,但实际上,直到某个条件满足之前,这些测试都是徒劳的。
缺点:浪费CPU周期,对性能产生极大的影响。
三、互斥量
pthread_mutex_t
是 POSIX 线程(pthreads)库中用于实现互斥锁(Mutex)的数据类型。互斥锁是并行编程中常用的同步机制,用于控制多个线程对共享资源的访问,确保一次只有一个线程可以访问该资源。
3.1 定义和初始化互斥锁
-
可以静态初始化:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
-
或动态初始化:使用
pthread_mutex_init()
函数。这个函数提供了一种灵活的方式来设置互斥锁的属性,不同于使用 PTHREAD_MUTEX_INITIALIZER 进行静态初始化。动态初始化允许程序在运行时根据需要创建和配置互斥锁。
该函数原型:int pthread_mutex_init( pthread_mutex_t *mutex, /*out*/ const pthread_mutexattr_t *attr /*in */);
参数: - mutex:指向 pthread_mutex_t 结构的指针,该结构代表互斥锁。这个互斥锁在调用 pthread_mutex_init() 之前不需要被特别初始化。 - attr:指向 pthread_mutexattr_t 结构的指针,该结构用于定义互斥锁的属性。如果传入 NULL,则使用默认属性。 返回值: - 成功:函数返回 0。 - 失败:返回一个错误码,表示初始化失败的原因。常见的错误码包括: - EINVAL:提供了无效的属性。 - ENOMEM:没有足够的内存来初始化互斥锁。
3.2 销毁。
使用 pthread_mutex_destroy()
函数销毁互斥锁,释放任何相关资源。这通常在互斥锁不再需要时进行。
该函数原型是
int pthread_mutex_destroy(pthread_mutex_t *mutex);
3.3 获得临界区的访问权(上锁)
使用 pthread_mutex_lock()
函数来锁定互斥锁。如果互斥锁已被其他线程锁定,调用线程将阻塞,直到互斥锁被解锁。
该函数原型:
int pthread_mutex_lock(pthread_mutex_t *mutex);
参数
- mutex:指向已初始化的 pthread_mutex_t 结构的指针,表示要锁定的互斥锁。
返回值
- 成功:如果函数成功锁定互斥锁,它返回 0。
- 失败:返回一个错误码,表明为什么锁定失败。常见的错误码包括:
- EINVAL:如果互斥锁未正确初始化,会返回此错误。
- EDEADLK:如果是错误检查互斥锁,并且当前线程已经锁定了这个互斥锁,会返回此错误,指示死锁风险。
3.4 退出临界区(解锁)
使用 pthread_mutex_unlock()
函数来解锁互斥锁,允许其他正在等待的线程获得资源访问权限。
该函数原型:
int phtread_mutex_unloc(pthread_mutex_t* mutex_p);
参数
- mutex:指向需要解锁的 pthread_mutex_t 结构的指针。该互斥锁应该是先前由调用线程使用 pthread_mutex_lock() 锁定的。
返回值
- 0:函数成功解锁了互斥锁。
- 失败:返回一个错误码,表明为什么锁定失败。常见的错误码包括:
- EINVAL:如果互斥锁没有被正确初始化,或者互斥锁指针无效,将返回此错误。
- EPERM:如果当前线程不持有该互斥锁的锁定权,即尝试解锁一个它并没有锁定或者根本未被锁定的互斥锁,将返回此错误。
3.5 小节
pthread_mutex_t
是 POSIX 线程(pthreads)库中用于实现互斥锁(Mutex)的数据类型。互斥锁是并行编程中常用的同步机制,用于控制多个线程对共享资源的访问,确保一次只有一个线程可以访问该资源。
互斥锁的基本概念
- 互斥:互斥锁保证当一个线程访问共享资源时,其他线程必须等待,直到该资源被释放(解锁),从而防止数据冲突和不一致性。
- 死锁:如果不正确使用互斥锁,可能导致死锁,即两个或多个线程相互等待对方释放资源,结果都无法继续执行。
使用 pthread_mutex_t
类型的互斥锁通常包括以下几个步骤:
- 定义和初始化互斥锁
- 锁定互斥锁
- 访问共享资源
- 解锁互斥锁
- 销毁互斥锁
下面是使用 pthread_mutex_t
的简单示例:
#include <pthread.h>
#include <stdio.h>
pthread_mutex_t lock; //拿到pthread_mutex_t类型的对象lock,它这里还是个全局变量
int counter = 0;
void* increment_counter(void* arg) {
pthread_mutex_lock(&lock); // 锁定互斥锁
int i = *((int*) arg);
counter += i; // 修改共享资源
printf("Counter value: %d\n", counter);
pthread_mutex_unlock(&lock); // 解锁互斥锁
return NULL;
}
int main() {
pthread_t t1, t2;
pthread_mutex_init(&lock, NULL); // 初始化互斥锁
int increment1 = 1;
int increment2 = 2;
pthread_create(&t1, NULL, increment_counter, &increment1);
pthread_create(&t2, NULL, increment_counter, &increment2);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_mutex_destroy(&lock); // 销毁互斥锁
printf("Final Counter value: %d\n", counter);
return 0;
}
注意事项
- 避免死锁:确保每个锁定的互斥锁最终都会被解锁,特别是在可能引发异常或提前返回的代码段之前。
- 适当的锁粒度:选择正确的锁粒度很重要。过粗的锁可能导致性能低下,而过细的锁可能增加复杂性和死锁的风险。
互斥锁是保护共享数据和防止并发错误的关键工具,在设计多线程程序时需要仔细管理。
3.6 改进pi
值估计的例子
主要是改进线程函数里面访问全局变量的那段代码(也就是临界区)
void* thread_sum(void *rank){
long my_rank = (long)rank;
double factor;
long long i;
long long my_n = n / num_thread; //每个线程所要计算的个数,这里理想情况,可以被整除
long long my_first_i = my_n * my_rank;
long long my_last_i = my_first_i + my_n;
//这里定义my_sum是因为不想频繁调用互斥锁的访问临界区的权限(for循环里),所以只在最后将my_sum赋给sum的时候调用访问权限和退出权限
double my_sum;
if (my_first_i % 2 == 0){
factor = 1.0;
}else{
factor = -1.0;
}
for (i = my_first_i; i < my_last_i; i++, factor = -factor){
my_sum += factor / (2 * i + 1);
}
pthread_mutex_lock(&mutex);
sum += my_sum;
pthread_mutex_unlock(&mutex);
//在一个线程函数中只调用一次申请锁和释放锁的条件
return NULL;
}
主函数:
#include <stdlib.h>
#include <stdio.h>
#include <pthread.h>
#define n 100000000
pthread_mutex_t mutex;
int num_thread;
double sum = 0;
void* thread_sum(void* rank);
int main(int argc, char* argv[]){
long thread;
pthread_t* thread_handles;
double pi;
num_thread = strtol(argv[1], NULL, 10);
thread_handles = (pthread_t *)malloc(num_thread * sizeof(pthread_t *));
//初始化互斥锁
pthread_mutex_init(&mutex, NULL);
for (thread = 0; thread < num_thread; thread++){
pthread_create(&thread_handles[thread], NULL, thread_sum, (void *)thread);
}
for (thread = 0; thread < num_thread; thread++){
pthread_join(thread_handles[thread], NULL);
}
pi = 4 *sum;
printf("Result is %lf\n", pi);
free(thread_handles);
pthread_mutex_destroy(&mutex);
return 0;
}
运行结果:
四、忙等待 vs 互斥量
总结
-
发现问题:线程之间会产生竞争条件
-
解决思路:临界区:在更新共享资源的代码段处,一次只允许一个线程执行该代码段。但是如何使得该区域每次只能有一个线程访问(如何使得该区域成为临界区)
-
解决方法:
- 忙等待:使用标志变量flag,在线程函数中,每次要更新共享资源的代码处时设置一个判断flag的条件语句,只有当flag满足特定条件,才能让相应的线程进行更新共享资源。
- 互斥量/锁:
- 初始化锁(因为互斥锁是pthread库中的一个数据类型,得要初始化,当然也涉及到销毁)
- 上锁
- 访问共享内存
- 解锁
- 销毁锁
-
忙等待 vs 互斥锁:忙等待因为要频繁地执行判断语句,所以效率低。最好使用互斥锁
-
在使用互斥锁的时候也尽量避免频繁上锁,解锁操作,这样会印象性能。尽量每个线程只执行一次(这不是绝对,看具体执行什么操作)
-
这里也只是讨论了每个线程执行结果没有逻辑上的先后顺序,就像有理数的乘法交换律一样,不管什么顺序乘,结果都一样。有先后顺序的情况将在下一篇文章讨论,就如同矩阵乘法,顺序很重要!
参考
- 【团日活动】C++实现高性能并行计算——⑨pthreads并行编程