聊聊协程里的 Semaphore:别让协程挤爆门口

前言

最近在工作中遇到了一个非常普通的小需求,客户端需要从云端上拉取一堆图片链接,并且针对图片做升序处理后展示最终列表。听起来是不是很简单呢,不就是"下载 → 看大小 → 排排序"就搞定了。结果一跑,服务器差点被我按在地上摩擦。因为图片一多,协程一个个像打了鸡血一样,同时起飞!同时请求!同时冲向服务器! 这边的客户端是觉得很爽,很嗨了。但云服务器那边就不太乐观了:"兄der你冷静点,我不是 CDN,我只是个普通打工人 API 啊!"

是的,当成百上千万个协程一起拉图片的时候,网络连接开始爆满,服务端响应变慢,客户端排序逻辑还没跑起来,先被自己的并发压垮了。这时候恭喜你,体验到了无限制并发的恐怖后果

就是在这混乱的"协程暴走事件"中,笔者意识到:需要有人来控场,需要一个能让协程排队、有序、文明办事的存在。

于是KotlinSemaphore信号量闪亮登场了,相信各位小伙伴都不陌生了。它像一个经验丰富的交通指挥员,站在网络请求入口处,对协程们说:"别挤别挤,一个一个来,最多五个同时下载,懂?"。从那以后,图片请求不再挤爆服务器,排序流程顺顺利利。

OK, 相信小伙伴们已经知道今天我们要聊什么了,没错,今天要聊的主角,就是那个能帮大家维持秩序、防止过度热情 的小家伙:Semaphore(信号量)

一句话解释协程中的Semaphore

这时候就有同学要问了,啥是协程中的Semaphore ,它和Java当中的Semaphore是一个东西么?

我们打个比方,假如我们刚刚开了一家火锅店,但店里此时只有 5 个炉子。顾客排成长龙,每个顾客都想自己涮菜。这时候怎么办?

于是你定了以下的规则:

  • 同时最多5个顾客拿到烤炉(许可证)
  • 当有顾客吃完了(release ),下一个顾客再接着上(acquire)

这就是信号量的职责所在,Kotlin的Semaphore 也干着类似的事情,它控制着同时能有多少个协程进入某段代码 。一旦超过了?那后面就乖乖排队、挂起等待

Java中同样也叫这个,但很可惜,它们并不算是同一个东西。我们可以简单理解为:一个是 "协程世界的交警" ,一个是 "线程世界的交警" ,都不在一个地方,工作方式也有所不同。具体这里就不过多延展,感兴趣的小伙伴可以仔细研究研究,下面笔者用一个表格简单概括下它们之前的区别

