1. 从“堵车”到“并发”:Semaphore的直觉理解
如果你在高峰期开车经过一个只有两条车道的隧道,而前方因为施工变成了单车道,会发生什么?所有车辆必须轮流通过,一次只能过一辆。这时,路口通常会设置一个红绿灯或者有工作人员指挥,确保同一时间只有一辆车进入单车道区域。这个指挥者,或者说这个“一次只允许一辆车通过”的规则,在并发编程的世界里,就叫做信号量(Semaphore)。
我第一次深入使用Semaphore,是在处理一个图片批量处理的服务时。服务需要从消息队列中消费任务,每个任务都涉及下载图片、调用AI模型进行智能裁剪、再上传到云存储。AI模型的GPU实例非常昂贵,我们只部署了有限的几个。突然的流量高峰导致大量任务堆积,如果放任所有工作线程同时去抢GPU,结果就是所有任务都卡住,GPU内存爆掉,服务直接雪崩。那一刻,我需要的不是一个复杂的任务调度系统,而是一个简单、粗暴、有效的“门卫”,告诉我的线程们:“嘿,里面只有4个工位(GPU),出来一个,才能进去下一个。” 这个“门卫”就是Semaphore。
简单来说,Semaphore是一个计数器,用来控制同时访问某个特定资源的线程数量。它的核心是两个字:许可(Permits)。你可以把许可想象成进入核心资源区的通行证。Semaphore在初始化时就规定了总共有多少张通行证(总许可数)。当一个线程想要使用资源时,它必须先调用acquire()方法获取一张通行证。如果还有剩余的通行证,线程就拿到并继续执行;如果通行证被发完了,后来的线程就必须在acquire()方法那里排队等待,直到有线程使用完资源,调用release()方法归还通行证。
所以,它解决的不是“互斥”(一个资源只能一个人用)的问题,那是锁(Mutex)的领域。Semaphore解决的是“定量并发”的问题:允许有限数量的多个线程同时进入临界区。这非常适用于池化资源(数据库连接池、线程池)、限流(API调用频率限制)、以及我遇到的这种硬件资源(GPU、打印机)访问控制场景。
2. Semaphore的核心机制:不只是个计数器
理解Semaphore,不能停留在“计数器”的比喻上。在Java的java.util.concurrent.Semaphore实现中,它的内部运作远比一个简单的int变量加减要精妙。这关系到我们能否正确地使用它,并避免一些隐蔽的坑。
2.1 公平性与非公平性:谁先拿到通行证?
这是Semaphore构造函数中的一个关键参数:new Semaphore(int permits, boolean fair)。
- 非公平模式(默认,
fair = false):当有线程释放许可时,所有正在等待的线程会一起竞争这个新释放的许可,谁抢到算谁的,不讲究先来后到。这就像一群人在超市收银台排队,突然新开了一个柜台,大家一拥而上。它的优点是吞吐量高,因为减少了线程切换的开销(唤醒的线程可以直接运行,不用按顺序来)。但缺点是可能导致某些线程“饥饿”,一直抢不到许可。 - 公平模式(
fair = true):严格按照线程调用acquire()的顺序来分配许可,先来的线程先获得。这就像真正的队列,井然有序。优点是公平,避免了饥饿现象。缺点是性能有损耗,因为需要维护一个等待队列。
该如何选择?我的经验法则是:在绝大多数资源访问控制的场景下,使用默认的非公平模式。因为我们的目标通常是最大化系统吞吐量,让资源尽可能不被闲置。线程短暂的“饥饿”在大多数高并发场景下是可以接受的。除非你的业务逻辑对绝对的公平性有严格要求,并且能承受相应的性能损失,否则不要轻易开启公平模式。
2.2acquire()与tryAcquire():阻塞还是试探?
获取许可有两种主要方式,对应不同的业务逻辑。
acquire()/acquire(int permits):这是一个阻塞方法。如果当前没有可用的许可(或不足请求的数量),调用线程会进入等待状态,直到有其他线程释放足够的许可,或者线程被中断。这是最常用的方法,适用于“必须等到资源可用才能进行下一步”的场景。// 场景:必须连接到数据库才能查询 semaphore.acquire(); // 如果没有连接可用,就在这里安心等待 try { Connection conn = connectionPool.getConnection(); // 执行查询... } finally { connectionPool.releaseConnection(conn); semaphore.release(); // 务必在finally块中释放! }tryAcquire()/tryAcquire(long timeout, TimeUnit unit):这是一个非阻塞或限时阻塞的方法。它尝试获取许可,如果立即获取不到(对于无参版本),或者在一段时间内获取不到(对于超时版本),它就返回false,而不会让线程无限期等待。// 场景:快速失败(Fail-fast)的限流 if (semaphore.tryAcquire()) { try { // 执行需要限流的操作,例如调用外部API callExpensiveAPI(); } finally { semaphore.release(); } } else { // 立即拒绝请求,返回“系统繁忙”或让用户稍后重试 throw new RateLimitExceededException("请稍后再试"); }tryAcquire在构建响应迅速的限流组件时非常有用,它避免了请求堆积在等待队列中,能够快速给客户端一个明确的反馈。
2.3 释放(release)的陷阱:可以多还,但不能不还
release()方法看似简单,但有两个至关重要的细节:
- 谁获取,谁释放:这是一个黄金法则。通常,获取和释放操作必须放在同一个线程上下文中,并且强烈建议放在
try-finally块中,确保即使业务代码抛出异常,许可也一定能被归还。否则会导致许可“泄漏”,可用许可数永久减少,最终所有线程都卡在acquire()上。 - 可以释放比获取更多的许可:
release()方法可以增加许可的总数,即使调用它的线程并没有事先获取。这意味着你可以动态地“增加”Semaphore的容量。但这是一个需要慎用的高级特性,因为它破坏了“许可总数固定”的约定,可能扰乱你的控制逻辑。一个更安全的做法是使用Semaphore的子类或包装类来禁止此行为。
3. 实战场景:用Semaphore解决三类经典问题
理解了原理,我们来看看Semaphore在真实代码中如何大显身手。我会用三个逐渐深入的例子来说明。
3.1 场景一:简单的资源池模拟(数据库连接池)
这是最经典的例子。假设我们有一个固定大小的“资源”,比如3个模拟的数据库连接。
import java.util.concurrent.*; public class ConnectionPoolDemo { // 一个非常简陋的连接池,只有3个连接 private static final int MAX_CONNECTIONS = 3; private static final Semaphore connectionSemaphore = new Semaphore(MAX_CONNECTIONS); private static final BlockingQueue<MockConnection> pool = new LinkedBlockingQueue<>(); static { // 初始化连接池 for (int i = 0; i < MAX_CONNECTIONS; i++) { pool.offer(new MockConnection("Connection-" + i)); } } public static MockConnection getConnection() throws InterruptedException { connectionSemaphore.acquire(); // 获取访问池的许可(等待直到有连接空闲) // 注意:这里获取的是“从池里拿连接”的许可,不是连接本身。 // 实际连接池(如HikariCP)内部有更复杂的逻辑,但限流思想类似。 return pool.take(); } public static void releaseConnection(MockConnection conn) { pool.offer(conn); // 把连接放回池里 connectionSemaphore.release(); // 释放许可,允许其他线程来获取连接 System.out.println(Thread.currentThread().getName() + " 释放了连接,当前可用许可: " + connectionSemaphore.availablePermits()); } static class MockConnection { String name; MockConnection(String name) { this.name = name; } public void query() throws InterruptedException { System.out.println(Thread.currentThread().getName() + " 正在使用 [" + name + "] 执行查询..."); Thread.sleep((long) (Math.random() * 2000)); // 模拟查询耗时 } } public static void main(String[] args) { ExecutorService executor = Executors.newFixedThreadPool(10); // 10个线程争抢3个连接 for (int i = 0; i < 10; i++) { executor.submit(() -> { try { MockConnection conn = getConnection(); conn.query(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { // 在实际连接池中,这里通常是调用 conn.close(),由close方法内部处理释放逻辑 // 本例中我们简化了,直接调用releaseConnection } }); } executor.shutdown(); } }运行这段代码,你会看到控制台输出总是显示最多只有3个线程在同时“执行查询”,其他线程则在acquire()处等待。这就是Semaphore在控制并发访问数量上的直观体现。
3.2 场景二:生产者-消费者模型中的流量整形
在经典的生产者-消费者问题中,我们通常用有界队列(BlockingQueue)来解耦。但有时候,消费者能力有限(比如下游服务吞吐量低),我们不仅需要队列有界,还需要控制活跃消费者的数量,防止它们一下子把队列掏空然后压垮下游。这时可以在消费者侧再加一个Semaphore。
public class ThrottledConsumerDemo { private static final BlockingQueue<String> taskQueue = new LinkedBlockingQueue<>(100); // 限制最多只有2个消费者能同时处理任务 private static final Semaphore activeConsumerSemaphore = new Semaphore(2); static class Consumer implements Runnable { @Override public void run() { while (!Thread.currentThread().isInterrupted()) { try { // 关键点:先获取处理任务的“资格”,再从队列拿任务 activeConsumerSemaphore.acquire(); String task = taskQueue.take(); // 如果队列空,这里会阻塞 processTask(task); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } finally { activeConsumerSemaphore.release(); } } } private void processTask(String task) throws InterruptedException { System.out.println(Thread.currentThread().getName() + " 开始处理: " + task); Thread.sleep(1000); // 模拟耗时处理 System.out.println(Thread.currentThread().getName() + " 处理完成: " + task); } } public static void main(String[] args) throws InterruptedException { // 启动5个消费者线程 for (int i = 0; i < 5; i++) { new Thread(new Consumer(), "Consumer-" + i).start(); } // 生产者不断放入任务 ScheduledExecutorService producer = Executors.newSingleThreadScheduledExecutor(); producer.scheduleAtFixedRate(() -> { String task = "Task-" + System.currentTimeMillis(); if (taskQueue.offer(task)) { System.out.println("生产了: " + task); } else { System.out.println("队列已满,丢弃: " + task); } }, 0, 200, TimeUnit.MILLISECONDS); // 每200ms生产一个 Thread.sleep(10000); producer.shutdown(); } }在这个模型里,BlockingQueue控制了待处理任务的积压量(内存边界),而Semaphore控制了同时处理任务的消费者数量(处理能力边界),两者结合实现了更精细的流量控制。
3.3 场景三:实现一个简单的CyclicBarrier(循环栅栏)
CyclicBarrier是JUC中另一个工具,用于让一组线程互相等待,到达一个公共屏障点后再同时继续。我们可以用Semaphore来模拟它的核心行为,这有助于理解两者的区别。
public class SemaphoreAsBarrier { private final int parties; // 需要等待的线程数 private int count; // 当前已到达的线程数 private final Semaphore arrivalSemaphore = new Semaphore(0); // 初始为0,用于线程到达后等待 private final Semaphore departureSemaphore = new Semaphore(0); // 初始为0,用于一起释放 private final Object lock = new Object(); // 保护count的修改 public SemaphoreAsBarrier(int parties) { this.parties = parties; } public void await() throws InterruptedException { synchronized (lock) { count++; if (count == parties) { // 我是最后一个到达的线程,释放所有等待的线程 departureSemaphore.release(parties - 1); // 释放其他parties-1个线程 arrivalSemaphore.release(); // 释放自己(虽然自己不需要等arrivalSemaphore) System.out.println(Thread.currentThread().getName() + " 是最后一个,触发释放!"); } else { // 不是最后一个,先释放arrivalSemaphore一个许可(表示自己已到达), // 然后等待departureSemaphore arrivalSemaphore.release(); } } // 关键:先获取arrivalSemaphore的许可(由最后一个线程或前面的逻辑释放) // 这个设计是为了确保所有线程都执行完上面的同步块,更新完count。 // 实际上,更简单的模拟可以省略这一步,这里展示一种协调思路。 // arrivalSemaphore.acquire(); // 本例中简化,不严格模拟CyclicBarrier的精确阶段 departureSemaphore.acquire(); // 所有线程在这里等待最后一个线程的信号 System.out.println(Thread.currentThread().getName() + " 冲破屏障,继续执行!"); // 重置逻辑(模拟CyclicBarrier的循环)比这个复杂,需要考虑所有线程都离开后才能重置count。 // 此处省略,仅演示一次性的屏障。 } public static void main(String[] args) { int parties = 3; SemaphoreAsBarrier barrier = new SemaphoreAsBarrier(parties); Runnable task = () -> { try { System.out.println(Thread.currentThread().getName() + " 正在准备..."); Thread.sleep((long)(Math.random() * 1000)); System.out.println(Thread.currentThread().getName() + " 到达屏障点"); barrier.await(); System.out.println(Thread.currentThread().getName() + " 屏障后工作"); } catch (InterruptedException e) { e.printStackTrace(); } }; for (int i = 0; i < parties; i++) { new Thread(task, "Thread-" + i).start(); } } }这个例子旨在展示Semaphore的灵活性。但请注意,在实际开发中,对于“线程集合点”的需求,应直接使用CyclicBarrier或CountDownLatch,它们经过了充分测试,语义更清晰。这里只是为了加深对Semaphore“协调”能力的理解。
4. 避坑指南:Semaphore使用中的五个常见“雷区”
即使理解了原理,在实际编码中,Semaphore也容易用错。下面是我和同事们踩过或见过的坑。
4.1 坑一:许可泄漏(Permit Leak)
这是最严重也最常见的问题。忘记在finally块中释放许可,或者在复杂的业务逻辑中,由于异常、提前return、分支跳出等原因,导致release()没有被执行。
// 错误示范 semaphore.acquire(); doSomething(); // 如果这里抛出异常,下面的release永远不会执行! semaphore.release(); // 正确示范(模板) semaphore.acquire(); try { doSomething(); } finally { semaphore.release(); // 确保无论如何都会执行 }对于需要获取多个许可的情况,也要确保释放相同的数量。
semaphore.acquire(3); try { // 使用资源... } finally { semaphore.release(3); // 获取多少,释放多少 }4.2 坑二:在持有许可时调用可中断的阻塞方法
如果你的线程在持有Semaphore许可的同时,去调用另一个可能阻塞且可中断的方法(比如Thread.sleep()、BlockingQueue.take()),并且这个阻塞被中断(InterruptedException),你需要小心处理。
semaphore.acquire(); try { while (!condition) { lock.wait(); // 或者 otherBlockingMethod.take() // 如果在此阻塞时线程被中断,会抛出InterruptedException } // 处理业务 } catch (InterruptedException e) { // 如果在这里直接返回或抛出异常,而没有释放许可,就会泄漏! Thread.currentThread().interrupt(); // 恢复中断状态 throw new RuntimeException(e); // 或者做其他处理,但务必... } finally { semaphore.release(); // GOOD! finally块保证了释放。 }关键点:将资源获取(acquire)放在try块外面,确保只有成功获取资源后,才进入需要清理的try块。上面的写法是正确的。如果把acquire()放在try里面,一旦acquire()被中断抛出异常,会直接跳到catch或finally,此时并没有成功获取许可,却执行了release(),会导致许可数量异常增加。
4.3 坑三:误用Semaphore实现互斥锁(Mutex)
Semaphore的许可数为1时(new Semaphore(1)),确实可以当作一个互斥锁来用,因为它只允许一个线程进入。但这通常不是一个好主意。
- 语义不清:
Semaphore的API是为“许可”设计的,acquire/release,而锁的API是lock/unlock。用Semaphore做锁,代码可读性差。 - 不可重入:
Semaphore不是可重入的。如果一个线程已经持有了唯一的许可,它再次调用acquire()会被阻塞,导致死锁。而ReentrantLock允许同一个线程多次获取锁。
// 不推荐:用Semaphore当锁 Semaphore mutex = new Semaphore(1); mutex.acquire(); try { // 临界区 someMethod(); } finally { mutex.release(); } void someMethod() { mutex.acquire(); // 如果是同一个线程,这里会死锁! try { // ... } finally { mutex.release(); } } // 推荐:直接用ReentrantLock ReentrantLock lock = new ReentrantLock(); lock.lock(); try { // 临界区 someMethod(); } finally { lock.unlock(); } void someMethod() { lock.lock(); // 可重入,同一个线程没问题 try { // ... } finally { lock.unlock(); } }结论:需要互斥时,直接用ReentrantLock或synchronized;需要控制并发数量时,再用Semaphore。
4.4 坑四:动态调整许可数带来的混乱
如前所述,release()可以增加总许可数,Semaphore也提供了reducePermits(int reduction)(减少许可)和drainPermits()(耗尽并返回所有许可)等方法。动态调整是一个强大但危险的功能。
想象一下,你根据数据库连接池大小(10)初始化了一个Semaphore。某个运维脚本动态地将连接池缩容到了5,但Semaphore的许可数还是10。这会导致超过实际连接数的线程试图获取连接,从而出错。反之,扩容时亦然。
如果确实需要动态调整,必须建立严格的对应关系,最好将Semaphore封装在一个管理类中,对外提供resize(int newCapacity)这样的原子性方法,同步更新资源池和Semaphore的许可数。
4.5 坑五:忽略系统资源限制
Semaphore控制的是逻辑上的并发数,但它不关心线程本身的资源消耗。如果你用一个许可数为1000的Semaphore来控制某个任务,同时启动1000个线程去争抢,虽然只有1000个能同时执行任务,但1000个线程本身已经创建出来了。在Java中,大量线程会消耗大量内存(每个线程有独立的栈)和CPU调度资源。这可能导致系统在达到Semaphore限制之前,就因为线程数过多而性能下降或崩溃。
解决方案是将Semaphore与线程池(ExecutorService)结合使用。用固定大小的线程池控制物理线程的数量,在线程池的任务内部,再用Semaphore来控制对特定资源的并发访问。这样就从两个维度(计算资源和业务资源)进行了管控。
// 更好的实践:线程池 + Semaphore ExecutorService executor = Executors.newFixedThreadPool(20); // 最多20个物理线程 Semaphore resourceSemaphore = new Semaphore(5); // 但只允许5个同时访问稀缺资源 for (int i = 0; i < 1000; i++) { executor.submit(() -> { resourceSemaphore.acquire(); try { accessExpensiveResource(); } finally { resourceSemaphore.release(); } }); }5. 进阶模式:当Semaphore遇到其他JUC组件
在复杂的系统中,Semaphore很少单独作战。它经常与其他Java并发工具类组合,形成更强大的模式。
5.1 Semaphore + CountDownLatch:实现并行任务限流与总汇合
假设你有大量独立任务要并行处理(比如调用100个外部API),但对方服务有严格的QPS限制(比如每秒10次)。同时,你需要等待所有任务完成后进行汇总。
public class ParallelThrottledTasks { private static final int TOTAL_TASKS = 100; private static final int MAX_QPS = 10; // 用于限制每秒并发数(这里简化,实际限流更复杂,可能用令牌桶) private static final Semaphore rateLimiter = new Semaphore(MAX_QPS); // 用于等待所有任务完成 private static final CountDownLatch allDone = new CountDownLatch(TOTAL_TASKS); static class ApiCallTask implements Runnable { private final int taskId; ApiCallTask(int id) { this.taskId = id; } @Override public void run() { try { rateLimiter.acquire(); // 获取限流许可 try { System.out.println("Task-" + taskId + " 开始调用API..."); Thread.sleep(100); // 模拟API调用 System.out.println("Task-" + taskId + " API调用成功"); } finally { rateLimiter.release(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { allDone.countDown(); // 无论成功失败,都标记任务结束 } } } public static void main(String[] args) throws InterruptedException { ExecutorService executor = Executors.newFixedThreadPool(20); for (int i = 0; i < TOTAL_TASKS; i++) { executor.submit(new ApiCallTask(i)); } executor.shutdown(); // 停止接收新任务 allDone.await(); // 主线程等待所有任务完成 System.out.println("所有" + TOTAL_TASKS + "个任务已完成!"); } }这里,Semaphore负责平滑并发(尽管是简单的并发数限制,而非精确的QPS),CountDownLatch负责最终的同步点。ExecutorService管理着线程的生命周期。
5.2 实现一个简单的“对象池”(Object Pool)
虽然已经有成熟的池化库(如Apache Commons Pool),但用Semaphore实现一个简易版有助于理解池化原理。
public class SimpleObjectPool<T> { private final int size; private final List<T> pool; private final Semaphore available; private final Supplier<T> creator; private final Consumer<T> resetter; public SimpleObjectPool(int size, Supplier<T> creator, Consumer<T> resetter) { this.size = size; this.creator = creator; this.resetter = resetter; this.pool = new ArrayList<>(size); for (int i = 0; i < size; i++) { pool.add(creator.get()); } this.available = new Semaphore(size, true); // 使用公平锁,保证先来的线程先拿到对象 } public T borrowObject() throws InterruptedException { available.acquire(); synchronized (pool) { return pool.remove(pool.size() - 1); } } public void returnObject(T obj) { resetter.accept(obj); // 归还前重置对象状态 synchronized (pool) { pool.add(obj); } available.release(); } }这个池子利用Semaphore来跟踪池中可用对象的数量,acquire对应借用,release对应归还。synchronized块保护了实际的List操作。注意,这里使用了公平模式的Semaphore,让等待时间最长的线程优先获得对象,这对于池化资源来说通常是更合理的行为。
6. 性能考量与监控:你的Semaphore健康吗?
在生产环境中使用Semaphore,不能只关注功能,还要关注它的运行状态。
6.1 关键指标监控
Semaphore类本身提供了一些有用的方法:
availablePermits(): 当前可立即获取的许可数。如果这个值长期为0,说明你的资源一直处于满负荷状态,可能需要扩容,或者业务压力过大。getQueueLength(): 返回正在等待获取许可的线程数。这是一个瞬时的估计值。如果这个队列长度持续增长,说明资源严重不足,等待时间变长,是系统瓶颈的明显信号。hasQueuedThreads(): 简单判断是否有线程在等待。
你可以定期(比如通过JMX或应用日志)采集这些指标,绘制成图表。例如,绘制“可用许可数”和“等待队列长度”随时间变化的曲线,能直观反映资源的使用压力和饱和情况。
6.2 性能开销
Semaphore的实现(无论是公平还是非公平)底层都依赖于AbstractQueuedSynchronizer (AQS),这是一个非常高效且经过千锤百炼的框架。在非竞争或低竞争情况下,它的开销极小。在高竞争情况下,非公平模式的性能通常优于公平模式,原因如前所述,它减少了线程的上下文切换和排队管理开销。
如果你的场景是超高并发(每秒数十万次以上的acquire/release),并且临界区代码执行时间极短(纳秒或微秒级),那么任何同步原语(包括Semaphore)都可能成为瓶颈。这时需要考虑无锁(Lock-Free)或更细粒度的并发数据结构。但对于绝大多数应用层业务控制(如连接池、限流器)来说,Semaphore的性能是完全足够的。
6.3 一个诊断示例:模拟资源竞争监控
我们可以写个小程序来模拟并观察Semaphore在压力下的状态。
public class SemaphoreMonitorDemo { private static final Semaphore sem = new Semaphore(5); private static volatile boolean running = true; public static void main(String[] args) throws InterruptedException { // 启动工作线程 for (int i = 0; i < 20; i++) { new Thread(() -> { while (running) { try { sem.acquire(); try { // 模拟资源使用时间 Thread.sleep(100 + (long)(Math.random() * 100)); } finally { sem.release(); } // 模拟思考时间 Thread.sleep((long)(Math.random() * 50)); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } } }, "Worker-" + i).start(); } // 启动监控线程 Thread monitor = new Thread(() -> { while (running) { System.out.printf("[监控] 可用许可: %d, 等待队列长度: %d, 有等待线程: %b%n", sem.availablePermits(), sem.getQueueLength(), sem.hasQueuedThreads()); try { Thread.sleep(1000); // 每秒采样一次 } catch (InterruptedException e) { break; } } }, "Monitor"); monitor.start(); Thread.sleep(10000); // 运行10秒 running = false; monitor.join(); } }运行这个程序,你会看到监控输出。在系统稳定后,可用许可可能经常为0,而等待队列长度会在一个范围内波动。如果等待队列长度持续接近或等于工作线程数(20),说明5个许可完全不够用,需要调整。这就是一个最简单的健康度检查。
说到底,Semaphore是一个朴素而强大的并发控制原语。它不像CompletableFuture那样能编排复杂的异步流程,也不像Disruptor那样追求极致的性能。它的定位非常清晰:守护一个计数,控制访问的闸门。在分布式系统中,你可能更需要Redis或Sentinel来做全局限流;但在单个JVM进程内,当你需要确保有限的物理或逻辑资源不被超额使用时,Semaphore往往是那个最直接、最可靠的选择。理解它的“许可”本质,牢记“获取与释放必须配对”的铁律,再结合具体的业务场景,你就能用它搭建出稳健的并发控制逻辑。