← 返回题目列表

Python 有读写锁吗?读多写少的场景该怎么保护共享数据?

中等 第 24 / 27 题 更新于 2026/08/01
读写锁并发控制不可变替换写者饥饿

简化版

Python 标准库没有读写锁(threading 里只有 Lock/RLock/Semaphore/Condition/Barrier)。读写锁的语义是「读读并行、读写互斥、写写互斥」——多个读者可以同时进入,但写者要独占。它适合「读远多于写」的场景(配置缓存、路由表、连接池状态)。自己实现的核心是「读者计数 + 一把写锁」:第一个读者进来时获取写锁、最后一个读者离开时释放,读者之间只用一把短小的互斥锁保护计数器。但这个朴素实现有个经典问题——写者饥饿:只要读者源源不断,计数永远不归零,写者可能永远等不到锁;解决办法是「写优先」(有写者在等时新读者必须排队)或用公平队列。Python 里的关键判断是:多数场景其实不需要读写锁。 因为 GIL 的存在,Python 代码的临界区通常极短(读一个 dict、取一个配置值只要几十纳秒),而读写锁本身的开销(要维护计数器、还要一把内部互斥锁)往往比它节省的等待时间还大——实测在临界区很短时,读写锁比直接用一把 Lock 更慢。真正推荐的方案是**「不可变数据 + 原子替换」:读者直接读全局引用(完全不加锁),写者构造一份新的完整对象再一次性替换引用(config = new_config,赋值是原子的)——这就是 RCU(Read-Copy-Update)的思想,读路径零开销。核心记忆:标准库没有读写锁;朴素实现会写者饥饿**;临界区短时不如直接用 Lock,读多写少的最佳解法是不可变对象 + 原子替换

详细版

几种方案对比

