线程同步-条件变量解析

概念

线程同步的方法有多种,互斥量、信号量、条件变量、读写锁等。互斥量在允许或阻塞对临界区的访问上是很有效的,线程是在对已加锁的互斥量加锁时发生阻塞;条件变量则允许线程由于一些未达到的条件而阻塞,此处的“条件”可以由用户来定义,在访问该条件时需要加锁(互斥量),如果条件没达到,线程将阻塞在该条件上。
条件变量特别适用于多个线程等待某个条件的发生。如果不使用条件变量,那么每个线程就需要不断尝试获得互斥锁并检查条件是否发生,这样大大浪费了系统的资源。

条件变量与互斥量通常一起使用,原因是线程在因条件未满足而阻塞并等待前,需要访问“条件”,而“条件”是允许其它线程修改的,因此,访问“条件”时需要加锁,访问结束后释放锁。所以,这也就可以解释,条件变量的等待操作pthread_cond_wait()需要一个互斥量参数,在pthread_cond_wait()内部将调用线程放到等待队列上后,要解锁互斥量,以让其它线程可以访问“条件”;否则,该线程将一直占用互斥量,其它线程将不能访问“条件”。不过,pthread_cond_wait()内的解锁互斥量只是临时的,在其它线程修改“条件”使其满足要求并唤醒当前阻塞线程时,pthread_cond_wait()内部又会锁住互斥量,这样做的目的是:

  1. 使互斥量的状态在进入、退出pthread_cond_wait()函数时保持一致;
  2. 本身“判断‘条件’是否满足要求,否则阻塞并等待‘条件’”这部分程序属于临界区,在访问临界区前对互斥量加锁,退出临界区后对互斥量解锁是应有的操作,也就是说,真正地释放互斥量锁的操作在退出临界区后;所以,在pthread_cond_wait()函数结束时,应保持互斥量的加锁状态;

通常,“判断‘条件’是否满足要求,否则阻塞并等待‘条件’”这部分临界区代码中的“判断‘条件’”采用while循环来做,因为pthread_cond_wait()在阻塞并等待的过程中,“条件”满足要求后,其它线程会调用pthread_cond_signal()或pthread_cond_broadcast()唤醒阻塞线程,在pthread_cond_wait()将其从阻塞队列放到就绪队列及pthread_cond_wait()重新获取互斥锁之间,其它线程有可能又改变了“条件”,使得此时的“条件”已不再满足要求;因此,若不采用while()循环判断“条件”是否成立,在阻塞线程被唤醒之后,它以为“条件”满足要求,实际上已经被其它线程修改了。

当其它线程修改了“条件”使之满足要求后,会调用pthread_cond_signal()或pthread_cond_broadcast()发送信号,发送信号的步骤顺序有两种:

  • 顺序一
    1. 调用pthread_mutex_lock()对互斥量加锁;
    2. 改变条件使之满足要求;
    3. 向阻塞并等待条件的线程发送信号(比如调用pthread_cond_broadcast());
    4. 调用pthread_mutex_unlock()对互斥量解锁;
  • 顺序二
    1. 调用pthread_mutex_lock()对互斥量加锁;
    2. 改变条件使之满足要求;
    3. 调用pthread_mutex_unlock()对互斥量解锁;
    4. 向阻塞并等待条件的线程发送信号(比如调用pthread_cond_broadcast());

这两种步骤顺序都可以,但都存在一些不足。在顺序一中,发送条件成立信号的步骤在对互斥量解锁之前,也就是发送线程仍是占有锁的,当阻塞线程收到信号后结束休眠,但pthread_cond_wait()在退出之前会对互斥量重新加锁,可发送信号的线程尚未释放锁,所以刚结束休眠的阻塞线程,对互斥量加锁又导致阻塞了;在顺序二中,发送条件成立信号的步骤在对互斥量解锁之后,此时发送信号时,发送线程已经解锁互斥量,但在刚解锁互斥量之后,有可能其它线程在发送线程发送信号之前,成功对互斥量加锁,拿到了“条件”的访问权,因此,可以修改“条件”,这样一来,使得“条件”不再满足阻塞线程的要求,但发送线程不知道,仍会调用pthread_cond_broadcast()发送信号,阻塞线程收到信号后被唤醒,可此时的“条件”是不满足要求的,这一点可以通过while循环判断“条件”是否成立来修正,即便阻塞线程被唤醒,但它仍会判断“条件”是否成立,不成立则继续阻塞等待。

实例 - 读写锁的实现

目标

