Unix/Linux 互斥量、条件变量的作用及C代码案例分析

    在分析“条件变量”这个概念之前,我们需要了解两个相关的概念,分别是:线程同步、互斥量。

1、线程同步    

所谓线程同步,“同步”二字单从字面意思来看,是很容易有歧义的,起码不太容易理解是什么意思,“同步”不是同时,而是 只两个或两个以上随时间变化的量在变化过程中保持一定的相对关系(也特别拗口)。这里我用白话解释就是:排队,按顺序来。线程的同步就是说具有一定关系的两个或两个以上的线程按照设定的 顺序, 前后或交叉执行,比如说有两个线程A、B,在线程A中修改一个变量gn,在线程B中等待变量gn大于0,然后执行相关的动作,程序员经常给这两个线程起名为 生产者A线程(A修改了gn),消费者B线程(B消费了 修改过的gn),如下图所示:

  上图中,只有生产者A线程设定gn后,消费者B线程才能执行后面的动作,也就是B的执行必须要在A之后,有先后顺序的关系,这就叫做“同步”。再举个例子,比如在嵌入式硬件开发中,硬件资源一般只能同时被一个线程或任务来执行,比如说串口,如果A线程正在通过串口发送数据,这个时候B线程就不能再使用串口来打印数据了。所以这个串口资源就相当于共享的资源,而共享资源就需要“同步”的思维来使用。听了这些个例子后,可能有些人还会有些疑惑,貌似这些的场景都是比较复杂的场景,我写的程序好像不会用到啊,没错,简单的应用程序是不涉及到这些复杂功能的,甚至于复杂的系统也可能用一些别的办法就自己来实现了,甚至于错过了一次,后面还有机会,但是呢,如果我们想要程序更加稳健,我们就需要对任何可能出现的bug或细节都要考虑到,利用这些所谓的复杂功能来实现程序更加的稳健。好的结果多数情况下都不是什么天才的架构设计,而是跌跌撞撞,一个坑一个坑填完后的结果。

2、互斥量

    说完了线程同步,我们再来说下“互斥量”,所谓互斥量是为了保护资源或者接口的,对于很多的接口,如果多个线程同时读,一般是没有什么影响的,但是同时写,不仅一些接口不支持同时写,更多的是会造成一些意想不到的结果,比如说上面提到的串口例子,多个线程去读串口接收buf是没有问题的,但是多个线程同时去向串口写数据,那就不行了。这个举例可能有些人还是会难理解,毕竟多个线程同时写串口,尤其是“同时”的概率是比较小的,毕竟现在的CPU的速度都特别快,看起来是同时,但是往往都是先执行一个,然后再执行一个。我们再举一个例子,在嵌入式linux开发中,如果涉及到嵌入式数据库,比如sqlite3,这个数据库接口本身就不支持并发,这里说的并发,就包括读和写,而多个线程同时读或写数据库的情况还是比较多的,除非我们只有一个线程专门来读写数据库。这个时候我们就可以使用互斥量了,所谓互斥量相当于一把锁,这里再举一个比较恰当的比喻,假设一个洗手间里只有一个坑位,可能每个人上厕所的时候都不喜欢别人盯着或者一起吧,所以我们往往采取的措施就是进入洗手间坑位后,关门上锁,这个时候,如果别人也来,他先看到的是这个锁已经被锁上了,那么他只能等,也就是被阻塞,直到里面的人把这个锁打开,他才能进去使用这个坑位,当然他使用的时候也是会上锁的。所以这把锁就是 “互斥量”,就能实现线程的同步,当然了线程的同步方式有很多种,互斥量只是其中一个。所以互斥量的使用流程如下图所示:

   这里有个地方 容易忽略,就是互斥量上锁(lock)的功能不仅仅是上锁,还包括查询当前互斥量能否上锁,如果不能上锁,说明已经被别的线程上锁了,那么这个时候这个上锁失败的线程就原地等,也就是被阻塞。

3、死锁

    在上面的案例中,我们使用了互斥量,实现了线程的同步,但是如果我们仔细分析一下,这个程序有个bug,也就是除非A线程先执行,使得gn++,也就是设定gn>0后,线程B才能顺利执行,如果B线程执行,由于它会判断gn是否大于0,如果线程B中,判断gn>0条件不满足后 等待,比如

while(!gn){
    
}

 由于线程B给互斥量上了锁,并且使用while,阻塞了,也就不能执行后面的 互斥量解锁动作,这就意味着A线程将没有机会去修改gn,gn的值移植为0,所以A也只能一直等待。这就会造成 “死锁”。

