Windows下CriticalSection与Mutex锁表现异常的哲学家就餐程序问题
现象成因拆解
为什么全速运行时几乎看不到锁竞争失败日志
- 你的锁获取逻辑没有给每个线程分配固定的拿锁顺序,所有线程都是从0号筷子开始按固定顺序遍历抢锁,只要任意一次没凑够2把,就会把已经拿到的锁全部释放,重新从头开始遍历。
- Windows线程调度器对CPU核心的分配有天然亲和性:当5个线程被一次性创建完成,代码执行路径完全一致,全速运行时会自然形成稳态——每个线程刚好顺次拿到相邻的两把筷子,全程不会出现抢同一把锁的冲突。这种状态下,所有
TryEnterCriticalSection的调用都是直接成功,根本触发不了你写的“凑不够锁就打红色日志”的分支。 - 你在调试时、加
Sleep(1)后看到大量红色日志,本质是人为打破了线程之间的执行节奏同步:当线程的执行顺序被随机打乱,才会出现多个线程同时尝试抢同一把筷子的碰撞,进而触发失败分支。 - 全速运行时,锁操作本身是纳秒级开销,远小于毫秒级的线程上下文切换间隔。绝大多数线程在自己的时间片内就能完成两次加锁、打日志、解锁的全流程,根本不会遇到时间片切换导致的锁占用冲突;只有人为把操作的时间尺度拉到和调度间隔同级别,才会频繁出现锁碰撞。
为什么跨进程Mutex版本比线程版CRITICAL_SECTION运行更快
这完全不是对锁性能的认知错误,是两个版本的实际运行负载根本不在一个量级:
- 当你用
WaitForSingleObject(handle, 0)设置0超时做非阻塞尝试加锁时,Windows对这类调用实现了用户态快速路径:如果锁没有被持有,根本不需要陷入内核态做等待,仅通过用户态原子操作就能完成加解锁,开销和TryEnterCriticalSection的差距极小。 - 线程版本的性能损耗来自CPU缓存伪共享:全局的
chopsticks数组是连续排布的CRITICAL_SECTION结构,5个相邻的锁结构刚好落在同一个CPU缓存行上。只要一个线程修改了其中某一把锁的状态,就会让其他核心上的所有线程的对应缓存行失效,触发大量跨核心缓存同步开销,大幅拖慢整体速度。 - 进程版本没有共享地址空间,不存在上面说的缓存伪共享问题;再加上5个进程创建时的微小延迟自然打散了执行节奏,反而更容易形成无冲突的顺次拿锁稳态,没有不必要的锁释放、重试开销,整体速度自然超过了有缓存损耗、频繁做无意义锁释放的线程版本。
为什么你预期的“必然出现锁竞争”不符合实际情况
相同执行路径的多个执行流,在没有人为制造节奏差的场景下,天然会进入“无冲突顺次执行”的稳态。这不是没有锁竞争,是竞争的结果刚好落到了无冲突的平衡点上。
- 你以为只要有多个执行流访问共享锁,就必然频繁出现冲突,但实际只要线程之间的执行时差小于一次锁操作的纳秒级耗时,就根本不会出现碰撞:A线程刚释放锁,下一个刚好轮到B线程尝试加同一把锁,这只是锁所有权的顺次传递,不存在竞争开销。
- 如果要稳定看到锁竞争的失败日志,根本不需要加
Sleep,只需要把每个线程遍历抢锁的起始筷子编号随机打乱,打破固定的执行路径,自然就会出现大量碰撞,触发红色的失败日志。
代码里的逻辑漏洞
- 进程版的Mutex加锁成功判断是错的:
WaitForSingleObject(chopsticksMutex[j], 0) != WAIT_ABANDONED这个判断逻辑有问题——当锁被其他进程占用时,0超时的等待会返回WAIT_TIMEOUT,这个值本身不等于WAIT_ABANDONED,所以哪怕锁被占了,代码也会判定加锁成功。只是因为你的运行场景刚好没有出现锁被占的情况,这个bug没有暴露。正确的成功判断应该是返回值等于WAIT_OBJECT_0。 - 线程版的拿锁逻辑设计本身就会放大不必要的竞争:只要一次没凑够两把锁,就把已经拿到的锁全部释放,从头开始遍历,完全没有等待、重试已持锁的逻辑,这种测试用的逻辑本身就和正常的哲学家就餐实现逻辑不同,自然也不会出现教材里描述的典型竞争场景。
内容的提问来源于stack exchange,提问作者Yeho
相关产品推荐
相关产品推荐

