暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

关于linux条件变量的深入探讨

二进制人生 2020-08-15
872

点击上方蓝字关注我!

注意本文不是教你条件变量的使用,而是探讨条件变量的一些难点问题。

条件变量通常配合互斥锁一起使用。

mutex体现的是一种竞争,我离开了,通知你进来。cond体现的是一种协作,我准备好了,通知你开始吧。

互斥锁一个明显的缺点是它只有两种状态:锁定和非锁定。而条件变量通过允许线程阻塞和等待另一个线程发送信号解除阻塞的方法弥补了互斥锁的不足,它常和互斥锁一起配合使用。

使用时,条件变量被用来阻塞一个线程,当条件不满足时,条件变量会解开相应的互斥锁并等待条件发生。一旦其他的某个线程触发了条件变量(相当于条件达成,给阻塞在该条件变量的线程发送解除阻塞信号),一个或多个正被此条件变量阻塞的线程将会被唤醒。条件变量将重新锁定互斥锁,线程需要重新测试条件满足才能进入临界区,退出临界区时线程需要解锁(因为进来之时条件变量已经自动帮我们上了锁)。一般说来,条件变量被用来进行线程间的同步

这里头有几个重要问题。

0)传入前为何要加锁

传入前加锁是为了保证线程从条件判断到进入pthread_cond_wait前,条件不被其他线程改变,因为条件判断通常是借助线程共享变量,所以条件也属于临界资源。这个问题也顺带回答了第4个问题:为何条件变量要配合互斥锁使用。

1)条件不满足时,为何要解锁(该动作由pthread_cond_wait自动完成)?

条件不满足时,pthread_cond_wait解锁之后就进入阻塞状态。如果不解锁,会导致其他线程无法修改判断条件,从而导致死锁。例如工人A会去查询仓库库存本子,本子记录存货量,发现无货就睡觉,有货就发货。工人B负责进货,每进一批货都要在本子上记录。如果A睡觉拿着本子,那么B就无法登记了。这个本子就表示判断条件。

2)线程被唤醒之后,为何需要加锁呢?(注意这个加锁的动作由pthread_cond_wait自动完成)

进入临界区自然需要加锁。仓库本子同一时刻只能由一个人使用,否则数据错乱。

3)线程被唤醒并加锁之后,为何还需要重新测试条件是否满足呢?

虽然线程被唤醒,意味着条件已经满足。但是由于线程被唤醒和重新加锁这个时间间隙内,可能有其他线程抢先进入了临界区,即破坏了测试条件,所以我们还需要验证这个条件是否真的成立。

所以我们通常的写法是一个while循环:

    pthread_mutex_lock(&count_lock);
   while(count == 0)
  {
       pthread_cond_wait(&count_nonzero, &count_lock);//while循环使得退出之后再次判断条件是否成立
  }
   count -= 1;
   pthread_mutex_unlock(&count_lock);

4)退出临界区时为何要解锁(这个动作需要我们完成)?

因为进来之时条件变量已经自动帮我们上了锁,但这个解锁动作要由我们自己完成。

5)为何条件变量要配合互斥锁使用?

考虑不加锁的场景:

while(count == 0)
{
     pthread_cond_wait(&count_nonzero);//while循环使得退出之后再次判断条件是否成立
}

count作为一种多线程共享的判断条件,它本身就是一个竞争资源,所以自然需要互斥锁来保证对它的访问和修改在某一时刻只有一个线程在操作,避免因条件判断语句与其后的正文或wait语句之间的间隙而产生的漏判或误判。条件本身就是一个竞争资源,这个资源的作用是对其后程序正文的执行权,于是用一个锁来保护。这样就关闭了条件检查和线程进入休眠状态等待条件改变这两个操作之间的时间通道,这样线程就不会有任何变化。

我们要理解条件变量的作用是在等待某个条件达成时自身要进行睡眠或阻塞,避免忙等待带来的不必要消耗,所以条件变量的作用在于同步。条件变量这个变量其实本身不包含条件信息,条件的判断不在pthread_cond_wait函数功能中,而需要外面进行条件判断。这个条件通常是多个线程或进程的共享变量(在这里是count),这样就很清楚了,对于共享变量很可能产生竞争条件尤其还对共享变量加了条件限制,所以从这个角度看,必须对共享变量加上互斥锁。

6)条件变量和互斥锁的结合使用提高程序效率

两个线程操作同一临界区时,通过互斥锁保护,若A线程已经加锁,B线程再加锁时候会被阻塞,直到A释放锁,B再获得锁运行,进程B必须不停的主动获得锁、检查条件、释放锁、再获得锁、再检查、再释放,一直到满足运行的条件的时候才可以(而此过程中其他线程一直在等待该线程的结束),这种方式是比较消耗系统的资源的。

