Gevent 是什么,和 asyncio 一起用有什么坑
Posted on 二 07 4月 2026 in Journal
| Abstract | Gevent 是什么,和 asyncio 一起用有什么坑 |
|---|---|
| Authors | Walter Fan |
| Category | Journal |
| Status | v1.1 |
| Updated | 2026-04-08 |
| License | CC-BY-NC-ND 4.0 |
Gevent 是什么,和 asyncio 一起用有什么坑
短大纲
- 一句话:Gevent 用 greenlet 做协作式多任务,再配合 monkey patch,把"阻塞 I/O"变成"先让一让,等会儿再回来"。Flask 里最常见的搭法,是 Gunicorn
geventworker。 - 和 asyncio 的差别:asyncio 是明牌,代码里得老老实实写
async和await;Gevent 则尽量让你保留同步写法,在 I/O 边界悄悄切换。 - 值不值得用:如果是存量 Flask 老项目,瓶颈又主要在等 I/O,Gevent 往往是成本更低的一步;代价是 patch 边界、排障难度,还有一堆"看起来没问题,其实卡住了"的坑。
正文
先从一个很常见的误会说起
你写了个 Flask 接口,查数据库,调下游 HTTP,拼个 JSON 返回。本地单测飞快,一上压测,CPU 还没满,QPS 先趴下了。有人丢过来一句:"上 Gevent。" 你打开文档,看见 monkey.patch_all(),心里咯噔一下,这玩意儿像在运行时给标准库"换心",靠谱吗?
靠谱不靠谱,要看你解决的是哪一类问题。如果你的服务主要是在等 I/O,等 socket,等 DB,等下游接口,那么 Gevent 干的事无非一句话:别让一个请求傻等的时候,把整条执行线霸着不放。 它不是仙丹,救不了纯算力瓶颈。
Gevent(Flask 模式)到底在干什么
一句话拆开说,无非三件事:
- Greenlet:可以把它理解成用户态的轻量协程,由程序自己调度。谁该往下跑,谁先停一停,不是内核说了算,而是在 I/O 可能阻塞的地方主动让一下。
- Monkey patch:在进程启动足够早的时候,把
socket、ssl、time.sleep这些会阻塞的入口,换成 gevent 兼容的实现。这样一来,原来傻等的地方,就有机会先把执行权让出去。 - 和 Flask 的搭法:最常见的是 WSGI 服务器用 gevent worker,例如 Gunicorn
-k gevent。开发环境里也有人在入口最前面patch再app.run,不过生产环境还是交给成熟的 WSGI 配置更稳。
所以你听到的"Flask 模式",往往不是 Flask 本身有什么魔改,而是 运行方式和进程模型变了。上面还是那套同步视图函数,底下已经换成了能协作并发的栈。
咱们先看一个 Flask 老项目里的例子
假设你有个很普通的接口,做三件事,查用户信息,调订单服务,再调积分服务。代码大概像这样:
from flask import Flask, jsonify
import requests
app = Flask(__name__)
@app.get("/profile/<uid>")
def profile(uid):
user = requests.get(f"http://user-service/users/{uid}").json()
orders = requests.get(f"http://order-service/orders/{uid}").json()
points = requests.get(f"http://point-service/points/{uid}").json()
return jsonify({
"user": user,
"orders": orders,
"points": points,
})
这段代码的问题,不在于"写得丑",而在于它老老实实地排队等。等用户服务,等完再等订单服务,等完再等积分服务。要是下游都不快,这个接口就像窗口里办事的大爷,一边喝茶一边翻材料,后面的人再着急也得排着。
如果你给这类应用换上 gevent worker,再把阻塞 I/O patch 成可协作的版本,那么请求在等网络返回的时候,就可能把执行机会让给别的 greenlet。代码还是这副同步长相,底下的调度方式却已经不是原来那种线程傻等了。
这就是 Gevent 最让人心动的地方。它不像装修房子时把承重墙全拆掉,更像先把最堵的十字路口改成智能红绿灯。不是推倒重来,但往往先能缓一口气。
再看 greenlet 到底"绿"在哪里
很多人一听 greenlet,就以为它和线程差不多,只是名字更萌一点。其实不是。线程更像操作系统发的工牌,切换要惊动内核;greenlet 更像你办公室里几个人轮流用一把椅子,什么时候换人,主要靠大家自觉。
举个简化版的感觉:
import gevent
from gevent import monkey
monkey.patch_all()
import requests
def fetch(name, url):
print(f"start {name}")
data = requests.get(url).text
print(f"done {name}: {len(data)}")
g1 = gevent.spawn(fetch, "a", "https://example.com/a")
g2 = gevent.spawn(fetch, "b", "https://example.com/b")
gevent.joinall([g1, g2])
表面看,fetch() 还是同步函数,没有 async,也没有 await。但当 requests.get() 底下走到被 patch 过的 socket I/O 时,当前 greenlet 会先让开,让另一个 greenlet 跑。greenlet 的精髓,不是"让 Python 代码跑得更快",而是"别在等 I/O 时霸着执行权不放"。
这话听起来很朴素,工程里却很顶用。很多服务不是算得慢,而是等得久。目的无他,别白等。
和 asyncio 放一张桌上比
| 维度 | Gevent(典型 Flask/WSGI) | asyncio |
|---|---|---|
| 写法 | 多数时候仍像同步代码 | 必须 async def / await,链路上的库最好也是异步的 |
| 调度 | greenlet + 在 patch 点让出 | 事件循环调度 Task |
| 生态 | 同步库继续用(前提是 patch 覆盖得到或本身可协作) | 需要 aiohttp、asyncpg 等;混用同步阻塞要小心 |
| 心智负担 | "哪里被 patch、哪里还在真阻塞" 要查清楚 | "哪里忘了 await、哪里阻塞了 loop" 要查清楚 |
| 与 ASGI | 经典组合是 WSGI + gevent worker | FastAPI/Starlette 等 ASGI + uvicorn 更常见 |
asyncio 是明牌:你把异步边界写进语法里,读起来硬一点,可是调用栈好追。Gevent 是暗牌:代码表面还是老样子,运行时行为却变了,出问题时你得去查,是不是没 patch 到,是不是某个 C 扩展在偷偷真阻塞。
同一个需求,用 asyncio 会长什么样
还是刚才那个聚合接口。换成 asyncio 风格,思路通常不是"继续同步写",而是老老实实把异步写出来:
import asyncio
import aiohttp
async def fetch_json(session, url):
async with session.get(url) as resp:
return await resp.json()
async def profile(uid):
async with aiohttp.ClientSession() as session:
user, orders, points = await asyncio.gather(
fetch_json(session, f"http://user-service/users/{uid}"),
fetch_json(session, f"http://order-service/orders/{uid}"),
fetch_json(session, f"http://point-service/points/{uid}"),
)
return {
"user": user,
"orders": orders,
"points": points,
}
这段代码的好处是明牌。你一眼就知道这里有并发拉取,有 gather(),有异步 HTTP 客户端。代价也明牌:你的 Web 框架、HTTP 客户端、数据库驱动、缓存客户端,最好都得跟着 async 化。
asyncio 的底层到底在转什么
不少人用 asyncio 用得挺顺手,但问起"event loop 里面到底在转什么",就说不太清楚了。其实拆开了就三层。
第一层:I/O 多路复用——操作系统帮你盯着
asyncio 的 event loop 底层依赖操作系统提供的 I/O 多路复用机制:Linux 上是 epoll,macOS 上是 kqueue,Windows 上是 IOCP,实在不行还有 select 兜底。
这些机制做的事情本质上一样:你把一堆 socket(或者文件描述符)交给内核,告诉它"这些 fd 里谁准备好了就通知我",然后你可以安心等着,不用一个个去轮询。当任何一个 fd 有数据可读、可写、或者出了错,内核会把它标出来还给你。
所以 asyncio 的 event loop 每次迭代大致做三件事:
- 收作业:把所有新注册的 callback、pending 的 coroutine 对应的 I/O 事件,提交给
epoll/kqueue。 - 等通知:调用
epoll_wait()/kqueue()阻塞等待,直到有事件就绪或超时。 - 派活:把就绪的事件对应的 callback 或 coroutine 拎出来执行,执行完了再回到第 1 步。
整个 loop 是单线程的——一个 loop 迭代里,所有 callback 是串行跑的。某个 callback 里写了一段 CPU 密集的计算,或者调了一个没有 await 的阻塞操作,整个 loop 就被堵住,其他 coroutine 都得陪着等。
第二层:coroutine 是状态机,await 是让出点
Python 里的 async def 不是什么魔法——本质上就是一个可以暂停和恢复的状态机。编译器把 async def 编译成 coroutine 对象,每个 await 就是一个暂停点。
举个简化的心理模型:
async def fetch_data():
data = await http_get("http://example.com") # 暂停点 1
parsed = parse(data)
result = await db_save(parsed) # 暂停点 2
return result
当执行到 await http_get(...) 时,coroutine 会把控制权还给 event loop,同时告诉它:"我在等这个 socket 的数据,好了叫我。" event loop 记下来,转身去执行别的就绪 coroutine。等数据到了,event loop 把 coroutine 从暂停点恢复,继续往下跑到下一个 await。
这和 greenlet 的区别在于:greenlet 的切换点是隐式的(在被 patch 的 I/O 调用里自动发生),而 coroutine 的切换点是显式的(你得写 await)。 显式的好处是可预测——你看一眼代码就知道哪里会让出控制权;隐式的好处是透明——你不用改已有代码的写法。
第三层:Task 调度——谁先跑谁后跑
asyncio.Task 是 event loop 调度的基本单位。asyncio.create_task() 或 asyncio.gather() 里的每个 coroutine 都会被包成一个 Task,内部维护着执行状态,loop 通过就绪队列决定下一个跑谁。
调度策略很朴素:FIFO + I/O 事件驱动。没有优先级,没有抢占。谁的 I/O 先回来,谁先被唤醒;多个同时就绪的,按入队顺序来。所以 asyncio 里一个 CPU 密集的 Task 能把整个 loop 卡住——它没有 await,就没有让出点,调度器插不进手。
用一张图来理解就是:
┌─────────────────────────────────────────────┐
│ Event Loop │
│ │
│ ┌──────────┐ I/O 就绪 ┌──────────────┐ │
│ │ epoll/ │ ──────────> │ 就绪队列 │ │
│ │ kqueue │ │ Task A → B → C│ │
│ └──────────┘ └──────┬───────┘ │
│ │ │
│ 取出一个 Task │
│ │ │
│ ┌──────▼───────┐ │
│ │ 执行 Task │ │
│ │ 到下一个 │ │
│ │ await 暂停 │ │
│ └──────┬───────┘ │
│ │ │
│ 注册 I/O 等待 │
│ 回到 epoll/kqueue │
└─────────────────────────────────────────────┘
所以碰到"明明写了 async 但性能还是上不去",排查方向其实就两条:要么某个调用链里藏着同步阻塞(忘了 await 或者用了同步库),要么某段代码 CPU 跑得太久没有让出。
归根结底,两者最大的区别不只是 API 长相不同,而是团队干活的方式不同:
- Gevent 像给老厂房做节能改造,原来的管线尽量留着,先把最漏风的地方堵上。
- asyncio 更像按新规范盖楼,一开始就得按图纸来,后面维护会整齐得多。
用三个场景看它们的长短处
场景一:老 Flask 聚合服务,最适合 Gevent
一个后台接口要串 5 个内部 HTTP 服务,每个服务都不算慢,但加起来就慢了。代码里到处都是同步 requests、同步 ORM,团队也没有空把全链路改成 async。
这时候 Gevent 的好处很现实:
- 改造面通常更小,部署上换 gevent worker,代码上把 patch 放早一点。
- 团队认知成本低,很多同学继续按熟悉的同步方式写。
- 适合先验证瓶颈是不是 I/O,别一上来就搞"系统重构"这种大活。
它的短板也摆在桌面上:
- 某个库如果 patch 不到,或者内部有 C 扩展阻塞,链路里就会混进"伪并发"。
- 出问题时排查成本高,尤其是你以为它是非阻塞的,结果它偷偷卡住了。
场景二:新接口网关,asyncio 更顺手
如果你从零开始写一个新服务,天然就要并发调多个下游,还要接 WebSocket、流式响应、超时控制、取消任务,这时 asyncio 往往更舒服。
原因也不神秘:
- 语法层面就把异步边界写明白了。
- 超时、取消、批量等待这些能力是第一等公民。
- 和 ASGI 生态更贴,FastAPI、Starlette、uvicorn 这套组合拳比较顺手。
当然,代价也很实在:
- 你得接受
async/await侵入调用栈。 - 遇到只有同步驱动的库,往往要线程池兜底,看着就像在新房子里拉了一根老电线,不是不能用,就是总觉得别扭。
场景三:图片转码、报表生成,这俩都别硬扛
有些服务不是在等 I/O,而是在做 CPU 重活,比如视频转码、图片处理、复杂报表计算、特征提取。这个时候,不管是 greenlet 还是 asyncio,本质上都帮不上大忙。
原因也简单,它们擅长的是调度等待,不擅长凭空变出算力。 这类任务更像搬砖,搬砖的人只有一个,再优雅地排队,也不会让砖自己飞上楼。
这时该考虑的是多进程、任务队列,或者把重活丢到专门的计算服务。
Gevent 的好处(什么时候真香)
- 存量业务:一大坨同步 Flask、同步
requests、同步 DB 驱动,你没排期做全链路异步改造,但并发主要卡在等 I/O。这时候先上 gevent worker,往往比推倒重写便宜。 - 学习/迁移成本:团队熟悉同步编程模型时,不必一口气全员 async 化。
- 某些库只有同步官方驱动:强行 asyncio 可能要自己封线程池或找社区异步封装,而 Gevent 路线有时是 "先跑起来再演进" 。
反过来,如果你的瓶颈是 CPU、GIL 下的重计算,Gevent 和 asyncio 都不会替你变出多核线性加速——该多进程、该 offload 还是要做。
monkey.patch_all() 到底动了什么手脚
很多人第一次看到 monkey.patch_all() 的反应是——"好家伙,这不就是在运行时偷梁换柱吗?"没错,它干的就是这个。不过要把副作用讲清楚,得先知道它到底换了哪些梁、哪些柱。
调用 monkey.patch_all() 之后,以下标准库模块会被替换成 gevent 的协作式版本:
| 被 patch 的模块 | 替换成什么 | 影响范围 |
|---|---|---|
socket |
gevent.socket |
所有网络 I/O,包括 HTTP 库、数据库驱动底层 |
ssl |
gevent.ssl |
TLS 握手、HTTPS 连接 |
threading |
gevent.threading |
Thread 变成 greenlet 包装,Lock 变成协作锁 |
time.sleep |
gevent.sleep |
不再真阻塞线程,改为让出 greenlet |
select / selectors |
gevent 兼容版 | I/O 多路复用相关 |
os(部分) |
gevent 包装 | os.read、os.write、os.waitpid 等 |
subprocess |
gevent.subprocess |
子进程管理 |
signal(可选) |
gevent 信号处理 | 信号量注册和触发方式变化 |
queue |
gevent.queue |
线程安全队列变成 greenlet 安全 |
这张表看着挺整齐,坑就藏在"看起来换了,实际没换干净"的缝隙里。分几类说:
1. threading 被掏空了内核
monkey.patch_all() 默认会把 threading.Thread 替换成 greenlet 的包装。这意味着你代码里写的 Thread 不再是操作系统的真实线程,而是一个 greenlet。大多数时候无感,但遇到以下场景就会翻车:
- 需要真正并行的 CPU 密集任务:greenlet 是协作式的,同一时刻只有一个在跑,你以为开了 4 个"线程"能用满 4 个核,其实还是单核轮转。
- C 扩展内部持有 GIL 并做阻塞操作:C 扩展不经过 Python 层的 socket,patch 不到它,greenlet 也没法切走,整条 worker 就卡住了。
- 第三方库自己管理线程池:比如某些数据库连接池会在内部起
Thread做健康检查,patch 之后这些"线程"变成了 greenlet,如果它们恰好和 gevent 的调度产生竞争,就可能死锁或超时。
如果你只想 patch 网络 I/O,不想动 threading,可以显式关掉:
monkey.patch_all(thread=False)
不过这么做会丧失一部分并发能力——比如用了 threading.Lock 的库就不会在 I/O 时让出执行权。所以这是一个权衡,不是一个"正确答案"。
2. 导入顺序是一颗定时炸弹
monkey.patch_all() 必须在所有其他模块导入之前调用。原因很直白:它替换的是标准库里的模块对象。如果某个库在 patch 之前就已经 import socket 并缓存了原始引用,那这个库用的就还是没被 patch 的原版 socket,后续所有 I/O 操作仍然是真阻塞。
这在小项目里不容易踩到,但一旦项目大了、依赖链长了,不知道哪个 __init__.py 里藏着一个 early import,你就会遇到"明明 patch 了,某个调用还是卡住"的灵异现象。排查起来很痛苦,因为症状是间歇性的——取决于 Python 模块加载的顺序。
3. 信号处理可能不按剧本走
signal 模块被 patch 之后,信号的注册和回调触发时机可能发生变化。最典型的场景是 Gunicorn 的 graceful shutdown:master 进程给 worker 发 SIGTERM,worker 收到信号后应该优雅退出。如果信号处理被 gevent 接管,回调可能在你预期之外的 greenlet 里触发,导致退出逻辑和请求处理逻辑交叉执行。
生产环境里这种 bug 往往表现为"偶尔重启丢请求"或"stop 信号发了半天 worker 不退出"。不致命,但特别折磨人。
4. subprocess 的坑比想象中深
patch 之后的 subprocess 用 gevent 管理子进程的等待。如果子进程本身是 CPU 密集型的(比如调 FFmpeg 转码),gevent 会在 communicate() 时让出 greenlet,这是好事。但如果你的子进程和父进程之间有管道通信,且双方都在等对方先写数据,就可能出现 gevent 的调度和管道 buffer 之间配合不上的情况——绿色的死锁,日志里看不到明显错误,就是不出结果。
一句话总结:monkey.patch_all() 不是 "开关" 式的优化,更像是给整个运行时做了一台大手术。手术刀切的地方越多,恢复快的概率越高,但并发症的排查难度也越大。用它没问题,但你得知道自己动了哪些地方,而且最好有集成测试兜底。
代价与坑
除了 monkey patch 本身的副作用,Gevent 还有一些工程层面的代价:
- 并非所有阻塞都能被"变成绿":一些 C 扩展里的阻塞、意外的同步磁盘 I/O(比如写日志文件、读配置),仍可能卡住 worker。gevent 只能 patch Python 层面的标准库调用,C 层面的
recv()它管不着。 - 调试与可观测性:协程切换后,调用栈和日志时序不再是你熟悉的线性叙事。一条日志里两个请求的输出可能交叉出现,pdb 断点可能在你没预期的 greenlet 里停下来。习惯 gevent 的
hub调试和监控指标(worker 延迟、活跃 greenlet 数、hub 切换频率)是必修课。 - 依赖升级的脆弱性:某个库升级了一版,内部 import 顺序变了、换了一个 socket 实现、或者加了一层 C binding,你的 patch 可能就不生效了。这种回归往往测试环境不出问题(因为并发低),一上线就炸。
再多说一句不中听但有用的话:Gevent 最大的优点,恰好也是它最大的风险。 好处是你不用大改代码,坏处是你也更容易以为自己已经异步化了。很多团队最后不是败给模型选错,而是败给一知半解。
gevent 和 asyncio 混在一起为什么危险
前面分别讲了两边各自的机制,现在把它们塞进同一个进程里,看看会出什么事。这不是纸上推演——很多团队在给老项目引入新的 async SDK 时,确实会走到这一步。
根源:两套事件循环在同一个进程里抢地盘
gevent 底层跑的是自己的事件循环(基于 libev 或 libuv),asyncio 也有自己的事件循环(基于 epoll/kqueue 的 SelectorEventLoop 或 ProactorEventLoop)。两个 loop 都想管 I/O 事件,都想决定"谁先跑"。但它们互相不认识,也不会互相让。
这就好比一个十字路口同时装了两套红绿灯,一套说绿灯,另一套说红灯,车和行人都傻了。
危害一:monkey patch 破坏 asyncio 的内部假设
asyncio 的 event loop 内部依赖 threading、socket、selectors 等标准库模块,而且它假设这些模块的行为是"标准"的。一旦 monkey.patch_all() 把这些模块都替换了:
threading.Thread变成了 greenlet 的壳。asyncio 在某些场景下会起真线程做 executor(比如loop.run_in_executor()),patch 之后这些"线程"变成了 greenlet,而 greenlet 跑不了真正的并行任务,executor 就废了。selectors被替换后,asyncio 的SelectorEventLoop用的select/epoll不再是原生的系统调用包装,而是 gevent 的版本。两个 loop 在epoll_wait上互相干扰,可能出现事件丢失或重复派发。socket被替换后,asyncio 创建的 transport/protocol 底层走的是 gevent 的 socket。gevent 的 socket 在 I/O 阻塞时会切 greenlet,但 asyncio 根本不知道 greenlet 切走了,它还以为当前 coroutine 在正常等待,回调链就可能乱掉。
拿 run_in_executor 举个例子,patch 前后的行为差异一目了然:
from gevent import monkey
monkey.patch_all()
import asyncio
import threading
import time
def blocking_work():
tid = threading.current_thread()
print(f"[executor] thread type: {type(tid).__module__}.{type(tid).__qualname__}")
print(f"[executor] is real OS thread? {not hasattr(tid, '_greenlet')}")
time.sleep(1)
return 42
async def main():
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(None, blocking_work)
print(f"[main] result = {result}")
asyncio.run(main())
不 patch 的时候,blocking_work 跑在 concurrent.futures 的真线程里,和 event loop 各干各的。patch 之后,threading.Thread 是 greenlet 的壳,time.sleep 也变成了 gevent.sleep——executor 的"线程"其实是 greenlet,和 asyncio 的 loop 跑在同一条 OS 线程上。如果 blocking_work 里做了 CPU 密集的事情,它不会像真线程那样并行执行,而是独占当前执行权直到遇到下一个 I/O 让出点。
危害二:死锁——两个调度器互相等
最典型的死锁场景长这样:
from gevent import monkey
monkey.patch_all()
import gevent
import asyncio
import urllib.request
async def fetch_async(url):
# 底层 socket 已经被 patch 成 gevent 版本
# asyncio 的 event loop 在等 I/O 完成
# gevent 的 hub 也在等这个 socket 就绪
reader, writer = await asyncio.open_connection('example.com', 80)
writer.write(b'GET / HTTP/1.0\r\nHost: example.com\r\n\r\n')
await writer.drain()
data = await reader.read(4096)
writer.close()
return len(data)
def handle_request():
"""模拟 Flask 视图函数在 gevent worker 里执行"""
# 这里直接 asyncio.run(),会在当前 greenlet 里阻塞
# asyncio 创建的 loop 底层走 patched selectors
# 两个调度器的 I/O 等待可能互相卡住
result = asyncio.run(fetch_async("http://example.com"))
return result
# 模拟并发请求:多个 greenlet 同时调用 asyncio.run()
greenlets = [gevent.spawn(handle_request) for _ in range(5)]
gevent.joinall(greenlets, timeout=10)
for i, g in enumerate(greenlets):
if g.value is None:
print(f"greenlet {i}: timeout or deadlock (value=None)")
else:
print(f"greenlet {i}: got {g.value} bytes")
陷阱在哪?每个 greenlet 里都调了 asyncio.run(),各自创建一个新 loop。这些 loop 底层走的 selectors 已经被 patch 成 gevent 版本了,于是 asyncio 在做 select() 的时候,gevent 的 hub 可能在同一时刻也在做自己的 select()。低并发可能侥幸跑通,把并发一拉高,要么超时,要么某个 greenlet 永远拿不到结果——两个调度器各等各的,谁也叫不醒谁。
你可能会想:要不我就起一个 greenlet 跑 asyncio?问题是 asyncio.run() 本身就要接管当前线程的 loop,而 gevent 的 hub 也想接管这个线程。一条线程,两个主人,只是把死锁的概率从"高"降到了"不那么高"。
危害三:连接池和上下文被"撕裂"
连接池被撕裂是什么意思?看一段代码就明白了:
from gevent import monkey
monkey.patch_all()
import gevent
import asyncio
import httpx
async def fetch_with_new_client(url):
"""每次 asyncio.run() 都会创建又销毁 loop,
如果在里面创建 AsyncClient,连接池也跟着生生死死"""
async with httpx.AsyncClient() as client:
resp = await client.get(url)
return resp.status_code
def handle_request(url):
return asyncio.run(fetch_with_new_client(url))
# 模拟 100 个并发请求
greenlets = [
gevent.spawn(handle_request, "http://httpbin.org/get")
for _ in range(100)
]
gevent.joinall(greenlets, timeout=30)
# 这 100 次调用,创建了 100 个 event loop,100 个连接池,
# 开了 100 组 TCP 连接,又全部关掉。
# 对比:如果用同步 requests + gevent patch,
# 底层 urllib3 的连接池是复用的。
100 个请求就意味着 100 个 loop、100 个 AsyncClient、100 次 TCP 握手和 TLS 协商。同步的 requests 库在 gevent patch 下至少还有 urllib3 的连接池兜底,而这种写法把 async 库的连接池优势全丢了。而且当 loop 被销毁时,如果底层的 TCP 连接没来得及发 FIN,你会在服务端看到一堆 TIME_WAIT 或半开连接,监控上就是"连接数莫名飙高、然后慢慢回落"的锯齿波。
contextvars 跨边界丢失的问题也好验证:
from gevent import monkey
monkey.patch_all()
import gevent
import asyncio
import contextvars
request_id = contextvars.ContextVar('request_id', default='none')
async def check_context():
return request_id.get()
def handle_request(rid):
request_id.set(rid)
print(f"[greenlet] set request_id={rid}")
result = asyncio.run(check_context())
print(f"[asyncio] got request_id={result}")
# 在某些 gevent 版本下,result 可能不是 rid,
# 因为 asyncio.run() 创建的 Task 拿到的 context
# 和 greenlet 当前的 context 不一定是同一份
greenlets = [gevent.spawn(handle_request, f"req-{i}") for i in range(3)]
gevent.joinall(greenlets)
如果你的链路追踪依赖 contextvars 传 trace ID,在 gevent ↔ asyncio 边界上丢了,排查线上问题时 trace 链就是断的——这种 bug 不会让请求失败,但会让你在出事时完全找不到上下文。
危害四:异常传播链断裂
在纯 asyncio 环境里,await 链上的异常会沿着 coroutine 调用栈正常冒泡。但在 gevent + asyncio 混合环境里,异常需要跨越 greenlet → 真线程 → asyncio Task → 真线程 → greenlet 这样的边界。每过一个边界,异常的 traceback 就可能被截断或包裹一层。最后你在日志里看到的 traceback 面目全非,定位 bug 要靠猜。
一句话判断:如果你发现自己在 gevent 服务里越来越多地桥接 asyncio 代码,这不是一个要解决的技术问题,而是一个要做的架构决策——要么把 async 的部分拆成独立服务,要么把这个服务整体迁到 ASGI。继续在一个进程里伺候两个调度器,迟早会踩到上面某个坑。
如果 gevent/Flask 非要调用 asyncio 的库,怎么办
话虽这么说,现实是很多团队没法一步到位。你可能就是有一个 async SDK 必须调,项目又不可能马上全部重写。这事不是不能干,但要讲规矩。最忌讳的做法,是在 gevent 的请求处理函数里,把 asyncio.run() 当家常便饭一样直接调用。
为什么不推荐?因为它每次都会新建、运行、再销毁一个 event loop。低频工具脚本这么干还行,在线请求链路里这么玩,性能一般,资源管理也容易乱,碰到连接池、长连接、上下文传播时,尤其容易出幺蛾子。
更稳一点的思路有两种。
方式一:过渡期方案,把 asyncio 调用扔到真实线程里
如果你的场景只是少量调用某个 async SDK,或者只是迁移过程中的临时桥接,可以把异步调用包一层,扔进 gevent 的线程池或者普通线程池里跑:
import asyncio
from gevent.threadpool import ThreadPool
threadpool = ThreadPool(4)
async def call_async_sdk(uid):
# 这里假设某个第三方库只有 async API
return await async_client.get_user(uid)
def run_async_once(uid):
return asyncio.run(call_async_sdk(uid))
@app.get("/user/<uid>")
def get_user(uid):
data = threadpool.apply(run_async_once, (uid,))
return {"data": data}
这个办法的好处是简单,容易懂,适合救急。缺点也明显:每次调用都起一个 loop,开销不小,也不适合高频热点路径。 它更像搭一块临时跳板,不像正式桥梁。
方式二:更靠谱的方案,起一条专门的 asyncio 线程
如果 async 库会被频繁调用,比较靠谱的办法是:单独起一个真实线程,在那条线程里长期跑一个 event loop;Flask/gevent 这边把 coroutine 提交过去,等结果回来。
import asyncio
import threading
loop = asyncio.new_event_loop()
def start_loop():
asyncio.set_event_loop(loop)
loop.run_forever()
threading.Thread(target=start_loop, daemon=True).start()
async def call_async_sdk(uid):
return await async_client.get_user(uid)
def run_async(coro, timeout=2):
future = asyncio.run_coroutine_threadsafe(coro, loop)
return future.result(timeout=timeout)
@app.get("/user/<uid>")
def get_user(uid):
data = run_async(call_async_sdk(uid), timeout=2)
return {"data": data}
这个模式的好处是边界清楚。asyncio 在自己的 loop 里跑,gevent 继续跑自己的 greenlet,大家各守一摊,不在同一条线程里抢方向盘。它依然不是最优雅的架构,但比"一边 gevent,一边在请求里临时 asyncio.run()"稳得多。
一个更贴近实战的例子:Flask + gevent 调 httpx.AsyncClient
假设你有个 Flask 服务,自己跑在 Gunicorn + gevent worker 里,但下游有个内部网关只提供了 async SDK,或者你就是想复用 httpx.AsyncClient 的连接池、超时、HTTP/2 能力。这时候可以包一个桥接类:
import atexit
import asyncio
import threading
import httpx
from flask import Flask, jsonify
app = Flask(__name__)
class AsyncHttpxBridge:
def __init__(self):
self.loop = asyncio.new_event_loop()
self.thread = threading.Thread(target=self._run_loop, daemon=True)
self.thread.start()
self.client = self._submit(self._create_client())
def _run_loop(self):
asyncio.set_event_loop(self.loop)
self.loop.run_forever()
async def _create_client(self):
return httpx.AsyncClient(
timeout=httpx.Timeout(2.0, connect=0.5),
limits=httpx.Limits(max_connections=100, max_keepalive_connections=20),
http2=True,
)
def _submit(self, coro, timeout=3):
future = asyncio.run_coroutine_threadsafe(coro, self.loop)
return future.result(timeout=timeout)
async def _get_json(self, url, params=None):
resp = await self.client.get(url, params=params)
resp.raise_for_status()
return resp.json()
def get_json(self, url, params=None, timeout=3):
return self._submit(self._get_json(url, params=params), timeout=timeout)
async def _aclose(self):
await self.client.aclose()
def close(self):
self._submit(self._aclose())
self.loop.call_soon_threadsafe(self.loop.stop)
self.thread.join(timeout=1)
async_http = AsyncHttpxBridge()
atexit.register(async_http.close)
@app.get("/profile/<uid>")
def profile(uid):
user = async_http.get_json("http://user-service/users/info", params={"uid": uid})
orders = async_http.get_json("http://order-service/orders/list", params={"uid": uid})
return jsonify({
"user": user,
"orders": orders,
})
这里有几个点,和 demo 代码不太一样,线上味道会更重一点:
AsyncClient是在 event loop 所在线程里创建 的,不是在 Flask 主线程里顺手 new 一个。- Flask 这边暴露的是同步方法
get_json(),这样视图函数看起来还是同步风格,比较适合 gevent/WSGI 老项目。 - 连接池是复用的,不会像
asyncio.run()那样每次请求都从头折腾一遍。 - 退出时显式
aclose(),不然 keep-alive 连接和后台资源容易收不干净。
当然,这个例子还是做了一个妥协,profile() 里两次调用 get_json() 仍然是串行的。你如果真想把它并发起来,也可以在 bridge 里再包一个批量提交接口:
async def _gather_json(self, calls):
tasks = [self.client.get(url, params=params) for url, params in calls]
responses = await asyncio.gather(*tasks)
return [resp.json() for resp in responses]
不过话说回来,如果你已经在一个 gevent 服务里认真地封装批量 async HTTP 调用了,这通常说明你离"把这一块拆出去,或者改成 ASGI"只差领导点头了。 技术债这个东西,就像家里阳台上的杂物柜,先能塞就塞,塞到后来门就关不上了。
几条别踩的坑
- 不要混着共享 loop 里的对象:例如某个
AsyncClient、异步连接池、async session,最好只在它所属的 event loop 线程里创建和使用。 - 一定要加 timeout:不然 gevent 这边等结果,asyncio 那边也在等下游,最后就成了两边一起发呆。
- 把它当过渡方案,不要当终局设计:如果一个 Flask/gevent 服务里越来越多地方都要靠 async 库,往往说明这块逻辑已经值得单拆服务,或者直接迁到 ASGI。
- 进程退出要能收尾:长期运行的 loop 线程、连接池、后台任务,停机时要有关闭动作,别让它们像下班后没关灯的会议室一样一直亮着。
- 每个 worker 都有自己的一份 bridge:Gunicorn 起多个 gevent worker 时,连接池和 loop 不是全局共享的,这很正常,容量规划时别算错账。
如果你问我怎么粗暴判断,可以先记这一张表:
| 你的处境 | 更像 Gevent 的活 | 更像 asyncio 的活 |
|---|---|---|
| 老项目很多同步代码 | 是 | 否 |
| 需要快速缓解 I/O 等待 | 是 | 也可以,但改造更大 |
| 新项目、接口天然异步 | 一般 | 是 |
| 需要 WebSocket/流式处理/取消任务 | 能做,但不占便宜 | 更顺手 |
| 团队对 async 生态熟 | 未必 | 是 |
| 依赖链里有不少老同步库 | 是 | 可能会拧巴 |
收个尾
说了这么多,一句话收回来:
Gevent 在 Flask 语境里,本质是用"协作式 I/O 并发"换"少改同步代码";asyncio 是用"语法与库契约"换"显式可控的异步模型"。
选型时先问两句:瓶颈是在等 I/O,还是在算?团队和依赖链,哪条更现实?想明白这两个问题,再决定是 patch 跑起来,还是 async 重写一截。
总结
- Gevent:greenlet + monkey patch,WSGI 场景下常见配合 Gunicorn gevent worker,同步写法吃 I/O 并发。
- asyncio:显式 async/await,适合新项目和异步生态齐全链路。
- 收益:对大量同步 Flask + I/O 等待,Gevent 常是改造成本更低的一步;若从零搭系统,又要长久演进,asyncio 往往更整齐;二者都不是 CPU 密集问题的万能药。
思维导图(源码见下节 PlantUML,以下为渲染图):