Java Semaphore Kotlin Coroutine Semaphore
所属包 java.util.concurrent kotlinx.coroutines.sync
控制对象 线程 协程
阻塞方式 阻塞线程(Blocking 挂起协程(Suspending
性能特征 线程上下文切换开销大 非阻塞、轻量、可扩展
使用语法 acquire() / release() withPermit { ... } (挂起安全的)
出错后释放 手动 try...finally 自动(withPermit
使用场景 多线程同步、并发限流 协程并发控制、异步限流
公平性 可选公平模式 默认不公平(可自定义逻辑)

如何使用Semaphore

Kotlin的Semaphore核心用户其实就是两个字:进出

  • acquire:申请许可证(进门)
  • release: 释放许可证(出门)

由于Kotlin中懒得使用try..finally, 所以官方非常贴心的封装了个函数,自动帮你 acquire + release,就像门禁刷脸自动出入,不必担心忘记带卡而被关在了外面。

arduino 复制代码
semaphore.withPermit {
    // 受保护的代码区
}

聊聊一些使用Semaphore的场景

场景1:限流请求,不被封号

假设我们要抓取几十个网页,但网站规定每次最多 5 个并发。直接上Sempahore

kotlin 复制代码
suspend fun fetchAll(urls: List) = coroutineScope {
    val semaphore = Semaphore(5)
    urls.map { url ->
        async {
            semaphore.withPermit {
                println("开始抓取 $url")
                delay(500) // 模拟网络请求
                "内容来自 $url"
            }
        }
    }.awaitAll()
}

Semaphore在这里就是我们的"爬虫节流阀",优雅地让你在不被 ban 掉的边缘反复试探。兄弟,别试探了

场景2:CPU 密集任务的节流阀

如果我们有一些非常重且耗时的计算任务Task,

scss 复制代码
(1..10000000).map {
    async { heavyCompute(it) }
}.awaitAll()
​

CPU迟早被我们榨干了,改用Samphore试试吧

scss 复制代码
val semaphore = Semaphore(Runtime.getRuntime().availableProcessors())
(1..10000000).map {
    async {
        semaphore.withPermit {
            heavyCompute(it)
        }
    }
}.awaitAll()

CPU终于能喘口气,不再进行成千上百万个计算内卷中。

注意事项:一些容易翻车的点

1. 忘记 release() → 协程永远卡在门口

如果我们手动使用 acquire() ,却在中途代码块中报错了、return、或者忘记在 finally 中调用 release() ,那许可证就"丢了"。剩余permits 会越来越少,最后所有协程都会停在 acquire() 这一步,再也不继续执行。

就像饭店一共 3 个炉子,有个客人用完忘了还炉子,还把炉子带走了,全店从此只能开 2 个炉子,之后客人纷纷效仿,以至于最后厨房彻底瘫痪。

scss 复制代码
semaphore.acquire()
doSomething()  // 这里如果抛异常了...
semaphore.release() // 根本进不到这行

解决办法:一般情况下,用 withPermit {} 永远不会忘记释放!

2. 重入信号量 → 协程"自锁成仙"

这是啥意思呢?我们要知道Semaphore 不是可重入的 。同一个协程内部如果还没有释放前一个许可证,又再次 acquire() ,就会陷入死锁。因为自己拿着许可证,却又在等自己释放它。

scss 复制代码
semaphore.withPermit {
    println("外层开始")
    semaphore.withPermit {  // 冲突点就在这里
        println("内层:永远进不来")
    }
}

这段代码永远执行不到内层,因为外层拿着许可证不放。

解决方法:

  • 不要在受控区域里再次调用受控区域。
  • 如果确实需要嵌套结构,改用别的机制(如Mutex)。

3. 不公平调度 → 你以为是排队,其实是"插队现场"

协程的Semaphore不保证公平性的。它不会确保"先等待的协程先获得许可证"。只要许可证释放时,有协程竞争到,它就上,谁先谁后,调度器说了算。

这意味着:

  • 某些协程可能一直抢不到许可证。
  • 这在大量协程等待 + 高竞争的情况下更明显。

就好比在网红店门口排队买奶茶,店员一开门让客人进来,结果永远是站得近的那几个人挤进去了。排在远处的几个倒霉蛋可能等了 10 分钟还是没进去。

scss 复制代码
val semaphore = Semaphore(1)
​
repeat(100) { i ->
    launch {
        if (i == 0) {
            // 0号协程:可怜娃,可能永远等不到
            semaphore.withPermit {
                println("0号终于进来了")
            }
        } else {
            semaphore.withPermit {
                // 其他协程执行得更快,可能一直占着节奏
            }
        }
    }
}

因为调度器会更"偏爱"执行速度快、生命周期短的协程,0 号协程可能一直抢不到时机。

好了,那么我们如何避免不公平引起的问题?

如果我们业务确实需要"先来先服务(FIFO)",可以直接用手动队列:

kotlin 复制代码
val mutex = Mutex()
val queue = ArrayDeque>()
​
suspend fun fairAcquire() {
    val waiter = CompletableDeferred()
    mutex.lock()
    val first = queue.isEmpty()
    queue.add(waiter)
    mutex.unlock()
    if (!first) {
        waiter.await() // 等前面的协程释放
    }
}
​
suspend fun fairRelease() {
    mutex.lock()
    queue.removeFirstOrNull()?.complete(Unit)
    mutex.unlock()
}

此时我们再在 withPermit 外层套这套"公平队列",就能实现真正的公平信号量 。但大多数业务其实不需要这么严格,只要并发数量不夸张,默认的Semaphore是完全够用的。

尾声

如果把协程世界比作一座城市:launch 是汽车 ,delay 是红灯,那 Semaphore 就是交通指挥员 。没有它,协程就会像一群没刹车的司机,挤成一锅粥。有了它,大家文明出行,井然有序。

那么什么时候该用Semaphore

  • 当你想限制"同时干活的人数",比如说一次只允许 5 个协程下载图片

  • 当你要保护一个"有限资源",比如说数据库连接池只有 5 个连接,必须排队使用。

  • 当你担心 API 被你打到 429,比如说云端接口规定 每秒最多 10 个请求,那必须乖乖遵守。

  • 当你想让 CPU 活得久一点,比如说图片处理、AI 推理、压缩等重操作,不适合让几十个协程一起上。

简单来说,当你需要"允许最多 N 个协程同时执行某段代码"的时候,可以考虑考虑Semaphore

好了,就写到这里吧。

祝各位早安午安晚安,祉猷并茂,顺遂无虞。

相关推荐
xingpanvip1 分钟前
星盘接口开发文档:星相日历接口指南
android·开发语言·前端·css·php·lua
儿歌八万首3 小时前
Jetpack Compose 实战:实现一个动态平滑折线图
android·折线图·compose
李艺为7 小时前
Fake Device Test作假屏幕分辨率分析
android·java
zh_xuan7 小时前
github远程library仓库升级
android·github
峥嵘life7 小时前
Android蓝牙停用绝对音量原理
android
小书房8 小时前
Kotlin的内联函数
java·开发语言·kotlin·inline·内联函数
czlczl200209258 小时前
IN和BETWEEN在索引效能的区别
android·adb
Volunteer Technology8 小时前
ES高级搜索功能
android·大数据·elasticsearch
北京自在科技9 小时前
Find Hub App 小更新
android·ios·安卓·findmy·airtag
lbb 小魔仙9 小时前
2026远程办公软件夏季深度横测:ToDesk、向日葵、网易UU远程全面对比,远控白皮书
android·服务器·网络协议·tcp/ip·postgresql