通过线程通信原语实现读写锁,读写锁的要求:

  1. 同一时间,允许多个读者读取数据;
  2. 同一时间,只有一个写者写数据;
  3. 写优先级比读优先级高。

分析

最简单的读写锁实现

最简单的读写锁实现如下,只用一个互斥量表示读写锁,任何读操作或写操作都必须先对互斥量加锁,在读、写完毕之后,再释放锁。这样实现的读写锁虽然能保证读和写是互斥的,但有如下弊端:

  1. 读操作之间也是互相排斥的,由于多个读操作并不会改变共享区的内容,所以这样加锁再释放锁的读写锁实现,大大降低了读取的效率;
  2. 无法保证写操作的优先级高于读操作,下面的这种实现方式将读、写放到了同一层级上,访问共享区的操作权取决于谁先对互斥量成功加锁。
pthread_mutex_t rw_lock;
int rw_lock_init(pthread_mutex_t* prw_lock)
{
  pthread_mutex_init(prw_lock, NULL);
  return 0;
}

int rw_lock_lock(pthread_mutex_t* prw_lock)
{
  pthread_mutex_lock(prw_lock);
  return 0;
}

int rw_lock_unlock(pthread_mutex_t* prw_lock)
{
  pthread_mutex_unlock(prw_lock);
  return 0;
}

优化方法

互斥锁的目的,是为了避免出现如下竞争条件:一个线程在未完成读操作之前,另一个线程写操作改变了数据,或者多个线程同时进行写操作;而在多个读操作之间是没有必要互斥的。解决方式是引入对“读者数量”的计数。

读写锁优化一 - 读操作之间共享

第一次优化之后的读写锁实现如下,引入了对“读者数量”的计数,由于“读者数量”是动态变化的,由读者间共享,属共享变量,所以加入reader_mutex互斥量对其保护,以保证读者对其访问的互斥;写者之间、读者与写者之间通过write_mutex达到互斥。当读者调用读锁加锁操作时,首先判断调用者是否是第一个读者,若是,则对写锁加锁;若不是第一个读者,即前面仍有多个读者,则在释放reader_mutex后直接访问共享数据。这样做的结果是,在第一个读者读取共享数据前,对写锁加锁,导致读写之间互斥,之后的读者再读取共享数据时,便不用再因等待写锁而阻塞(“最简单的读写锁”方法),即读者之间是不互斥的,可以有多个读者访问共享数据。
但这样的读写锁实现,仍有一个弊端:无法保证写操作的优先级高于读操作,当有大量读者访问共享数据时,写者想对共享数据写入,必须等到所有读者退出之后,才得以操作,出现“写者饥饿”的情况。

typedef struct rw_lock_t
{
  pthread_mutex_t reader_mutex;
  pthread_mutex_t write_mutex;
  int reader_counts;
} rw_lock;

int rw_lock_init(rw_lock* prw_lock)
{
  pthread_mutex_init(&prw_lock->reader_mutex, NULL);
  pthread_mutex_init(&prw_lock->write_mutex, NULL);
  reader_counts = 0;
}

int rw_lock_read_lock(rw_lock* prw_lock)
{
  pthread_mutex_lock(&prw_lock->reader_mutex);
  if(0 == reader_counts++)
  {
    pthread_mutex_lock(&prw_lock->write_mutex);
  }
  pthread_mutex_unlock(&prw_lock->reader_mutex);
  return 0;
}

int rw_lock_read_unlock(rw_lock* prw_lock)
{
  pthread_mutex_lock(&prw_lock->reader_mutex);
  if(0 == --reader_counts)
  {
    pthread_mutex_unlock(&prw_lock->write_mutex);
  }
  pthread_mutex_unlock(&prw_lock->reader_mutex);
  return 0;
}

int rw_lock_write_lock(rw_lock* prw_lock)
{
  pthread_mutex_lock(&prw_lock->write_lock);
  return 0;
}

int rw_lock_write_unlock(rw_lock* prw_lock)
{
  pthread_mutex_unlock(&prw_lock->write_lock);
  return 0;
}

读写锁优化二 - 写操作优先级高于读操作

深入分析

