ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

协程与异步编程:从原理到实战,一文读懂Python、Kotlin、C++协程

协程与异步编程:从原理到实战,一文读懂Python、Kotlin、C++协程 “协程”这个词这几年几乎成了异步编程的默认答案。你搜Python一堆人让你用asyncio打开Kotlin文档开篇就是suspendC20一推出协程特性很多网络库连夜升级。但真要问一句“协程到底是什么”十个工程师能给出十种回答最常见的说法是“轻量级线程”。这个说法对不对对但很不完整甚至容易把人带偏。我在团队里带新人的时候只要发现对方用线程池的思路去理解协程后面一定会出各种诡异的并发问题。这篇文章不打算只讲某一个语言而是把协程这个概念本身拆开来看它到底解决了什么问题底层是怎么转起来的Python协程、Kotlin协程、C协程为什么长得不一样最后用Python跑一个能直接复制的最小案例再把我这些年踩过的坑一并交给你。无论你是刚接触异步编程还是已经会用asyncio但总觉得哪里没真正通透这篇文章应该都能帮上忙。1. 协程到底是什么从“线程的替身”到“被拆开的函数”1.1 线程与协程抢占式调度和协作式调度要理解协程先得看我们过去是怎么做并发的。线程是操作系统提供的并发单元一个进程里有多个线程每个线程都有自己的栈由操作系统内核负责调度它们。调度是什么意思就是决定哪个线程在哪个CPU核上跑多久时间片一到内核强制把正在运行的线程打断保存现场再把CPU交给下一个线程。整个过程线程没有发言权所以叫“抢占式调度”。协程完全不同。协程跑在用户态通常在一个线程内部。一个协程运行到某个点可以自己决定“我要等的东西还没到先让别人跑”然后把自己当前的状态保存好主动把控制权交回去等条件满足了再回到之前保存的位置继续执行。整个过程不需要操作系统插手切换成本天然就低。这里的关键词是“主动”线程是被强制换走的协程是自己让出CPU的这就是协作式调度。这个差别带来的后果非常深远。线程切换时内核态要做寄存器保存、栈切换、调度算法决策一次切换动辄几个微秒协程切换在用户态完成如果实现得好一次切换可以做到亚微秒甚至更低。更关键的是线程栈是预分配的默认可能1MB到8MB你开一万个线程光栈空间就要吃掉几十个GB协程则共享线程的栈只需要为每个协程保存“执行到哪一行、局部变量是什么”这样一份很小的快照。同样是开一万个并发任务用线程可能直接把内存打爆用协程却是小事一桩。1.2 一个类比后厨只有一个厨师的火锅店如果觉得术语太干我常用火锅店来打比方。线程版的后厨是店里有好几个厨师每个厨师同时炒好几道菜老板每隔一会儿吹哨子换人谁该休息、谁该继续都由老板强制安排。哪怕某个厨师正在颠勺哨子一响也得停下换另一个厨师接手他的锅。协程版的后厨是另一种画风只有一个厨师他面前摆着好几口锅。锅A还没开他趁这个时间赶紧去切锅B的配菜切完发现锅A冒泡了马上回来下肉下完肉又去处理锅C的订单等牛肉差不多了再回来捞。所有事情都是这个厨师自己决定先做哪个、在哪个位置暂停、过一会儿再回来继续。他没有“被打断”这个概念只有“主动让出”和“回来接着干”。这个类比能解释为什么协程适合IO密集场景锅在煮的时候厨师并没有闲着他利用等待时间去处理别的锅这就是“利用等待时间提升吞吐”。也能解释为什么协程对CPU密集场景没辙如果每道菜都要求厨师连续炒十分钟不能停一个厨师和多个厨师的差别就体现出来了协程帮不上忙因为根本没有等待时间可以利用。1.3 为什么需要协程从C10K到海量IO回到工程背景。早年服务器面对几千个连接就接近极限业内叫C10K问题。多线程能撑一阵子但线程数量一多内存和上下文切换的开销很快把收益吃掉事件驱动模型比如Node.js那么早火起来本质就是单线程加回调但回调多了又会出现“回调地狱”业务逻辑被拆得七零八碎。协程把两者的好处合到了一起编程模型看起来和同步代码几乎一样逻辑是顺序写的底层又像事件驱动那样高效等待时不占用线程资源。你在一个线程里挂起上万个协程等数据库、等网络响应等完一个拉回来一个线程始终满负荷运转吞吐自然就上去了。把“同步的写法”变成“异步的效率”这才是协程存在的核心价值。2. 协程运行的底层三件套挂起点、状态机与调度器2.1 挂起点协程在哪里暂停现场怎么保留普通函数的生命周期只有三件事开始、执行、返回。协程函数多了一环可以挂起然后恢复。那个让函数暂停并交还控制权的位置就是挂起点。Python里的await表达式、Kotlin里suspend函数内部的挂起点、C里的co_await表达式承担的都是这个角色。执行到挂起点时运行环境必须把当前局部变量的值、现在执行到哪一个挂起点、接下来走哪个分支等信息保存下来。注意这里保存的不是操作系统线程那种完整的寄存器上下文加内核栈而是协程自己的一份轻量快照。这份快照通常由编译器生成代码写到堆上的一块内存里协程挂起后调度器可以暂时不管它等需要恢复时再把这部分内存拿出来继续跑。所以你可以这么理解协程的“线程感”是靠保存和恢复“执行快照”模拟出来的它并没有自己独立的内核栈。这就是为什么协程常被称为“用户态并发”——它完全绕开了操作系统的进程线程模型调度逻辑都发生在应用层。2.2 编译器魔法协程本质是一个状态机技术文章里“编译器魔法”这个词很唬人但本质并不玄。协程只是编译器帮你把一个函数拆成了很多个小段再用一个状态变量把这些小段串起来。每一个挂起点就是一段代码结束的位置和下一段代码开始的位置。拿Python的async函数举例一个函数里如果依次有三个await编译器会把这个函数从逻辑上切成几段第一个await之前是一段第一个await之后到第二个await之前是一段依此类推。函数被调用时并不是直接执行全部代码而是先构造出协程对象被驱动之后根据当前状态编号跳到对应的代码段执行遇到await就记录状态和快照然后让出下次被恢复时直接进入下一个状态继续。整个流程和有限状态机几乎同构。这个本质意味着协程切换的开销不是“线程切换”那种级别的它只是在同一个线程里换一段代码继续执行相当于一次普通函数调用加一次状态跳转。很多初学者担心协程调度会不会像线程一样重其实完全多虑了协程切换的成本远低于你的直觉。2.3 调度器与事件循环谁在背后安排协程轮流干活有了能挂起、能恢复的协程对象还得有一个“老板”全局安排谁先跑、谁后跑、谁挂起、谁恢复。这个老板在Python里叫事件循环在Kotlin里由Dispatcher扮演在C中通常由具体的库来实现。Python事件循环的内部大致这样维护一个注册表记录每个协程在等什么事件比如socket可读、定时器到期。然后进入一个while循环不停从就绪队列取出协程驱动它执行到挂起点挂起后把它登记到对应的事件上等事件发生再把协程放回就绪队列。整个过程就是一个循环加一张登记表原理很朴素但它撑起了成千上万协程的调度。要特别提醒的是这个调度器跑在哪个线程上协程就共享哪个线程。Python的asyncio默认是单线程事件循环也就是说任意时刻真正在“执行”的协程只有一个其他协程要么在等待事件要么在就绪队列排队。如果你在某个协程里放了一个耗时的同步操作比如time.sleep或一段大计算循环整个事件循环会当场停摆所有协程都陪你干等。这个“表面并发、实际串行”的特点很多人刚学时体会不到等线上故障出现就晚了。3. 三种主流语言的协程实现Python、Kotlin、C为什么长得不一样3.1 Python协程asyncio、async/await与事件循环Python直到3.5才正式引入async/await语法底层跑在asyncio这个标准库上。它的模型比较纯粹asyncio提供事件循环、Future和Task等组件async def定义的函数返回一个coroutine对象这个对象只是一份“待运行的代码片段快照”必须进入事件循环才会真正执行。实际项目里最常用的模式是asyncio.run(coro)开一个事件循环并运行顶层协程协程内部用await等待另一个协程完成用asyncio.create_task(coro)把一个协程包装成Task立刻交给事件循环后台调度不等它完成就继续往下执行再用await asyncio.gather(*tasks)并行等待多个Task返回。这个模型最鲜明的特点是单线程、协作式。create_task能造出一堆“并发任务”但它们都在同一个线程里被事件循环轮着跑谁碰到await谁就暂停。Python协程非常适合网络请求、爬虫、网关这类IO密集程序但如果在事件循环里混入同步阻塞库并发优势会瞬间归零。遇到必须用同步库的场景怎么办我常用的做法是用asyncio.to_thread把它丢进线程池跑让事件循环不用干等。这个API是把同步代码和异步代码桥接起来最省事的方案新手往往不知道它结果硬生生把异步项目写成同步性能。3.2 Kotlin协程suspend、结构化并发与灵活的线程切换Kotlin协程和Python最大的区别是它并不绑定在单线程事件循环上而是通过调度器决定协程跑在哪个线程。Dispatchers.Main对应UI线程Dispatchers.IO和Dispatchers.Default分别对应IO线程池和CPU密集计算线程池协程可以在不同挂起点之间自由切换线程这能力很有用但概念也被撑多了不少。Kotlin协程有两块基石第一用suspend修饰函数标记它可以挂起第二协程必须在CoroutineScope中启动由scope负责约束协程生命周期避免协程泄漏。suspend函数写起来像普通同步函数编译器会在每个可能挂起的位置做变换把后面的代码变成状态机的下一个状态。和Python相比Kotlin的对应API是async{}和awaitAll()体感更像多线程模型因为协程真的会被切到另一个线程去跑。在Android或后端业务里Kotlin协程的核心价值是“结构化并发”和“取消协作”。你创建了一个CoroutineScope页面销毁时调用cancel()这个scope下所有子协程会跟着取消不会再出现页面已经关掉、协程还在后台偷偷发请求的问题。不过代价也很实在kotlinx.coroutines是一个独立库调度器、作用域、取消传播这些概念都要额外理解对零基础的人门槛比Python高不少。3.3 C协程co_await、co_yield与无栈设计C20把协程直接做进了语言但它给的是相当底层的机制co_await、co_yield、co_return再加上一个需要用户实现promise_type的框架。C协程默认是无栈协程这一点和Python、Kotlin有本质差异。无栈协程的含义是协程挂起时不需要在堆里保存一整套调用栈只保存执行帧里的局部变量和挂起点状态内存占用极小特别适合大规模高并发场景。代价也很明显co_await只能挂起当前函数不能挂起整条调用链函数之间的互相调用要格外小心编译器生成大量隐藏的状态机代码出了问题栈回溯经常像天书协程对象的生命周期、promise的构造析构时机全都得程序员自己把握。所以社区里经常有人说“C20协程只给了五块拼图连说明书都缺页”还真不是调侃。如果项目没有极端性能需求我不建议新项目一上来就裸用C20协程通常先用封装好的库让库作者把调度和生命周期处理掉。但这个设计也帮我们看清了一件事协程的核心就是编译器把可挂起函数编译成状态机外加一套让调用方能恢复它的机制。3.4 一张表看完三种语言语言核心语法调度方式底层模型最主要的坑Pythonasync def / await / create_taskasyncio事件循环默认单线程协程对象Future/Task协程里做同步阻塞会拖垮整个循环Kotlinsuspend / async / awaitDispatchers切换线程状态机协程调度器作用域不一致导致协程泄漏Cco_await / co_yield / co_return由库或框架自行调度无栈状态机执行帧在堆上保存生命周期和异常处理都要手写差异大的根本原因是语言定位不同。Python追求易用把调度细节封装在上层库里Kotlin面向应用开发既要照顾UI线程又想要后端能力所以设计成可切换线程的结构化并发C面向底层和高性能语言只提供最小机制剩下全靠库和开发者自己拼。理解这层差异你看文档时就不容易被“怎么各家用法都不一样”困住了。4. Python协程实操从同步代码到并发代码的最小示例4.1 先跑通同步版本拿最经典的场景练手模拟并发请求三个网页。为了不依赖真实网络我直接用asyncio.sleep(1)模拟一个请求耗时1秒。同步版本先写上import time def fetch_sync(url): print(f开始请求 {url}) time.sleep(1) print(f请求完成 {url}) return f{url} 的数据 def main_sync(): urls [a.com, b.com, c.com] start time.perf_counter() results [fetch_sync(url) for url in urls] print(结果:, results) print(耗时:, time.perf_counter() - start) main_sync()运行结果没什么悬念三次请求顺序执行每次阻塞1秒总耗时约3秒。time.sleep会让当前线程彻底睡过去这个过程中什么都干不了这就是同步阻塞的本质——它把CPU和线程都白白晾在一边。4.2 改成协程并发的版本接着改成协程版本。先写好一个async函数里面用await asyncio.sleep(1)模拟异步等待然后通过create_task创建多个任务最后用gather合并等待结果import asyncio import time async def fetch_async(url): print(f开始请求 {url}) await asyncio.sleep(1) print(f请求完成 {url}) return f{url} 的数据 async def main_async(): urls [a.com, b.com, c.com] start time.perf_counter() tasks [asyncio.create_task(fetch_async(url)) for url in urls] results await asyncio.gather(*tasks) print(结果:, results) print(耗时:, time.perf_counter() - start) asyncio.run(main_async())跑完后你会看到三次请求几乎同时启动1秒左右全部完成。原因在于每次await asyncio.sleep(1)时事件循环并没有傻等而是登记了一个“1秒后唤醒我”的定时任务然后立刻去跑下一个协程。三个协程各自等待的1秒钟在时间线上重叠了总耗时自然从3秒压到1秒。这就是并发等待的核心价值把串行等待变成并行等待。4.3 关键点拆解await到底在等什么很多新手看到这段代码会问asyncio.run到底做了什么它创建了一个事件循环把main_async这个协程放进去执行直到整个协程结束再关闭循环。create_task做了什么它把传进来的协程注册成事件循环的一个任务让它进入待调度队列代码本身不会等待这个协程跑完会立刻继续往下走。那如果不加create_task直接写for url in urls: await fetch_async(url)结果如何总耗时又变回3秒。因为每次await都在等当前协程彻底结束才继续下一次循环根本没有并发。想明白这个你就知道“并发”不是靠async关键字碰出来的而是靠create_task这种把任务主动交给调度器的操作创造出来的。gather还有一个隐藏细节默认情况下如果其中某个协程抛异常gather会立刻把异常抛给等待方而其他还没跑完的Task不会自动取消。生产环境里按需加一下return_exceptionsTrue可以避免一个任务失败把整批结果全部吞掉。提示并发不是async碰出来的而是create_task、gather这类调度API的设计结果。检查代码时重点看有没有把任务“交出去再统一收回来”而不是看async关键字出现了多少次。4.4 Kotlin和C的对应写法速览带着“语言只是同一概念的不同表象”这个思路看Kotlin会很容易suspend fun fetch(url: String): String { println(开始请求 $url) delay(1000) println(请求完成 $url) return $url 的数据 } fun main() runBlocking { val start System.currentTimeMillis() val results listOf(a.com, b.com, c.com) .map { url - async { fetch(url) } } .awaitAll() println(结果: $results) println(耗时: ${System.currentTimeMillis() - start}) }这里的async{}相当于创建并发任务awaitAll()相当于gather。除了Kotlin的协程可能被调度到不同线程整体逻辑跟Python几乎一一对应。C20的入门示例会更啰嗦因为它要求自定义返回对象的promise_type。一个最小可编译的例子大致长这样#include coroutine #include iostream struct Task { struct promise_type { Task get_return_object() { return {}; } std::suspend_never initial_suspend() { return {}; } std::suspend_never final_suspend() noexcept { return {}; } void return_void() {} void unhandled_exception() { std::terminate(); } }; }; Task hello() { std::cout before await\n; co_await std::suspend_always{}; std::cout after await\n; } int main() { auto t hello(); std::cout main continue\n; }这个demo里hello()执行到co_await suspend_always时把控制权交还给调用方。所以输出顺序是“before await”然后回main打印“main continue”“after await”不会出现因为没有代码去手动恢复这个协程。C协程就是底层的这副面孔帮你自动调度的东西基本不存在需要库作者把调度和生命周期封装好普通使用者才有体感。5. 常见问题与排查心得这些坑我几乎都在项目里见过5.1 调了async函数为什么什么都没执行现象写了一个async函数调用foo()结果函数体一句话都没打印就返回了打印返回值发现是一个coroutine对象。原因async函数不是普通函数那样直接执行体而是先创建协程对象真正代码要等对象被await或被丢进事件循环之后才跑。排查方法看调用处有没有await或create_task没有就补上。打印一下返回值类型看到coroutine就说明你只是造了个对象没有点火。这个坑带新人时我几乎必讲。本质上它来自“async函数是状态机工厂”这件事调用它就像买了一辆还没发动的车你不拧钥匙它自然一动不动。5.2 明明开了很多任务整个程序却像卡死现象代码里create_task了一堆任务结果第一个协程打印完“开始请求”之后就再也没有下文。最可能的原因某个协程内部用了同步阻塞操作比如time.sleep、requests.get这种同步网络库或者一段很大的循环。事件循环意识不到这种阻塞它只看到这个协程还占着CPU不会主动去切给别人于是所有任务全部排队程序看起来像卡死。排查思路很简单把代码里所有同步等待换成异步版本。time.sleep换成await asyncio.sleeprequests换成aiohttp或httpx的异步接口一时换不了的就用asyncio.to_thread把同步代码块丢到线程池里去跑。提示协程代码里不要在“快路径”上做任何耗时的同步CPU或IO操作。一旦出现整个单线程事件循环都会为它陪跑。5.3 为什么gather和create_task用不好会悄悄退化成串行这个坑不会报错而是性能一点一点变差。比如写results [await fetch_async(url) for url in urls]看起来用了await实际是逐个等待第一个请求完成才发第二个并发根本没发生。解决办法是先把所有create_task收集起来最后统一用gather收取或者改用asyncio.wait、asyncio.as_completed。有个判断技巧看总耗时。如果串行执行n个任务时总耗时约为单任务耗时乘以n基本就是没有并发如果总耗时接近单个任务的耗时说明等待重叠了。性能数字是协程并发最好的照妖镜一测就知道自己写没写对。5.4 Kotlin协程泄漏与取消协作问题现象Android应用里协程在页面销毁后还在跑或者父协程取消后子协程仍然继续执行。最常见的原因是用GlobalScope直接开协程绕开了作用域约束或者启动协程时用了Dispatchers.Unconfined把取消传播链弄断。正确做法是让协程挂在一个和页面生命周期绑定的CoroutineScope上Android开发里就是viewModelScope或lifecycleScope。结构化并发的好处正在于此在某个scope内部用launch或async启动的协程自动成为这个scope的子协程父scope取消时子协程会跟着取消。如果协程里涉及数据库、文件流等资源记得放在finally里清理才能保证取消时也不泄漏。5.5 线程与协程互相等待造成死锁另一种隐蔽问题是跨边界死锁。比如线程A里调用runBlocking启动一个协程协程内部又调用thread.join等待线程A完成协程等线程线程等协程两边一起挂。Python也有对应版本在异步协程里用线程池的future.result()去等线程而那个线程又在等事件循环的结果非常容易形成循环等待。我的经验是跨线程和跨协程的边界处尽量遵守“异步优先”原则不要让协程直接等待同步线程结果。如果必须桥接限制在顶层做一次转换比如只在一个入口调用asyncio.run或runBlocking不要在异步逻辑里反复嵌套跨边界等待。6. 到底该不该用协程适用边界与选型判断6.1 IO密集场景协程是第一选择协程的主战场是IO密集型应用HTTP请求、数据库访问、消息队列、长连接网关、爬虫、文件读写。这类任务共同点是CPU占用不高大量时间在等外部资源恰好是协程利用等待时间去调度其他任务的甜点区。Python后端、Kotlin服务端、C网络库这些领域里协程基本已经是标配。真实业务里一个协程服务端能轻松扛住成千上万个同时挂着的长连接而线程方案在这种场景下往往会先被资源耗尽逼疯。我做过一个推送网关用asyncio单线程撑起了几万个长连接内存和CPU都稳得很换成等量线程早就不敢想了。6.2 CPU密集场景协程救不了你如果任务是纯CPU密集型比如图像处理、音视频编解码、机器学习推理、复杂数值计算协程没有意义。因为协程A让出CPU后协程B还是在同一个CPU核上算总计算量没有减小反而多了切来切去的消耗。这种情况需要的是多进程或多线程把计算分散到多个CPU核心上。真要有人把一堆计算函数改成async再gather结果不光是没变快还可能因为额外的调度开销变慢。协程优化的是等待不是计算。搞错这个前提技术选型会从一开始就跑偏。6.3 判断清单与团队兼容性我在项目里做决策时会拿下面这张清单过一遍并发规模是否可能超过几百上千如果是协程比线程更合适。瓶颈是IO等待还是CPU算力前者协程后者多进程或多线程。团队是否熟悉当前语言协程生态不熟的话先用一个小模块试点。框架是否原生支持协程看Python Web框架是否走ASGIKotlin后端能不能直接暴露suspend接口C项目能不能引入封装好的协程库。是否有一大堆成熟同步库绕不开这类兼容成本会让协程收益大幅缩水。说到底协程是一种调度模型的选择不是银弹。选型先看业务特征再看语言生态和团队基础最后才是赶时髦。我见过有些团队把Python同步代码硬改成async结果性能没提升反而被异步库的兼容性问题拖得维护成本翻倍这种案例在行业里一点都不少。写到这里还是想再啰嗦几句心里的体会。我接触协程这么多年最大的感受是“轻量级线程”这个比喻真的很误导人它把人往线程调度模型上带而协程本质上更像一个可以被外部按需恢复的暂停函数加上一个负责调度它的循环。你只要能抓住三条线挂起点保存现场、编译器把函数拆成状态机、调度器负责恢复和切换绝大多数问题都能自己分析出答案。最后再多说一个小技巧初学时不要在代码里堆各种并发API先跑通一个最小的事件循环示例就像文章里那个sleep并发demo一样打印开始和结束时间亲手看到等待重叠再一点点往里面加复杂度。真等这些概念在脑子里扎下根再回头看官方文档那些术语会突然全部变得很好懂。
返回列表