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 { }构建器里的代码是顺序执行的。如果你需要并发处理,可以用flatMapMergebuffer操作符。我有个项目需要从多个数据源合并数据,用flatMapMerge把并发度设成10,性能提升很明显。

小技巧: 在Ktor里用Flow做SSE推送时,记得给每个连接单独创建一个Flow实例。如果多个客户端共享同一个Flow,会出现数据竞争。我习惯在路由处理里用flow { }构建器现场创建。

并发控制:别让协程乱跑

协程虽然轻量,但也不是无限制的。如果不加控制,一个恶意请求就能把你的服务器拖垮。我见过最夸张的情况:一个接口里用async启动了1000个协程去查数据库,结果连接池直接爆了。

Ktor提供了几种并发控制手段:

  • 限流:install(RateLimit)限制每个IP的请求频率
  • 协程超时:withTimeoutwithTimeoutOrNull防止协程卡死
  • 信号量: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协程模型的核心关系。你可以看到,从请求进入到最后响应,整个流程都是协程驱动的。

Ktor协程模型核心结构 HTTP请求进入 路由分发(协程上下文) 挂起函数(非阻塞等待) Flow数据流 并发控制(信号量/超时) IO操作(数据库/网络) 返回HTTP响应

从图里你可以看到,挂起函数是整个流程的核心。它把请求分发、数据流处理、并发控制和IO操作串联起来。每个环节都是非阻塞的,所以Ktor能用很少的线程处理大量请求。

最后说一句:协程不是银弹。如果你的业务逻辑里有大量CPU密集计算,该用线程池还是得用线程池。但如果是IO密集型场景——说白了就是大部分后端API的场景——协程绝对是更优解。

公众号:蓝海资料掘金营,微信deep3321