Linux系统编程之多线程同步
本章节主要讨论
linux多线程编程中的「多线程同步」问题,不讨论线程的创建、退出等问题
在讨论多线程的同步问题之前,我们需要明白多线程带来的进步生产力以及所潜在带来的业务障碍。理论上而言,多线程在带来先进生产力的同时是没有带来其他消极影响的,问题的根源在于:不当(或错误)的使用多线程API,这放在复杂(甚至不需要太复杂)的业务场景中,造成的潜在问题难以察觉。
多线程可以:
- 同时或者「伪同时」执行代码(机器码)
「同时」指的是时间资源,单位时间轴上的任务分层越多,效率越高,这是多线程最大也几乎是唯一意义。
但实际业务开发中的矛盾在于:
- 一些代码无法被「同时」执行,否则存在系统错误和业务逻辑错误的潜在风险
这就要求能够从某种层面上「控制」这些同时执行的线程,让它们以某种合乎逻辑的执行顺序执行,而linux提供的这些工具就是「linux多线程编程线程同步API」
这些API里我们本章着重讨论其中3个(类)
pthread_mutex_lockpthread_cond_waitsem_wait
这正式讨论这些API之前我们先看下经典的一个业务模型:
A + B = > C
- 完成A任务得出A结果
- 完成B任务得出B结果
- 结合A和B结果,得出C结果
这样的业务模型在实际工作中常见,也是多线程常见的应用场景。
在这个模型中,A任务可以有多个线程同时执行,B任务同理,而将A和B结合这个任务也是一个线程,但显然,这里面临的问题不同:
- 多个线程执行A(或B)任务,A(或B)任务中存在资源竞争问题
- C任务必须等A任务和B任务都结束后,才能启动
以上面临的问题就是:
- 资源的竞争性问题,需要使用锁构建临界区,防止资源被争夺而引发错误
- 线程的调度性问题,需要使用线程同步机制,让线程符合某种逻辑执行,防止出现业务错误
另一个经典的业务模型:
生产—>消费—>生产—>消费
- 一个(些)线程负责产生数据,另一个(些)线程负责消费(使用)数据
比如典型的网络数据处理,一个线程负责从socket接口中读取数据,让另外一些线程去使用处理这些数据,这涉及到:
- 临界区的构建,防止多个线程干扰生产数据,重复消费处理数据,需要加锁
- 线程之间同步,没有数据时无法消费,消费线程应该挂起等待(而不空耗CPU),有数据产生时需及时得到响应,处理数据
这2个经典的场景就涉及到我们上文中提到的3类线程同步API
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <pthread.h>
#include <semaphore.h>
/*互斥锁,这种锁只能由同一个线程加锁和解锁,无法跨线程加解锁*/
//互斥锁的核心就是「互斥」,是对资源的稀有性而言的,主要解决资源竞争的问题
pthread_mutex_t mutex;
int g_count;
void *producer(void *para){
printf("begin producer\r\n");
while (1){
/*生产者线程这里只做了2件事情
- 给资源加锁
- 生产资源
但是并没有做另外的事情,比如没有将生成出来的资源投递,或者通知消费者线程处理
*/
pthread_mutex_lock(&mutex);
g_count += 1;
printf("produce the value = %d\r\n",g_count);
pthread_mutex_unlock(&mutex);
sleep(2);//模拟耗时的生产操作
}
return NULL;
}
void *comsumer(void *para){
printf("begin comsume\r\n");
while (1){
/*消费者线程这里做了3件事情
- 给资源加锁
- 判断资源是否可用(是否大于0)
- 循环
加锁的目的是为了防止资源竞争而导致的资源错误,判断资源是否可用为业务逻辑要求,而循环的目的是为了CPU忙等,因为消费线程并不知道资源什么时候可用,只能通过不停的循环来检测资源的可用情况,只不过是在检测之前上锁而已,显然,这是一种无意义的低效方式
*/
pthread_mutex_lock(&mutex);
if(g_count<=0){//在34行-42行间循环忙等
pthread_mutex_unlock(&mutex);
continue;
}
g_count-=1;
printf("comsume the value = %d\r\n",g_count);
pthread_mutex_unlock(&mutex);
sleep(0.5);
}
return NULL;
}
/*
主线程中有2条工作线程
- 1条生产线程,负责产生资源(g_count++),每2秒产生1个资源
- 1条消费线程,负责处理资源(g_count--),每0.5秒消耗1个资源
最终的打印结果为:
produce the value = 1
comsume the value = 0
produce the value = 1
comsume the value = 0
produce the value = 1
comsume the value = 0
produce the value = 1
comsume the value = 0
... ...
生产、消费、生产、消费...依次
---------------------------
这个生产消费者模型要解决2个问题:
- 资源的竞争问题,生产与消费者线程不能同时操作同一资源,这由锁解决
- 资源的同步问题,当资源数不够(小于等于0)时,消费线程停止消费,这一问题在这个方案中并没有很好的解决,只是忙等而已
*/
int main() {
pthread_mutex_init(&mutex,NULL);
pthread_t proId;
pthread_t comsId;
pthread_create(&proId,NULL,producer,NULL);
pthread_create(&comsId,NULL,comsumer,NULL);
pthread_join(proId,NULL);
pthread_join(comsId,NULL);
printf("main task end\r\n");
return 0;
}
以上就是一个生产者-消费者的基本模型,但有个问题没有解决,那就是线程的同步问题,线程之间没有任何同步协作,导致的问题就是多线程之间完全没有「合作」一事。
以上生产消费者模型实现的问题在于:
- 消费者只能通过死循环(忙等)来轮询得知资源的可用状态
- 生产消费者线程间没有协同,导致存在生产者生产过多资源后而消费者并没有及时消费(即使通过轮询的方式)的问题(上面测试中,当我们取消sleep函数后,会发现生产与消费之间并没有协同,出现生产线程产生了大量资源而消费线程并没有及时消费的问题,存在大量「库存」未被处理,诚然,当生产者线程暂停(停止)工作后,消费者线程最终会消费完这些库存。)
接下来使用条件变量来处理线程的同步问题。
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <pthread.h>
#include <semaphore.h>
int g_count = 0;
pthread_mutex_t mutex;
pthread_cond_t cond;
pthread_cond_t producer_cond;
/*关于条件变量的使用「准则:」
- 使用pthread_cond_wait之前必须pthread_mutex_lock
- 使用pthread_cond_wait时需要使其处于某个循环条件中
*/
void *producer(void *para){
while (1){
pthread_mutex_lock(&mutex);
#if 1
//这里为业务需求设计,只要当前资源大于等于1,我们就进入等待,pthread_cond_wait,不要「无脑」不停的生产,需要与消费者线程协同,这里我们使用while来作为条件判断使其进入pthread_cond_wait而不是if的原因,并不是因为if不行,而是在多个线程竞争访问的情况下,if不行。(在下文有说明)
while (g_count >=1){
pthread_cond_wait(&producer_cond,&mutex);
}
#endif
#if 0
if(g_count >= 1){
pthread_cond_wait(&producer_cond,&mutex);
}
#endif
g_count+=1;
/*生产一个资源后,通知条件变量可用。这样其他wait这个cond的线程就会被唤醒,pthread_cond_signal的语义是唤醒「至少」1个等待线程,其实本意应该是唤醒1个等待线程,但实现中这是不可能的,因为现在CPU基本是多核的,api实现无法保证「只」唤醒一个等待线程而不是多个,因此pthread_cond_signal语义变成了「至少唤醒1个等待线程(可能唤醒多个等待线程)」*/
pthread_cond_signal(&cond);
pthread_mutex_unlock(&mutex);
printf("producer g_count = %d\r\n",g_count);
sleep(0.05);
}
pthread_exit(NULL);
}
void *customer(void *para){
while (1){
pthread_mutex_lock(&mutex);
#if 1
/*当资源没有的时候,线程pthread_cond_wait,挂起,当接收到pthread_cond_signal通知时唤醒,pthread_cond_wait函数内部实现中:
- 1.会解锁mutex
- 2.返回前会加锁mutex
这里符合条件时等待之所以使用while而不是if的原因在于多线程的问题,根据上文,pthread_cond_signal时并不能保证只有1个线程被唤醒,有可能多个线程被唤醒,因为调用pthread_cond_wait时已经unlock了mutex,因此当多个线程此时被唤醒时,多个线程会同时往下执行,这样就出现了错误。
*/
while (g_count == 0){
pthread_cond_wait(&cond,&mutex);
}
#endif
#if 0
if(g_count == 0){
pthread_cond_wait(&cond,&mutex);
}
#endif
g_count -= 1;
pthread_cond_signal(&producer_cond);//消费1个资源后,通知等待的线程可以从挂起中恢复工作了
printf("customer g_count = %d\r\n",g_count);
pthread_mutex_unlock(&mutex);
sleep(0.05);
}
pthread_exit(NULL);
}
////////////////////////////////////////
/*在以上的生产消费者模型中,我们使用2个条件变量在生产与消费者线程中构建了一个全双工通信,可以用来协同2个端的工作节奏*/
////////////////////////////////////////
/*
我们这里使用了条件变量pthread_cond_t来同步生产与消费者线程,解决2个问题:
- 让生产者线程不要「无脑」生产,当处于某个条件下,挂起,停止生产
- 让消费者线程不要「无脑」循环,当处于某个条件下,挂起,停止轮询
*/
int main() {
pthread_mutex_init(&mutex,NULL);
//用2个条件变量,用来在生产者、消费者之间建立「双工」协同通道
pthread_cond_init(&cond,NULL);
pthread_cond_init(&producer_cond,NULL);
pthread_t producer_id;
//有2个消费者线程
pthread_t customer_id;
pthread_t customer_id2;
pthread_create(&producer_id,NULL,producer,NULL);
pthread_create(&customer_id,NULL,customer,NULL);
pthread_create(&customer_id2,NULL,customer,NULL);
pthread_join(producer_id,NULL);
pthread_join(customer_id,NULL);
return 0;
}
以上似乎解决了在实现生产消费者模型时遇到的问题,但我们还是需要就mutex和cond问题展开聊一聊
pthread_cond_wait(&cond,&mutex);实现中我们在条件等待接口中传入了互斥锁mutex,传入互斥锁的目的在于:防止死锁
/*
pthread_mutex_lock互斥锁只能被同一线程上锁和解锁,无法跨线程,这是一个非常重要的特性,由此会引发死锁的问题。比如下面的情况,该线程获取了锁后,如果此时调用pthread_cond_wait,线程被挂起,那么mutex互斥锁永远没有机会被其他线程释放,而该线程此刻又进入了挂起,所以mutex被上锁后永远无法释放锁了。
解决方案就是讲mutex传入pthread_cond_wait内部,让该函数内部处理mutex,而实际是,在该函数内部mutex会被解锁,在函数返回之前会被重新上锁。而pthread_cond_wait内部本身是有新的锁来保护条件变量cond的。
所以,之所以要往pthread_cond_wait中传入mutex的目的在于,防止死锁。
*/
void *customer(void *para){
pthread_mutex_lock(&mutex);
while(the_condition){
pthread_cond_wait(&cond,...)
}
pthread_mutex_unlock(&mutex);
return NULL;
}
上面我们提到pthread_cond_wait通过在内部释放mutex在返回前获取mutex来避免死锁问题,但pthread_cond_wait是一个线程撤销点,这意味着另一个线程可以随时调用pthread_cancel来结束指定线程的运行,其他线程调用pthread_cancel结束指定线程是,其线程内调用的pthread_cond_wait会返回,退出前会获取锁,但是线程被结束掉没来得及释放锁,这就造成了死锁的潜在可能。那么,解决方案是什么呢?
需要我们及时的释放获取的锁。
有2种处理方式:
-
使用
pthread_cleanup_push和pthread_cleanup_pop这并非原生函数,而是宏,使用时需要成对出现,用于将函数压入「执行栈」,当调用者中途被中断时,被压入执行栈的函数被弹出,同时被执行。
这对宏的意义在于,无论如何,函数结束(中断)时必须要执行某个任务。(类似高级语言中的
finally)pthread_mutex_t mutex; pthread_cond_t cond; //这个函数最终会被调用(线程非正常ret时),在这个函数里解锁即可。 void end(){ printf("final execu\r\n"); } void *task(void *para){ pthread_cleanup_push(end,NULL); while (1){ sleep(1); printf("sub thread\r\n"); //线程在这里被挂起,没有其他线程pthread_cond_signal,该线程永远被挂起 pthread_cond_wait(&cond,&mutex); } pthread_cleanup_pop(1); return NULL; } int main(){ pthread_mutex_init(&mutex,NULL); pthread_cond_init(&cond,NULL); pthread_t pid; pthread_create(&pid,NULL,task,NULL); sleep(5); //结束指定线程 pthread_cancel(pid); pthread_join(pid,NULL); printf("main thread\r\n"); return 0; } -
使用
mutex的相关属性控制pthread_mutexattr_t设置
mutex互斥锁的robust属性值为PTHREAD_MUTEX_ROBUST。对于
robust互斥锁,当持有它的线程没解锁就退出以后,别的线程再去调用pthread_mutex_lock,函数会回一个EOWNERDEAD错误,线程检测到这这个错误后可以调用pthread_mutex_consistent使robust互斥锁恢复一致性,紧接着就可以调用phtread_mutex_unlock解锁了(尽管这个锁并不是当前这个线程加持的)。解锁完毕就可以重新调用pthread_mutex_lock了int main(){ pthread_mutexattr_t mutexattr; pthread_mutexattr_init(&mutexattr); pthread_mutexattr_setrobust(&mutexaddtr, PTHREAD_MUTEX_ROBUST); pthread_mutex_init(&mutex, &mutexattr); //... } void wait_thread(){ while(EOWNERDEAD==pthread_mutex_lock(&mutex)) { pthread_mutex_consistent(&mutex); pthread_mutex_unlock(&mutex); } global_count++; pthread_cond_signal(&cond); pthread_mutex_unlock(&mutex); } void noti_thread(){ /*不再是无脑的调用pthread_mutex_lock上锁,而是会检查该函数的返回结果,如果mutex返回EOWNERDEAD,则说明上锁失败,该锁已经被获取了,需要调用pthread_mutex_consistent将标志置位,从而可以再次解锁,进入正常流程*/ while(EOWNERDEAD==pthread_mutex_lock(&mutex)) { pthread_mutex_consistent(&mutex); pthread_mutex_unlock(&mutex); } while(global_count<=0) { pthread_cond_wait(&cond, &mutex); } global_count--; pthread_mutex_unlock(&mutex); }到目前为止,生产消费者模型已告一段落。
接下来我们看另外一种模型:
A + B = > C#include <stdio.h> #include <unistd.h> #include <stdlib.h> #include <string.h> #include <pthread.h> #include <semaphore.h> int g_a = 0; int g_b = 0; sem_t sem; pthread_mutex_t a_mutex; pthread_mutex_t b_mutex; void *createA(void *para){ for (int i = 0; i < 500000; ++ i) { pthread_mutex_lock(&a_mutex); g_a += 1; pthread_mutex_unlock(&a_mutex); } sem_post(&sem);//信号量+1 } void *createB(void *para){ for (int i = 0; i < 500000; ++i) { pthread_mutex_lock(&b_mutex); g_b += 1; pthread_mutex_unlock(&b_mutex); } sem_post(&sem);//信号量+1 } void *getC(void *para){ sem_wait(&sem);//信号量为-1,挂起,当信号量为0后,继续往下执行 sem_wait(&sem); sem_wait(&sem); sem_wait(&sem); int c = g_a + g_b; printf("c = %d\r\n",c); } /* 3个任务,分别是2个线程执行A任务,2个线程执行B任务,1个线程执行C任务; C任务依赖A、B任务,需要等到A、B任务都完成了才能执行C任务 实现逻辑是,分别设计2个mutex,用来控制2个任务内的资源竞争,然后用信号量来实现通知的作用,这个是单工的,C线程单纯从A、B任务线程接受「通知」 */ int main() { sem_init(&sem,0,0);//初始信号量为0 pthread_mutex_init(&a_mutex,NULL); pthread_mutex_init(&b_mutex,NULL); pthread_t pa_id; pthread_t paa_id; pthread_t pb_id; pthread_t pbb_id; pthread_t pc_id; pthread_create(&pa_id,NULL,createA,NULL); pthread_create(&paa_id,NULL,createA,NULL); pthread_create(&pb_id,NULL,createB,NULL); pthread_create(&pbb_id,NULL,createB,NULL); pthread_create(&pc_id,NULL,getC,NULL); pthread_join(pa_id,NULL); pthread_join(pb_id,NULL); pthread_join(pc_id,NULL); pthread_join(paa_id,NULL); pthread_join(pbb_id,NULL); printf("g_a + g_b = %d\r\n",g_a + g_b); printf("main thread end\r\n"); return 0; }
至此,我们已经讨论完毕linux多线程编程中关于线程的同步问题,归纳起来主要在讨论2件事
-
资源的保护
-
多线程间的协同
协同又可以分为
- 全双工的双向协同(生产消费模式)
- 单工的单向协同(流水线合作模式)
我们在协同上主要使用了2个(类)api,一个条件变量、一个信号量,从等价关系而言。条件变量似乎是信号量的一个特例,一个2元信号量就能达到使用条件变量的效果,但这并不意味着二者是可以混用的,这常常会造成语义混乱。
条件变量的核心是资源,关注处理的是资源,而信号量的核心是同步,关注处理的是流程。那如果一定要使用信号量来完成条件变量的功能,有什么问题呢?我只能说,至少信号量的性能要低于条件变量。
posted on 2021-12-27 23:45 shadow_fan 阅读(98) 评论(0) 收藏 举报
浙公网安备 33010602011771号