Skip to content

信号量有 2 个许可,两个线程却拿到了同一个连接 ​

用 Semaphore(2) 加一个列表做了个简单的对象池,压测时两个线程的数据串了:它们各自拿到了一个许可,手里却是同一个对象。另一个服务跑了几天之后,「最多 20 个并发」的限制变成了 23 个,没有人改过配置。

信号量只维护一个数字:还剩几个许可。「限制同时进行的数量」它做得很好;一旦要「借出一个具体的东西」(连接、缓冲区、客户端实例、许可证席位),就多出四个它不管的问题:借出去的是哪一个、现在归谁、用完还了没有、还回来的还能不能用。

本文在 JDK 25.0.4 上把这些问题逐个排出来,再给出一个带租约的最小实现。场景都是单线程顺序执行,结果是确定的。实验见文末。

一、许可不等于资源 ​

同一个对象放了两次。 2 个许可,池里的两个位置指向同一个对象(初始化时写成了「创建一个、添加 N 次」,或者从配置里读到了重复项)。两个许可都借出去,两个借用者拿到的是同一个对象。信号量没有任何异常,它数的是许可,不检查后面的东西。

没借到也归还。

java
try {
    acquired = permits.tryAcquire(10, MILLISECONDS);
    …
} finally {
    permits.release();          // 没拿到也执行
}

实测上限 2 的信号量,一次超时的 tryAcquire 之后照样 release,等所有人都归还,可用许可是 3。Semaphore 不记录谁持有许可,release() 只是把数字加一,文档也说明了释放许可的线程不要求曾经获取过它。每发生一次这样的超时,并发上限就永久加一,开头那个 20 变 23 就是这么来的。

借了不还。 借出之后抛异常、没有 finally:实测两次之后可用许可为 0,再借 tryAcquire(50ms) 返回 false。池子没有「坏」,只是再也借不出东西,表现为所有请求都在等。

对信号量本身,写法是固定的:只有 acquire 成功之后才进入 try,在 finally 里释放。

java
if (!permits.tryAcquire(timeout, unit)) throw new RejectedException();
try {
    …
} finally {
    permits.release();
}

二、归还时的三个问题 ​

把信号量换成装着不同对象的阻塞队列(队列空了就等,本身起到限流作用),「同一个对象」的问题没有了,归还又有三个:

操作结果
同一个资源归还两次(close() 被调了两次)池里出现两份 id 1;之后有两个借用者同时持有它
归还之后继续使用手里的引用原借用者和新借用者指向同一个对象,两方都能调用
把使用中损坏的资源原样归还下一个借用者拿到一个坏的

前两个是同一类问题:池认为资源空闲了,却还有人拿着它。重复关闭在实际代码里很常见,try-with-resources 关一次、异常处理里又手动关一次。第三个会让一次网络抖动的影响扩散开:坏连接回到池里,被一个又一个请求借走、失败、再放回去。

三、租约:把归属做成一个对象 ​

Semaphore只有一个数字:剩余许可同一个对象放两次:两个许可拿到同一个资源没借到也 release:许可从 2 变成 3异常后没归还:许可只减不增借用者try (lease = pool.borrow())租约 Lease持有一个资源,closed 标志空闲队列各不相同的资源工厂创建新资源借出归还close() 第二次compareAndSet 失败,不做事关闭后 conn()抛 IllegalStateExceptionclose() 时资源已损坏丢弃,向工厂要一个新的放回队列借用者把资源对象另存一份引用仍能绕过租约:真实的连接池借出的是代理对象,关闭后代理拒绝一切调用
图 1 · 信号量回答「现在还能进几个」,它不知道借出去的是哪个对象、谁在用、用完有没有还、还回来的是不是好的。把这四件事放进一个只能关闭一次的租约对象:借用时生成,关闭时把资源还回空闲队列或替换掉,关闭之后再用就报错

借用时不直接给资源,而是给一个租约对象。它持有资源,实现 AutoCloseable,只能关闭一次:

java
final class Lease implements AutoCloseable {
    private final Conn conn;
    private final AtomicBoolean closed = new AtomicBoolean();

    Conn conn() {
        if (closed.get()) throw new IllegalStateException("lease closed");
        return conn;
    }

    @Override public void close() {
        if (!closed.compareAndSet(false, true)) return;     // 第二次关闭什么都不做
        if (conn.broken) idle.add(factory.get());            // 损坏:丢弃,补一个新的
        else idle.add(conn);
    }
}

实测:

场景结果
池大小 2,借出两个id 1 与 id 2,各不相同;第三次借用在超时后抛 pool exhausted
同一个租约 close 两次空闲资源 1 个,没有多出一份
关闭后通过租约取资源抛 IllegalStateException(lease closed)
try-with-resources 里业务抛异常资源照常归还,空闲 2 个
资源在使用中损坏后归还被替换;之后池里是 id 1 与 id 3,id 2 不再出现

几点说明:

  • 资源由工厂逐个创建,保证各不相同。池的大小就是资源的个数,不需要另一个信号量。
  • 借用带超时。无限期等待会把池耗尽变成线程全部挂住,超时等待与取消见超时了、取消了,任务其实还在跑。
  • 损坏与否要有判断方式:使用中抛出的特定异常做标记,或者归还、借出时做一次轻量检查。
  • 这个最小实现挡不住借用者把 conn() 的返回值另存一份。成熟的连接池借出的是代理对象,所有调用都经过代理,归还后代理拒绝一切调用。java.sql.Connection 的文档也规定,对已关闭的连接再调用 close 是空操作。

四、什么时候不用自己写 ​

数据库连接、HTTP 连接、Redis 连接都有成熟的池,上面这些问题它们都处理过,还处理了本文没有涉及的空闲检测、最大寿命、泄漏检测。要做的是把它们的配置和指标用起来:借用超时、最大寿命、等待借用的线程数。连接池大小与线程数的关系见线程池参数怎么定。

需要自己管理的通常是没有现成池的东西:按许可证限制数量的第三方客户端、预分配的大缓冲区、需要昂贵初始化的计算上下文。这时用「阻塞队列加租约」,不要用「信号量加列表」。

只需要限制并发数、不需要借出具体对象时,信号量是合适的,注意第一节的写法。跨进程的并发限制是另一个问题,见基于 Redis 的分布式信号量,那里的租约还要处理持有者崩溃后的过期。

五、检查清单 ​

  1. 池里的资源各不相同,由工厂创建。
  2. 获取成功之后才进入 try,在 finally 或 try-with-resources 里归还。
  3. 归还是幂等的;归还之后的使用会报错,而不是悄悄成功。
  4. 损坏的资源不放回。
  5. 借用有超时;暴露空闲数、等待数和借用耗时。
  6. 定期核对:空闲数加借出数是否等于池大小。对不上说明有泄漏或重复归还。

配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权