在高并发后端这个圈子里混久了你会发现有两句话被反复提起一句是“想扛高并发得上协程”另一句是“Python 里谁乱改全局变量谁是小狗”。这两句话放在一起恰好指向今天要聊的核心——命名空间与协程。我这两年排查线上问题发现一半以上的诡异 Bug 并不出在并发设计本身而是出在名字与作用域的纠缠上变量挂在哪个命名空间、被谁引用、在任务切换的哪个瞬间变了值。标题把这两件事称为“构建高并发系统的基石”听起来有点大落到代码里其实就是一句话把命名空间隔离做明白把协程用对高并发系统的一半地基就稳了。这篇文章适合三类人刚接触 asyncio被各种异步写法绕晕的写过好一阵多线程、最近转协程总觉得“哪里不对劲”的以及已经在线上踩过变量互相污染的坑想系统补一遍原理的。我会从命名空间的本体讲起再拆协程的调度机制然后给出一套能直接抄的并发调度骨架最后列一份排障清单。不会写花哨的东西基本都是能立刻放进项目里用的经验。1. 从“命名空间”说起Python 作用域与并发隔离的本质1.1 命名空间是什么写代码的人为何容易忽略命名空间的官方定义比较拗口名字到对象的映射。说白了它只是一张“变量名 → 内存对象”的字典。普通函数每调用一次Python 就会在内存里新建一张局部表登记这个函数内部用到的局部变量函数跑完这张表就销毁。同样是名叫 x 的变量在两个不同函数里各是各的不会互相覆盖。这个机制平时太自然以至于我们很少意识到它存在。但任务一旦并发这张“命名空间表”就变成隔离墙每个协程实例都有自己独立的一张表只要你不主动伸手去够别的表里的东西数据天然就是隔离的。反过来如果你把状态放在模块顶层、类属性或闭包外层变量里这些表就变成了大家共享的“公共区域”谁来都能改改的顺序还不可控。一个很常见的误解是把“全局变量”等同于“位于模块顶层的变量”以为只要声明位置不在函数内部那它就是绝对公共的。其实准确地说模块只要执行过 import模块级的名字空间就被固定下来后续所有函数、协程都会朝它查名。它适合放常量、配置、一次性加载的工具对象不适合放“每个请求都在变化”的业务状态。并发项目里我会把绝大多数运行期状态限制在函数局部、任务参数和返回值里让模块级命名空间尽量保持“冷静”。1.2 LEGB 查找规则与并发任务的关系Python 名字查找按一条固定链条进行局部作用域Local→ 嵌套函数的外层作用域Enclosing→ 模块全局Global→ 内建Builtins。这就是 LEGB 规则。理解它本身不难难的是在并发代码里时刻记得“当前这条链到底能查到哪一层”。举个例子。协程函数内部如果要修改外层函数里的变量必须用 nonlocal 声明如果要修改模块级变量必须用 global 声明。这个语法不是为了刁难人而是避免你无意中踩进共享区。到了并发场景语言设计者的好意就变成了救命稻草它逼着你在修改共享状态前先写一个显式声明给自己一次停下来思考的机会——这个变量我到底有没有权限改改了会不会影响别处的任务。不过 LEGB 也会带来一些非常隐蔽的坑。最典型的是循环变量捕获for 循环里写着 i所有在循环体内定义的内部函数查找链上指向的是同一个外层 i而不是循环每一次的“快照”。等协程真正开始运行时循环可能早就结束了i 也已经变成了最后一个值。这时候再排查“为什么所有任务都输出同一个数字”答案往往不在协程而在 LEGB 的查找路径。这个现象具体长什么样下一节直接看代码。1.3 一个一下就复现的反例闭包里“共享的 i”很多人在第一次接触协程时都写过或者见过下面这段代码import asyncio async def main(): tasks [] for i in range(5): async def inner(): await asyncio.sleep(0.01) return i tasks.append(asyncio.create_task(inner())) results await asyncio.gather(*tasks) print(results) asyncio.run(main())运行结果大概率是[4, 4, 4, 4, 4]不是你想的 0 到 4。第一次看到这个输出的人都会愣一下这不是并发吗怎么五个任务全返回同一个数字其实每个inner()的局部命名空间里根本没有 i它只能沿 LEGB 链条向上找找到的是main()这一层由 for 循环复用的 i。五个协程共享同一个外层变量调度顺序不同谁最后看到的都是循环结束后的值。修复方式很简单把 i 作为参数传给内层函数。参数进入协程的局部命名空间之后每个任务各自持有一个独立快照互不干扰。async def inner(index): await asyncio.sleep(0.01) return index然后创建任务时写成asyncio.create_task(inner(i))即可。这个反例虽然小但同时戳中了标题里的两件事命名空间规则决定了变量的可见性与归属协程的并发调度决定了“谁先跑、谁后跑”。两者一旦错位就会出现这类隐蔽 Bug。我后来看别人代码凡是出现“所有任务结果都相同”的诡异现象第一反应就是查闭包和循环变量的作用域十次有九次是对症的。2. 协程单线程里也能“同时”跑任务的机制2.1 为什么要从线程切换到协程高并发不一定等于多线程。Python 的 GIL 让同一时刻只有一个线程在解释器里跑字节码CPU 密集计算几乎没法靠线程并行加速但对网络请求、文件读写、数据库等待这类 IO 密集型任务瓶颈根本不在 CPU而在于“等”。等一次网络往返可能几十毫秒慢的时候几百毫秒期间线程挂在阻塞调用上什么都不干。线程方案的做法是多开线程操作系统把阻塞中的线程切走等数据到了再切回来。一个线程的内存开销动辄几 MB上千个线程会带来巨大的调度压力。协程则不同底层通常只有一个线程但可以维护成千上万个轻量级任务。每个任务本质上只是一份函数栈帧和运行状态遇到等待时主动让出控制权交给事件循环去跑别的任务。它的切换开销通常只有线程切换的几十分之一而且不涉及内核态切换成本更低。如果做一个类比线程像是每个饭店都多招几个服务员客人一多就人手不够、工资爆炸协程更像一个手脚麻利的全能服务员点的菜还在后厨他先去接待下一桌等菜好了再上菜。一个人能顶十个人用前提是大部分活儿都在“等”而协程最擅长处理的恰恰就是这种“等待密集”型工作。2.2 从 yield 到 async/await 的演进协程并不是新概念。早期的 Python 用生成器构造协作式多路复用函数运行到 yield 会把当前值交出来同时冻结现场外部继续调用next()时再恢复执行。这很像协程的雏形但写起来很不直观也容易混淆“迭代”和“暂停”两个概念。后来 asyncio 库出现配合asyncio.coroutine装饰器和yield from才把“异步等待”这件事从语法层面表达出来。最终在 Python 3.5 引入 async/await协程从语法上被正式支持。async def定义的是一个协程函数调用它不会立刻执行函数体而是得到一个协程对象await后面的表达式必须是可以等待的对象执行到await时当前协程会挂起并交出控制权。Python 3.7 之后 async 和 await 成为保留字官方也推荐用asyncio.run()作为统一入口协程的使用方式基本稳定下来。整个演进过程最核心的一点是协程的挂起和恢复是协作式的只有协程自己遇到await时才会让出没有抢占也没有强制切换。如果不小心在 async 函数里写了time.sleep()那不是挂起而是把整个事件循环冻住所有协程都跟着停摆。这个细节后面第五部分还会专门展开。2.3 事件循环是怎么决定先跑谁事件循环是整个 asyncio 的中枢。它内部维护一个就绪队列和一组等待对象一开始调度器会把注册好的任务逐个放进去执行当某个任务 await 一个未完成的 IO 对象时任务被挂到等待列表事件循环立刻回头找下一个就绪任务等 IO 完成回调把对应任务重新放回就绪队列在下一轮循环迭代里继续执行。拿网络请求举例更容易理解一个客户端连接进来后数据通常不会立刻到齐。事件循环不会傻等这个连接而是把它注册到 selectors 监听列表里同时去服务其他已经就绪的连接。等数据包到了selector 通知事件循环再恢复原先那个协程。所以在宏观上“高并发”并不是 CPU 在同一瞬间执行了很多个协程而是同一时间段内很多协程都处于“等待中”CPU 几乎没有闲着只是在不同协程之间快速切换。这个模型决定了协程的适用边界IO 密集是主场CPU 密集则要交给线程池或进程池去处理。做技术选型前先把这一点想明白后面就不会把协程用在它不擅长的地方也不会误以为协程能带来多少 CPU 上的加速。3. 当并行遇上作用域命名空间与协程的磨合点3.1 每个协程实例都有自己的局部命名空间协程函数和普通函数在命名空间机制上没有本质差别。调用 async def 拿到的是协程对象它不会立刻执行但一旦被调度运行同样会创建一份局部命名空间函数参数和内部局部变量都放在里面。两个不同的 Task即使跑的是同一个协程函数也各拿各的局部表谁也不会覆盖谁。这带来一个天然优势从命名空间层面看协程本身就是“防数据污染”的最小单元。只要做到“任务之间只用参数沟通、返回值汇总结果”系统就几乎不存在内存层面的数据竞争。这不是靠锁而是靠作用域隔离。我在实际开发中一直强调一个原则能传参就传参能返回就返回别让任务之间隔空喊话。不过要提醒一点局部命名空间只保证“变量名互相看不见”不保证“对象内容不会被改”。如果把可变对象比如 list、dict、某个实例作为参数同时传进多个协程那它们共享的还是同一个底层对象。参数做了隔离对象没有。这种情况下要么用不可变数据要么加锁要么设计成“谁拥有谁修改”的单写模式。隔离的是名字不是对象这两件事必须分开记。3.2 不要随手改全局变量如果非要给协程并发总结出一条最高优先级规则我大概会说适当读全局没问题尽量别写全局。为什么是“尽量别写”因为协程的切换点藏在每一个 await 里。表面看代码是一行一行往下走的你无法预知 await 前后会不会有其他任务插入执行。想象一个很典型的场景模块里放一个全局计数器 counter任务 A 执行counter 1后把结果写进数据库任务 B 也在做同样的事。你可能会想GIL 会保证一条赋值语句不被打断应该没问题。但问题在于“读旧值、计算、写回”是三件事中间还隔着一次 await。任务 A 读到 1await 让出控制权任务 B 也读到 1把它改成 2等任务 A 恢复继续把 1 当成自己的累计结果写进数据库。最后全局计数总是偏小。这种交叉污染你不看运行日志根本发现不了。处理办法分两类如果只是需要一个准确的计数器用asyncio.Lock把修改动作包起来如果这个全局状态的本质是“跨任务的公共流水线信息”更推荐让状态“流动”起来——用asyncio.Queue把数据从生产者交给消费者谁处理完谁负责更新避免多个任务同时碰同一个变量。后者比单纯加锁更贴近异步系统的设计思路也能少掉很多锁等待时间。3.3 contextvars把上下文当成一种显式命名空间从 Python 3.7 开始官方提供了 contextvars 模块。它解决的问题听起来很小但并发场景里非常关键让同一个 Task 里的各处代码都能读取属于“当前任务”的上下文信息而不必层层传参。最常见的例子是请求 ID。服务端收到一个请求后后续日志希望自动带上这个请求 ID传统做法是把 request_id 一路传进每个函数少数人用全局变量保存风险上文已经说过了。contextvars 的思路是每个 Task 在创建时会拷贝一份“上下文副本”Task 内部通过ContextVar.set()写进去的值只在这份副本里生效换一个 Task 再读读到的仍是它自己的那份互不干扰。from contextvars import ContextVar request_id ContextVar(request_id, default-) async def handle(req_id): request_id.set(req_id) await asyncio.sleep(0.01) # 当前任务里任意位置都能读到刚设置的值 print(f当前任务请求ID: {request_id.get()})这其实提供了一种“按任务划分”的命名空间可以把它当作并发场景里安全保存上下文的小技巧。不过也别过度依赖它更适合传播只读上下文不适合放频繁变化、需要多个任务共享互改的业务状态。业务状态的规则依然回到 3.2 那条——少写共享需要共享就显式加锁或走队列。4. 实操用 async/await 搭建一个高并发调度骨架4.1 一个可运行的“并发度受限”调度模板下面这套骨架是我平时最常用的不用装任何第三方依赖Python 3.7 以上就能跑。它要演示三件事并发入口怎么写、并发度怎么限制、结果怎么收集。import asyncio from contextvars import ContextVar current_job ContextVar(current_job, default) async def fetch(job_id: int, sem: asyncio.Semaphore): async with sem: current_job.set(fjob-{job_id}) await asyncio.sleep(0.01) # 模拟网络请求 / 数据库查询 return job_id, current_job.get() async def main(): sem asyncio.Semaphore(5) # 同一时间最多处理 5 个任务 tasks [asyncio.create_task(fetch(i, sem)) for i in range(20)] results await asyncio.gather(*tasks) for row in results: print(row) if __name__ __main__: asyncio.run(main())运行这段代码会看到(0, job-0)、(1, job-1)……一直到(19, job-19)并且每个任务拿到的 current_job 都是属于自己的那份。这里有两个控件在共同生效Semaphore 控制“同一时间最多跑几个任务”ContextVar 控制“每个正在运行的任务各自是谁”。一个管资源一个管身份职责非常清楚。4.2 每个环节为什么要这么写先看入口。asyncio.run(main())是官方推荐的顶层入口负责创建事件循环、运行 main 协程、最后关闭循环。旧写法里常见的loop asyncio.new_event_loop()、loop.run_until_complete(main())这些操作现在已经不需要自己手写了。再看任务创建。asyncio.create_task()会把协程对象包装成 Task并立刻把它安排进事件循环的就绪队列。要注意它返回的是 Task不是 Coroutine调用方随时可以调用task.cancel()取消它。for 循环里逐个创建任务所有任务从创建那一刻起就处于“可被并发执行”的状态。关键在 sem。asyncio.Semaphore(5)的初始化值是 5表示同一时刻最多允许 5 个协程进入async with sem的临界区。第 6 个协程到达后会在门口等待等前 5 个里有一个执行完退出它才被放行这就是限流。很多系统出现“并发一高就崩”往往不是任务逻辑写错了而是没有限流把所有请求同一时间冲出去把下游连接数打爆。可控并发可以说是高并发系统的一个必备特征。最后是 gather。gather(*tasks)会等待所有任务完成并按传入顺序返回结果列表。如果某个任务抛异常默认情况下 gather 会直接把这个异常抛出来导致后面的结果拿不完整。需要容错时给 gather 加上return_exceptionsTrue异常本身也会作为结果出现在列表中方便逐个检查。整套骨架最核心的地方就是三句话用asyncio.run做入口用create_task创建并发任务用 Semaphore 限定并发度。业务逻辑只要往 fetch 里填就能稳定扩展到几百上千个并发而不会把系统拖垮。4.3 给骨架加上取消和超时真实项目里不可能让任务无限期挂在那里。比如下游数据库三秒没响应你希望给调用方一个超时报错同时把占用的任务名额释放出来。asyncio 提供了一个非常顺手的工具asyncio.wait_for。async def fetch_with_timeout(job_id: int, sem: asyncio.Semaphore): try: return await asyncio.wait_for(fetch(job_id, sem), timeout3.0) except asyncio.TimeoutError: return job_id, timeoutwait_for 做了两件事如果 fetch 在三秒内完成正常返回结果如果超时它会给 fetch 对应的 Task 发送取消信号内部未完成的 await 会立刻抛 CancelledErrorwait_for 再把异常包装成 TimeoutError 抛给调用方。注意业务代码里不要随便用try except Exception把 CancelledError 吞掉。Python 3.8 之后 CancelledError 继承自 BaseException普通except Exception不会误伤它这是好事别去拦它。把并发度、超时、取消、结果收集这四个点组合起来几乎能覆盖所有 IO 密集型服务的核心需求。把上面这套骨架跑通再往里塞自己的业务代码一个小型异步服务的地基就搭好了。5. 真实项目里踩过的坑与排查清单5.1 阻塞函数砸穿整个事件循环协程最忌讳的一类写法是在 async 函数里直接调用阻塞 API。比如time.sleep()、requests.get()、同步文件读、不带 async 的数据库驱动。这些调用一旦执行整个线程就会被占住事件循环里其他所有任务都在原地等待。现象是线上偶尔出现一段时间的全线卡顿日志看起来像“某个任务变慢了”实际上其他任务不是慢而是被前面的阻塞调用按住了。排查思路很简单如果系统里同时挂着很多协程却有一个任务在跑同步阻塞库先怀疑它是不是罪魁祸首。解决办法是把阻塞调用替换成await asyncio.sleep()需要等待时、改用 aiohttp 或异步数据库驱动实在绕不开就把阻塞操作丢给asyncio.to_thread()让它在独立线程池里执行避免占用事件循环。这个技巧我认为是写协程的人应该背下来的因为它救过我太多次。5.2 协程忘记 await任务悄悄消失写异步代码最典型的语法事故就是协程对象没有被 await。你写task some_coro()然后忘了 await只会得到一行警告任务既不执行也不报错。更隐蔽的情况是把协程对象当普通函数调用放进列表里以为它在跑其实只是创建了一堆还没被调度的对象。等解释器判定对象可回收时会弹出一句RuntimeWarning: coroutine ... was never awaited但那时往往已经造成业务丢单了。要避免这类问题我的习惯是所有创建协程对象的地方要么立刻 await要么立刻用asyncio.create_task()包装成 Task。创建了不调度等于没创建创建了不收集结果等于白忙创建了却不限制并发量等于给系统埋雷。这三个“不等于”我会直接在代码评审清单里写死也是带新人时最常讲的一句话。5.3 全局变量被多个任务交叉修改前面说的计数器例子就属于这一类。看着是不起眼的全局变量实际表现出来的是“偶发少算”和“结果不一致”。因为你无法在静态读代码的时候推演出 await 前后到底发生了什么只能靠日志和时间线慢慢复盘。如果项目里已经出现这种问题最快的补救是给修改点加asyncio.Lock最干净的长期方案是消灭这种全局可变状态改成“数据在队列里流动任务各管一段”。还要提醒一句加锁也要注意 await 的时机。锁保护的临界区里如果还带着 await锁会被一直持有其他任务排队时间变长可能引入新的超时问题。锁里的代码越短、越不碰 IO锁的副作用越小。写并发代码时多想一想“这里其实可以不共享”往往比费尽心机设计锁要好得多。5.4 几个我长期在用的心法最后把经验压成一张表平时照着做基本不会踩大坑场景建议做法每个任务自己的业务状态尽量放协程局部变量或参数需要按任务区分读取的上下文用 contextvars 或显式参数多任务共享且必须互改加 asyncio.Lock 或改用队列全局常量、只读配置可以放在模块级命名空间IO 密集型并发优先 asyncio 方案限制并发度协程内遇到阻塞调用用 asyncio.to_thread 或换异步库再多说一句真实体会。很多人做高并发第一反应是找“最猛的技术框架”但我在一堆线上事故里学到的恰恰相反真正拦住我们的往往不是 CPU 不够快而是命名空间和等待时机这两件小事。把小的作用域边界划清楚把每个 await 挂起的地方想明白基于协程的系统才能真正又稳又顺。希望这篇能帮你少踩几个我踩过的坑。