ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

进程与线程同步:从互斥锁、信号量到死锁的并发编程核心机制

进程与线程同步:从互斥锁、信号量到死锁的并发编程核心机制 1. 并发编程的起点为什么进程与线程需要同步讲进程与线程的第三部分绕不开的永远是那个最让人头疼的话题同步与互斥。前两节我们理清了进程的状态模型、PCB的组织方式、线程在内核态与用户态的实现差异还有各种调度算法的优劣对比那些东西说到底是在回答“系统如何把CPU时间分给谁”的问题。但CPU时间分出去之后真正的麻烦才刚刚开始——多个进程或线程要访问同一份数据、同一个外设、同一条通信管道时怎么保证结果是对的这就是这一节的核心命题。1.1 竞态条件并发问题的本质我在给学生讲这节课时最爱用银行转账的例子开场。假设你卡里有1000块同时发生两笔操作一笔给你转进来800另一笔从你卡上扣掉500。如果这两个操作是顺序执行的最终余额一定是1300不管是先转入还是先扣款。但如果两个操作并发执行每个操作都分成“读余额-计算新余额-写回余额”三步问题就出现了两个线程可能同时读到1000一个算出1800写回另一个算出500写回最后谁的写操作后执行余额就是谁的另一个计算结果直接被覆盖。这种多个执行流因访问共享数据而导致结果不确定的问题就是竞态条件。竞态条件不是“可能出错”的理论风险而是实际系统中每天都在发生的bug来源。凡是做过高并发服务端开发的人几乎都被这种问题坑过一个计数器值不对、一个缓存被脏数据覆盖、一个数据库记录被重复更新追查到最后往往都是某个共享变量没有被正确保护。1.2 临界区与原子操作要解决竞态条件关键要理解两个概念临界区和原子性。临界区指的是访问共享资源的代码段。上例中的“读余额-计算-写回”就是一个临界区。同步机制的核心目标就是保证多个执行流互斥地进入临界区同一时刻只允许一个执行流在临界区内运行其他执行流必须等在门外。这就像卫生间只有一个坑位谁进去了别人就得排队出来一个才能进下一个。原子性指的是一个操作要么全部执行完要么完全不执行中间不能被中断。注意原子性不等于“一条机器指令”。在单CPU环境下一个操作如果能在一条指令内完成天然就是原子的比如一个整数的读取。但“读-算-写”这种复合操作显然不是原子的它会在执行过程中被调度器打断让另一个执行流插入进来。这里有一个容易混淆的地方并发不等于并行。并发是逻辑上同时发生多个执行流交替推进并行是物理上同时执行多个CPU核心同一时刻各自跑一个执行流。单核时代我们通过关中断、信号量等手段解决的是并发问题多核时代还要考虑不同核心之间缓存一致性带来的新麻烦。操作系统课程里讲的经典同步机制很多是为单核设计的搬到多核环境下需要重新审视这个问题我在后面实操部分会专门展开。2. 互斥的实现从硬件原语到软件机制理解了问题是什么接下来看怎么解决。互斥实现方案的发展脉络很有意思从最早的软件算法到硬件指令支持再到操作系统封装出的各种同步原语每一层都在解决上一层遗留的问题。2.1 单核时代的出发点关中断与原子指令最早的方案非常直接既然并发问题是因为执行流在临界区中间被调度打断那我在临界区里不让它被打断不就行了在单核系统上这确实可行——进入临界区前关中断执行完再开中断。中断一关时钟中断也触发不了调度器根本没有机会切换执行流临界区自然就互斥了。但关中断有两个致命问题。第一关中断的权力太大一旦临界区里有死循环或者代码执行时间过长整个系统就僵死了所有外设中断都响应不了鼠标键盘都失效。第二关中断只对当前CPU核有效多核系统上别的核照样可以访问共享数据。所以关中断只能作为内核底层非常短时间的保护手段不可能作为通用的同步机制提供给应用程序使用。硬件层面给出的更好支持是原子指令。典型的有Test-and-Set测试并设置、Compare-and-Swap比较并交换这些。它们的作用是在一条指令内完成“读旧值-判断-写新值”的操作中间不会被其他执行流插入。基于原子指令就能实现自旋锁了// Test-and-Set 实现的自旋锁伪代码 int test_and_set(int *lock) { int old *lock; *lock 1; return old; } void lock(int *lock) { // 不断尝试将 lock 置为 1如果返回 0 说明之前是 0代表获取锁成功 while (test_and_set(lock) 1) { // 自旋等待 } } void unlock(int *lock) { *lock 0; }自旋锁的思路是拿不到锁就原地打转不断循环检测锁变量直到获取到锁为止。它避免了线程切换的开销因为整个等待过程不涉及内核态切换用户态就能完成。但代价是浪费CPU——拿不到锁的线程一直在空转。所以自旋锁适合临界区极短的场景比如内核里保护一个链表节点几纳秒就完事如果你的临界区里有耗时操作或者锁竞争激烈再用自旋锁就是灾难CPU会被大量无意义的空转消耗掉。2.2 信号量与PV操作操作系统的经典答案原子指令是硬件基础但直接面向程序员用起来还是太底层。操作系统的贡献是把这些机制封装成更高层的同步原语。信号量就是其中最经典、影响最深远的抽象。信号量的概念来自荷兰计算机科学家Dijkstra它本质上是一个整型变量S外加两个原子操作P操作Proberen荷兰语“测试”的意思也叫wait和V操作Verhogen“增加”也叫signal。P操作让S减1如果S变为负数执行P的进程或线程就阻塞V操作让S加1如果S仍不大于0就唤醒一个被阻塞的执行流。信号量可以有两种用法定势。一种是把S初始化为1当作互斥锁用保证临界区互斥访问另一种是把S初始化为0或者某个N值用作资源计数和同步协调。两种用法对应两种完全不同的场景初学者总搞混我多解释一句互斥场景S1P相当于加锁V相当于解锁。同一时刻只有一个执行流能进入临界区。同步场景S0表示某个事件尚未发生。线程A执行P操作时发现S0阻塞等待线程B完成某个操作后执行V操作S变为0恰好唤醒A。这就实现了一个执行流等另一个执行流完成工作的先后关系。信号量的语义清晰、功能强大能解决几乎所有的同步问题。但工程上直接用信号量也容易出事——P操作和V操作的使用必须严格配对漏一个就是死锁或者竞态信号量的计数由程序员自己维护一旦逻辑复杂判断“现在该对哪个信号量做P操作”很容易出错。2.3 互斥量与条件变量工程实践的更优解因为信号量太底层、太容易用错后来的系统在它的基础上进一步发展出互斥量mutex和条件变量condition variable。在Linux的POSIX线程库pthread里这两者是配合使用的几乎是所有多线程程序的标准姿势。互斥量可以看成是二进制信号量的改良版只有锁定和解锁两种状态。它的关键特性是所有权归属哪个线程锁定了互斥量就得由哪个线程解锁不允许“别人代解”。信号量没有这种约束谁都能V操作这其实是一把双刃剑——灵活但也容易乱。条件变量解决的则是另一种需求当一个线程需要等待某个条件变成真才能继续时比如等待队列非空、等待计数器达到某个值如果只用互斥量你只能反复“解锁-查看条件-锁定”地忙等CPU空转且效率极低。条件变量提供了一个优雅的解法// 伪代码使用互斥量条件变量实现“等待任务到达” pthread_mutex_lock(mutex); while (queue_empty()) { pthread_cond_wait(cond, mutex); // 原子地释放mutex并阻塞等cond被signal后重新获取mutex } Task task dequeue(); pthread_mutex_unlock(mutex);这里有一个经常被忽略的细节pthread_cond_wait为什么会自动释放互斥量因为条件变量本身不是锁它依赖互斥量来保护条件变量的状态。如果wait时不释放互斥量其他线程就无法修改条件比如往队列里放新任务那“等待条件变为真”就永远等不到。所以pthread_cond_wait做的事情是原子地“释放互斥量进入睡眠”等被唤醒后再重新获取互斥量。正是这个“先放锁再睡觉、醒来先抢锁”的设计避免了丢失唤醒的问题。还有一个细节是对数据的共享保护。在条件变量等待时while循环而不是if语句是必需的标准做法因为它能防范虚假唤醒——系统可能会因为信号干扰等原因唤醒一个并未收到signal的线程。用if的话被误唤醒后线程会直接往下走拿到一个还没准备好的数据用while的话唤醒后会重新检查条件条件不满足就继续等待。3. 经典同步问题详解生产消费、读者写者、哲学家就餐操作系统教材之所以反复讲生产者消费者、读者写者、哲学家就餐这三个问题不是因为它们本身多复杂而是它们代表了同步编程中最典型的几类模型有限缓冲区的协调、读写权限的不对等、多个资源之间的循环依赖。把这三个问题吃透你对信号量和互斥量的理解才算真正落地才能具备自己分析实际同步问题的能力。3.1 生产者消费者问题有限缓冲区的协作这个问题的场景是若干个生产者线程向一个固定大小的缓冲区里投放数据若干个消费者线程从缓冲区里取数据。规则有三条缓冲区满时生产者必须等待缓冲区空时消费者必须等待生产者和消费者不能同时操作缓冲区。用信号量来解需要三个信号量semaphore mutex 1; // 保护缓冲区的互斥访问 semaphore full 0; // 缓冲区中已填充的槽位数 semaphore empty N; // 缓冲区中空闲的槽位数 void producer() { while (true) { produce_item(); P(empty); // 申请一个空位槽 P(mutex); // 进入临界区 add_to_buffer(); V(mutex); // 退出临界区 V(full); // 已填充槽位1可能唤醒消费者 } } void consumer() { while (true) { P(full); // 申请一个有数据的槽位 P(mutex); remove_from_buffer(); V(mutex); V(empty); // 空位槽1可能唤醒生产者 } }这里最关键的细节是P操作的顺序必须先P资源信号量empty/full再P互斥信号量mutex。如果反过来先P(mutex)再P(empty)系统可能会出大问题。设想缓冲区已满消费者一个都没运行生产者先拿到了mutex然后阻塞在P(empty)上——因为它占着mutex不放消费者想P(mutex)进入临界区取数据也进不去于是所有执行流全部阻塞死锁了。这个顺序问题在期末考试里是高频考点在真实程序里同样是血泪教训。实际工程实现中这个模型会被各种框架封装成“有界队列”或者“线程安全队列”。Java里的BlockingQueue、Go里的channel、C里基于condition_variable实现的线程安全队列本质上都是生产者消费者模型的变体。理解了上面的信号量解法看这些框架的源码实现会轻松很多。3.2 读者写者问题读写优先级的博弈读者写者问题描述的场景是多个读者可以同时读取共享数据但写者必须独占数据也就是说写者与写者之间、写者与读者之间都要互斥只有读者和读者之间可以并发。读者优先的实现思路是用一个读者计数变量readcount记录当前读者数量第一个进入的读者要加写者锁最后一个离开的读者释放写者锁。由于readcount本身是共享变量也需要用互斥量保护。伪代码如下semaphore rmutex 1; // 保护readcount semaphore wmutex 1; // 控制写者访问 void reader() { while (true) { P(rmutex); if (readcount 0) P(wmutex); // 第一个读者进入锁住写者 readcount; V(rmutex); read_data(); P(rmutex); readcount--; if (readcount 0) V(wmutex); // 最后一个读者离开释放写者 V(rmutex); } } void writer() { while (true) { P(wmutex); write_data(); V(wmutex); } }这个是典型的“读者优先”策略写者有可能会被后续不断到达的读者饿死——只要一直有读者在写者就永远拿不到wmutex。实际系统里还有一种“写者优先”或者“公平策略”的需求实现会复杂不少需要额外的信号量来让新到达的读者等待已等待的写者完成。现代系统里读写锁pthread_rwlock_t一般都提供了可选的策略参数实际使用时要搞清楚你依赖的库默认是什么策略避免在高并发场景下出现写者长时间得不到执行的情况。3.3 哲学家就餐问题死锁的温床哲学家就餐问题说的是五个哲学家围着一张圆桌吃饭每人面前一盘意面每两人之间有一根叉子。哲学家只有拿到左右两根叉子才能吃面。问题在于如果每个哲学家都先拿起左边的叉子再等右边的叉子极端情况下五个人同时拿起了左边的叉子每个人都等右边那根被别人拿着的叉子于是谁也没法吃——这就是教科书级的死锁场景。解决方案不止一种常见的有最多允许四个哲学家同时拿叉子或者要求哲学家必须同时拿到两根叉子才能动叉子或者规定奇数号哲学家先拿左边偶数号先拿右边。一个更工程化的思路是使用额外的互斥量来对“拿叉子”这个操作整体加锁void philosopher(int i) { while (true) { think(); P(mutex); // 对“拿起叉子”这个复合操作加锁 P(fork[i]); // 拿左边 P(fork[(i1) % 5]); // 拿右边 V(mutex); eat(); V(fork[i]); V(fork[(i1) % 5]); } }这个方案的思路是把“先拿左再拿右”两步合并为一个不可分割的临界区这样就不会出现五个人各自拿着一根叉子僵持的局面——因为如果有哲学家在拿叉子的过程中发现拿不到第二根他不会持有第一根不放而是会等mutex释放后重新尝试。哲学家就餐问题在生产场景中对应的是“多个资源必须同时满足才能推进”的同步问题比如多个数据库连接、多把分布式锁同时获取时都要小心这种循环等待。4. 死锁问题为什么同步会成为系统炸弹竞态条件是同步失败的一种形式而另一种更可怕的形式是死锁。死锁不像竞态条件那样随机出错一旦触发整个系统会直接卡死任何请求都得不到响应。更重要的是死锁往往是在系统高负载、并发量大的时候才出现平时测试很难暴露一上线就炸。4.1 死锁产生的四个必要条件死锁的发生必须同时满足四个条件缺一不可互斥条件资源同一时刻只能被一个进程占用。这是资源本身的属性多数资源天然具备。持有并等待进程已经持有了至少一个资源又在等待获取其他被占用的资源。不可剥夺条件进程持有的资源只能由自己主动释放不能被系统或其他进程强制抢占。循环等待条件存在一条进程等待环路A等B的资源B等C的资源C等A的资源。理解这四个条件不仅是为了应对考试更是为了实际排查问题有据可依。当你怀疑系统出现死锁时第一反应应该是逐一对照这四个条件看哪个条件被满足了然后思考能不能打破其中任意一个来恢复系统。例如如果某次故障分析发现根本不存在循环等待那就不是资源型死锁要考虑是不是别的原因比如线程卡在IO上导致的现象。4.2 死锁的处理策略预防、避免、检测与解除处理死锁有四种层次递进的策略各自的代价和适用场景差别很大。死锁预防思路是从源头上破坏四个必要条件中的至少一个。破坏“持有并等待”可以要求进程一次性申请所有资源但这样资源利用率会很差破坏“不可剥夺”则允许系统抢夺资源实现复杂且可能影响数据一致性破坏“循环等待”可以给所有资源编号要求进程必须按编号顺序申请资源代码约束上最容易落地。死锁预防的优点是简单、不会死锁缺点是资源利用率低、限制太多适合嵌入式或专用性强的系统。死锁避免思路是在资源分配前先判断如果这次分配给某进程后系统会不会进入不安全状态所谓不安全状态是指存在一种未来必然导致死锁的分配方式。最著名的算法是银行家算法。银行家算法的本质是模拟资源分配检查是否存在一个安全序列让所有进程都能依次完成。如果有就分配没有就不分配让进程等待。银行家算法逻辑严谨但实际系统里用得少主要原因是要获取每个进程的最大资源需求量这在真实运行环境下基本不现实。不过算法的思想还是很重要的它让你理解资源分配需要全局视野不能只看眼前某个进程有没有拿到资源。死锁检测与解除是实际操作系统采用最多的方案。Linux内核里有hung task检测机制Java虚拟机有线程转储thread dump分析工具数据库有锁等待超时机制。思路是不预防、不避免让死锁先发生了再说但系统要保持检测能力发现死锁就强制回收资源或终止进程。解除方式主要有两种撤销进程代价是可能丢失工作进度资源抢占把某个进程的资源剥夺给其他进程但要注意被打断的进程之后能否恢复现场。4.3 银行家算法一键明白银行家算法经常让学生头疼其实是把它想复杂了。它的核心就一句话在分配资源之前先算一笔账看这笔钱借出去之后系统里所有进程还有没有可能全部完成。假设系统有3类资源A、B、C总量分别是(10, 5, 7)。现在有5个进程各自声明了对每类资源的最大需求量目前已经分配了一些资源。当一个进程请求新的资源时系统不立刻答应而是假设把资源给它之后计算Available剩余可用资源变化后的值然后检查是否存在一个序列使得进程们按这个序列执行都能依次拿到它们所需的剩余资源。能找出这个序列就说明系统仍处于安全状态可以分配找不出就说明这次分配会让系统进入不安全状态必须拒绝。有个细节值得注意“安全状态”不等于“没有死锁”也就是说处于安全状态的系统一定不会死锁这点保证了银行家算法在逻辑上的严谨性。但实际使用中因为要预先知道每个进程的最大需求所以应用范围很窄。考试要会算工程上知道它的原理和局限就够了。5. 进程通信与线程同步的工程实践理论讲了这么多回到真实工程场景里进程和线程的同步通信有好多东西是课本里不讲的。用过Linux、Windows、Java、C的人应该都有感受——API的名字不一样底层的原理却是同一套。这里我把最常用的几种机制放在一起对比再放一些我踩过的坑和处理过的实际问题。5.1 进程间通信IPC与线程同步的适用边界进程间通信和线程同步是两个层面的问题。进程是资源分配的基本单位拥有独立的地址空间所以进程间的数据共享必须经过内核提供的机制线程是CPU调度的基本单位同一进程内的线程共享地址空间所以同步主要解决的是对共享内存中数据的并发访问问题。进程间通信的主要手段有管道pipe、消息队列、共享内存、信号量、套接字socket等。其中共享内存是效率最高的——它把同一块物理内存映射到多个进程的地址空间数据交换不需要经过内核拷贝。但共享内存本身不提供同步机制你仍然需要信号量或互斥锁来保证多个进程对共享内存的安全访问。很多人一说“共享内存快”就直接用结果忘了加锁生产环境时不时出现数据错乱就是这个原因。线程同步则主要靠互斥量、条件变量、读写锁、自旋锁这几种。选择哪种取决于临界区的长短和同步的语义同步机制适用场景开销特征注意事项互斥量mutex通用临界区保护获取/释放需要内核态切换但阻塞时让出CPU避免持锁时间过长避免锁内调用未知外部代码自旋锁临界区极短微秒级以内用户态忙等不切换线程无内核开销临界区过长或竞争激烈会浪费大量CPU读写锁读多写少的场景读者之间并发写者独占注意写者优先还是读者优先防止写者饿死条件变量等待某个条件成立与互斥量配合使用必须用while检查条件防止虚假唤醒5.2 线程安全的编码习惯工程上解决线程安全问题不能只依赖锁还要靠合理的编码习惯。我自己总结了几条非常实在的准则第一优先考虑不共享。很多数据结构如果用在不同线程里可以不共享数据改为线程本地存储Thread Local Storage就完全没有锁竞争。比如Java的ThreadLocal、C的thread_local、Linux的TLS线程局部存储机制都是为了解决这个问题。能用“不共享”解决的问题就不要引入锁。第二锁的粒度要恰到好处。锁太粗比如整个方法体都加锁并发度会急剧下降最终变成串行执行锁太细比如把一个大操作拆成十几次加锁解锁锁获取释放的开销会抵消并发带来的收益。合理的粒度是只锁真正修改共享数据的部分把耗时操作比如网络IO、文件读写移出临界区。但也要注意移出临界区的代码如果依赖共享数据的完整性那就不能移只能通过状态机或复制数据来规避。第三加锁顺序要全局一致。当多个线程需要同时获取多把锁时比如操作两个共享队列必须保证所有线程按照相同的顺序获取锁。否则就会出现哲学家就餐的经典死锁线程A拿了锁1想拿锁2线程B拿了锁2想拿锁1。我曾在一个项目里见过这样的死锁查了半天根源就是不同代码路径里加锁的顺序不统一有的先锁订单表再锁用户表有的反过来。5.3 线程池的常见问题从参数配置到线程异常聊线程就绕不开线程池。Java的ThreadPoolExecutor、C的ThreadPool库、Go的goroutine调度背后都是线程复用的思想。线程池解决的核心问题是线程创建/销毁的开销过大以及无限制创建线程导致系统资源枯竭。线程池参数配置是我被问到最多的问题之一。以Java为例核心线程数、最大线程数、队列容量、拒绝策略这四个参数就是一套完整的资源控制逻辑。有一个流传很广的经验公式是CPU密集型任务用“CPU核心数1”作为核心线程数IO密集型任务用“CPU核心数 × 2”或者更高。但现实情况比这复杂得多——如果你的任务是混合型的或者相互之间有依赖、有等待这个经验值只能作为起点最终要靠压测调优。有一个特别容易被忽视的点线程池中的线程异常退出后池子会怎么处理。Java的ThreadPoolExecutor中如果任务抛出运行时异常线程会死亡ThreadPoolExecutor会创建一个新线程来补充。但如果你用的是submit()方法而非execute()异常会被包装在Future里不会被打印出来——这导致很多线上问题表现得“任务没执行完但日志里没有任何异常信息”。排查这类问题时第一反应应该去看任务有没有可能抛异常并确保业务代码里对异常有完整的记录。5.4 实战排查如何定位和解决同步问题最后聊一聊我处理过的两个真实的同步问题希望能帮你建立排查问题的思路。第一个问题是典型的高CPU占用线程卡死。现象是Java服务CPU跑满但QPS骤降重启后短暂恢复过一会儿又不行了。排查过程先用top命令找到CPU占用最高的线程ID再把线程ID转成十六进制用jstack导出线程转储在转储中查找对应线程的堆栈。结果发现该线程卡在一个HashMap的get操作上。原因是项目早期为了性能使用了一个非线程安全的HashMap在并发读写时发生了死循环。这个案例说明源码里“看起来无害”的不安全集合在高并发下会以最离谱的方式表现出来。排查同步问题工具链是top或ps-jstack/mtrace/gdb的组合但更重要的是对可疑代码路径的警觉。第二个问题是死锁导致的服务不可用。现象是某个接口偶发超时拉线程转储thread dump后发现两个线程互相持有对方需要的锁。原因是一个任务在持有A锁的情况下调用了远程服务而远程服务超时时间很长导致线程长时间占着A锁另一个任务持有B锁又需要获取A锁于是也卡住了。这个案例的核心教训是绝对不要持锁调用不确定耗时的外部服务。如果你必须在锁内发起远程调用一定要设置超时时间并且考虑能否先复制数据、释放锁、再调用外部服务。排查同步问题最常用也最有效的三板斧通过线程转储thread dump看线程状态和栈信息找BLOCKED、WAITING状态的线程看它们等在哪把锁上。检查日志中是否有异常被吞没比如execute/submit的使用差异、try-catch后未记录异常的情况。分析代码路径确认加锁顺序全局是否一致锁内是否有耗时操作。6. 一个值得动手复现的经典实验用互斥量实现生产者消费者如果你想把这一节的内容真正变成自己的技能光看书是不够的必须动手写代码。我在给学生布置作业时最推荐的就是用POSIX线程库实现一个完整的生产者消费者模型因为它在“理论-代码-实际运行”三个环节都有很强的检验价值。6.1 环境准备与基础框架Linux环境下只需要gcc和pthread库代码量不大但能把这一节的核心知识点全部覆盖互斥量保护缓冲区、条件变量等待与唤醒、资源计数、多线程的创建与回收。我建议你用C语言写一遍不是为了让你以后在业务里用C写并发而是因为C语言里没有语法糖每一步同步操作都是显式的你才能真正理解底层发生了什么。基础框架结构如下#define BUFFER_SIZE 10 typedef struct { int data[BUFFER_SIZE]; int head; int tail; int count; pthread_mutex_t mutex; pthread_cond_t not_full; // 生产者等待缓冲区不满 pthread_cond_t not_empty; // 消费者等待缓冲区不空 } bounded_buffer_t;缓冲区用环形数组实现head和tail分别标记生产和消费的位置count记录当前元素数量。两个条件变量分别对应两种等待生产者等“有空位”消费者等“有数据”。没有条件变量的话生产者和消费者只能靠轮询CPU会被白白消耗。6.2 关键代码条件变量与互斥量的配合生产者代码和消费者的核心部分我在前面伪代码里已经展示过这里再补充一些C语言实现中的细节。void producer(bounded_buffer_t *buf, int id) { for (int i 0; i 20; i) { pthread_mutex_lock(buf-mutex); while (buf-count BUFFER_SIZE) { pthread_cond_wait(buf-not_full, buf-mutex); } // 写入缓冲区 buf-data[buf-tail] i; buf-tail (buf-tail 1) % BUFFER_SIZE; buf-count; // 唤醒可能等待的消费者 pthread_cond_signal(buf-not_empty); pthread_mutex_unlock(buf-mutex); } }注意while循环判断条件是关键不能写成if原因我在前面条件变量那个地方说过——防止虚假唤醒。pthread_cond_signal只需要唤醒一个等待线程即可因为生产者每次只生产一个单位对应地只需要唤醒一个消费者如果你一次生产了多个单位才需要pthread_cond_broadcast。这里还要注意一个性能细节先signal再unlock还是先unlock再signal两种写法在逻辑上都正确但性能表现有差异。如果先signal再unlock被唤醒的消费者线程会在锁还没释放时就被调度到然后立刻阻塞在锁上造成一次无意义的内核态切换如果先unlock再signal消费者可以先被唤醒同时锁已经释放等消费者真正运行并尝试获取锁时大概率能直接拿到。所以实践上更推荐“先解锁再通知”的顺序当然这个差异只有在高频率生产消费场景下才明显。6.3 实验结果观察与常见运行问题把上述代码编译运行注意链接pthread库gcc编译时要加-lpthread参数你会看到生产者和消费者的输出是交替出现的而且最终生产总数和消费总数一定相等。这本身是对同步正确性的一个基本验证。我在带学生做这个实验时最常见的问题是运行结果不确定有时卡死有时数据错乱。排查思路依次看三点第一检查条件变量的wait和signal是否配对正确。比如生产者只signal(not_empty)而忘了signal(not_full)消费者消费后就不会唤醒等待的生产者最终生产者全部阻塞在not_full条件上。第二检查共享状态count、head、tail的所有读写是否都在mutex保护下。如果有一处不小心在锁外修改了count就会出现数据错乱但很难稳定复现的bug。第三检查线程回收是否完整。用pthread_create创建线程后主线程最好调用pthread_join等待所有线程结束否则主线程先退出会导致整个进程终止子线程的任务没跑完就被强行结束。这个现象和“程序运行结果不完整”很容易混淆。7. 从操作系统视角看现代语言与框架的并发模型学了进程与线程的同步理论再看各种现代语言和框架的并发设计会有一种“原来如此”的通透感。Java的synchronized、Go的channel、Rust的所有权模型本质上都是在用不同的手段解决同一类问题——共享可变状态的同步访问。7.1 Java与Go的并发原语对比Java的传统方案是synchronized和ReentrantLock配合wait/notify或者Condition对象对应到操作系统层面就是互斥量与条件变量的封装。Java 8之后引入了CompletableFuture等异步工具但底层的AQSAbstractQueuedSynchronizer依然是基于volatile变量和CAS操作实现的锁机制。Go语言则选择了完全不同的路径用goroutine加channel来避免显式锁的使用。goroutine是用户态协程由Go运行时调度初始栈很小几KB创建销毁开销极低可以轻松开几十万个。channel可以看成是带类型的、线程安全的管道内部实现其实还是互斥量和条件变量但从语言层面把它变成了一等公民开发者几乎不需要自己操作锁只要通过channel传递数据就能自然地实现同步。这两种设计各有优劣Java的显式锁模型更灵活适合控制细粒度同步Go的channel模型更强调“不要通过共享内存来通信而要通过通信来共享内存”代码更简洁不容易出错但某些场景下性能不如手写锁优化到位。7.2 无锁编程与内存模型还有一个不可忽视的趋势是无锁编程。既然锁会导致阻塞、死锁、性能损耗能不能不锁答案是利用硬件提供的原子指令也就是前面提到的CAS加上内存屏障实现无锁数据结构。Java里的AtomicInteger、Go里的sync/atomic包、C里的std::atomic都属于这一类。无锁编程对内存模型的理解要求很高。在多核CPU上不同核心对共享变量的修改不一定立即对其他核心可见因为存在寄存器、多级缓存、写缓冲。为了解决这个问题CPU和编程语言都定义了一套内存模型规定了哪些情况下内存操作需要对其他核心可见。比如Java的volatile关键字保证可见性C的atomic变量可以指定memory order内存序来控制重排序行为。我在实际使用中总结的教训是无锁编程的调试成本极高产出bug后极难复现如果没有充分的性能证据表明锁是瓶颈请谨慎引入无锁方案——它带来的麻烦往往比你解决的问题更多。7.3 守护线程与线程生命周期管理最后再说说线程生命周期管理中的一个实用话题守护线程。Java中可以通过setDaemon(true)把线程设为守护线程它的特征是当进程中只剩下守护线程时JVM会自动退出。这个机制特别适合后台任务——比如监控线程、日志刷新线程、清理线程它们不应该阻止程序的正常退出。但这里有一个很容易踩的坑守护线程中执行的任务不保证完成。当主线程退出、JVM开始关闭时守护线程会立即被终止即使它还在执行finally块或正在写关键数据。我有一次就用守护线程做定时上报任务结果服务正常重启时上报总是丢最后一两批数据查了很久才发现是守护线程被强杀导致。如果你的后台任务涉及持久化操作尽量不要设置成守护线程或者至少要在程序退出前通过显式的关闭流程来保证它执行完毕。从操作系统的角度看Java的守护线程概念和底层操作系统的线程没有直接对应关系——操作系统层面的线程没有“守护”属性线程的生死完全由用户态调度维护。这种语言层面的抽象差异恰恰说明了理解底层原理的重要性你看懂了下层的操作系统机制才能准确判断上层API真正承诺了什么、没承诺什么。写到这里进程与线程的第三部分已经覆盖得比较完整了从竞态条件、临界区、原子操作到互斥量、信号量、条件变量再到经典同步问题、死锁的预防与处理最后结合工程实践聊了线程池、IPC和现代语言的并发模型。这些内容环环相扣背后就一根主线多个执行流并行是提升系统效率的根本手段但并行带来的共享资源管理问题必须以系统化的同步机制来应对。理解这根主线比死记硬背任何API都重要。
返回列表