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 的三个工程收益

  1. 可哈希:frozen dataclass 自动生成 __hash__,可以进 set / 做 dict 的 key;
  2. 线程友好:不可变对象天然线程安全——没有「改一半被读到」的状态;
  3. 意图清晰:读代码的人一看 frozen 就知道「这是值对象,不是有生命周期的状态机」。

2.2 对比:哪些该 frozen,哪些不该

对象 示例项目的选择 为什么
ConnectionSpec(连接静态定义) frozen 定义表:加载后永不变
ConnectionHandle(连接记录) frozen 字段创建后由连接自身变化,池子不改它
ResolvedConfig(合并后的运行配置) frozen 三段合并的结果,必须不可变才可信
ConnectionPool(池子) 普通类 有 _connections 状态字典,生命周期内持续变化

套路:「一次计算、 thereafter 只读」的东西 frozen;「持续演化的状态」普通类。配置、定义、合并结果 → frozen;运行时状态容器 → 可变。

三、可迁移的套路

  1. 全进程唯一的管理器/连接池/注册表 → __new__ + Lock + _is_initialized 三件套(或模块级实例);
  2. 防重复初始化的哨兵模式(_is_initialized)值得背——单例的坑不在创建,在 __init__ 重放;
  3. 配置、定义、合并结果 → @dataclass(frozen=True),把「不许改」从注释变成异常;
  4. 嵌套加锁风险 → RLock;简单互斥 → Lock。

↓下一步:02 章 · 模板方法与抽象基类

进入 keel 阅读