昨天,两条线程各做了十万次“加一”,结果却可能不到二十万。问题出在这里:读取旧值、计算、写回必须作为一个整体完成,另一条线程不能插进来。
最直接的办法,是给这段操作加一把锁:
pthread_mutex_lock(&mutex);counter++;pthread_mutex_unlock(&mutex);两条线程必须使用同一把锁。一条线程拿到锁后,另一条要等它释放,才能进入这段代码。这种同一时刻只允许一个线程进入的区域,叫临界区。
锁并不是贴在counter上的封条。它保护的是程序员约定好的一段操作:所有访问这份共享数据的线程,都得遵守同一约定。只要有一处绕开锁,保护就可能失效。
不过,线程有时需要的不只是“别同时动数据”。它还需要等一句话变成真:货架上有东西,才能取;货架空了,才能放。
一格货架,两个线程
我们做一个只有一格的缓冲区:
- 主线程负责生产数字
1到5,一次放一个。 - 工作线程负责取走数字。
slot存数字,full表示这一格是否已被占用。
这里要保护的其实是一条规则:full == 1时,slot里必须有一个可取的数字。修改slot和full的过程不能被另一条线程看到一半。
把下面代码保存为queue.c,在 Linux 或 WSL 中运行:
#include<pthread.h>#include<stdio.h>#include<string.h>#include<unistd.h>enum{COUNT=5};staticintslot;staticintfull=0;staticpthread_mutex_tmutex=PTHREAD_MUTEX_INITIALIZER;staticpthread_cond_tnot_empty=PTHREAD_COND_INITIALIZER;staticpthread_cond_tnot_full=PTHREAD_COND_INITIALIZER;staticvoid*consume(void*arg){(void)arg;for(inti=0;i<COUNT;i++){pthread_mutex_lock(&mutex);while(!full){pthread_cond_wait(¬_empty,&mutex);}intitem=slot;full=0;pthread_cond_signal(¬_full);pthread_mutex_unlock(&mutex);printf("取走 %d\n",item);sleep(1);}returnNULL;}intmain(void){pthread_tconsumer;interror=pthread_create(&consumer,NULL,consume,NULL);if(error!=0){fprintf(stderr,"创建线程失败:%s\n",strerror(error));return1;}for(inti=1;i<=COUNT;i++){pthread_mutex_lock(&mutex);while(full){pthread_cond_wait(¬_full,&mutex);}slot=i;full=1;printf("放入 %d\n",i);pthread_cond_signal(¬_empty);pthread_mutex_unlock(&mutex);}pthread_join(consumer,NULL);return0;}编译运行:
gcc-std=c11-Wall-Wextra-pthread-oqueue queue.c ./queue你会看到数字依次被放入、取走。消费者每取一个都会暂停一秒,因此生产者很容易遇到“货架已满”,必须等消费者腾出位置。程序并不要求两个线程按某个固定时间交替运行;不管谁先拿到 CPU,都不能覆盖未取走的数字,也不能从空货架取东西。
等待时为什么要把锁放开?
看生产者的这几行:
pthread_mutex_lock(&mutex);while(full){pthread_cond_wait(¬_full,&mutex);}如果货架满了,生产者不能一直拿着锁等待。消费者也需要这把锁,才能取走数字、把full改成0。生产者不放锁,消费者进不来;消费者进不来,货架就永远不会空。
pthread_cond_wait帮我们完成关键动作:放开锁并进入等待;被唤醒后,重新拿到锁,再从函数返回。“放锁并等待”作为一个配套动作完成,避免线程刚放开锁、还没开始等待时,错过另一条线程发出的通知。
消费者取走数字后调用pthread_cond_signal(¬_full),告诉等待“货架有空位”的线程:你可以起来再检查了。生产者放入数字时,也用not_empty通知消费者。
注意,“通知了”不等于“条件现在一定成立”。线程被唤醒、重新拿到锁之前,其他线程可能已改变状态;等待也可能无缘无故结束。因此代码用的是while,不是if:
while(full){pthread_cond_wait(¬_full,&mutex);}醒来后再检查full。真空了才继续放;仍然满,就接着等。
**条件变量负责通知,full才是判断能否继续的依据。**如果发通知时没人等待,条件变量不会替你“存下一次通知”;共享状态仍要由程序自己记录。
为什么不用一个循环反复检查?
可以写出这样的代码:
while(full){/* 一直检查 */}它看似也在等待,实际上可能持续消耗 CPU。如果还拿着互斥锁循环,消费者根本无法把full改掉;即使不拿锁,也得正确处理共享数据的同步问题。
条件变量让线程在条件不满足时睡眠,等状态改变再来检查。这和第 3 天讲的调度接上了:等货架空位的线程,此刻不需要 CPU 来做计算。
信号量又解决什么问题?
生产者—消费者问题也常用信号量来写。信号量内部维护一个计数;wait尝试把计数减一,没额度就等待;post把计数加一,并可能唤醒等待者。
对一格货架,可以设:
空位数 empty = 1 物品数 items = 0 生产者:等待 empty → 放入物品 → 增加 items 消费者:等待 items → 取走物品 → 增加 empty互斥锁、条件变量和信号量的侧重点不同:
| 工具 | 在这里解决的问题 |
|---|---|
| 互斥锁 | 修改货架状态时,别让另一条线程同时修改 |
| 条件变量 | 货架满或空时,睡眠并等待状态变化 |
| 信号量 | 记录可用空位或物品的数量 |
如果扩展到多个生产者、多个消费者,只靠表示数量的信号量,还得仔细保护实际读写货架的过程。
这篇代码里最值得记住的是两条线:**改共享状态时拿锁;条件不满足时放锁等待,醒来再检查。**下一篇要看,如果两个线程各拿着一把锁,偏偏又等对方放锁,会发生什么。