Semaphore 与 RateLimiter:并发控制双雄详解

Semaphore 与 RateLimiter:并发控制双雄详解

面向生产实践的并发控制知识总结。核心结论:Semaphore 管"同时有多少个"(并发数),RateLimiter 管"每秒有多少次"(速率)。两者不是竞争关系,而是互补关系。


一、Semaphore 与 RateLimiter 的区别

1.1 一句话理解

  • Semaphore(信号量):停车场模型。10 个车位,第 11 辆车在门口等,直到有车出来。车在里面停多久都不影响"10 个并发"这个约束。
  • RateLimiter(限流器,Guava):地铁闸机模型。每秒只放 10 个人进去,不管里面现在有多少人。人进去之后干什么它不管。

1.2 核心对比

维度 java.util.concurrent.Semaphore com.google.common.util.concurrent.RateLimiter
控制对象 并发数(同一时刻允许多少个线程同时执行) 速率(单位时间内允许多少次操作)
计量单位 许可数量(permits),无量纲 QPS / permits per second
时间因素 与时间无关,任务执行多久都行,只要没超过并发数 强时间相关,令牌按速率持续发放
算法本质 计数器(基于 AQS 实现,acquire/release 增减) 令牌桶(Token Bucket):SmoothBursty / SmoothWarmingUp
许可归还 必须手动 release(),否则许可泄漏 不需要归还,令牌随时间自动恢复
阻塞行为 许可不足时阻塞(支持中断 / 超时 / 非阻塞尝试) 超过速率时阻塞等待(或 tryAcquire(timeout)
突发处理 有多少空闲许可就能瞬间放行多少 SmoothBursty 允许攒令牌突发,SmoothWarmingUp 预热缓升
作用范围 单 JVM(分布式需分布式信号量) 单 JVM(分布式需 Redis/Sentinel 等)

1.3 一个关键差异场景

假设每个任务执行 1 秒:

  • Semaphore(10):稳态吞吐"恰好"是 10 QPS,但这只是结果不是约束------任务一旦变快,吞吐立刻升高。
  • RateLimiter.create(10):不管任务快慢,强制不超过 10 QPS。
java 复制代码
// Semaphore:控制"同时有 10 个请求在飞"
Semaphore semaphore = new Semaphore(10);
semaphore.acquire();
try {
    callDownstream();      // 执行 1 秒还是 1 分钟都可以
} finally {
    semaphore.release();   // 必须归还
}

// RateLimiter:控制"每秒最多发出 10 个请求"
RateLimiter limiter = RateLimiter.create(10.0);
limiter.acquire();         // 可能阻塞等待令牌,无需归还
callDownstream();

1.4 使用场景选型

用 Semaphore(保护资源容量):

  • 限制对有限资源的并发访问:数据库连接、外部服务在途请求数、文件句柄
  • 约束耗时任务的并发度:批量任务"最多同时跑 N 个",防止打满下游
  • 轻量级资源池(无需真正持有资源实体)

用 RateLimiter(保护速率配额):

  • 对接有 QPS 限制的外部接口:第三方开放平台限流 100 QPS,本地提前削峰,避免被拒绝
  • 平滑突发流量:定时任务瞬间产生上万请求,匀速发出
  • 日志/采样输出:限制错误日志每秒最多打 N 条

组合使用(生产常见):

java 复制代码
RateLimiter limiter = RateLimiter.create(50);   // 对下游限速 50 QPS
Semaphore semaphore = new Semaphore(20);         // 本地最多 20 个在途请求

limiter.acquire();        // 先过速率关
semaphore.acquire();      // 再过并发关
try {
    return client.call(request);
} finally {
    semaphore.release();
}

二、Semaphore 的实现原理

2.1 整体结构

JDK 的 java.util.concurrent.Semaphore 完全构建在 AQS(AbstractQueuedSynchronizer) 之上:

java 复制代码
Semaphore
 └── Sync extends AbstractQueuedSynchronizer   // 核心:state 表示剩余许可数
      ├── NonfairSync                          // 非公平(默认)
      └── FairSync                             // 公平
  • state 字段 :AQS 中的 int state 被复用为"当前剩余许可数量"。
  • acquire() :尝试通过 CAS 扣减 state;失败则进入 CLH 等待队列(doAcquireSharedInterruptibly),以共享模式挂起(LockSupport.park)。
  • release() :CAS 增加 state,成功后唤醒队列头部的等待节点(unparkSuccessor)。
  • 共享模式:一个线程释放许可后,可以唤醒多个等待者(如果许可足够),这与独占锁不同。

2.2 核心流程(简化源码)

java 复制代码
// 获取许可(共享模式)
protected int tryAcquireShared(int acquires) {
    for (;;) {
        int available = getState();
        int remaining = available - acquires;
        // remaining < 0 表示许可不足,返回负数 → 进入排队流程
        if (remaining < 0 || compareAndSetState(available, remaining))
            return remaining;
    }
}

// 释放许可
protected boolean tryReleaseShared(int releases) {
    for (;;) {
        int current = getState();
        int next = current + releases;
        if (compareAndSetState(current, next))
            return true;
    }
}

2.3 公平与非公平

模式 获取策略 特点
非公平(默认) 直接 CAS 抢占,失败才排队 吞吐高,但可能"饿死"排队久的线程
公平 先检查 hasQueuedPredecessors(),有人排队就不插队 严格 FIFO,吞吐略低
java 复制代码
new Semaphore(10);          // 非公平
new Semaphore(10, true);    // 公平

2.4 常用 API

API 说明
acquire() / acquire(n) 阻塞获取,可响应中断
acquireUninterruptibly() 阻塞获取,不响应中断
tryAcquire() 立即返回,拿不到不阻塞
tryAcquire(timeout, unit) 限时等待
release() / release(n) 归还许可(可以归还比获取更多的数量,许可可被"制造")
availablePermits() 当前剩余许可
getQueueLength() 排队等待的线程数(监控用)
drainPermits() 一次性拿走所有剩余许可

三、Semaphore 的作用与优势

3.1 作用

  1. 并发度控制:限制同时访问某资源的线程数(最核心用途)。
  2. 资源池化:作为轻量信号量版的"资源池",不必真正持有资源实体。
  3. 背压(Backpressure):在"生产快、消费慢"的链路中,让上游主动降速等待,而不是无限堆积任务。

3.2 优势

  • 轻量:仅一个 int 计数器 + 等待队列,无需创建/管理真实资源对象。
  • 灵活 :公平/非公平、可中断、可超时、批量获取释放(acquire(n))一应俱全。
  • 解耦:资源使用方与资源实体解耦------许可数甚至可以设置得与实际资源数不同(如适度超卖,或纯做并发闸门而无对应实体)。
  • 可观测availablePermits()getQueueLength() 天然支持运行状态监控。

3.3 与 synchronized / Lock 的定位差异

工具 语义
synchronized / ReentrantLock 互斥:最多 1 个线程进入
Semaphore 计数:最多 N 个线程进入
RateLimiter 速率:每秒最多 N 次

Semaphore 是互斥锁的泛化(permits = 1 时退化为互斥锁,但通常不用它做互斥)。


四、实际代码开发中的应用案例

案例 1:通用对象池(Semaphore + 无锁队列)

经典模式:Semaphore 负责"准入计数",队列负责"实体存取",两者配合构成一个线程安全的资源池。

java 复制代码
public class ObjectPool<T> {
    private final Semaphore semaphore;
    private final ConcurrentLinkedQueue<T> pool = new ConcurrentLinkedQueue<>();

    public ObjectPool(List<T> resources) {
        pool.addAll(resources);
        // 许可数 = 资源数,保证"拿到许可必有资源"
        this.semaphore = new Semaphore(resources.size());
    }

    public T borrow(long timeout, TimeUnit unit) throws InterruptedException {
        if (!semaphore.tryAcquire(timeout, unit)) {
            throw new TimeoutException("借用资源超时");
        }
        return pool.poll();   // 拿到许可则必然非空
    }

    public void giveBack(T resource) {
        pool.offer(resource);
        semaphore.release();
    }
}

案例 2:批处理工具的并发闸门(生产实践)

背景:一个通用批处理工具把大列表切分成批次并发执行(如 10 万工号按 500/批拆成 200 个 IN 查询)。原始实现每次调用新建线程池,多请求并发时线程数与数据库连接占用按"请求数 × 线程池大小"放大,存在打爆连接池的风险。

改造方案:共享线程池收敛线程总数 + Semaphore 作为全局并发闸门,将在飞批次数钳制在连接池预算之内。

java 复制代码
public class BatchProcessUtil {

    /** 全 JVM 同时执行的最大批次数,须小于数据库连接池容量的一部分 */
    private static final int MAX_CONCURRENT_BATCHES = 8;

    /** 全局并发闸门:限制所有调用方合计的在飞批次数 */
    private static final Semaphore BATCH_CONCURRENCY_GATE =
            new Semaphore(MAX_CONCURRENT_BATCHES);

    /** JVM 级共享线程池,杜绝每次调用建池/销池 */
    private static final ExecutorService SHARED_EXECUTOR = new ThreadPoolExecutor(
            MAX_CONCURRENT_BATCHES, MAX_CONCURRENT_BATCHES * 2,
            60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(512),
            new ThreadPoolExecutor.CallerRunsPolicy());

    private static <T, R> List<R> runWithGate(List<T> batch,
                                              Function<List<T>, List<R>> processor) {
        try {
            BATCH_CONCURRENCY_GATE.acquire();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException("等待并发许可时被中断", e);
        }
        try {
            return processor.apply(batch);   // 一个批次 ≈ 占用一个数据库连接
        } finally {
            BATCH_CONCURRENCY_GATE.release(); // 必须归还
        }
    }

    public static <T, R> List<R> batchProcess(List<T> dataList, int batchSize,
                                              Function<List<T>, List<R>> processor) {
        List<List<T>> batches = Lists.partition(dataList, batchSize);
        List<CompletableFuture<List<R>>> futures = batches.stream()
                .map(batch -> CompletableFuture.supplyAsync(
                        () -> runWithGate(batch, processor), SHARED_EXECUTOR))
                .collect(Collectors.toList());
        return futures.stream()
                .map(CompletableFuture::join)
                .flatMap(List::stream)
                .collect(Collectors.toList());
    }
}

为什么线程池还不够、还要加 Semaphore?

  • 线程池只约束"本池的线程数",若存在多个池(或多处各自建池),总量失控;
  • Semaphore 是跨池、跨调用方的全局闸门,其许可数可以直接对齐下游资源容量(如连接池大小),语义更贴近"要保护的东西";
  • 线程可以比许可多:拿不到许可的线程只是阻塞等待,不占用数据库连接。

案例 3:爬虫 / 批量任务的并发度约束

任务提交端不关心速率,只关心"同时最多 5 个任务在跑":

java 复制代码
Semaphore concurrency = new Semaphore(5);
ExecutorService executor = Executors.newFixedThreadPool(5);

for (String url : urls) {
    executor.submit(() -> {
        try {
            concurrency.acquire();
            crawl(url);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            concurrency.release();
        }
    });
}

案例 4:页面聚合查询(并行调用多个下游)

商品详情页需要同时查库存、价格、物流,限制对下游服务总体的在途请求数:

java 复制代码
private static final Semaphore DOWNSTREAM_GATE = new Semaphore(32);

CompletableFuture<Stock> stock = supplyWithGate(() -> stockService.query(skuId));
CompletableFuture<Price> price = supplyWithGate(() -> priceService.query(skuId));
CompletableFuture<Logistics> logistics = supplyWithGate(() -> logisticsService.query(skuId));
CompletableFuture.allOf(stock, price, logistics).get(3, TimeUnit.SECONDS);

private static <T> CompletableFuture<T> supplyWithGate(Supplier<T> supplier) {
    return CompletableFuture.supplyAsync(() -> {
        DOWNSTREAM_GATE.acquireUninterruptibly();
        try {
            return supplier.get();
        } finally {
            DOWNSTREAM_GATE.release();
        }
    }, bizExecutor);
}

案例 5:用 Semaphore 模拟限流(了解边界)

配合定时任务"补许可",Semaphore 也可以实现粗粒度限速:

java 复制代码
Semaphore quota = new Semaphore(0);
scheduler.scheduleAtFixedRate(() -> {
    int missing = 100 - quota.availablePermits();
    if (missing > 0) quota.release(missing);   // 每秒补到 100
}, 0, 1, TimeUnit.SECONDS);

quota.acquire();   // 用完本秒配额则阻塞到下一秒
doRequest();       // 无需归还(配额由定时任务回收)

注意:这只是演示原理。真正的平滑限流请使用 Guava RateLimiter(令牌桶、平滑突发、预热支持),上述写法存在突发不均匀的问题。


五、最佳实践与常见陷阱

  1. release() 必须放在 finally:异常路径漏归还 = 许可永久泄漏,系统会越跑越慢,最终全部线程阻塞。
  2. 许可数要对齐下游资源容量 :若 Semaphore 保护的是数据库连接,许可数应 连接池容量(建议取其 1/3~1/2,给其他业务留余量)。许可数 > 连接数时,线程会拿到许可却在"等连接"处阻塞,闸门形同虚设。
  3. 正确响应中断acquire()InterruptedException 后,要么继续抛出,要么 Thread.currentThread().interrupt() 恢复中断标志,不要静默吞掉。
  4. 警惕嵌套获取导致死锁:持有许可 A 的过程中再去获取许可 B,若全局获取顺序不一致,可能死锁;尽量保持"获取---使用---归还"的短生命周期。
  5. 单机限制:Semaphore 与 Guava RateLimiter 都只在单 JVM 内生效。分布式场景需要 Redis + Lua、Sentinel 或分布式信号量方案。
  6. 监控建议 :把 availablePermits()getQueueLength() 暴露到监控面板,排队长度持续上涨是容量不足的先行指标。

六、总结

问题 选型
同时挤的人太多把资源压垮 Semaphore(管并发)
单位时间请求太频繁触发限流/配额 RateLimiter(管速率)
既要控速率又要控在途数 两者组合:先限流、再限并发

Semaphore 的本质是 AQS 共享模式上的一个可中断、可超时、可公平排队的计数器。它不创造资源,只回答一个问题:"现在还能进去几个?"------把这个问题的答案对准你最想保护的资源容量,就是它最好的用法。

相关推荐
掘金者阿豪1 小时前
异构数据同步最怕什么?不是同步慢,而是数据对不上
后端
程序员cxuan2 小时前
Claude 官方的学习教程,太强了。
人工智能·后端·程序员
老孙讲技术2 小时前
把工地遮挡和掉线接进项目部:setMessageCallback 订 alarm 与 deviceStatus
后端·物联网·音视频开发
老孙讲技术2 小时前
关店后有人进门,监控值班群却没响:用 setMessageCallback 订 human,再用 aiHuman 打开人形
后端·物联网·音视频开发
LEE2 小时前
原来 Claude Code 最厉害的工具,一行代码都不写
前端·后端
老孙讲技术2 小时前
把园区几百路摄像头收进值班台账:bindDevice 入账与 listDeviceDetailsByPage 翻页
后端·物联网·音视频开发
用户7813667114452 小时前
emplace_back vs push_back 详解
后端
老孙讲技术2 小时前
把没网没电的户外点位接进出画值班台:unBindDeviceInfo 核 SIMCard 与 bindDeviceLive
后端·物联网·音视频开发
万物智能2 小时前
硬件调试三板斧—【万物智能之开源鸿蒙OpenHarmony系统实战开发系列教程】
后端·架构