ARTICLE DETAIL

资讯详情

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

互斥锁与信号量深度解析:线程互斥、临界区与死锁实战

互斥锁与信号量深度解析:线程互斥、临界区与死锁实战 先给新朋友说一句这篇文章不是教科书式的概念罗列也不是某个框架的 API 文档而是一份我自己反复踩坑之后整理出来的互斥学习笔记。我做多线程下载工具时第一次被“互斥”狠狠教育了一顿——两个线程同时更新同一个进度变量界面上的百分比跟抽搐一样乱跳文件明明在下数字却来回横跳甚至直接卡在某个值不动。后来认真把互斥锁、互斥信号量、线程互斥这些概念全部翻出来啃了一遍才算真正理解了“互斥的本质操作”到底在讲什么。如果你刚开始接触并发编程或者写了几次线程代码仍然对锁和信号量模模糊糊这篇文章应该能帮你把整条逻辑线理顺。1. 并发世界里的“同时操作”互斥到底防的是什么1.1 一次进度条事故让我决定彻底搞懂互斥先说那次事故。我用 Python 写了一个多线程下载器每个线程下载一个分片下载完成后往一个全局的进度字典里写入“已下载字节数”。最开始所有线程各写各的看起来没什么问题。后来为了做实时进度显示我在另一个线程里每隔一秒读取这个字典并刷新 UI。很快我就发现进度值偶尔会倒退而且某些分片明明下载完了记录里却缺失了一段数据。排查过程很顺利——不是网络问题不是文件读写问题而是多个线程同时对一个共享字典做更新。Python 里dict[key] value这种操作根本不是原子操作它内部要经过“读取-修改-写回”多个步骤。两个线程同时执行到“读取”这一步拿到相同的旧值各自加上自己的增量再写回去结果就是一次更新被另一次更新覆盖。这就是并发编程里最经典的“竞争条件”。互斥要解决的核心问题本质上就是防止多个执行流同时进入共享数据的修改区域。1.2 原子性并发问题的根源也是互斥的最终目标深入想下去你会发现互斥并不是目的保证原子性才是目的。所谓原子操作就是“要么全部执行完要么一个字节都不执行”的操作在执行过程中不允许被其他线程插入。我们看一个最典型的例子。假设有两个线程同时执行countercounter 初始为 0。这个表达式在 CPU 眼里要做三件事把 counter 从内存读到寄存器、在寄存器里加 1、把结果写回内存。两个线程交错执行时可能发生这样的顺序线程A读 counter - 0 线程B读 counter - 0 线程A加1 - 写回 1 线程B加1 - 写回 1最终结果不是 2而是 1。原子性被破坏了。互斥锁、互斥信号量这些工具全部是给一段代码“包”上一个原子边界让这段代码内部的操作要么不执行要么连续执行完中间不允许插入其他线程的操作。我用生活里的场景来类比火车站售票窗口只有一个工作人员两条队伍的人想同时买票如果没有“排队”这个机制两个人就会同时把手伸进窗口谁也没法确定自己买到的是哪张票。互斥就是那个“一次只放一个人进窗口”的铁栏杆。1.3 临界区互斥保护的是“一段代码”搞懂互斥必须先会识别临界区。临界区就是访问共享资源的那段代码它可以是三行也可以是三十行但它的特征非常明确一旦有多个线程同时进入就会造成数据错乱。识别临界区有个很笨但有效的方法把一个线程去掉再看另一个线程如果结果发生了变化那这段代码很可能就是临界区。更精确地说凡是“读改写共享变量”“检查后再操作共享对象”“多个线程共用一个非线程安全的容器”这类代码都是潜在的临界区。实际项目里最容易被忽略的临界区是“检查再操作”型代码。比如if (queue.count 0) { item queue.pop(); }两个线程可能同时检查queue.count 0都通过然后都去执行pop其中一个就会拿到空值或者直接触发断言错误。这种代码不写锁靠运气活着迟早出事。1.4 互斥不是同步别把两者混为一谈我见过不少初学者把互斥和同步当成一个概念这是要命的误解。互斥解决的是“同一时刻只能有一个线程进入临界区”的问题强调排他访问同步解决的是“线程之间按某种顺序推进”的问题强调先后关系。用一个生活中的例子厕所门口挂的“有人/无人”牌子是互斥保证同一时刻厕所里只有一个人。而“你先做饭我吃完饭你再洗碗”这种先后顺序的约定是同步。信号量既可以做互斥二值信号量也可以做同步计数信号量互斥锁则专门用于互斥场景。这也是为什么很多人看到“互斥信号量”这个词时会困惑——它其实指的就是把二值信号量用在了互斥场景里但严格来说它和真正的互斥锁在行为上有很大差异这一点放到第 3 节里细讲。2. 互斥的本质拆解锁的三个基本动作与底层支撑2.1 三个基本动作加锁、执行临界区、解锁互斥锁的本质操作抽象起来就三步加锁lock/take/acquire、执行临界区代码、解锁unlock/release。加锁的动作语义是“我要进入临界区了如果别人已经持有锁我就等着如果没人持有我就把锁拿过来别人得等我。”解锁的语义是“我完事了锁释放下一个等待者可以进来。”所有互斥机制从最简单的pthread_mutex到 RT-Thread 里的rt_mutex底层跑的都是这个模型。这个模型有一个容易忽略的隐藏前提加锁和解锁必须成对出现。我在实际代码 review 里经常看到业务逻辑里某个return分支忘记解锁或者异常分支直接跳出锁一直不释放其他线程全部卡在加锁的地方。这种问题最隐蔽因为代码编译不变运行也不报错就是整个程序像死了一样。所以现在写锁我基本形成肌肉记忆了加锁之后第一件事不是写业务代码而是先想清楚“哪些出口需要解锁”。有些团队用 RAIIResource Acquisition Is Initialization资源获取即初始化或者 try/finally 来保证解锁一定会执行这确实是更稳妥的思路。2.2 硬件原子指令CPU 为互斥提供的底层保证问题来了加锁这个动作本身也是“读取锁状态、修改锁状态”两步它自己都不原子怎么保证临界区的原子性答案是硬件层面的原子指令。最著名的是 Compare-And-SwapCAS指令它的语义是只在当前值和期望值相等时才把新值写入整个比较和写入在硬件级别保证不可分割。现代 CPU 还提供LOCK前缀、atomic exchange、test-and-set等指令操作系统和锁库就是在这些指令上构建更高级的互斥原语。举个很粗糙的例子一个自旋锁的内部实现思路大致如此// 伪代码 while (atomic_compare_and_swap(lock, 0, 1) ! 0) { // 锁已经被别人拿走继续原地打转 } // 拿到锁进入临界区 ... // 释放锁 atomic_store(lock, 0);这个过程里CPU 在硬件层面保证 CAS 不会被其他核打断。如果没有这层硬件支撑锁的实现就要靠操作系统关闭中断等手段不但复杂而且浪费 CPU。理解这一层你就会明白为什么有些锁在单核和多核环境下的表现完全不同。2.3 自旋锁与阻塞锁两种实现风格同样是互斥锁底层实现风格分两大流派自旋锁和阻塞锁也叫睡眠锁。自旋锁的做法是“拿不到锁就反复尝试”像一个在门口不停转圈的人一直问“好了没、好了没”。优点是线程不会被挂起切换开销小缺点是在等待期间白白占用 CPU。所以自旋锁适合临界区非常短的情况比如只修改一个计数器。在多核系统上自旋锁还有可能让持锁线程在其他核心上运行等待线程却在自己的核心上抢占了持锁线程的时间片反而拖长持锁时间。阻塞锁的做法是“拿不到锁就让线程休眠”等持有者释放后再唤醒等待者。优点是等待期间不消耗 CPU缺点是线程挂起和唤醒的过程要操作系统介入切换成本较高如果临界区本身很短这个成本可能比执行临界区代码还要大。真实工程里互斥锁往往是两种策略的组合先短暂自旋自旋超过阈值还没有拿到锁就转入阻塞状态。这样既照顾短临界区又不会因为临界区被长时间占用而白烧 CPU。这也是 Redis、Nginx 这些高并发组件里常见的锁优化思路。2.4 锁粒度互斥设计的另一个决定性选择弄懂基本原理之后设计互斥方案时最重要的权衡点其实是锁粒度。锁粒度说的是“一段临界区保护了多少数据”。如果锁粒度过粗比如用一个全局锁保护所有的共享变量那么并行的线程会因为互相等待而严重退化。最经典的例子是一段耗时的文件解析放在锁里面执行其他线程即使只是访问一个毫不相关的变量也得干等着。如果锁粒度过细又会引入新的问题需要保证多个细锁获取的先后顺序一致否则很容易产生死锁。比如线程 A 先拿锁 1 再拿锁 2线程 B 先拿锁 2 再拿锁 1这俩线程就可能互相等对方释放锁一起卡死。我个人的经验是一开始先按“最自然的共享数据边界”来划分锁跑起来之后用性能分析工具看锁等待时间再决定是拆分大锁还是合并小锁。不要一上来就为了极致性能设计一大堆细锁那样代码复杂度和死锁风险都会急剧上升。3. 互斥锁与互斥信号量名字接近脾气差得很远3.1 二值信号量为何会被叫成“互斥信号量”先澄清一个术语问题。很多教材把信号量分成“计数信号量”和“二值信号量”又把二值信号量称为“互斥信号量”因为它的计数值只能是 0 或 1看起来刚好能表达“被占用/未被占用”两种状态。这种叫法在理论上没问题但在工程实践里埋了一个大坑二值信号量虽然外表看起来像互斥锁内里却缺少互斥锁最关键的一个属性——归属ownership。拿互斥锁表示“这个锁现在由哪条线程持有”信号量则完全不关心这个问题。具体到代码上同一个二值信号量可以被线程 A 获取take然后被线程 B 释放release这在信号量的语义里是允许的但互斥锁绝不允许这种操作它只允许持锁线程自己释放。这个差异在项目里会演变成非常隐蔽的 bug所以各家 RTOS 在设计时都强烈建议做互斥请用专门的互斥量不要让二值信号量冒充互斥锁。3.2 互斥锁的“认主”机制同样的 API不同的语义拿 RT-Thread 举例它的互斥量和信号量都提供take和release之类的 API表面看起来差不多但内部机制完全不同。互斥量会记录持有者的线程 ID。当同一条线程再次调用 take 获取同一个互斥量时系统能够识别出来并且支持“递归持有”——也就是可以嵌套加锁锁的深度计数会递增每次释放逐步递减直到全部释放才算真正解锁。这对那些在一个函数里加锁、又调用另一个也要加锁的函数的场景非常有用。信号量没有 owner 概念就是一个计数器加上等待队列。每次 take 让计数减一每次 release 让计数加一并唤醒等待者。它天然支持大于 1 的资源计数比如一个缓冲区有 5 个槽位就可以用计数值为 5 的信号量来管理。3.3 一张表看清互斥锁和二值信号量的区别特性互斥锁Mutex二值信号量Binary Semaphore核心语义排他访问共享资源资源可用性计数上限为 1是否记录持有者记录只允许持有者释放不记录任何线程都可以释放递归加锁通常支持锁深度增加不支持重复 take 会把自己卡住优先级继承通常支持解决优先级反转不支持典型用途保护临界区任务同步、ISR 通知、资源计数误用风险忘记释放导致死锁释放者对不上号导致状态错乱这张表是从工程实践视角做的对比不同 RTOS 的实现细节会有差异但大方向一致。你如果看到一个项目里把二值信号量当互斥锁用同时还抱怨系统有时候“卡死”、有时候“多个任务同时进入临界区”基本都可以从这里找到原因。3.4 混用的代价优先级反转与误释放为什么不能用二值信号量替代互斥锁两个最直接的代价第一缺少优先级继承机制。关于优先级反转第 5 节会有详细展开这里先给结论当高优先级任务在等待一个被低优先级任务持有的“锁”时如果中间还有一个中等优先级任务在运行高优先级任务会被无限期拖延。互斥锁通过优先级继承可以修复这个问题二值信号量做不到。第二误释放问题。互斥锁只允许持有者释放这相当于一道安全检查二值信号量允许任何线程释放一旦某个线程在错误的时间点执行了 release就相当于把门直接打开了另一个线程会毫无阻碍地冲进临界区数据异常随之而来。这种 bug 极难复现也极难排查。4. 线程互斥实操从 POSIX 到 RT-Thread 的完整落地4.1 POSIX 线程互斥锁的标准模板日常写 Linux 应用最常用的互斥接口是 POSIX 线程库里的pthread_mutex_t。标准流程分三步初始化、加锁/解锁、销毁。#include pthread.h pthread_mutex_t lock; int shared_counter 0; void *worker(void *arg) { for (int i 0; i 1000000; i) { pthread_mutex_lock(lock); shared_counter; pthread_mutex_unlock(lock); } return NULL; } int main(void) { pthread_t t1, t2; pthread_mutex_init(lock, NULL); pthread_create(t1, NULL, worker, NULL); pthread_create(t2, NULL, worker, NULL); pthread_join(t1, NULL); pthread_join(t2, NULL); pthread_mutex_destroy(lock); printf(counter %d\n, shared_counter); return 0; }这段代码里有一个细节很值得学习shared_counter本身只是一行代码但仍然需要用锁保护因为“”不是原子操作。如果把它换成__sync_add_and_fetch(shared_counter, 1)这种 GCC 内置原子函数可以在无锁情况下完成同样的效果不过那是另一个话题了。初学互斥时老老实实加锁比自作聪明重要得多。4.2 RT-Thread 互斥量的创建、获取与释放在嵌入式 RTOS 里RT-Thread 对互斥量的封装非常典型。创建互斥量用rt_mutex_create获取用rt_mutex_take释放用rt_mutex_release名字直观好记。#include rtthread.h static rt_mutex_t my_mutex; void thread_entry(void *param) { rt_err_t err; /* 等待互斥量超时时间设为永久等待 */ err rt_mutex_take(my_mutex, RT_WAITING_FOREVER); if (err RT_EOK) { /* 进入临界区 */ shared_var; /* 释放互斥量 */ rt_mutex_release(my_mutex); } else { rt_kprintf(take mutex failed: %d\n, err); } } int init_my_mutex(void) { my_mutex rt_mutex_create(my_mutex, RT_IPC_FLAG_PRIO); if (my_mutex RT_NULL) { return -1; } return 0; }这里有一个初学者很容易忽略的点创建函数里的第二个参数RT_IPC_FLAG_PRIO很关键它决定互斥量是否支持优先级继承。RT-Thread 官方文档通常推荐使用RT_IPC_FLAG_PRIO因为它能让等待队列按优先级排序并且开启优先级继承机制。如果使用RT_IPC_FLAG_FIFO互斥量退化成普通 FIFO 等待顺序优先级继承也会失效在混合优先级任务场景下容易出问题。4.3 优先级继承RT-Thread 互斥量的关键差异优先级继承是互斥量区别于信号量的最大卖点。它的运行机制是这样的假设当前有三个任务优先级从高到低分别是 A、B、C。任务 A 进入等待互斥量 M 的状态而 M 正被低优先级任务 C 持有。此刻如果任务 B 还在运行它不依赖 M 却能抢占 C导致 C 没法释放 MA 就会一直等待下去这种现象叫优先级反转。带优先级继承的互斥量会这样做当系统发现高优先级任务 A 在等 M 时立即将持有者 C 的优先级临时提升到 A 的优先级让 C 尽快运行并释放 M。C 释放后优先级恢复原状。这样一来B 就无法抢占 CA 的等待时间被压缩到最小。RT-Thread 的互斥量在RT_IPC_FLAG_PRIO模式下默认启用这个逻辑。所以在 RTOS 项目里如果明确是保护共享数据优先选择互斥量而不是信号量正是因为它多做了这一件关键的事。4.4 用互斥锁保护一个共享队列稍微完整一点的例子为了把整个流程串起来我贴一个稍微完整的队列例子。这个例子里有一个生产者和一个消费者共享一个环形队列用互斥量保护入队和出队操作。#define QUEUE_SIZE 16 static int queue_buf[QUEUE_SIZE]; static int queue_head 0; static int queue_tail 0; static rt_mutex_t queue_lock; static int queue_push(int value) { rt_mutex_take(queue_lock, RT_WAITING_FOREVER); if ((queue_tail 1) % QUEUE_SIZE queue_head) { rt_mutex_release(queue_lock); return -1; /* 队列满 */ } queue_buf[queue_tail] value; queue_tail (queue_tail 1) % QUEUE_SIZE; rt_mutex_release(queue_lock); return 0; } static int queue_pop(int *value) { rt_mutex_take(queue_lock, RT_WAITING_FOREVER); if (queue_head queue_tail) { rt_mutex_release(queue_lock); return -1; /* 队列空 */ } *value queue_buf[queue_head]; queue_head (queue_head 1) % QUEUE_SIZE; rt_mutex_release(queue_lock); return 0; }注意这个例子里的锁使用方式在“检查队列满/空”和“修改队列索引”之间用锁包住了保证“检查”和“修改”连续执行不能被生产者/消费者线程插入。很多人会在取锁之后先判断条件不满足就 return此时一定记得在 return 前释放锁。这段代码里我刻意加了两处rt_mutex_release就是为了处理这种情况。5. 死锁、优先级反转与互斥设计中的常见陷阱5.1 死锁四条件与破解方向互斥用多了死锁就成了绕不开的话题。死锁的产生需要同时满足四个条件互斥资源同时只能被一个线程持有、持有并等待持有一个锁的同时去等待另一个锁、不可剥夺锁只能由持有者释放、循环等待两个或多个线程相互等对方手里的锁。实际代码里最可控的突破口是“循环等待”。最常见的做法是给锁编号规定所有线程必须按编号顺序获取锁。比如有两把锁 L1、L2约定“先拿 L1 再拿 L2”任何线程都不允许反着拿循环等待就被破坏了。这个方法简单有效应用极广但它依赖代码规范需要 review 保证。5.2 一个标准的双锁死锁复现死锁的复现代码看起来往往非常傻但真实系统里就是这么发生的。下面是一个典型的例子/* 线程 A */ pthread_mutex_lock(lock1); pthread_mutex_lock(lock2); /* 临界区 */ pthread_mutex_unlock(lock2); pthread_mutex_unlock(lock1); /* 线程 B */ pthread_mutex_lock(lock2); pthread_mutex_lock(lock1); /* 临界区 */ pthread_mutex_unlock(lock1); pthread_mutex_unlock(lock2);线程 A 拿到 lock1 后等待 lock2线程 B 拿到 lock2 后等待 lock1两个线程谁也不让谁。这种问题在内核态、用户态、嵌入式 RTOS 里都可能出现。排查时如果看到任务卡死在锁获取处而且两个任务的栈上都停留在线程库内部首要怀疑就是死锁。5.3 优先级反转不仅是理论是实际工程问题优先级反转不是教科书里编出来的伪命题它会在真实产品里表现为“高优先级任务莫名其妙超时”。FreeRTOS 的 Mutex、RT-Thread 的 RT_IPC_FLAG_PRIO 互斥量都是为了解决这个问题引入优先级继承。最典型的发生场景是一个高优先级的中断处理相关线程需要读取某个被低优先级任务长时间占用的共享数据。如果没有优先级继承低优先级任务可能正在做耗时操作而一个中等优先级的非相关任务又在抢占 CPU高优先级任务就只能干等着。系统中的“实时性”彻底丧失。解决优先级反转除了使用互斥量还有一个工程手段是“禁止在持锁情况下做耗时操作”。如果你发现自己持锁做的第一件事是打印日志、写 Flash、做文件系统操作那这已经不是锁的问题是架构设计的问题。5.4 锁内别干重活锁外别动锁内数据这两句话几乎是我对互斥设计所有教训的总结。“锁内别干重活”很好理解临界区里的代码应该尽量短只做必要的共享数据读写任何可能阻塞的调用比如网络请求、磁盘 IO、动态内存分配都不要放进临界区。否则一个线程进了临界区其他线程全部在锁外排队整个并发的意义就没了。“锁外别动锁内数据”是容易被忽略的对称问题。有些程序员在临界区里读了一个共享结构体的首地址离开锁之后继续访问这个结构体里的字段。这就等于把共享数据的访问拆成了“加锁访问”和“不加锁访问”两段后一段随时可能被别的线程修改或释放产生隐蔽的内存错误。正确做法是在锁内把需要的数据拷贝出来或者延长锁保护范围保证访问共享数据的完整过程都在锁内。6. 复盘我踩过的互斥相关的坑与排查路径6.1 忘记释放锁任务静默卡死我之前维护过一个嵌入式项目某个任务每隔一段时间就停止输出看门狗也没触发整个系统像被冻住了一样。查了很久最后用调试器把任务调用栈打出来发现它卡在一个互斥量 take 上而持有这个互斥量的线程已经因为异常退出。根因是持有线程在某条错误分支里提前 return忘记调用rt_mutex_release。锁永远不释放所有等待者全部卡死。这次之后我给自己定了一条规矩任何加锁函数先把所有 return 分支找齐逐一确认解锁动作能改成 RAII 风格或 try/finally 的绝不手写手动解锁。排查这类问题时调试工具非常关键。RT-Thread 可以通过list_mutex命令查看当前互斥量信息包括持有线程和等待队列。看到等待队列里堆了一串线程而持有线程显示“已退出”问题基本就定位了。6.2 在中断上下文里拿锁直接“翻车”另一个让我印象深刻的问题是在中断处理函数里试着获取一个互斥量。中断上下文并不是普通线程上下文它没有“阻塞”的能力。互斥量 take 的永久等待本质上依赖任务调度在中断里发不发生调度都是一回事。我的代码运行到那里表现极不稳定有时候直接触发断言有时候卡住整个系统。后来我做了两处修正一是中断里不使用互斥量改用标志位加信号量通知线程处理二是如果确需在中断与线程之间共享数据使用专门的rt_sem_release这类“中断安全”的接口来上报事件实际的数据处理放到线程里做。这属于 RTOS 开发的常识但我承认是自己踩进去之后才真正记住的。6.3 信号量初值设错闸门一开就放水第三个坑来自我把二值信号量当互斥量用的时期。创建信号量时我把初值设成了 1本意是让一个共享外设同一时间只能被一个线程使用。但测试中发现两个线程竟然同时进入了“临界区”原因就是信号量没有 owner 概念线程 A 在崩溃前已经 take 成功另一个线程 B 又执行了一次 take由于计数已经被 A 减到 0B 正常情况下应该等待但系统里另一个错误路径上执行了多余的 release把计数重新加到了 1B 就混进去了。这个 bug 光看信号量 API 层面很难理解后来彻底切换到互斥量才从根本上解决。现在我的习惯是只要有“排他访问”语义一律使用互斥量信号量只用于事件通知和资源计数。两个工具适用场景不同不能因为名字里都带“互斥”就随手替换。6.4 排查互斥问题的一点心得排查互斥相关的并发问题最难的是复现。很多 bug 要跑数百小时才会出现而且出现一次之后很难稳定复现。我的做法是三步走先看代码里所有加锁/解锁路径是否严格成对再检查锁对象的生命周期——是否在某个清理函数里被销毁了最后用调试工具抓取等待线程和持有线程的栈信息。如果连栈信息都抓不到那就只能靠日志增强在加锁、解锁、异常分支处打点才能逐步缩小范围。另外强烈建议在开发阶段开启 RT-Thread 的 mutex 调试选项它能打印出互斥量创建、获取、释放的详细信息。很多隐患在早期就能暴露比线上问题阶段再排查要省力得多。我个人学到的最关键一课是互斥的本质操作说到底是“识别共享数据划定临界区用合适的锁保护它并且在所有出口保证释放”。技术工具日新月异这一条却始终不变。你可以在代码里堆满各种花哨的锁但只要没理清临界区边界早晚会被并发问题教做人。反过来只要把临界区边界划得清清楚楚哪怕用最基础的互斥锁也能写出稳定得可怕的多线程程序。
返回列表