12、异步编程与协程:Ktor协程模型、挂起函数、Flow集成、并发控制
说到Ktor的异步编程,我得先坦白一件事。几年前我刚从Spring Boot转向Ktor时,第一反应是:「这框架怎么连个像样的线程池都不给我配?」后来我才明白,Ktor压根就不走传统阻塞式IO那条路。它从根上就是协程驱动的。
你想想看,一个Android后端API,要同时处理几百个请求。如果用传统线程模型,每个请求占一个线程,光上下文切换就能把CPU吃满。Ktor的做法是:用少量线程,跑大量协程。说白了,就是让线程别闲着。
Ktor的协程模型:挂起才是王道
Ktor的ApplicationEngine默认使用kotlinx.coroutines作为底层调度器。每个请求进来,Ktor会启动一个协程来处理。这个协程跑在默认的Dispatchers.Default上,但你可以随时切换。
我个人习惯在路由处理里直接写挂起函数,而不是用launch或async包一层。为什么呢?因为挂起函数天然就是非阻塞的,而且异常传播更清晰。
fun Application.module() {
install(CallLogging)
routing {
get("/user/{id}") {
val id = call.parameters["id"] ?: return@get
// 直接调用挂起函数
val user = userRepository.findById(id)
call.respond(user)
}
}
}
// 挂起函数,不阻塞线程
suspend fun UserRepository.findById(id: String): User? {
return withContext(Dispatchers.IO) {
// 模拟数据库查询
delay(100) // 非阻塞等待
User(id, "Kotlin")
}
}
这里有个细节要注意:delay是挂起函数,它不会阻塞当前线程。如果是Thread.sleep,那整个线程就卡住了。我在项目中遇到过有人把Thread.sleep混在协程里用,结果压测时吞吐量直接掉了一半。嗯,这是个坑。
挂起函数:不只是加个suspend关键字
挂起函数的核心价值在于:它可以在不阻塞线程的前提下,暂停执行,等结果准备好了再回来继续跑。这有点像JavaScript里的async/await,但Kotlin的协程更灵活。
我建议你把挂起函数当作「可暂停的计算单元」来理解。它可以在任意点暂停,而线程可以去干别的事。等暂停结束,线程(可能是另一个线程)回来接着跑。
挂起函数的本质: 编译器会把挂起函数改造成一个状态机。每次遇到挂起点,就保存当前状态,然后返回。恢复时从下一个状态继续执行。所以挂起函数不是魔法,是编译器帮你做了状态机转换。
写挂起函数时,有几点要注意:
- 挂起函数只能在协程里或其他挂起函数里调用
- 不要用挂起函数做CPU密集计算,那会阻塞协程调度器
- 挂起函数里可以安全地调用普通函数,但普通函数不能调用挂起函数
我曾经在项目里犯过一个错:把一个复杂的JSON解析逻辑写成了挂起函数。结果压测时发现,虽然不阻塞线程,但CPU时间片全被解析占用了,其他协程根本抢不到调度。后来改成用withContext(Dispatchers.Default)把CPU密集任务切到默认调度器,问题就解决了。
Flow集成:响应式数据流
Ktor对Flow的支持非常自然。你可以把Flow当作异步数据流,从数据库、消息队列或外部API里源源不断地拿数据,然后通过SSE(Server-Sent Events)或WebSocket推给客户端。
我个人最喜欢的是Flow的背压处理。传统响应式编程里,背压是个让人头疼的问题。但Flow的设计很优雅:它是冷流,只有被收集时才执行。而且支持各种操作符,比如map、filter、catch等。
// 在Ktor路由里返回Flow
get("/events") {
call.respondTextWriter(ContentType.Text.EventStream) {
val flow = produceEvents() // 返回Flow
flow.collect { event ->
write("data: ${event.toJson()}\n\n")
flush()
}
}
}
// 模拟事件流
fun produceEvents(): Flow<String> = flow {
var count = 0
while (count < 10) {
delay(1000) // 每秒产生一个事件
emit("Event #${count++}")
}
}
这里要注意:flow { }构建器里的代码是顺序执行的。如果你需要并发处理,可以用flatMapMerge或buffer操作符。我有个项目需要从多个数据源合并数据,用flatMapMerge把并发度设成10,性能提升很明显。
小技巧: 在Ktor里用Flow做SSE推送时,记得给每个连接单独创建一个Flow实例。如果多个客户端共享同一个Flow,会出现数据竞争。我习惯在路由处理里用flow { }构建器现场创建。
并发控制:别让协程乱跑
协程虽然轻量,但也不是无限制的。如果不加控制,一个恶意请求就能把你的服务器拖垮。我见过最夸张的情况:一个接口里用async启动了1000个协程去查数据库,结果连接池直接爆了。
Ktor提供了几种并发控制手段:
- 限流: 用
install(RateLimit)限制每个IP的请求频率 - 协程超时: 用
withTimeout或withTimeoutOrNull防止协程卡死 - 信号量: 用
Semaphore控制同时访问资源的协程数量
// 使用信号量控制并发
val semaphore = Semaphore(5) // 最多5个协程同时访问
suspend fun limitedAccess() {
semaphore.withPermit {
// 这里最多只有5个协程能同时执行
delay(1000)
println("Access granted")
}
}
// 协程超时
suspend fun fetchWithTimeout(): String? {
return withTimeoutOrNull(3000) {
// 如果3秒内没返回,就返回null
delay(5000) // 模拟超时
"Result"
}
}
我曾经在项目里遇到过一个诡异的问题:某个接口偶尔会卡住几十秒,但代码看起来没问题。后来排查发现,是数据库连接池满了,所有协程都在等连接。加上withTimeout后,超时的协程会主动释放连接,问题就解决了。
注意: 不要滥用async启动大量协程。每个协程虽然只占几KB内存,但启动100万个协程还是会吃掉几个GB。我建议在循环里用map配合awaitAll,或者用coroutineScope限制并发范围。
知识体系图
下面这张图展示了Ktor协程模型的核心关系。你可以看到,从请求进入到最后响应,整个流程都是协程驱动的。
从图里你可以看到,挂起函数是整个流程的核心。它把请求分发、数据流处理、并发控制和IO操作串联起来。每个环节都是非阻塞的,所以Ktor能用很少的线程处理大量请求。
最后说一句:协程不是银弹。如果你的业务逻辑里有大量CPU密集计算,该用线程池还是得用线程池。但如果是IO密集型场景——说白了就是大部分后端API的场景——协程绝对是更优解。