frankfan的胡思乱想

学海无涯,回头是岸

Linux系统编程之多线程同步

本章节主要讨论 linux多线程编程中的「多线程同步」问题,不讨论线程的创建退出等问题

在讨论多线程的同步问题之前,我们需要明白多线程带来的进步生产力以及所潜在带来的业务障碍。理论上而言,多线程在带来先进生产力的同时是没有带来其他消极影响的,问题的根源在于:不当(或错误)的使用多线程API,这放在复杂(甚至不需要太复杂)的业务场景中,造成的潜在问题难以察觉

多线程可以:

  • 同时或者「伪同时」执行代码(机器码)

「同时」指的是时间资源,单位时间轴上的任务分层越多,效率越高,这是多线程最大也几乎是唯一意义。

但实际业务开发中的矛盾在于:

  • 一些代码无法被「同时」执行,否则存在系统错误和业务逻辑错误的潜在风险

这就要求能够从某种层面上「控制」这些同时执行的线程,让它们以某种合乎逻辑的执行顺序执行,而linux提供的这些工具就是「linux多线程编程线程同步API

这些API里我们本章着重讨论其中3个(类)

  • pthread_mutex_lock
  • pthread_cond_wait
  • sem_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;
}

以上似乎解决了在实现生产消费者模型时遇到的问题,但我们还是需要就mutexcond问题展开聊一聊

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_pushpthread_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)    收藏  举报

导航