KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
03 · 连接池的三条共享边界:进程、事件循环、线程 — keel 龙骨
第 2 章讲透了进程边界。但「Event loop is closed」这种报错和进程毫无关系——它是第二条边界。这一章把连接池的归属规则补完整:三条边界、一张判定图、一条通用原则。
第 2 章讲透了进程边界。但「Event loop is closed」这种报错和进程毫无关系——它是第二条边界。这一章把连接池的归属规则补完整:三条边界、一张判定图、一条通用原则。
一、池化到底是什么
不池化的话,每次查询都要走一遍 TCP 握手 + TLS + 认证 + 协议协商,几十毫秒烧在「准备」上;池化就是预先建好一批连接放在池子里反复用。池子里的每个连接,本质上是一个活着的操作系统 socket(一个 fd,带 TCP 状态机、认证上下文、可能的会话变量)。
理解了这一点,三条边界就能统一成一句话:连接是绑定在「创建它的上下文」上的 OS 资源。上下文一换——换进程、换 event loop——连接的归属就断了。
二、边界一:进程(复习 + 收口)
第 2 章的结论:fork 型拉起会把整张 fd 表复制给子进程,共享同一批 TCP 连接导致协议错乱;exec 型默认不传。防御是子进程入口 dispose + 懒加载重建。这里只补充一条判别口径:
判断一个池会不会跨进程出事,看的是「拉起方式」,不是「你想不想共享」。 你没打算共享,fork 也会把 fd 塞过去。归属问题永远由 OS 行为决定,不由代码意图决定。
三、边界二:event loop(asyncio 专属)
asyncio 的连接(asyncpg、redis.asyncio、aiohttp 等)在建立时会把 fd 注册进创建它的那个 event loop 的选择器(selector/epoll),回调也排进那个 loop 的队列。这个绑定关系建起来之后:
- 连接只能在创建它的 loop 里安全使用;
- 换一个 loop 用它——比如重启了 loop、在另一个线程新建 loop 跑协程——原 loop 已关闭或不在运行,读写的就绪通知永远不来,直接炸
RuntimeError: Event loop is closed或悬挂。
典型事故:同步入口(比如定时任务、信号处理器、调度器线程)里需要发一个异步请求。图省事复用了全局的异步 Redis 池——但它是在主服务的 loop 里建的,同步路径新开(或没有)loop,于是:
# 错误:复用主 loop 的池
pool = get_global_async_pool() # 绑定在主服务的 event loop 上
await redis_call(pool) # 新 loop 或已关闭的 loop → Event loop is closed
# 正确:同步路径自建独立池,用完即关
pool = await create_pool(settings)
try:
await redis_call(pool)
finally:
await pool.close()
「每次新建、用完即关」在这个场景不是浪费:同步路径调用频率低(定时、管理操作),而自建池换来的是归属清晰。对比第 2 章的 dispose——一个在子进程入口清一次,一个在同步路径每次重建,手法相反,原则相同:资源归属跟随使用它的上下文。
四、边界三:线程(最容易误判的一条)
「连接池能不能跨线程」——答案是分池型的:
| 池型 | 跨线程 | 原因 |
|---|---|---|
同步池(SQLAlchemy Engine、redis.Redis、psycopg 池) |
可以 | 池内部有锁保护 checkout/checkin;连接同一时刻只被一个线程用,用完归还 |
异步池(asyncpg、redis.asyncio) |
可以,但仅限同一个 event loop 上 | loop 本身单线程,池的并发安全由 loop 的串行性保证;跨线程等于跨 loop,撞边界二 |
所以精确的说法是:同步池怕进程不怕线程;异步池怕 loop、loop 又怕跨线程。日常开发里真正的坑几乎都在异步侧——因为异步池的「跨线程使用」往往伪装成「我在另一个协程里复用一下」,而那个协程其实跑在别的 loop 上。
五、归属判定图
三条边界合成一张判定图,拿任何「这个池能不能在这里用」的问题走一遍:
flowchart TD
A["要在上下文 X 里用连接池 P"] --> B{"X 和 P 的创建上下文<br/>是同一个进程吗?"}
B -- "否" --> R1["不可用。fork 泄漏则 dispose 重建;<br/>真要共享改走消息 / RPC"]
B -- "是" --> C{"P 是异步池吗?"}
C -- "否(同步池)" --> R2["可用(跨线程 OK,池内锁保护)"]
C -- "是" --> D{"X 的 event loop<br/>= P 创建时的 loop?"}
D -- "否(别的线程 / 别的 loop)" --> R3["不可用。自建独立池用完即关"]
D -- "是" --> R4["可用。这本来就是它的归属地"]
用这张图过一遍四个常见场景,检验自己是否真的掌握:
- uvicorn 多 worker 部署(每个 worker 一个进程 + 一个 loop):各 worker 的池互不相干——进程边界。
- gunicorn
preload_app=True:master 建的池被 fork 到所有 worker——进程边界 + fork 型,worker 入口必须 dispose。 - FastAPI 应用内,请求处理协程里用全局异步池:同进程同 loop——可用,这正是它的归属地。
- APScheduler 的线程池里跑的同步函数,想 await 一下异步 Redis:跨线程 + 跨 loop——必须在该线程新建 loop + 独立池,用完关。
六、通用原则与生产映射
三条边界背后是同一句话,值得作为你自己的工程原则记下来:
归属不清的资源,宁可重建,不要共享。 重建的成本由懒加载摊薄(第 2 章),共享的代价是偶发的、按字节计的数据错乱。
生产映射(各替换点):
| 教学替身 | 生产形态 | 归属要点 |
|---|---|---|
| 教学里的「daemon 拉起 worker」 | gunicorn / 自写 daemon / 容器多进程 | 进程入口 dispose + 懒加载 |
| 教学里的「同步路径自建池」 | 定时任务、CLI 管理命令、信号处理器 | 每次新建、finally 关闭 |
| 教学里的「线程池跑协程」 | asyncio.run in thread、run_coroutine_threadsafe |
确认 loop 归属;优先 run_coroutine_threadsafe 借回主 loop |
| 教学里的「多 worker HTTP 服务」 | uvicorn --workers N、容器副本 |
每副本独立池,容量按副本数重新计算 |
最后一条值得展开:连接池容量规划要乘以副本数。4 个 uvicorn worker 各开 20 条数据库连接,数据库看到的是 80 条——上限配置、连接数监控、数据库侧的 max_connections 都要按乘积算。这是归属原则在容量维度上的投影。
七、练习
- 把第 5 节的四个场景在本地搭出最小复现(或至少写出演示代码),其中场景 3 和 4 各写一版错误用法,确认能触发对应的报错或悬挂。
- 你的服务要在
atexit/ 信号处理器里发一条 Redis 记录。画一遍判定图,写出正确姿势。(参考:信号处理器内不能 await——用同步客户端 + 独立连接,或提前把发送职责交给常驻协程。) - 一句话回答:为什么「同步池可以跨线程」和「异步池不可以跨 loop」并不矛盾?
现在你能解释:三条边界各自由什么决定、同步池与异步池的并发安全来源、以及归属判定图的走法。最后一章把这些知识排成一张按症状出发的排查手册——从报错信息反推到根因。