之前接手的系统里有一个对账流程:支付结果、风控结果、库存预占三个异步任务返回后,日志必须按固定顺序落盘,结果线上时不时出现乱序。排查的人对着时间戳手工拼半天,最后发现根源就是一个经典的并发协调问题:多个线程都在跑,但后续步骤必须等前面步骤完成。换个说法,这就是多线程按序打印问题。今天这篇就围绕信号量方案展开,把题目本质、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 打印完,把权杖交给 second;second 打印完,把权杖交给 third;third 拿到权杖后打印完,权杖作废。任何线程没有权杖,就只能等在门外。
如何实现权杖的等待和传递,正是信号量(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 不同语言里信号量的长相
信号量不是某种语言特有的东西,几乎所有主流并发模型里都有对应物:
| 语言 | 使用方式 | 关键点 |
|---|---|---|
| Java | java.util.concurrent.Semaphore | 构造时可传公平参数,acquire()/release() |
| Python | threading.Semaphore | acquire()默认阻塞,初始值可以为 0 |
| Go | 无 Semaphore 类型 | 用无缓冲 channel 模拟,读操作等价阻塞等待 |
| C++ | C++20 引入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()还支持blocking=False和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/finally:
public 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、初始许可是多少,这三个问题想清楚,代码基本不会跑偏。按序打印虽然是一道练习题,但把它吃透之后再看生产者-消费者、限流、多条件汇合这些实际场景,你会发现信号的闸门模型就在那里,剩下的只是换一个业务壳而已。