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 一起用有什么坑

短大纲

  1. 一句话:Gevent 用 greenlet 做协作式多任务,再配合 monkey patch,把"阻塞 I/O"变成"先让一让,等会儿再回来"。Flask 里最常见的搭法,是 Gunicorn gevent worker。
  2. 和 asyncio 的差别:asyncio 是明牌,代码里得老老实实写 asyncawait;Gevent 则尽量让你保留同步写法,在 I/O 边界悄悄切换。
  3. 值不值得用:如果是存量 Flask 老项目,瓶颈又主要在等 I/O,Gevent 往往是成本更低的一步;代价是 patch 边界、排障难度,还有一堆"看起来没问题,其实卡住了"的坑。

正文

先从一个很常见的误会说起

你写了个 Flask 接口,查数据库,调下游 HTTP,拼个 JSON 返回。本地单测飞快,一上压测,CPU 还没满,QPS 先趴下了。有人丢过来一句:"上 Gevent。" 你打开文档,看见 monkey.patch_all(),心里咯噔一下,这玩意儿像在运行时给标准库"换心",靠谱吗?

靠谱不靠谱,要看你解决的是哪一类问题。如果你的服务主要是在等 I/O,等 socket,等 DB,等下游接口,那么 Gevent 干的事无非一句话:别让一个请求傻等的时候,把整条执行线霸着不放。 它不是仙丹,救不了纯算力瓶颈。

Gevent(Flask 模式)到底在干什么

一句话拆开说,无非三件事:

  1. Greenlet:可以把它理解成用户态的轻量协程,由程序自己调度。谁该往下跑,谁先停一停,不是内核说了算,而是在 I/O 可能阻塞的地方主动让一下。
  2. Monkey patch:在进程启动足够早的时候,把 socketssltime.sleep 这些会阻塞的入口,换成 gevent 兼容的实现。这样一来,原来傻等的地方,就有机会先把执行权让出去。
  3. 和 Flask 的搭法:最常见的是 WSGI 服务器用 gevent worker,例如 Gunicorn -k gevent。开发环境里也有人在入口最前面 patchapp.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 覆盖得到或本身可协作) 需要 aiohttpasyncpg 等;混用同步阻塞要小心
心智负担 "哪里被 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 每次迭代大致做三件事:

  1. 收作业:把所有新注册的 callback、pending 的 coroutine 对应的 I/O 事件,提交给 epoll/kqueue
  2. 等通知:调用 epoll_wait() / kqueue() 阻塞等待,直到有事件就绪或超时。
  3. 派活:把就绪的事件对应的 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.reados.writeos.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 的 SelectorEventLoopProactorEventLoop)。两个 loop 都想管 I/O 事件,都想决定"谁先跑"。但它们互相不认识,也不会互相让。

这就好比一个十字路口同时装了两套红绿灯,一套说绿灯,另一套说红灯,车和行人都傻了。

危害一:monkey patch 破坏 asyncio 的内部假设

asyncio 的 event loop 内部依赖 threadingsocketselectors 等标准库模块,而且它假设这些模块的行为是"标准"的。一旦 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,以下为渲染图):

Gevent vs asyncio 要点

@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(明天就能用)

  1. 压测先确认:CPU 空转等 I/O 还是 CPU 打满;后者别指望 Gevent 单独救场。
  2. 若试 Gevent:尽早 patch、固定依赖版本、把关键路径用集成测跑一遍。
  3. 若走 asyncio:从 边界服务新模块 试点,别把"半同步半异步"混到同一调用栈里还不自知。
  4. 若 gevent 服务必须调用 async 库,优先用 独立线程里的长期 event loop 做桥接,别把 asyncio.run() 塞进热点请求路径。
  5. 做选型前,先把你最慢的两个接口画出来,看看时间到底花在 等下游 还是 本地计算
  6. 无论哪条:给 P99 延迟、错误率、worker 数 设告警,用数据说话。

扩展阅读



本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可。