方案读开销写开销适用
直接用 Lock一次加解锁(~100ns)同左临界区短(多数情况)
读写锁加解锁 + 计数维护独占等待临界区长(读要几毫秒)
不可变 + 原子替换(直接读引用)复制整份数据读多写少的首选
queue.Queue 串行化入队入队写操作可异步时
每线程副本(threading.local需要同步机制数据可分区时
import threading, time

# ① ★朴素读写锁:读者计数 + 写锁★
class RWLock:
    def __init__(self):
        self._readers = 0
        self._counter_lock = threading.Lock()   # 保护 _readers
        self._write_lock = threading.Lock()     # 写者独占 / 第一个读者持有

    def acquire_read(self):
        with self._counter_lock:
            self._readers += 1
            if self._readers == 1:
                self._write_lock.acquire()      # ★第一个读者挡住写者★

    def release_read(self):
        with self._counter_lock:
            self._readers -= 1
            if self._readers == 0:
                self._write_lock.release()      # ★最后一个读者放行写者★

    def acquire_write(self):
        self._write_lock.acquire()

    def release_write(self):
        self._write_lock.release()
# ★问题:读者源源不断时 _readers 永不归零 → ★写者饥饿★

# ② ★写优先版本:有写者在等,新读者就得排队★
class WriterPreferringRWLock:
    def __init__(self):
        self._cond = threading.Condition()
        self._readers = 0
        self._writer = False
        self._waiting_writers = 0

    def acquire_read(self):
        with self._cond:
            # ★有写者持有或在等待 → 读者等待(避免写者饥饿)★
            while self._writer or self._waiting_writers > 0:
                self._cond.wait()
            self._readers += 1

    def release_read(self):
        with self._cond:
            self._readers -= 1
            if self._readers == 0:
                self._cond.notify_all()

    def acquire_write(self):
        with self._cond:
            self._waiting_writers += 1
            while self._writer or self._readers > 0:
                self._cond.wait()
            self._waiting_writers -= 1
            self._writer = True

    def release_write(self):
        with self._cond:
            self._writer = False
            self._cond.notify_all()

# ③ ★用上下文管理器封装(实际使用时必备)★
from contextlib import contextmanager
@contextmanager
def read_locked(rw):
    rw.acquire_read()
    try: yield
    finally: rw.release_read()      # ★异常路径也要释放★

# ④ ★★推荐方案:不可变数据 + 原子替换(RCU)★★
_config = {"timeout": 30}           # 全局引用

def get_config():
    return _config                  # ★读:零加锁★(读引用是原子的)

_write_lock = threading.Lock()
def update_config(key, value):
    global _config
    with _write_lock:               # ★只有写者需要互斥★
        new = dict(_config)         # ★复制一份★
        new[key] = value
        _config = new               # ★原子替换引用★
# → 读者要么看到旧的完整版本,要么看到新的完整版本,★不会看到中间状态★

# ⑤ 第三方库(不想自己写时)
# pip install readerwriterlock
# from readerwriterlock import rwlock
# lock = rwlock.RWLockFair()        # 还有 RWLockRead(读优先)/ RWLockWrite(写优先)
# with lock.gen_rlock(): ...
# with lock.gen_wlock(): ...

⚠️ 三个必须想清楚的点:① 朴素读写锁必然写者饥饿——「第一个读者加写锁、最后一个读者解锁」的实现里,只要读者的到达速率高于处理速率,_readers 就永远不会归零,写者可能永久阻塞。生产环境要么用写优先版本(有写者在等时新读者也排队),要么用公平队列(按到达顺序)。② Python 里读写锁常常是负优化:GIL 让 Python 代码的临界区本来就极短(读一个 dict 值约几十纳秒),而读写锁的一次读加锁要维护计数器、还要抢一把内部互斥锁——总开销比直接用一把 Lock 还大。只有当读操作本身很慢(读的同时要做计算、序列化、IO,临界区达到毫秒级)时,读写锁的并行收益才能覆盖它的成本。③ 读多写少的最佳解法通常不是加锁,而是「不加锁」——用不可变对象 + 原子替换(RCU):读者直接读全局引用(零开销),写者构造完整的新对象后一次性替换。这利用了 Python 的一个重要保证:全局变量的赋值和读取是原子的(一条字节码),所以读者要么看到旧版本、要么看到新版本,绝不会看到半更新的中间状态

完整版教学

一、读写锁的语义与标准库的缺席

读写锁(Readers-Writer Lock)的语义:
  ┌──────────┬──────────┬──────────┐
  │          │ 读者进入  │ 写者进入  │
  ├──────────┼──────────┼──────────┤
  │ 无人持有  │ ✅       │ ✅        │
  │ 读者持有  │ ✅ ★并行★│ ❌ 等待   │
  │ 写者持有  │ ❌ 等待  │ ❌ 等待   │
  └──────────┴──────────┴──────────┘
  → ★读读并行、读写互斥、写写互斥★

  和普通互斥锁的区别:
    Lock:任何时刻只有一个线程进入(★读者之间也互斥★)
    RWLock:多个读者可以同时进入
  → 理论收益:读者多且临界区长时,并行度大幅提升

为什么标准库没有(★面试常问★):
  ① ★Python 的临界区通常极短★
     GIL 让纯 Python 代码天然串行,读一个 dict/list 只要几十纳秒
     → 读者"并行"的收益几乎为零(反正 GIL 也不让你真并行执行字节码)
  ② ★实现复杂度高、语义分歧大★
     读优先 / 写优先 / 公平:三种策略适合不同场景,标准库不好选
     还要考虑可重入、升级降级(读锁升级成写锁 → ★极易死锁★)
  ③ ★有更好的替代★
     不可变数据 + 原子替换在 Python 里又简单又快(见后文)
  ④ 真正需要它的场景(读操作本身很重)比较少见

  ★ 对比:Java 有 ReentrantReadWriteLock、Go 有 sync.RWMutex、
    C++ 有 shared_mutex —— 因为那些语言里读者是★真并行★的,收益明显;
    ★Python 有 GIL,读者并不能真正并行执行字节码★,收益要小得多

  ★ 但注意:这个理由在★自由线程(no-GIL)★下会改变!
    3.13+ 的自由线程构建里读者真能并行 → 读写锁的价值上升
    → 面试时能提到这一点是加分项

什么时候读写锁才真的有价值:
  ✓ 读操作★本身很慢★:读的时候要做计算、序列化、格式化(毫秒级)
  ✓ 读操作会★释放 GIL★:读的过程中调用了 numpy 计算、IO、C 扩展
  ✓ ★自由线程构建★下的读多写少场景
  ✗ 只是读一个 dict / 取一个属性 → ★直接用 Lock 或不加锁方案★

读写锁的语义是**「读读并行、读写互斥、写写互斥」——相比普通互斥锁,它让多个读者能同时进入,理论收益是「读者多且临界区长时并行度大幅提升」。Python 标准库没有它,有四个原因:① Python 的临界区通常极短(GIL 让纯 Python 代码天然串行,读一个 dict 只要几十纳秒,读者「并行」的收益几乎为零);② 实现复杂、语义分歧大(读优先/写优先/公平三种策略适合不同场景,还有可重入和锁升级这些极易死锁的问题);③ 有更好的替代(不可变数据 + 原子替换);④ 真正需要它的场景少见。对比 Java 的 ReentrantReadWriteLock、Go 的 sync.RWMutex——那些语言里读者是真并行的,收益明显,而 Python 有 GIL、读者并不能真正并行执行字节码。但要注意这个理由在自由线程(no-GIL)构建下会改变**:3.13+ 的自由线程里读者真能并行,读写锁的价值随之上升。所以判断标准是:只有当读操作本身很慢、或读的过程中会释放 GIL(numpy 计算、IO、C 扩展)时,读写锁才真的有价值

二、实现:从朴素版到写优先版

★ 朴素实现(读优先)的核心思路:
  "第一个读者负责加写锁,最后一个读者负责解写锁"
  读者之间只用一把很短的锁保护计数器

  acquire_read:
      with counter_lock:
          readers += 1
          if readers == 1: write_lock.acquire()   # ★挡住写者★
  release_read:
      with counter_lock:
          readers -= 1
          if readers == 0: write_lock.release()

  ★ 致命问题:写者饥饿(writer starvation)
    时间线:
      读者 A 进入(readers=1,持有 write_lock)
      写者 W 尝试 acquire_write → ★阻塞在 write_lock★
      读者 B 进入(readers=2)← ★不需要 write_lock,直接进★
      读者 A 离开(readers=1)← ★不释放 write_lock★
      读者 C 进入(readers=2)…
      → 只要读者不断到来,readers 永不归零 → ★W 永远等下去★
    → 在"读 QPS 很高"的服务里,配置更新可能永远生效不了

★ 写优先实现:有写者在等待时,★新读者也必须排队★
  acquire_read:
      while writer_active or waiting_writers > 0:   # ★关键条件★
          cond.wait()
      readers += 1
  acquire_write:
      waiting_writers += 1
      while writer_active or readers > 0:
          cond.wait()
      waiting_writers -= 1
      writer_active = True

  → 保证写者最多等待"当前正在读的那批读者"完成
  → 代价:★可能读者饥饿★(写者持续不断时读者一直等)——但通常写少,问题不大

★ 公平实现(FIFO):按到达顺序服务
  用一个队列记录等待者的顺序,或用"票号"机制
  → 谁都不会饿死,但实现更复杂、吞吐略低

三种策略对比:
  ┌────────┬──────────────┬──────────────┬────────────────┐
  │ 策略    │ 谁可能饿死    │ 吞吐          │ 适用            │
  ├────────┼──────────────┼──────────────┼────────────────┤
  │ 读优先  │ ★写者★       │ 读吞吐最高    │ 写极少且不紧急  │
  │ ★写优先★│ 读者(少见)  │ 中            │ ★通用推荐★     │
  │ 公平    │ 都不会        │ 略低          │ 严格要求公平    │
  └────────┴──────────────┴──────────────┴────────────────┘

★ 必须用上下文管理器封装(否则一定会漏释放):
  @contextmanager
  def read_locked(rw):
      rw.acquire_read()
      try:
          yield
      finally:
          rw.release_read()       # ★异常时也释放★
  → 直接暴露 acquire/release 给业务代码,迟早有人忘了释放或异常时漏掉

★ 两个高危陷阱:
  ① ★锁升级必死锁★
     with read_locked(rw):
         if need_update:
             with write_locked(rw):    # ✗ ★自己持有读锁,等自己释放★
                 ...
     ✓ 正确:先释放读锁,再获取写锁,然后★重新检查条件★
       (因为中间状态可能已被别人改变 → 这是"双重检查"模式)
  ② ★不可重入★
     同一线程重复 acquire_read 在多数实现里会计数错乱或死锁
     → 除非显式实现可重入(要记录 owner 和计数,复杂度陡增)

朴素实现的思路是「第一个读者负责加写锁、最后一个读者负责解写锁」,读者之间只用一把很短的锁保护计数器。但它有致命的写者饥饿问题:只要读者源源不断到来,readers 就永远不会归零,写者可能永久阻塞——在读 QPS 很高的服务里,配置更新可能永远生效不了。写优先版本的关键改动是:有写者在等待时,新读者也必须排队,这样写者最多等待「当前正在读的那批读者」完成;代价是理论上可能读者饥饿,但写少时问题不大。三种策略中写优先是通用推荐。实现时有两条纪律:必须用上下文管理器封装(直接暴露 acquire/release 给业务代码,迟早有人忘了释放或在异常路径漏掉),以及绝对不要做锁升级——在持有读锁时去获取写锁必然死锁(自己等自己释放),正确做法是先释放读锁、再获取写锁、然后重新检查条件(中间状态可能已被别人改变)。

三、Python 里到底需不需要读写锁

★ 算一笔账(这是本题最有价值的部分):

  假设:读操作临界区 = T_read,锁本身开销 = T_lock
  用普通 Lock:   每次读 = T_lock + T_read(★读者串行★)
  用读写锁:      每次读 = T_rwlock + T_read(★读者并行,但 T_rwlock > T_lock★)

  T_lock(一次 acquire+release)        ≈ ★50~100ns★
  T_rwlock(读锁:计数器锁 + 计数 + 判断)≈ ★300~600ns★(约 3~6 倍)

  情况一:读一个 dict 值,T_read ≈ 40ns
    Lock:   100 + 40 = 140ns,串行
    RWLock: 500 + 40 = 540ns,"并行"(★但 GIL 下并不能真并行执行字节码★)
    → ★读写锁慢了近 4 倍,且没有任何并行收益★

  情况二:读的时候要序列化成 JSON,T_read ≈ 500μs(且 json 部分会释放 GIL?不会)
    Lock:   500μs 串行 → 4 个线程要 2ms
    RWLock: 500μs 并行 → ★但 GIL 让它们仍然串行执行★ → 还是约 2ms
    → ★仍然没有收益!★(除非临界区里的操作会释放 GIL)

  情况三:读的时候调用 numpy 做矩阵运算(★会释放 GIL★),T_read ≈ 5ms
    Lock:   4 个线程串行 → 20ms
    RWLock: 4 个线程真并行(numpy 释放了 GIL)→ ★约 5ms★
    → ★这才是读写锁真正有价值的场景★

  ★ 结论(★背下来★):
    在有 GIL 的 CPython 里,读写锁只在
    ★"临界区里的操作会释放 GIL"★(numpy/IO/C 扩展/time.sleep)时才有收益。
    纯 Python 的读操作,用读写锁 = ★纯粹的负优化★。

★ 自由线程(no-GIL)下结论会变:
  3.13+ 的自由线程构建里,纯 Python 的读操作也能真正并行
  → 读写锁的收益变得实在
  → 这也是"未来读写锁在 Python 里可能变重要"的原因

★ 但即便如此,仍然优先考虑"不加锁"的方案:
  读写锁的读路径仍然有 300~600ns 的开销,
  而"不可变 + 原子替换"的读路径是 ★0★

这是本题最有价值的部分——算清这笔账。一次 Lock 的 acquire+release 约 50~100ns,而读写锁的读加锁(要抢内部计数器锁、改计数、判断)约 300~600ns(3~6 倍)。情况一:读一个 dict 值(40ns),读写锁慢了近 4 倍且毫无并行收益情况二:读时要序列化 JSON(500μs),看似临界区很长,但纯 Python 操作持有 GIL,多个读者仍然串行执行——还是没有收益情况三:读时调用 numpy 做矩阵运算(会释放 GIL),4 个线程从串行的 20ms 变成并行的 5ms——这才是读写锁真正有价值的场景结论要背下来:在有 GIL 的 CPython 里,读写锁只在「临界区里的操作会释放 GIL」(numpy/IO/C 扩展)时才有收益,纯 Python 的读操作用读写锁是纯粹的负优化。而在自由线程构建下这个结论会改变(纯 Python 读操作也能真并行),但即便如此,「不可变 + 原子替换」的读路径开销是 0,依然更优。

四、更好的方案:不可变数据 + 原子替换(RCU)

★ 核心思想:读者不加锁,写者构造新版本后原子替换引用★

  _config = {"timeout": 30, "retries": 3}      # 全局引用

  def get(key):
      return _config[key]                       # ★读:完全不加锁★

  _wlock = threading.Lock()
  def update(key, value):
      global _config
      with _wlock:                              # ★只有写者互斥★
          new = dict(_config)                   # ① 复制
          new[key] = value                      # ② 修改副本
          _config = new                         # ③ ★原子替换引用★

  ★ 为什么安全(关键的语言保证):
    "读取一个全局变量"和"给全局变量赋值"在 CPython 里都是★单条字节码★
    (LOAD_GLOBAL / STORE_GLOBAL),不会被打断到"半个引用"的状态
    → 读者拿到的要么是旧字典的完整引用,要么是新字典的完整引用
    → ★绝不会看到"改了一半"的字典★
    ★ 这个保证在自由线程构建下同样成立(指针赋值是原子的)

  ★ 三个关键细节:
    ① 读者必须★一次性取到引用★,不要反复读全局变量:
       ✗ if _config["a"] > 0: use(_config["b"])   # ★两次读,中间可能被替换★
       ✓ cfg = _config                            # ★取一次快照★
         if cfg["a"] > 0: use(cfg["b"])           # 全程用同一个版本
    ② 副本要★足够深★:浅拷贝时嵌套对象仍是共享的
       new = {**old, "sub": {**old["sub"], "k": v}}   # 需要改哪层就复制哪层
       (这正是 "persistent data structure" 的思路)
    ③ 旧版本会被 GC 自动回收(★读者用完了引用计数才归零★)

★ 这就是 RCU(Read-Copy-Update):
  Linux 内核用它保护读极多的数据结构
  Python 里的版本更简单:靠引用计数自动完成"宽限期"的判断
  (旧对象在最后一个读者放手后自动释放)

★ 适用与不适用:
  ✓ 读远多于写(配置、路由表、特征开关、缓存元数据、白名单)
  ✓ 数据量不大(复制成本可接受)
  ✓ 读者可以接受"短暂读到旧值"(最终一致)
  ✗ 数据非常大(每次写都要复制整份 → 写开销爆炸)
  ✗ 写非常频繁(复制成本 × 频率)
  ✗ 需要"读到的一定是最新值"(强一致)→ 还是要加锁
  ✗ 读者需要在读的过程中修改数据 → 不适用

★ 变体:只替换变化的部分(结构共享)
  用 immutables / pyrsistent 这类持久化数据结构库
  → 更新一个键只复制路径上的节点(O(log n)),而不是整份复制
  → 适合"数据大 + 写不算太少"的场景

更好的方案是「不可变数据 + 原子替换」:读者直接读全局引用(完全不加锁),写者复制一份、修改副本、然后一次性替换引用。它安全的语言保证是:读取和赋值全局变量在 CPython 里都是单条字节码LOAD_GLOBAL/STORE_GLOBAL),不会被打断到「半个引用」的状态——读者拿到的要么是旧字典的完整引用、要么是新字典的完整引用,绝不会看到改了一半的数据(这个保证在自由线程下同样成立,因为指针赋值是原子的)。三个关键细节:① 读者必须一次性取到引用cfg = _config 后全程用它,而不是反复读全局变量——否则两次读之间可能被替换,看到不一致的组合);② 副本要足够深(浅拷贝时嵌套对象仍共享);③ 旧版本由引用计数自动回收(最后一个读者放手后释放)。这就是 RCU(Read-Copy-Update) 的思想——Linux 内核用它保护读极多的数据结构,而 Python 版本更简单:引用计数自动完成了「宽限期」的判断。它的边界是:数据很大或写很频繁时复制成本爆炸(可以改用 pyrsistent 这类持久化数据结构做结构共享),以及需要强一致时仍然要加锁