读写锁要满足的条件
  1. 写者的优先级大于读者
    这条规则主要体现读者进入共享区和写者离开共享区的判断流程。
    (1) 当读者进入共享区时,应首先检查是否有写者阻塞,再检查是否有写者在写,再检查是否有读者已在共享区;只有没有写者阻塞或写者在写时,读者才能进入共享区;(2)当写者离开共享区时,应首先检查是否有写者在阻塞,再检查是否有读者阻塞;只有没有写者阻塞时,才能继续对读者是否阻塞做判断;
  2. 读、写之间互斥
    这条规则的含义是只要一方在共享区内,另一方就必须阻塞,不得进入,只有等到在共享区内的一方退出时,另一方才有机会进入。
    (1) 当读者离开共享区时,应首先检查共享区内的读者是否已全部退出,若是,再检查是否有写者在阻塞,若有,则唤醒写者(写者阻塞有两种原因导致:1. 有读者正在共享区内读取数据,写者需等到当前读者都退出后,再进入共享区;2. 当前共享区内正有写者在写,此时想进入共享区的写者需阻塞)(2) 当写者进入共享区时,首先检查当前共享区内是否有读者存在,若有,则写者应阻塞;
  3. 写、写之间互斥
    这条规则的含义是只要有写者在共享区内执行写操作,其它的写者就必须在共享区外等待,直到当前写者退出后,其它写者才有机会。
    (1)当写者进入共享区,且检查了当前共享区内没有读者存在,应再检查共享区内是否有写者在写,若有,写者应阻塞;若没有,写者可直接进入共享区;
  4. 读、读之间共享
    这条规则的含义是即便当前共享区内有读者在读,其它读者也是可以进入到共享区内的,而不必阻塞。
    (1)当读者要进入数据共享区时,如果没有写者阻塞和写者在进行写操作,则可以直接进入共享区;
读写锁的加锁、解锁流程
  1. 读加锁流程
    读者在进入数据共享区时,应首先检查是否有写者阻塞或共享区内写者是否在写,两者只要有其一,读者阻塞数量应累加,且当前读者应阻塞;若两者皆没有,读者将共享区内的读者数目累加,然后直接进入共享区;
  2. 读解锁流程
    读者在离开数据共享区时,共享区内的读者数目减1,并判断值是否为0,若是,接着判断是否有写者在阻塞,若有,则直接唤醒某个写者;若没有写者阻塞或共享区内的读者数目不为0,则直接退出共享区(注意:当没有写者在写或没有写者阻塞时,读者是不会阻塞的)
  3. 写加锁流程
    写者在进入数据共享区时,首先判断当前共享区内是否有读者存在,若有,写者阻塞数目累加,然后写者阻塞;若没有,再接着判断共享区内是否有写者在写,若有,写者阻塞数目累加,然后写者阻塞;若没有,写者将写标志置位,并进入共享区;
  4. 写解锁流程
    写者在离开数据共享区时,首先将写标志清零,接着判断是否有写者在阻塞,若有,则唤醒某个写者;若没有,再判断是否有读者在阻塞,若有,则唤醒所有读者;若没有,则直接离开共享区;
读写锁的实现

经过上述分析,在实现读写锁时,需要记录“存在于共享区内在读的读者数量”、“读者阻塞的数量”、“写者阻塞的数量”、“写者是否在写共享区的标志”,以及和读者、写者阻塞相关的两个条件变量,进入共享区的互斥锁。

typedef struct rw_lock_ {
  unsigned int on-readers;              //readers exists in sharing region
  unsigned int read_waiters;            //waiting readers
  unsigned int write_waiters;           //waiting writers
  unsigned int write_flag;              
  pthread_mutex_t mutex_lock;
  pthread_cond_t  rwait_cond;
  pthread_cond_t  wwait_cond;
} rw_lock_t;

int rw_lock_init(rw_lock_t* prw_lock)
{
  prw_lock->on-readers = 0;
  prw_lock->read_waiters = 0;
  prw_lock->write_waiters = 0;
  prw_lock->write_flag = 0;
  pthread_mutex_init(&prw_lock->mutex_lock, NULL);
  pthread_cond_init(&prw_lock->rwait_cond, NULL);
  pthread_cond_init(&prw_lock->wwait_cond, NULL);
  
  return 0;
}

int rw_lock_destroy(rw_lock_t* prw_lock)
{
  prw_lock->on-readers = 0;
  prw_lock->read_waiters = 0;
  prw_lock->write_waiters = 0;
  prw_lock->write_flag = 0;
  pthread_mutex_destroy(&prw_lock->mutex_lock);
  pthread_cond_destroy(&prw_lock->rwait_cond);
  pthread_cond_destroy(&prw_lock->wwait_cond);

  return 0;
}

int rw_lock_read_lock(rw_lock_t* prw_lock)
{
  pthread_mutex_lock(&prw_lock->mutex_lock);
  while(prw_lock->write_waiters > 0 || prw_lock->write_flag == 1)
  {
    prw_lock->read_waiters++;
    pthread_cond_wait(&prw_lock->rwait_cond, &prw_lock->mutex_lock);
    prw_lock->read_waiters--;
  }
  prw_lock->on-readers++;
  pthread_mutex_unlock(&prw_lock->mutex_lock);

  return 0;
}