而条件变量同样是阻塞,还需要通知才能唤醒,线程被唤醒后,要求重新检查判断条件是否满足,如果还不满足,重新进入阻塞状态,等待条件满足后被唤醒,节省了线程不断运行浪费的资源。这个过程一般用while语句实现。当线程B发现被锁定的变量不满足条件时会自动的释放锁并把自身置于等待状态,让出CPU的控制权给其它线程。其它线程此时就有机会去进行操作,当修改完成后再通知那些由于条件不满足而陷入等待状态的线程。这是一种通知模型的同步方式,大大的节省了CPU的计算资源,减少了线程之间的竞争,而且提高了线程之间的系统工作的效率。这种同步方式就是条件变量。

以上说明可能有点抽象,考虑这样的简单场景:通过伪代码说明。

A线程从队列中取元素,B线程往队列中存放元素。不考虑免锁的实现。需要一个mutex用来保护队列的一致性,避免两个线程同时操作队列破坏数据结构。

当队列为空的时候,A需要不断的探测队列状态:

while(1)
{
   if(队列为空)
       sleep(10);//睡眠10秒
   else
  {
       lock();
       取元素;
       unlock();
    }
}

这就有一个问题,可能A在刚进入休眠时,B放入元素了,但A仍然需要休眠完整个10s的时间,造成不必要的延迟。当然如果不sleep,也可以,但会造成不必要的CPU开销(当队列为空时,线程A一直在做无用的重复动作:检查队列是否空,结果非空,继续检查)。

使用基于条件变量的事件通知唤醒机制,就可以避免这些问题。一旦B放入元素完成后就执行pthread_cond_signal(),当前阻塞的线程就A会立即被唤醒开始干活儿。

7)pthread_cond_signal唤醒的两种写法

1)在加锁状态下发送唤醒信号

lock(&mutex);
//一些操作
pthread_cond_signal(&cond);
//一些操作
unlock(&mutex);

缺点
:在某些线程的实现中,会造成等待线程从内核中唤醒(由于cond_signal)回到用户空间,然后pthread_cond_wait返回前需要加锁,但是发现锁没有被释放,又回到内核空间所以一来一回会有性能的问题。

但是在LinuxThreads或者NPTL里面,就不会有这个问题,因为在Linux 线程中,有两个队列,分别是cond_wait队列和mutex_lock队列, cond_signal只是让线程从cond_wait队列移到mutex_lock队列,而不用返回到用户空间,不会有性能的损耗。所以Linux中这样用没问题。

2)解锁之后发唤醒信号

lock(&mutex);
//一些操作
unlock(&mutex);
pthread_cond_signal(&cond);

优点
:不会出现之前说的那个潜在的性能损耗,因为在signal之前就已经释放锁了。

缺点
:如果unlock之后signal之前,发生进程交换,另一个进程(不是等待条件的进程)拿到这把梦寐以求的锁后加锁操作,那么等最终切换到等待条件的线程时锁被别人拿去还没归还,只能继续等待。在只有1个生产者的情况下不会有这个问题。

总结

条件变量常和互斥锁一起使用,条件变量通过允许线程阻塞和等待另一个线程发送信号的方法弥补了互斥锁的不足。使用时,条件变量被用来阻塞一个线程,当条件不满足时,线程往往解开相应的互斥锁并等待条件发生变化。一旦其它的某个线程改变了条件变量,它将通知相应的条件变量唤醒一个或多个正被此条件变量阻塞的线程。这些线程将重新锁定互斥锁并重新测试条件是否满足。

pthread_cond_wait()需要传入一个已经加锁的互斥锁,该函数把调用线程加入等待条件的调用列表中,然后释放互斥锁,在条件满足从而离开pthread_cond_wait()时,mutex将被重新加锁,这两个函数是原子操作。可以消除条件发生和线程睡眠等待条件发生间的时间间隙。其他线程在获得互斥锁之前不会察觉到这种改变,因为必须锁定互斥锁才能计算条件。

条件变量用于某个线程需要在某种条件成立时才进入它将要操作的临界区,这种情况从而避免了线程不断轮询检查该条件是否成立而降低效率的情况,这是实现了效率提高。。。在条件满足时,自动退出阻塞,再加锁进行操作。





二进制人生


专注于编程知识和软件设计分享,包括但不限于C/C++、linux开发。偶尔也聊聊程序人生。





长按二维码,关注我们



免责声明:整理文章为传播相关技术,版权归原作者所有,如有侵权,请联系删除。


【点击查看留言】




文章转载自二进制人生,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论