五、其他替代方案

① ★串行化:把写操作交给单个线程★
  用 queue.Queue 收集写请求,一个专门的线程串行处理
  → ★写者之间不需要锁★(只有一个写者)
  → 读者配合"不可变替换"→ 全程零锁竞争
  ✓ 适合:写操作可以异步、允许短暂延迟
  ✗ 不适合:需要同步拿到写入结果

② ★分片锁(lock striping)★
  把数据按 key 哈希分成 N 份,每份一把锁
  locks = [threading.Lock() for _ in range(16)]
  def get(key):
      with locks[hash(key) % 16]:
          return shards[hash(key) % 16][key]
  → ★把一把大锁的竞争分散到 16 把小锁★
  ✓ 适合:大字典/缓存,读写都不少
  ✗ 需要"跨分片的原子操作"时会很麻烦(要按序加多把锁防死锁)

③ ★threading.local:每线程一份副本★
  数据可以按线程分区时,根本不需要同步
  ✓ 适合:连接、缓冲区、上下文
  ✗ 不适合:需要全局一致视图的数据
  ★ 协程场景要用 contextvars 而不是 threading.local★

④ ★直接用 Lock(不要过度设计)★
  临界区短(读一个值、改一个计数)→ ★一把 Lock 就是最优解★
  → 简单、无饥饿、无死锁风险、性能最好

