聊聊协程里的 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

好了,就写到这里吧。

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

相关推荐
Dev7z4 小时前
在MySQL里创建数据库
android·数据库·mysql
invicinble5 小时前
mysql建立存数据的表(一)
android·数据库·mysql
似霰5 小时前
传统 Hal 开发笔记1----传统 HAL简介
android·hal
Zender Han6 小时前
Flutter Gradients 全面指南:原理、类型与实战使用
android·flutter·ios
火柴就是我6 小时前
Flutter Path.computeMetrics() 的使用注意点
android·flutter
モンキー・D・小菜鸡儿8 小时前
Android 系统TTS(文字转语音)解析
android·tts
2501_915909068 小时前
iOS 反编译防护工具全景解析 从底层符号到资源层的多维安全体系
android·安全·ios·小程序·uni-app·iphone·webview
Swizard8 小时前
速度与激情:Android Python + CameraX 零拷贝实时推理指南
android·python·ai·移动开发
summerkissyou19878 小时前
Android13-Audio-AudioTrack-播放流程
android·音视频