KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
01 · 线程安全单例与 frozen dataclass — keel 龙骨
问题:一个服务里管理数据库连接池的组件,全进程必须只有一个——谁来保证?连接规格(host、端口、库名)创建后绝不允许被改——谁来强制?生产代码用两个语言特性回答:带锁的 new 单例、frozen dataclass。
问题:一个服务里管理数据库连接池的组件,全进程必须只有一个——谁来保证?连接规格(host、端口、库名)创建后绝不允许被改——谁来强制?生产代码用两个语言特性回答:带锁的
__new__单例、frozen dataclass。
一、线程安全单例:__new__ + 锁
一个典型的 pool.py
import threading
class ConnectionPool:
"""进程内唯一的数据库连接池。"""
_instance: "ConnectionPool | None" = None
_instance_lock = threading.Lock() # 类属性锁:所有实例化调用共享
def __new__(cls) -> "ConnectionPool":
# 加锁是为了避免并发创建出两个池子
with cls._instance_lock:
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self) -> None:
# 单例对象只在第一次创建时初始化内部状态
if getattr(self, "_is_initialized", False):
return # ← 二次调用直接返回,不清空状态
self._dsn = "postgresql://localhost/app"
self._connections: dict[str, object] = {}
self._lock = threading.RLock()
self._is_initialized = True
为什么 __init__ 还要防一手:__new__ 保证实例只有一个,但 __init__ 在每次调用 ConnectionPool() 时都会执行(Python 语义如此)。第二次调用时 __init__ 若照常跑,self._connections = {} 会把已建立的连接清空——注释里那句「后面即使别的地方再次调用,也不能把已经记录的连接重新清空」就是这个意思。_is_initialized 哨兵就是防这个。
为什么不用装饰器/元类实现单例:都可以,但 __new__ 版有两个工程优点——① 类还是普通类,继承/类型注解不受影响;② 锁的粒度清晰可见。代价是要记得写 _is_initialized 防重复初始化,这正是「看懂这段代码」的考点。
1.1 为什么必须加锁?
单例的竞态窗口:
线程 A:if cls._instance is None → True(还没创建)
线程 B:if cls._instance is None → True(A 还没赋值)
线程 A:cls._instance = Pool() ← A 的实例
线程 B:cls._instance = Pool() ← B 的实例把 A 的覆盖了!A 持有的引用变成「孤儿」
两个池子 = 两套连接记录 = 关闭时有一批连接成为没人管的孤儿。加锁把「检查+创建」变成原子操作。RLock(可重入锁)用于 _connections 的操作可能嵌套加锁的场景——Lock 会在同线程二次加锁时死锁,RLock 不会。
1.2 另一种思路:模块级单例
Python 里最朴素的单例其实是模块本身(模块天然只导入一次):
# pool.py 末尾
connection_pool = ConnectionPool() # 很多项目也这么用(模块即单例)
选型口诀:状态简单、无并发创建风险 → 模块级实例;需要控制创建时机、有并发风险 → __new__ + 锁。两种在真实项目里都有,按风险选。
二、frozen dataclass:创建后不许改的值对象
配合上面用的两个定义
@dataclass(frozen=True)
class ConnectionSpec:
"""一个连接目标的静态定义,创建完成后不允许再修改字段值。"""
host: str
port: int = 5432
database: str = "app"
@dataclass(frozen=True)
class ConnectionHandle:
"""一条已建立的连接的基本信息,由连接自身变化,池子不改它。"""
name: str
native_conn: object
frozen=True 做了什么:生成 __setattr__ 会抛 FrozenInstanceError——
spec = ConnectionSpec(host="localhost")
spec.port = 6379 # → dataclasses.FrozenInstanceError: cannot assign to field 'port'
这是把「运行时约定」升级成「语言强制」。没有 frozen 的话,某处代码手滑写一行 spec.port = 9999,这个池就会开始连错端口——排查起来是灵异事件。frozen 让它当场炸在写错的那一行。
2.1 frozen dataclass 的三个工程收益
- 可哈希:frozen dataclass 自动生成
__hash__,可以进 set / 做 dict 的 key; - 线程友好:不可变对象天然线程安全——没有「改一半被读到」的状态;
- 意图清晰:读代码的人一看 frozen 就知道「这是值对象,不是有生命周期的状态机」。
2.2 对比:哪些该 frozen,哪些不该
| 对象 | 示例项目的选择 | 为什么 |
|---|---|---|
ConnectionSpec(连接静态定义) |
frozen | 定义表:加载后永不变 |
ConnectionHandle(连接记录) |
frozen | 字段创建后由连接自身变化,池子不改它 |
ResolvedConfig(合并后的运行配置) |
frozen | 三段合并的结果,必须不可变才可信 |
ConnectionPool(池子) |
普通类 | 有 _connections 状态字典,生命周期内持续变化 |
套路:「一次计算、 thereafter 只读」的东西 frozen;「持续演化的状态」普通类。配置、定义、合并结果 → frozen;运行时状态容器 → 可变。
三、可迁移的套路
- 全进程唯一的管理器/连接池/注册表 →
__new__+Lock+_is_initialized三件套(或模块级实例); - 防重复初始化的哨兵模式(
_is_initialized)值得背——单例的坑不在创建,在__init__重放; - 配置、定义、合并结果 →
@dataclass(frozen=True),把「不许改」从注释变成异常; - 嵌套加锁风险 →
RLock;简单互斥 →Lock。
↓下一步:02 章 · 模板方法与抽象基类