⑤ ★利用 CPython 的原子操作(谨慎)★
  dict[key] = value、list.append(x) 在 CPython 实现上是原子的
  → 单个操作不需要加锁
  ★ 但这是★实现细节不是语言保证★,且"检查后修改"仍然不安全:
    if k not in d: d[k] = v     # ✗ ★两步之间可能被插入★
    ✓ d.setdefault(k, v)         # ★单个原子操作★
  ★ 自由线程构建下更要小心(详见对应专题)

⑥ ★asyncio 场景:不需要读写锁★
  单线程事件循环里,★没有 await 就不会被切换★
  → 纯同步的读写天然互斥
  → 只有"读写之间有 await"时才需要 asyncio.Lock
  ★ asyncio 也没有内置读写锁(同样是因为收益有限)

选型顺序(★从简单到复杂★):
  ① 能不共享吗?        → threading.local / 每线程副本 / 传参
  ② 能不可变吗?        → ★不可变 + 原子替换(首选)★
  ③ 临界区短吗?        → 一把 Lock 就够
  ④ 数据能分片吗?      → 分片锁
  ⑤ 读操作会释放 GIL 吗?→ ★这时才考虑读写锁★
  ⑥ 实在要写读写锁     → 用第三方库(readerwriterlock)或写优先实现