4、条件变量

    条件变量就是解决上述“死锁”的一种线程同步方法,按照Unix环境高级编程中提供的概念,“条件变量是线程可用的一种同步机制,条件变量给多个线程提供了一个回合的场所,条件变量与互斥量一起使用,允许线程以无竞争的方式等待特定的条件发生。”

   上面这段定义还是比较难理解的(翻译的太“专业”),我们还是结合上面的案例,自己尝试着如何去解决死锁,假设B线程先执行,并且给互斥量上锁了,这个 时候由于A线程不能获取互斥量,也就不能设定gn,进而B线程也只能阻塞,所以问题的症结在于这个两个线程互不相让,导致都阻塞在那里,而解决问题的唯一途径貌似就剩下B线程先给互斥量释放锁,同时并不改变整体代码逻辑,也就是仍然等待gn>0,然后线程A获取互斥量,设定gn>0,然后线程B再去执行。这相当于给互斥量 增加了一种变通策略,既保证了使用了互斥量,又能避免了死锁。条件变量的作用就是实现这种变通。所以条件变量有下面几种作用:

(1)释放互斥量,给别的等待该互斥量的线程来使用。

(2)不改变 当前线程的等待逻辑,也就是B线程仍然等待,只不过是等待在这个 条件变量上。

(3)当另一个线程 设定完 相关变量后,通过pthread_cond_signal发送给特定线程信号,告诉那个线程不用再等待阻塞了。

(4)那个“变通”的线程有限获取互斥锁,也就是会再次拿到互斥锁,进而消费资源。

  这里参考《pthread_mutex_t 和 pthread_cond_t 配合使用的简要分析》中的分析模型, 向作者 致敬。程序流程图如下:

5、条件变量和互斥量的结合使用方法

条件变量的数据模型为:

pthread_cond_t    //条件变量数据模型

条件 变量的几个重要函数如下:

#include <pthread.h>

//条件变量初始化
int pthread_cond_init(pthread_cond_t *restrict cond, 
                      const pthread_condattr_t *restrict attr);

//条件变量销毁
int pthread_cond_init(pthread_cond_t *restrict cond);

//条件变量等待,一定是结合 互斥量 同时使用的。
int pthread_cond_wait(pthread_cond_t *restrict cond,
                      pthread_mutex_t *restrict mutex);  

//向等待条件变量的线程发送 信号
int pthread_cond_signal(pthread_cond_t *restrict cond); 
//注意,一定等改变条件状态以后再给线程发信号,否则也就没有意义了。

消费者线程等待条件的伪代码流程:

pthread_mutex_lock(&mutex);     //拿到互斥量,上锁,进入临界区
while( 条件为假 ){
    pthread_cond_wait(cond, mutex);  //使线程等待在条件变量上
    条件为真后,执行代码
    pthread_mutex_unlock(&mutex);   //释放互斥量锁
}

生产者线程通知消费者线程的伪代码:

pthread_mutex_lock(&mutex);    //拿到互斥锁,进入临界区
设置条件为真
pthread_mutex_unlock(&mutex);    //释放互斥锁
pthread_cond_signal(cond);    //通知等待在条件变量上的消费者线程。

我们以Unix环境高级 编程中的案例代码来分析,如下 所示:

#include <pthread.h>

struct msg{
	struct msg *m_next;
	/* more stuff here...*/
};

struct msg *workq;

pthread_cond_t qready = PTHREAD_COND_INITIALIZER;
pthread_mutex_t qlock = PTHREAD_MUTEX_INITIALIZER;

void process_msg(void)
{
	struct msg *mp;

	while(1){
		pthread_mutex_lock(&qlock);
		while(workq == NULL){    
			pthread_cond_wait(&qready, &qlock);
			mp = workq;
			workq = mp->m_next;
			pthread_mutex_unlock(&qlock);
			/* now process the message mp*/
		}
	}
}

void enqueue_msg(struct msg *mp)
{
	pthread_mutex_lock(&qlock);
	mp->m_next = workq;
	workq = mp;
	pthread_mutex_unlock(&qlock);
	pthread_cond_signal(&qready);
}

注意,在process线程中,判断 条件使用的是while 而不是if,这是因为pthread_cond_wait的返回并不一定意味着其他线程释放了条件成立信号。而是意外返回。这种情况称为Spurious wakeup。之所以这样做的原因是从效率上考虑的。简单来说造成Spurious wakeup的原因在于,Linux中带阻塞功能的system call都会在进程收到了一个signal后返回。这就是为什么要用while来检查的原因。因为我们并不能保证wait函数返回就一定是条件满足,如果条件不满足,还需要继续等待,理解困难就硬背吧。

发布了247 篇原创文章 · 获赞 257 · 访问量 62万+

猜你喜欢

转载自blog.csdn.net/u012351051/article/details/100863402