@startmindmap
* Gevent vs asyncio
** Gevent(Flask)
*** 协作式 greenlet
*** monkey patch 阻塞 I/O
*** 同步写法 + 高并发 I/O
*** monkey.patch_all() 副作用
**** threading 被掏空 → greenlet 不等于真线程
**** 导入顺序敏感 → early import 导致 patch 失效
**** 信号处理异常 → graceful shutdown 出问题
**** C 扩展阻塞 patch 不到
** asyncio
*** 显式 async/await
*** 底层:epoll/kqueue I/O 多路复用
*** coroutine = 可暂停的状态机
*** Task 调度:FIFO + I/O 事件驱动
*** 单线程 loop → 阻塞调用卡全局
** 混用危害
*** 两套 event loop 抢地盘
*** monkey patch 破坏 asyncio 内部假设
*** 死锁:两个调度器互相等
*** 连接池/上下文被撕裂
** 选型
*** I/O 密集 + 遗留同步代码 → Gevent 友好
*** 新项目/全链路异步 → asyncio
** 代价
*** patch 脆弱、排障难
*** CPU 密集仍要进程/线程
@endmindmap
可执行 CheckList(明天就能用)
- 压测先确认:CPU 空转等 I/O 还是 CPU 打满;后者别指望 Gevent 单独救场。
- 若试 Gevent:尽早 patch、固定依赖版本、把关键路径用集成测跑一遍。
- 若走 asyncio:从 边界服务 或 新模块 试点,别把"半同步半异步"混到同一调用栈里还不自知。
- 若 gevent 服务必须调用 async 库,优先用 独立线程里的长期 event loop 做桥接,别把
asyncio.run()塞进热点请求路径。 - 做选型前,先把你最慢的两个接口画出来,看看时间到底花在 等下游 还是 本地计算。
- 无论哪条:给 P99 延迟、错误率、worker 数 设告警,用数据说话。
扩展阅读
- Gevent 官方文档:https://www.gevent.org/
- Python
asyncio标准库:https://docs.python.org/3/library/asyncio.html - Flask 异步相关说明:https://flask.palletsprojects.com/en/stable/async-await/
- Gunicorn 设计(含 worker 类型概念):https://docs.gunicorn.org/en/stable/design.html
本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可。