除了读写锁,还有五类替代方案。① 串行化写操作(用 queue.Queue 把写请求交给单个线程处理,写者之间就不需要锁了,配合不可变替换实现全程零锁竞争)。② 分片锁(按 key 哈希分成 N 份、每份一把锁,把一把大锁的竞争分散开,适合大缓存;代价是跨分片的原子操作很麻烦)。threading.local(数据能按线程分区时根本不需要同步;协程场景要用 contextvars)。④ 直接用 Lock——临界区短时它就是最优解,简单、无饥饿、无死锁风险。⑤ asyncio 场景根本不需要读写锁:单线程事件循环里没有 await 就不会被切换,纯同步的读写天然互斥。选型应该从简单到复杂:先问「能不共享吗」→「能不可变吗」→「临界区短吗」→「能分片吗」→最后才问「读操作会释放 GIL 吗」,只有答案为是时才考虑读写锁

六、实践建议

★ 决策流程图:
  需要保护共享数据
    ├─ 能避免共享吗?(每线程副本、传参、消息传递)
    │    → ★优先:不共享就没有并发问题★
    ├─ 读远多于写?数据不大?能接受最终一致?
    │    → ★不可变 + 原子替换(RCU)★  ← 90% 的场景选这个
    ├─ 临界区很短(读写一个值)?
    │    → ★一把 threading.Lock★
    ├─ 大字典且读写都多?
    │    → 分片锁
    └─ 读操作会释放 GIL(numpy/IO/C 扩展)且读者很多?
         → ★读写锁(写优先实现或第三方库)★

