信号量Semaphore如何解决多线程按序打印的并发协调难题
之前接手的系统里有一个对账流程支付结果、风控结果、库存预占三个异步任务返回后日志必须按固定顺序落盘结果线上时不时出现乱序。排查的人对着时间戳手工拼半天最后发现根源就是一个经典的并发协调问题多个线程都在跑但后续步骤必须等前面步骤完成。换个说法这就是多线程按序打印问题。今天这篇就围绕信号量方案展开把题目本质、Semaphore 原理、手写实现和几个真实踩过的坑都讲清楚。打算系统学并发控制、或者面试前想快速梳理同步机制的朋友都可以直接参考。1. 先跑个裸实验并发下顺序为什么一定会乱1.1 题目在说什么按序打印问题的描述很简单有一个共享对象里面有三个方法分别对应三个输出动作。第一个线程调用方法 A输出 first第二个线程调用方法 B输出 second第三个线程调用方法 C输出 third。要求不论三个线程谁先被调度执行最终的输出都必须是 first、second、third每个动作且仅执行一次。这个描述听起来很像约定顺序很多人第一次看到会觉得我按顺序start()三个线程不就行了实际跑一遍就会发现不是这么回事。线程启动的顺序只代表你向操作系统发出了可以运行的请求不代表 CPU 会按这个顺序执行。调度器完全可能先把后来启动的线程调度起来或者让早启动的线程中途被抢占。1.2 不加任何同步时的真实输出给一个最原始的版本三个线程分别打印 first、second、third什么都不加。public class Demo { public static void main(String[] args) { Thread t1 new Thread(() - System.out.print(first )); Thread t2 new Thread(() - System.out.print(second )); Thread t3 new Thread(() - System.out.print(third )); t1.start(); t2.start(); t3.start(); } }把这段代码连续跑几十次输出五花八门first second third会出现但first third second、second first third、third second first也都会出现。之所以还能偶尔得到正确顺序完全是因为三个打印动作耗时极短调度撞巧了而不是逻辑上有任何保证。这背后是典型的竞态条件多个线程同时访问共享输出流执行顺序由内核调度决定而不是由代码书写顺序决定。一旦打印动作换成写日志、调接口、更新数据库耗时不均匀乱序会更加频繁和明显。1.3 把需求翻译成权杖传递既然调度不可控就只能靠线程之间的协调来人为建立顺序。其实按序打印要的不是三个线程都不许并行而是第二个动作必须先等第一个动作完成第三个动作必须先等第二个动作完成。这种依赖可以理解成权杖传递系统里只有一根权杖它最初在 first 手上。first 打印完把权杖交给 secondsecond 打印完把权杖交给 thirdthird 拿到权杖后打印完权杖作废。任何线程没有权杖就只能等在门外。如何实现权杖的等待和传递正是信号量Semaphore最擅长的场景。锁也能做但需要额外维护状态位和条件变量信号量用一个计数器就能把当前有几个权杖可发表达得明明白白。2. 信号量的门卫模型一张许可就是一道闸门2.1 从停车场门卫理解许可数Semaphore 内部维护一个整数计数器把它想成停车场的剩余车位更形象。每个想进场的线程都是一辆车进场前必须向门卫申请一个车位acquire()对应申请一个许可。如果剩余许可数大于 0则申请成功计数减 1线程直接进入如果剩余许可数为 0线程就在门外阻塞等待直到有其他车辆离场释放许可。release()对应离场交还车位。计数加 1并且唤醒一个正在等待的线程。这里有一个对按序打印极其重要的特性初始许可数可以设成 0。这意味着门卫手里一开始没有空车位任何线程来申请都会阻塞在门外。只有当其他线程主动release()一次门外等待的线程才会被放进去。放到按序打印里一切就清晰了first 线程可以自由执行second 线程和 third 线程从程序启动那一刻起就站在门外等待因为两个信号量的初始许可都是 0。first 打印完成后调用release()相当于把第二道门的钥匙交出去second 拿到钥匙打印完成再release()把第三道门打开third 最终拿到钥匙完成打印。这就像接力赛每一棒都必须等上一棒递出接力棒。2.2 锁与信号量顺序控制为什么不适合硬用锁不少初学者会问按序打印用synchronized或者Lock能写吗能写但写起来绕。锁的核心语义是互斥解决的是同一时刻只允许一个线程进入临界区而按序打印要解决的是先后依赖。直接用锁你需要一个共享状态int turn 1再加上Condition分别等待和唤醒轮到自己就打印否则就挂起。这种写法不是不行但它把一个简单的顺序约束拆成了状态判断、条件等待、信号通知三件事代码量和出错概率都明显增加。信号量直接把当前允许几个线程进入/当前轮到谁进入编码在计数器中。初始许可为 0表示当前不放行release()一次表示放行一个等待者。语义和需求是一一对应的少了很多翻译成本。维度锁Lock / synchronized信号量Semaphore核心目标互斥保证临界区独占资源计数控制放行数量表达先后依赖需要额外状态位 Condition初始许可设为 0天然表达等待允许多个线程同时进入通常只有一个通过许可数 N 控制典型场景写共享变量、保护临界区限流、闸门、顺序协调2.3 不同语言里信号量的长相信号量不是某种语言特有的东西几乎所有主流并发模型里都有对应物语言使用方式关键点Javajava.util.concurrent.Semaphore构造时可传公平参数acquire()/release()Pythonthreading.Semaphoreacquire()默认阻塞初始值可以为 0Go无 Semaphore 类型用无缓冲 channel 模拟读操作等价阻塞等待CC20 引入std::counting_semaphore模板参数指定最大计数构造时传初始值不同语言实现细节不同但核心模型一致一个计数器一个阻塞等待操作一个释放加一操作。下面的实现部分我会给出 Java、Python、Go 三份可直接跑的代码方便对照。3. 手写实现两个信号量如何把三道闸门依次打开3.1 解法框架如果只有 one、two、three 三个环节只需要两个信号量secondGate控制第二个环节能不能开始thirdGate控制第三个环节能不能开始。初始都是 0。逻辑如下first 方法打印 first然后执行secondGate.release()把第二道门的许可释放出去。second 方法先执行secondGate.acquire()拿不到许可就阻塞拿到后打印 second再执行thirdGate.release()。third 方法先执行thirdGate.acquire()拿到许可后打印 third。这里的关键是先等再打印打印完再开下一道门。谁先执行不重要重点是第二和第三个线程都会先卡在acquire()上直到上一环节明确释放。3.2 Java 实现可运行完整代码import java.util.concurrent.Semaphore; public class OrderedPrinter { private final Semaphore secondGate new Semaphore(0); private final Semaphore thirdGate new Semaphore(0); public void first(Runnable printFirst) throws InterruptedException { printFirst.run(); secondGate.release(); } public void second(Runnable printSecond) throws InterruptedException { secondGate.acquire(); printSecond.run(); thirdGate.release(); } public void third(Runnable printThird) throws InterruptedException { thirdGate.acquire(); printThird.run(); } }测试时不要按顺序启动线程最好故意反着启动让第三个线程先跑、第二个线程第二个跑、第一个线程最后跑这样更能验证信号量的约束能力public static void main(String[] args) throws InterruptedException { OrderedPrinter printer new OrderedPrinter(); Thread t1 new Thread(() - { try { printer.first(() - System.out.print(first )); } catch (InterruptedException ignored) {} }); Thread t2 new Thread(() - { try { printer.second(() - System.out.print(second )); } catch (InterruptedException ignored) {} }); Thread t3 new Thread(() - { try { printer.third(() - System.out.print(third )); } catch (InterruptedException ignored) {} }); t3.start(); t1.start(); t2.start(); }无论线程启动顺序怎么打乱输出都稳定为first second third。因为 second 线程即使先被调度也一定阻塞在secondGate.acquire()直到 first 线程执行完成后释放许可。3.3 Python 实现Python 的threading.Semaphore(0)默认就是阻塞获取写起来思路完全一致import threading class Foo: def __init__(self): self.second_gate threading.Semaphore(0) self.third_gate threading.Semaphore(0) def first(self, print_first): print_first() self.second_gate.release() def second(self, print_second): self.second_gate.acquire() print_second() self.third_gate.release() def third(self, print_third): self.third_gate.acquire() print_third()注意一点Python 的acquire()还支持blockingFalse和timeout参数。按序打印这种场景必须用默认的阻塞模式如果用了非阻塞且拿不到许可就直接跳过顺序逻辑就会被破坏。3.4 Go 实现没有信号量就用 channel 模拟Go 语言标准库没有暴露 Semaphore 类型但无缓冲 channel 天然就是初始许可为 0 的信号量在没有任何协程写入数据时读操作会一直阻塞直到有数据被写入。package main import fmt type Foo struct { second chan struct{} third chan struct{} } func NewFoo() *Foo { return Foo{ second: make(chan struct{}), third: make(chan struct{}), } } func (f *Foo) First(printFirst func()) { printFirst() close(f.second) } func (f *Foo) Second(printSecond func()) { -f.second printSecond() close(f.third) } func (f *Foo) Third(printThird func()) { -f.third printThird() } func main() { f : NewFoo() done : make(chan struct{}) go func() { f.Third(func() { fmt.Print(third ) }); close(done) }() go func() { f.First(func() { fmt.Print(first ) }); close(done) }() go func() { f.Second(func() { fmt.Print(second ) }); close(done) }() -done -done -done }这里close(f.second)相当于一次性释放一个许可-f.second相当于阻塞等待许可。由于每个 channel 只关闭一次这种写法只适合单次流程如果要做多轮按序打印每一轮都应重新创建 Foo 实例。3.5 验证时最容易忽略的三个小细节第一必须每次创建一个新的信号量实例。如果复用了上一次运行的对象信号量内部的许可状态可能已经发生变化结果会变得不可预期。多轮测试的正确姿势是在循环体内new Foo()/NewFoo()。第二测试代码里如果直接按顺序调用printer.first(); printer.second(); printer.third();那是在单线程里顺序执行根本测不出并发效果。真正的按序测试必须用三个独立线程并且故意打乱启动顺序。第三输出最好收集到一个 StringBuilder 里做断言而不是只靠肉眼观察。跑个 100 轮如果每次结果都是first second third才算真正通过。肉眼看的偶然性太大尤其当线程调度恰好比较规律时。4. 我踩过的那几个坑翻车现场与排查思路4.1 初始许可设成 1门卫手里多了一把备用钥匙信号量解按序打印最隐蔽的错误就是把new Semaphore(0)写成new Semaphore(1)。表面上看只差一个数字逻辑却完全变了。初始许可为 1说明 second 线程一启动就能拿到一张许可根本不需要在门外等 first 线程完成。这时候输出可能变成second first third甚至second third first顺序完全失去约束。为什么有人会写出new Semaphore(1)因为潜意识里觉得信号量至少要有一个许可才能工作或者把它和互斥锁混淆了。按序打印需要的恰恰是初始不放行0 不是错误而是这个方案的精髓。写完代码后可以用availablePermits()验证初始许可数确认是 0 再往下走。4.2 release 写在 acquire 前面顺序依然乱套另一个高频翻车姿势是把第二阶段的代码写成// 错误示例 public void second(Runnable printSecond) throws InterruptedException { secondGate.release(); secondGate.acquire(); printSecond.run(); }这种写法的问题在于second 线程一旦被调度先往 semaphore 里放一张许可紧接着又自己把它取走。它完全不需要等 first 线程直接打印顺序约束被彻底绕过。如果是多个线程并发调用情况还会更糟每个线程都先释放再获取许可数被反复加一最后所有人都能拿到许可按序约束形同虚设。正确的顺序永远是先acquire()确认上一阶段完成再执行当前动作最后release()告知下一阶段。4.3 业务代码抛异常导致门永远锁死这是从练习题走向真实业务时最容易踩的坑。练习里的打印动作不会失败但真实场景里printFirst.run()可能是一个写日志、落数据库或者远程调用的方法任何一步抛异常后面的release()都不会执行。结果就是下游线程一直在acquire()处阻塞线程悬挂任务不结束线上出现一批僵尸线程。排查时堆栈里通常卡在Semaphore.acquire但根本原因却在更早的打印动作上。当时的现场处理方式是为 release 加上 try/finallypublic void first(Runnable printFirst) throws InterruptedException { try { printFirst.run(); } finally { secondGate.release(); } }这样即使第一阶段内部抛异常第二阶段也能被放行至少系统不会整体卡死。当然更严谨的做法是记录错误状态并让下游感知到前面的步骤失败了这取决于业务对失败的处理策略。但对顺序控制本身来说永远要保证有释放动作是必须守住的底线。4.4 死锁定位套路jstack、许可数日志与顺序检查如果线上真的出现线程卡住不要盲目重启按下面的思路排查通常能快速定位第一先抓线程快照。用jstack查看卡住的线程如果堆栈停在java.util.concurrent.Semaphore.acquire附近说明线程是在等待许可。注意等待许可并不代表问题一定出在信号量本身可能只是某个前置动作没完成。第二打印许可剩余量。在可疑的 release 点前后加上日志输出semaphore.availablePermits()。如果某个阶段一直显示 0说明对应的 release 从未执行问题大概率在 release 之前的调用链上。第三检查 acquire 和 release 是否严格配对。我遇到过把 release 写进 if 分支的代码某个分支走到异常路径时漏掉了 release导致偶发卡顿。这类问题用眼睛看不出来最好在代码评审阶段就要求所有 release 都走 finally或者用静态检查工具盯住信号量的使用。第四注意InterruptedException的处理。acquire()是响应中断的如果在 catch 块里什么都不做线程会在异常路径下继续往下走可能跳过打印动作继续释放下游许可最后输出缺字。练习代码里可以忽略中断生产代码里必须想清楚中断之后的业务语义。5. 从按序打印到真实生产几个能直接用信号量的场景5.1 N 个环节串行循环创建信号量题目通常是三个环节但真实系统里一条流水线可能有十个甚至更多环节。N 个环节串行的通用写法并不复杂Semaphore[] gates new Semaphore[n - 1]; for (int i 0; i n - 1; i) { gates[i] new Semaphore(0); }第 0 环节执行完释放gates[0]第 1 环节先获取gates[0]再执行执行完释放gates[1]依次类推。本质上就是把两把钥匙扩展到 N-1 把钥匙每一把钥匙控制一个下游环节。如果系统里有多条流水线并发跑每条流水线都需要独立的一组信号量不能共用否则一条流水线的 release 会把另一条流水线的等待线程放行造成串线。5.2 多条件汇合等 A 和 B 都完成再继续按序打印是直线依赖还有一种更常见的模式叫汇合某个环节 D 必须等 A、B 两个异步任务都完成才能开始。做法是给 D 准备两个信号量一个对应 A一个对应 B初始都是 0。A 完成时释放自己的信号量B 完成时释放自己的信号量。D 启动后先获取 A 的信号量再获取 B 的信号量两个都拿到才继续。public void awaitBoth() throws InterruptedException { aDone.acquire(); bDone.acquire(); // 两个前置任务都完成了继续执行 }在这种场景里信号量的数量等于前置条件的数量每获得一个许可就等价于确认一个前置条件满足。等所有许可拿到手所有前置条件都满足了。如果后续操作只允许放行固定数量的线程比如最多允许 5 个线程并发进入汇合后的处理把汇合信号量换成初始许可为 5 的普通信号量即可。5.3 限流、连接池与批量并发控制信号量的另一个高频用途是限流。new Semaphore(10)表示系统最多允许 10 个线程同时访问某个资源第 11 个线程必须等待。和线程池配合使用时可以用它限制对下游服务的并发压力避免批量任务把所有连接打爆。不过生产环境里我更推荐用tryAcquire()而不是裸acquire()并且要设置超时时间if (semaphore.tryAcquire(2, TimeUnit.SECONDS)) { try { // 业务逻辑 } finally { semaphore.release(); } } else { // 获取许可超时走降级逻辑 }这样即便某个特性时刻压力很大也不会让大量线程无限期阻塞更容易做优雅降级。面试时能讲出这一层通常会比只会acquire/release显得更有实战经验。5.4 哪些场景建议绕开信号量信号量虽然好用但不是万能的。第一它不具备可重入性。如果一个线程已经持有许可又在同一个线程内再次调用acquire()而初始许可只有 1就会把自己锁死。可重入的需求应该用ReentrantLock。第二当条件等待变得复杂时比如等 A 完成并且 B 的值大于某个阈值信号量只能表达有没有被 release 过表达不了更细粒度的条件。这时候Lock Condition或CompletableFuture更合适。第三信号量的许可数容易因为异常的 release 调用而膨胀。如果某段代码被多个线程同时 release许可数会越来越大后续所有线程都能通过限流和顺序约束都失效。这也是为什么所有 release 都要保证只在正确的位置执行一次。我个人的体会是信号量不需要天天用但一旦遇到环节之间有先后依赖、每个环节可能从不同线程进来的场景它比手写状态位加轮询干净太多。写之前先在注释里回答三个问题谁 acquire、谁 release、初始许可是多少这三个问题想清楚代码基本不会跑偏。按序打印虽然是一道练习题但把它吃透之后再看生产者-消费者、限流、多条件汇合这些实际场景你会发现信号的闸门模型就在那里剩下的只是换一个业务壳而已。