承接的问题
4.1 讲过 Semaphore 的许可模型:许可放在 AQS 的 state 里,release 不校验持有者。这个「不校验」在限流场景是优点——下游只关心同时有几个调用,不关心是谁——但在资源池场景就是最大的风险:任何线程都能多释放一个许可,而少释放一个许可没有任何人报错。
这一节把信号量做成一个真正的池:借出有租约、归还可校验、泄漏能检测、容量可收缩。最后区分两个经常被混用的东西:信号量桶限制的是并发数,令牌桶限制的是速率。
池子是许可加一张租约表
裸 Semaphore 只能告诉你「还剩几个」,不能告诉你「谁还没还」。加一张 ConcurrentHashMap<Long, Lease> 就有了持有者信息:
record Lease(long id, String owner, long acquiredAt) {}
final class LeakyPool {
private final int capacity;
private final Semaphore permits;
private final Map<Long, Lease> outstanding = new ConcurrentHashMap<>();
private final AtomicLong sequence = new AtomicLong();
private final long start = System.nanoTime();
LeakyPool(int capacity) {
this.capacity = capacity;
this.permits = new Resizable(capacity);
}
Lease borrow(String owner, long timeoutMillis) throws InterruptedException {
if (!permits.tryAcquire(timeoutMillis, TimeUnit.MILLISECONDS)) {
return null; // 超时:没拿到许可,什么都没发生
}
Lease lease = new Lease(sequence.incrementAndGet(), owner, System.nanoTime());
outstanding.put(lease.id(), lease);
return lease;
}
void release(Lease lease) {
if (outstanding.remove(lease.id()) != null) {
permits.release();
} // 重复归还被 remove 挡住,不会多出许可
}
int available() {
return permits.availablePermits();
}
int inUse() {
return capacity - permits.availablePermits();
}
}
release 里的 remove 是关键:它让归还变成幂等操作。裸 Semaphore.release() 调用两次就是两个许可,而这里的第二次调用 remove 返回 null,许可数不变。代价是每次归还多一次哈希表操作,相对一次下游调用可以忽略。
反过来,borrow 里 tryAcquire 超时返回 null 是有意的:没拿到许可就不该产生租约。如果先 put 租约再等许可,超时后租约表里会多一条永远不释放的记录,检测器会把正常超时误报成泄漏。
inUse() 用 capacity - availablePermits() 推导,而不是 outstanding.size()。两者正常情况下相等,不相等就说明有人绕过了租约表直接 release——这本身就是一个值得告警的不变量。
泄漏检测:许可数和持有时间一起看
只看 availablePermits() 会误判:一个合法的长任务持有许可 10 秒,期间可用许可一直是 0。所以检测器要同时看两件事:池里还有多少许可,以及每个租约已经持有了多久。
void detect(long thresholdNanos) {
long now = System.nanoTime();
for (Lease lease : outstanding.values()) {
long held = now - lease.acquiredAt();
if (held > thresholdNanos) {
System.out.printf("[%6.1f ms] detector LEAK-SUSPECT lease=%d owner=%-8s held=%6.1f ms available=%d%n",
(now - start) / 1e6, lease.id(), lease.owner(), held / 1e6,
permits.availablePermits());
}
}
System.out.printf("[%6.1f ms] detector held=%d leases=%d available=%d%n",
(now - start) / 1e6, capacity - permits.availablePermits(),
outstanding.size(), permits.availablePermits());
}
检测器是一个普通的守护线程,每 100 ms 跑一次 detect(300ms)。本机真实输出(capacity=3,其中一个调用借了不还,中间还有一个等待者超时):
=== 资源池 capacity=3, 泄漏阈值 300 ms ===
[ 5.9 ms] detector held=0 leases=0 available=3
[ 222.4 ms] detector held=0 leases=0 available=3
[ 260.9 ms] after normal batch: available=3
[ 261.2 ms] leaky borrowed lease=4, available=2 (no release)
[ 261.3 ms] two good leases borrowed, available=0
[ 323.5 ms] detector held=3 leases=3 available=0
[ 363.1 ms] waiter result=TIMEOUT after 101.6 ms, available=0
[ 363.5 ms] good leases released, available=2, leak still held
[ 424.6 ms] detector held=1 leases=1 available=2
[ 530.4 ms] detector held=1 leases=1 available=2
[ 631.2 ms] detector LEAK-SUSPECT lease=4 owner=leaky held= 370.1 ms available=2
[ 631.2 ms] detector held=1 leases=1 available=2
[ 715.1 ms] admin released leaked lease=4, available=3
[ 715.8 ms] duplicate release ignored, available=3
这段输出说明了检测器的三条判据:
- 租约年龄超过阈值:
lease=4 held=370.1 ms,超过 300 ms 的阈值才报,前面 323.5 ms 那次心跳只报总量,因为当时三个租约都还没到期。 - 许可数长期低于容量:
available=2持续了 300 多毫秒,期间没有新的借出请求,这不是「忙」而是「丢了」。 held与leases相等:两个数一直相等,说明没有绕过租约表的release。如果held > leases.size(),说明有人直接调了裸信号量的release,那是另一种 bug。
输出里还能看到一个合理的误报边界:363.5 ms 时两个正常租约已归还,只剩泄漏的那一个,检测器把 held 从 3 降到 1,没有继续对「已经还了」的租约告警。检测器只扫 outstanding,归还即消失。
管理员最终显式归还了 lease=4,available 回到 3;随后重复归还没有把许可变成 4,这是 remove 幂等性的直接体现。
acquire 必须在 try 之外
信号量池最常见的事故不是泄漏,而是多释放,根源是把 acquire 写进了 try:
// 错误写法
try {
permits.acquire(); // 如果这里被中断,异常直接跳到 finally
useResource();
} finally {
permits.release(); // 于是凭空多出一个许可
}
acquire() 响应中断。线程在等待许可时被中断,会抛 InterruptedException,此时它没有拿到任何许可;但 finally 照样执行,release() 就把许可数加了 1。跑一天下来,限流会慢慢失效。
正确写法只有一种:
// 正确写法
permits.acquire(); // 放在 try 外:到这里一定已经持有许可
try {
useResource();
} finally {
permits.release(); // 只归还确实拿到的那一个
}
如果还需要「拿到许可之后才能登记租约」,登记动作放在 acquire 与 try 之间;这段代码不能抛异常,否则许可就漏了。用 tryAcquire(timeout) 的版本则是先判断返回值再进 try:
if (!permits.tryAcquire(100, TimeUnit.MILLISECONDS)) {
return Result.BUSY; // 没有许可,不进入 try,也就没有 finally
}
try {
return useResource();
} finally {
permits.release();
}
公平性影响的是排队延迟的分布
new Semaphore(n, true) 走公平队列,false 走非公平。4.1 已经讲过源码差异:公平版每次获取前多一次 hasQueuedPredecessors()。这里只看它对延迟的影响。本机实测(3 个许可、24 个任务、每个持有 10 ms,连跑三轮):
run1 fair=false total=103.6 ms avgWait=36.68 ms maxWait=66.11 ms
run1 fair=true total=114.2 ms avgWait=42.45 ms maxWait=91.54 ms
run2 fair=false total=105.9 ms avgWait=37.52 ms maxWait=81.34 ms
run2 fair=true total=106.2 ms avgWait=44.00 ms maxWait=86.34 ms
run3 fair=false total=110.7 ms avgWait=44.76 ms maxWait=89.62 ms
run3 fair=true total=111.7 ms avgWait=42.91 ms maxWait=92.79 ms
本机 load average 113,单次运行、随调度波动,这组数字不能作为公平性更慢或更快的结论。三轮里公平版的 avgWait 有两轮更高、maxWait 三轮都更高,方向与「公平版减少插队、但增加唤醒与切换」的预期一致,但幅度都在噪声范围内。
能确定的是语义差异,不是性能差异:
| 模式 | 保证 | 代价 |
|---|---|---|
| 非公平 | 无顺序保证,新来的线程可以和队首抢 | 可能出现长尾,某个等待者迟迟拿不到 |
| 公平 | 近似 FIFO,先排队的先拿 | 每次多一次队列检查,唤醒更频繁 |
资源池默认用非公平就够了:池里的资源是同质的,谁先拿到不影响正确性。只有业务真的要求「按提交顺序分配」(例如按订单先后分配稀缺的打印配额)时才开公平。
reducePermits 让池子能缩容
reducePermits 是 protected,只能给子类用。JDK 25 的实现仍然是一个 CAS 循环:
// JDK 25 源码摘录,已删减
final void reducePermits(int reductions) {
for (;;) {
int current = getState();
int next = current - reductions;
if (next > current) // underflow
throw new Error("Permit count underflow");
if (compareAndSetState(current, next))
return;
}
}
它不阻塞、不唤醒,只是把 state 减掉。如果许可已经全部借出,state 会变成负数——这是「欠账」语义:后续的 release 先把欠账补上,补完之后才会真正多出可用许可。本机输出(capacity 3,三个全部借出后缩到 2):
=== reducePermits 缩容:capacity 3 -> 2,3 个许可全部借出 ===
after acquire 3: available=0
after shrink 1: available=-1 (negative = 欠账)
after release 1: available=0, tryAcquire=false
after release 1: available=1, tryAcquire=true
第一次 release 后 available=0,tryAcquire 仍然失败——那个许可用来还账了。第二次 release 之后才真正空出 1 个位置。
缩容的典型触发是「资源真的少了」:连接池里一个连接被判定损坏、许可证到期、下游把并发上限从 3 调到 2。这时调 shrink(1) 比「等现有租约全部归还再重建池」平滑得多。反过来扩容不能直接 release——那会和业务归还混在一起,正确做法是子类里加一个同样受保护的 grow,或者干脆重建池。
令牌桶限速率,信号量桶限并发
两者经常被当成一回事,其实约束的维度不同:
| 维度 | 信号量桶 | 令牌桶 |
|---|---|---|
| 约束 | 同时进行的数量 | 单位时间通过的请求数 |
| 状态 | 剩余许可 | 剩余令牌 + 填充速率 |
| 突发 | 上限就是容量 | 容量决定可积攒的突发量 |
| 空闲后 | 不会「攒」出额外能力 | 令牌可攒到容量,允许一次突发 |
| 典型实现 | Semaphore | Semaphore + 定时补充,或时间轮 |
用信号量拼一个最简令牌桶,只需要一个定时补充线程:
final class TokenBucket {
private final Semaphore tokens;
private final int capacity;
TokenBucket(int capacity, int permitsPerSecond, ScheduledExecutorService timer) {
this.tokens = new Semaphore(capacity, false);
this.capacity = capacity;
long periodMillis = Math.max(1, 1000L / permitsPerSecond);
timer.scheduleAtFixedRate(() -> {
if (tokens.availablePermits() < capacity) {
tokens.release(); // 每个周期补一个令牌,攒不超过 capacity
}
}, periodMillis, periodMillis, TimeUnit.MILLISECONDS);
}
boolean tryAcquire() {
return tokens.tryAcquire();
}
}
这里有两个近似:1000 / permitsPerSecond 取整会带来速率误差;availablePermits() < capacity 与 release() 之间有竞态,极端情况下会多补一个令牌,被容量上限吸收。要精确速率需要记录上次填充时刻、按纳秒补令牌,那就该用专门的时间轮而不是 ScheduledExecutorService 的固定周期。
选择标准很简单:保护下游并发用信号量(连接池、线程池、下游并发上限);保护下游速率用令牌桶(第三方 API 的 QPS 配额)。两者可以叠加:先过令牌桶拿到「允许发起」,再过信号量拿到「允许同时进行」。
在 libraryd-conc 里落地
mail 下游的连接池是信号量池的典型用法:最多 4 条连接,借出时登记租约,归还时校验;后台每 5 秒扫一次超过 30 秒的租约;某条连接被判定损坏时缩容 1,并异步重建。
package com.plume.conc.core;
import java.util.Map;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;
public final class MailConnectionPool implements AutoCloseable {
private static final class Resizable extends Semaphore {
Resizable(int permits) {
super(permits, false);
}
void shrink(int delta) {
reducePermits(delta); // protected,只能由子类暴露
}
}
private final Resizable permits;
private final Map<Long, Lease> outstanding = new ConcurrentHashMap<>();
private final AtomicLong sequence = new AtomicLong();
private final ScheduledExecutorService housekeeping =
Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "mail-pool-housekeeping");
t.setDaemon(true);
return t;
});
private final long maxHoldNanos;
public MailConnectionPool(int capacity, long maxHoldMillis) {
this.permits = new Resizable(capacity);
this.maxHoldNanos = TimeUnit.MILLISECONDS.toNanos(maxHoldMillis);
housekeeping.scheduleAtFixedRate(this::sweep, 5, 5, TimeUnit.SECONDS);
}
public Lease acquire(String owner, long timeoutMillis) throws InterruptedException {
if (!permits.tryAcquire(timeoutMillis, TimeUnit.MILLISECONDS)) {
return null;
}
Lease lease = new Lease(sequence.incrementAndGet(), owner, System.nanoTime());
outstanding.put(lease.id(), lease);
return lease;
}
public void release(Lease lease) {
if (outstanding.remove(lease.id()) != null) {
permits.release();
}
}
public void shrink(int delta) {
permits.shrink(delta);
}
private void sweep() {
long now = System.nanoTime();
for (Lease lease : outstanding.values()) {
if (now - lease.acquiredAt() > maxHoldNanos) {
System.err.printf("mail pool lease leak: id=%d owner=%s held=%d ms available=%d%n",
lease.id(), lease.owner(),
(now - lease.acquiredAt()) / 1_000_000, permits.availablePermits());
}
}
}
@Override
public void close() {
housekeeping.shutdownNow();
}
public record Lease(long id, String owner, long acquiredAt) {}
}
两点落地细节。
shrink 需要 reducePermits,而它是 protected,所以池里持有一个 Semaphore 的私有子类 Resizable,只暴露一个 shrink 方法。把缩容能力收在子类里,外部调用方就无法随手改许可数。
告警不要只打日志。sweep 里发现超时租约时应该同时增加一个计数器,接到 conc-ops 的 8091 端口,用「超时租约数 > 0 持续 1 分钟」做告警条件。只打日志的检测器在容器里没人看,等于没有。
这个设计的代价
租约表是有状态的,池子不再是纯无锁结构。 每次借还都有一次 ConcurrentHashMap 操作,高并发下这张表本身会成为热点。许可数少于几百时无所谓;要管理上万个租约,就该分片或者只采样。
超时阈值是一个猜出来的数。 设短了误报,设长了发现得晚。合理做法是按下游 P99 的 3–5 倍设,并且把「接近阈值」的租约也打点,观察分布再调整。
reducePermits 之后没有回头路。 缩容容易,扩容要么重建池,要么自己实现 grow。而重建池意味着等待所有在借租约归还,这期间新请求全部失败。
什么时候不要用
不要用信号量池管理有身份的资源(例如「必须用 3 号连接」)。信号量不记录谁拿了哪个,租约表只记录线程和时刻;需要绑定具体实例时,用 BlockingQueue 把资源本身放进队列,取出来就是那一个。
不要用信号量做速率限制。100 个许可不会限制每秒 100 次,只会限制同时 100 个;空闲时突发可以瞬间打满,这正是令牌桶要解决的问题。
不要在持有许可期间做阻塞等待另一个池的操作。两个池互相等待会死锁,而且信号量没有超时传播——A 池的租约持有者等 B 池,B 池的持有者等 A 池,双方都不会释放。跨池调用要么固定顺序,要么给每一层都设超时。
最后,不要把泄漏检测器当成修复手段。它只能告诉你「有租约没还」,不能替你归还;真正减少泄漏的还是 try/finally 和「acquire 在 try 外」这两条编码纪律。
小结
资源池 = 信号量 + 租约表。release 用 remove 做成幂等,acquire 必须在 try 外,泄漏检测同时看许可数和租约年龄,reducePermits 让池子能在不阻塞的情况下缩容。公平性影响的是排队顺序与延迟分布,不是吞吐结论;令牌桶限速率、信号量桶限并发,两者维度不同,不能互相替代。
阅读导航:上一节 14.2 批量聚合与背压 | 下一节 15.1 双重检查锁定的正确写法
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。