★ 如果决定用读写锁,检查清单:
  □ 用★写优先或公平★实现(别用朴素读优先版)
  □ 用★上下文管理器★封装 acquire/release
  □ ★绝不做锁升级★(持读锁时获取写锁 = 死锁)
  □ 明确是否可重入(多数实现★不可重入★)
  □ 读锁里★不要调用可能获取其他锁的代码★(死锁风险)
  □ 加超时(acquire(timeout=))避免永久阻塞
  □ 有监控:等待时间、持有时间、饥饿情况
  □ ★实测对比一把 Lock★——如果没快,就换回去

★ 第三方库:
  readerwriterlock(pip install readerwriterlock)
    RWLockRead   读优先(★写者可能饥饿★)
    RWLockWrite  写优先(★推荐★)
    RWLockFair   公平(FIFO)
    用法:with lock.gen_rlock(): / with lock.gen_wlock():
    ★ 支持超时和可重入变体

★ 一个常见的真实场景对比:
  需求:热更新的配置,读 QPS 10 万,写 1 分钟一次
  ✗ 读写锁:每次读多花 ~400ns → 10 万 QPS × 400ns = ★每秒 40ms 的额外开销★
  ✓ 不可变替换:读零开销,写时复制一份小字典(~10μs,一分钟一次可忽略)
  → ★后者完胜★,而且代码更简单

  什么时候反过来:
  需求:内存里的大矩阵,读者要用 numpy 做运算(每次 5ms,会释放 GIL),
        偶尔要整体替换
  ✓ 读写锁:4 个读者真并行 → 吞吐提升接近 4 倍
  ✓ 或者仍然用不可变替换(如果矩阵可以整体替换的话,★更简单★)

实践上按决策流程走:先问能不能避免共享(不共享就没有并发问题)→ 读远多于写、数据不大、能接受最终一致就用「不可变 + 原子替换」(90% 的场景选这个)→ 临界区短就用一把 Lock → 大字典读写都多用分片锁 → 只有「读操作会释放 GIL 且读者很多」时才用读写锁。真要用读写锁,检查清单里最关键的四条是:用写优先实现用上下文管理器封装绝不做锁升级、以及实测对比一把 Lock——如果没快就换回去。最后那个真实对比很有代表性:读 QPS 10 万、1 分钟写一次的热更新配置,用读写锁每次读多花约 400ns,每秒就是 40ms 的额外开销;而不可变替换读零开销、写时复制一份小字典(一分钟一次可忽略)——后者完胜且代码更简单

记忆钩子:「★Python 标准库没有读写锁★(只有 Lock/RLock/Semaphore/Condition/Barrier),因为①GIL 让纯 Python 的临界区极短、读者『并行』收益几乎为零 ②读优先/写优先/公平三种语义难以取舍、还有锁升级这类陷阱 ③有更好的替代。读写锁的语义是★读读并行、读写互斥、写写互斥★,自己实现的核心是『★第一个读者加写锁、最后一个读者解写锁★』,但这个朴素版本必然★写者饥饿★——读者源源不断时计数永不归零、写者永久阻塞;修法是★写优先★(有写者在等时新读者也排队)或公平队列。★本题最有价值的判断:在有 GIL 的 CPython 里,读写锁只在『临界区里的操作会释放 GIL』(numpy/IO/C 扩展)时才有收益★——一次 Lock 约 50100ns 而读写锁的读加锁要 300600ns,读一个 dict 值用读写锁是★纯粹的负优化★;即使临界区长达 500μs,只要是纯 Python 操作,多个读者照样被 GIL 串行、依然没有收益(★自由线程构建下这个结论会改变★)。★读多写少的最佳解法是『不可变数据 + 原子替换』(RCU)★:读者直接读全局引用(★零开销★),写者复制一份、改副本、再一次性 _config = new 替换——安全性来自『★读取和赋值全局变量都是单条字节码★』,读者要么看到旧的完整版本要么看到新的完整版本,绝不会看到半更新状态;三个细节是★读者要一次性取快照★(别反复读全局变量)、副本要够深、旧版本靠引用计数自动回收。其他替代:串行化写(queue 交给单线程)、★分片锁★、threading.local、以及『临界区短就直接用一把 Lock』。★锁升级(持读锁时获取写锁)必然死锁★,必须先释放读锁再取写锁并重新检查条件。asyncio 里根本不需要读写锁——★没有 await 就不会被切换★。」