int rw_lock_read_unlock(rw_lock_t* prw_lock)
{
  pthread_mutex_lock(&prw_lock->mutex_lock);
  prw_lock->on-readers--;
  if(prw_lock->on-readers == 0)
  {
    if(prw_lock->write_waiters > 0)
    {
      pthread_cond_signal(&prw_lock->rwait_cond);
    }
  }
  pthread_mutex_unlock(&prw_lock->mutex_lock);

  return 0;
}

int rw_lock_write_lock(rw_lock_t* prw_lock)
{
  pthread_mutex_lock(&prw_lock->mutex_lock);
  while(prw_lock->on-readers > 0 || prw_lock->write_flag == 1)
  {
    prw_lock->write_waiters++;
    pthread_cond_wait(&prw_lock->wwait_cond);
    prw_lock->write_waiters--;
  }
  prw_lock->write_flag = 1;
  pthread_mutex_unlock(&prw_lock->mutex_lock);

  return 0;
}

int rw_lock_write_unlock(rw_lock_t* prw_lock)
{
  pthread_mutex_lock(&prw_lock->mutex_lock);
  prw_lock->write_flag = 0;
  if(prw_lock->write_waiters > 0)
  {
    pthread_cond_signal(&prw_lock->wwait_cond);
  }
  else if(prw_lock->read_waiters > 0)
  {
    pthread_cond_broadcast(&prw_lock->rwait_cond);
  }
  pthread_mutex_unlock(&prw_lock->mutex_lock);

  return 0;
}

另有非阻塞读锁和非阻塞写锁,实现思路分别为:

  1. 非阻塞读锁:当读者进入共享区时,如果检查到有写者阻塞或共享区的写标志置为1,则直接返回,而不是阻塞等待;若两者均未检测到,则直接进入共享区;
  2. 非阻塞写锁:当写者进入共享区时,如果检查到共享区内有读者在读或有写者在写,则直接返回,而不是阻塞等待;若两者均未检测到,则直接进入共享区;
int rw_lock_read_trylock(rw_lock_t* prw_lock)
{
  pthread_mutex_lock(&prw_lock->mutex_lock);
  if(prw_lock->write_waiters > 0 || prw_lock->write_flag == 1)
  {
   return -1;
  }
  prw_lock->on-readers++;
  pthread_mutex_unlock(&prw_lock->mutex_lock);

  return 0;
}

int rw_lock_write_trylock(rw_lock_t* prw_lock)
{
  pthread_mutex_lock(&prw_lock->mutex_lock);
  if(prw_lock->on-readers > 0 || prw_lock->write_flag == 1)
  {
    return -1;
  }
  prw_lock->write_flag = 1;
  pthread_mutex_unlock(&prw_lock->mutex_lock);

  return 0;
}

参考文章

1. ChinaUnix | 可不可以不取名 | 线程同步:条件变量的使用细节分析
2. Cnblogs | 寒莓 | 动手实现读写锁
3. Cnblogs | myd620 | 一步一步实现读写锁
4. Cnblogs | Vamei | Linux多线程与同步
5. CSDN | LUCKYOJ | 实现线程读写锁的四种方法

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,639评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,277评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,221评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,474评论 1 283
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,570评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,816评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,957评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,718评论 0 266
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,176评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,511评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,646评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,322评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,934评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,755评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,987评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,358评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,514评论 2 348

推荐阅读更多精彩内容

  • 引用自多线程编程指南应用程序里面多个线程的存在引发了多个执行线程安全访问资源的潜在问题。两个线程同时修改同一资源有...
    Mitchell阅读 1,984评论 1 7
  • 转自:Youtherhttps://www.cnblogs.com/youtherhome/archive/201...
    njukay阅读 1,607评论 0 52
  • 线程 在linux内核那一部分我们知道,线程其实就是一种特殊的进程,只是他们共享进程的文件和内存等资源,无论如何对...
    大雄good阅读 664评论 0 2
  • 在上篇中,我们已经讨论过如何去实现一个 Map 了,并且也讨论了诸多优化点。在下篇中,我们将继续讨论如何实现一个线...
    一缕殇流化隐半边冰霜阅读 7,599评论 5 41
  • linux线程同步 信号灯:与互斥锁和条件变量的主要不同在于"灯"的概念,灯亮则意味着资源可用,灯灭则意味着不可用...
    鲍陈飞阅读 683评论 0 2