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 的队列。这个绑定关系建起来之后:

典型事故:同步入口(比如定时任务、信号处理器、调度器线程)里需要发一个异步请求。图省事复用了全局的异步 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["可用。这本来就是它的归属地"]

用这张图过一遍四个常见场景,检验自己是否真的掌握:

  1. uvicorn 多 worker 部署(每个 worker 一个进程 + 一个 loop):各 worker 的池互不相干——进程边界。
  2. gunicorn preload_app=True:master 建的池被 fork 到所有 worker——进程边界 + fork 型,worker 入口必须 dispose。
  3. FastAPI 应用内,请求处理协程里用全局异步池:同进程同 loop——可用,这正是它的归属地。
  4. 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 都要按乘积算。这是归属原则在容量维度上的投影。

七、练习

  1. 把第 5 节的四个场景在本地搭出最小复现(或至少写出演示代码),其中场景 3 和 4 各写一版错误用法,确认能触发对应的报错或悬挂。
  2. 你的服务要在 atexit / 信号处理器里发一条 Redis 记录。画一遍判定图,写出正确姿势。(参考:信号处理器内不能 await——用同步客户端 + 独立连接,或提前把发送职责交给常驻协程。)
  3. 一句话回答:为什么「同步池可以跨线程」和「异步池不可以跨 loop」并不矛盾?

现在你能解释:三条边界各自由什么决定、同步池与异步池的并发安全来源、以及归属判定图的走法。最后一章把这些知识排成一张按症状出发的排查手册——从报错信息反推到根因。

进入 keel 阅读