七、常见误区与追问

  • 误区:threading 里应该有读写锁,只是我没找到。 标准库确实没有——threading 提供的同步原语只有 LockRLockSemaphore/BoundedSemaphoreConditionEventBarrier没有任何读写锁。原因有四个:① GIL 让纯 Python 的临界区极短,读者「并行」在字节码层面根本实现不了,收益接近零;② 语义分歧大——读优先、写优先、公平三种策略适合不同场景,还要处理可重入和锁升级,标准库不好替用户做决定;③ Python 里有更简单更快的替代(不可变 + 原子替换);④ 真正受益的场景(读操作本身很重且会释放 GIL)比较少见。对比 Java 的 ReentrantReadWriteLock、Go 的 sync.RWMutex、C++ 的 shared_mutex——那些语言的读者是真并行的,所以标准库提供它是划算的。需要时可以用第三方的 readerwriterlock 包。
  • 误区:读多写少的场景,用读写锁一定比用普通 Lock 快。 在 CPython 里往往更慢。算笔账:一次 Lock 的 acquire+release 约 50~100ns,而读写锁的「读加锁」要先抢一把内部计数器锁、修改计数、判断是否是第一个读者,总共约 300600ns(36 倍)。如果读操作本身只是取一个 dict 值(约 40ns),用读写锁等于把开销放大了近 4 倍,却换不来任何并行——因为 GIL 决定了这些纯 Python 的读操作本来就不能同时执行字节码。只有当读操作在临界区内会释放 GIL(调用 numpy 计算、做 IO、进入 C 扩展)时,多个读者才能真正并行,读写锁的收益才可能覆盖它的成本。决定用之前一定要实测对比一把 Lock
  • 误区:写了个读写锁,读者用得很爽,写操作偶尔慢一点无所谓。 朴素实现(读优先)的问题不是「写操作慢一点」,而是写者可能永远得不到锁。因为「最后一个读者才释放写锁」,只要读者的到达速率高于离开速率,readers 计数永远不会归零——在读 QPS 很高的服务里,配置更新可能几小时都生效不了,而且现象非常隐蔽(没有异常、没有超时,就是「更新没生效」)。修复办法是改成写优先:新读者在「有写者正在等待」时也必须排队,这样写者最多等待当前这一批读者完成。代价是理论上可能读者饥饿,但既然是「读多写少」场景,写者持续不断的情况几乎不会发生。生产环境要么用写优先实现、要么用公平(FIFO)实现,并给 acquire 加超时
  • 误区:持有读锁时发现需要修改数据,直接再获取写锁就行(锁升级)。 这必然死锁:你正持有读锁(意味着 readers > 0 或写锁被读者持有),而获取写锁的条件是「没有任何读者」——你在等你自己释放。而且如果两个线程同时尝试升级,就是经典的互相等待。正确做法是「先释放读锁 → 获取写锁 → 重新检查条件 → 修改」,注意重新检查是必须的:在你放开读锁到拿到写锁的这段时间里,别的线程可能已经做了同样的修改(这就是「双重检查」模式)。相关的还有可重入性:多数读写锁实现不可重入,同一线程重复 acquire_read() 会导致计数错乱或死锁——如果你的读路径里可能间接再次获取读锁(比如递归调用、回调),必须用显式支持可重入的实现,或者干脆重构掉这种嵌套。
  • 误区:用「不可变 + 原子替换」时,读者可以放心地多次读取全局变量。 不行——每次读全局变量都可能拿到不同的版本。写成 if _config["enabled"]: use(_config["url"]) 时,两次读之间写者可能完成了替换,于是你用旧配置判断了条件、却用新配置取了值,得到一个从未真实存在过的组合状态。正确做法是一次性取快照cfg = _config 之后全程使用局部变量 cfg——因为你持有的是对某个具体版本的引用,那个字典是不可变的(约定上不再修改),所以整个处理过程看到的是一致的视图(旧版本会一直存活到最后一个读者放手,由引用计数自动回收)。这也是这套方案的精髓:用「版本快照」代替「加锁」来获得一致性
  • 追问:「不可变 + 原子替换」为什么是安全的?依赖了什么保证? 依赖的是 CPython 里「读取全局变量」和「给全局变量赋值」都是单条字节码LOAD_GLOBAL / STORE_GLOBAL)这一事实——线程切换只发生在字节码之间,不可能出现「引用改到一半」的中间状态。所以读者取到的必然是某个完整的对象引用:要么是旧字典、要么是新字典。写者的三步「复制 → 修改副本 → 替换引用」中,前两步操作的都是别人看不到的私有副本,只有第三步是对共享状态的唯一一次写,而它是原子的。这个保证在自由线程(no-GIL)构建下同样成立,因为指针赋值本身是机器级原子操作(PEP 703 也保证了这一点)。补充两点:写者之间仍然需要一把锁(否则两个写者各自基于旧版本复制、后写的会覆盖先写的更新,即「丢失更新」);以及这是最终一致而非强一致——读者可能在替换完成前的一瞬间仍读到旧版本,业务上要能接受。
  • 追问:这和 Linux 内核的 RCU 是一回事吗? 思想完全一致,但 Python 版本简单得多。RCU(Read-Copy-Update)的核心是「读者不加锁地读;写者复制一份新的、修改后原子地替换指针;等所有旧读者离开后再回收旧版本」。内核实现的难点在于最后一步——它必须自己判断「宽限期」(grace period)何时结束、旧数据何时可以安全释放,为此需要复杂的机制(如 synchronize_rcu()、per-CPU 计数)。而 Python 靠引用计数天然解决了这个问题:旧字典的引用计数会在最后一个持有它的读者离开作用域时归零,然后自动被回收——完全不需要显式的宽限期管理。代价是引用计数本身的开销,以及只适用于「整体替换」的数据(不能像内核那样做链表节点的局部替换)。数据很大时可以用 pyrsistentimmutables 这类持久化数据结构做结构共享(更新只复制路径上的节点,O(log n) 而非 O(n)),思路上更接近内核 RCU 的局部更新。
  • 追问:asyncio 里需要读写锁吗? 基本不需要,原因是单线程事件循环的执行模型:一个协程在两个 await 之间的所有代码是不会被打断的(没有抢占式切换),所以只要你的「读」或「写」是一段纯同步代码,它天然就是原子的——根本不需要任何锁。只有当临界区中间有 await 时(读的过程中要 await 数据库、写的过程中要 await 网络)才需要同步,此时用 asyncio.Lock 即可。asyncio 同样没有内置读写锁,理由和线程版一样:收益有限而复杂度不低。如果确实遇到「读操作里有 await 且读者很多」的场景,可以用 asyncio.Condition 手写一个(逻辑和线程版一致,只是 wait/notify 换成异步版本),或者继续用「不可变 + 原子替换」——在 asyncio 里这套方案更加自然,因为替换引用这一步本身就不会被打断。

八、加强记忆

Python 标准库没有读写锁threading 只有 Lock/RLock/Semaphore/Condition/Event/Barrier),原因有三:GIL 让纯 Python 的临界区极短、读者「并行」收益几乎为零;读优先/写优先/公平三种语义难以取舍,还有锁升级、可重入这类陷阱;以及存在更简单更快的替代。读写锁的语义是**「读读并行、读写互斥、写写互斥」,自己实现的核心是「第一个读者加写锁、最后一个读者解写锁」——但这个朴素版本必然写者饥饿**(读者源源不断时计数永不归零,写者永久阻塞,表现为「配置更新一直不生效」),修法是写优先(有写者在等时新读者也排队)或公平队列。本题最有价值的判断是:在有 GIL 的 CPython 里,读写锁只在「临界区内的操作会释放 GIL」(numpy/IO/C 扩展)时才有收益——一次 Lock 约 50100ns 而读写锁的读加锁要 300600ns,读一个 dict 值用读写锁是纯粹的负优化;即便临界区长达 500μs,只要是纯 Python 操作,多个读者照样被 GIL 串行、依然没有收益(自由线程构建下这个结论会改变)。读多写少的最佳解法是「不可变数据 + 原子替换」(RCU):读者直接读全局引用(零开销),写者复制一份、修改副本、再一次性 _config = new 替换——安全性来自「读取和赋值全局变量都是单条字节码」,读者要么看到旧的完整版本、要么看到新的完整版本,绝不会看到半更新状态;三个细节是读者要一次性取快照(别反复读全局变量,否则会得到从未存在过的组合)、副本要够深、旧版本靠引用计数自动回收(这正是 Python 版 RCU 比内核简单的地方),另外写者之间仍需一把锁防丢失更新。其他替代方案:串行化写(queue 交给单线程)、分片锁threading.local(协程用 contextvars)、以及「临界区短就直接用一把 Lock」。两个高危陷阱:锁升级(持读锁时获取写锁)必然死锁(要先释放读锁、再取写锁、并重新检查条件),以及多数实现不可重入。最后,asyncio 里根本不需要读写锁——没有 await 就不会被切换,纯同步的读写天然互斥。