厨房里的隐形猫:内存可见性问题
开篇除恐
你可能遇到过这样的情况:线程 A 把一个共享变量 flag 设置为 True,线程 B 在循环里不断检查 flag,却始终看不到变化,一直在忙等,直到你手动加入 sleep 或将变量包装在同步原语中之后才“忽然”看到更新。这时候你会怀疑:“是我写错了吗?还是解释器在偷偷优化?”实际上,这不是程序出错,而是现代 CPU 和解释器为了性能而做的一种“乱序写入与缓存”导致的可见性问题。把它想象成厨房里有个隐形的猫——它悄悄把刚切好的菜端到了另一张桌子上,但因为厨师们各自看着自己的案板,根本没看到菜被移动;只有当你打开厨房的门(或者强制大家刷新视线)时,大家才发现菜其实已经在那边了。理解这个“隐形猫”的行为,就能知道何时需要使用同步原语(如 Event、Condition、Lock)来让所有线程看到彼此的更新。
白话化
- 可见性(Visibility):一个线程对共享内存的写入,对其他线程何时才能被观察到。在没有适当同步的情况下,写入可能只保存在写线程的本地缓存(CPU 缓存)或写合并缓存中,而不立即刷新到主内存或其他核心的缓存,导致其他线程读取到旧值。
- 重排序(Reordering):编译器和解释器可能会在不改变单线程语义的前提下,调整指令的执行顺序。比如,先写
flag = True,再做一些耗时操作,可能被重排序为先做耗时操作再写flag,导致其他线程在看到flag为真之前就已经开始后续工作。 - 同步原语提供的内存屏障:在 Python 中,诸如
threading.Lock、threading.RLock、threading.Condition、threading.Event、threading.Semaphore以及queue.Queue等同步原语在内部实现了必要的内存屏障( acquire‑release 语义),确保一个线程的写入对持有相同原语的另一线程可见。 - 虽然 Python 没有显式的
volatile关键字,但我们可以通过将变量放置在这些同步原语的保护下(例如使用Lock保护读写)来获得可见性保证。
简而言之:要让一个线程的写入对另一个线程可见,需要确保写入已经刷新到主内存且无法被重排序到关键操作之后;这通常通过使用具备内存屏障的同步原语(如 Lock、Event)来实现。
直觉先行
想象你在厨房里有一块共享的白板,用来记录今天的特色菜。你(线程 A)在白板上写下“今日特价:红烧肉”,然后转身去炒菜。你的伙伴(线程 B)一直站在白板旁边,时不时抬头看看有没有新特色菜。理想情况下,你写完后,他应该立刻看到新内容。
然而,实际的白板其实是分成两块:一块是你手边的草稿纸(本地缓存),另一块是墙上挂着的正式白板(主内存)。你写在草稿纸上后,忘记把它贴到墙上;你的伙伴还是看着墙上旧的内容,根本看不到你刚写的新菜单。只有当你把草稿纸贴到墙上(相当于刷新缓存到主内存),或者你的伙伴强制去查看草稿纸(相当于强制读取主内存),才能看到更新。
如果你还担心自己写完草稿纸后,又在草稿纸上做了其他修改(比如先写“红烧肉”,再在下面加了“配啤酒”),而墙上只能看到最后一次贴上的全部内容,这就类似于重排序:你以为写完第一行就算完成,但实际写入的顺序可能被打乱,导致其他线程看到的是中间状态。
因此,要么你自己负责把写入及时同步到主内存(使用带释放语义的同步原语),要么你让读取方在读取时一定去主内存取值(使用带获取语义的同步原语),二者配合才能保证可见性。
例子贴身
例子 1(☼ 热身):没有同步的标志位导致可能的永久等待
任务:线程 A 设置 ready = True 后通知线程 B;线程 B 在循环里检查 ready,若为真则退出。观察没有同步时 B 可能永远看不到更新。
怎么想到的:我们用一个普通的 ready = False(无同步保护)来演示。线程 A 先休眠 100 ms 模拟做一些工作,然后设 ready = True。线程 B 一启动就忙等 while not ready:。如果没有适当的同步,B 有可能一直看到旧值 False,永不退出(虽然实际运行时由于操作系统调度和缓存刷新,有时会最终看到更新,但不具备保证)。
示例代码(Python)
import threading
import time
ready = False # 无同步保护,可见性不保证
def setter():
time.sleep(0.1) # 100 ms
ready = True # 可能被缓存,不立即对其他线程可见
print("Setter: ready = True")
def waiter():
while not ready: # 忙等
pass # 空循环
print("Waiter: saw ready = True")
def main():
t1 = threading.Thread(target=setter)
t2 = threading.Thread(target=waiter)
t1.start()
t2.start()
t1.join()
t2.join()
if __name__ == "__main__":
main()
运行结果(可能情况)
- 有时能看到 Waiter 打印(因为缓存最终刷新);
- 有时 Waiter 会卡住很久(看起来像死循环),因为它一直读取到旧值。
要点:这说明普通变量在多线程间没有可见性保证,依赖运气不是可靠的做法。
例子 2(☼☼ 正经):使用 threading.Event 保证可见性
任务:同上,但使用 threading.Event 来进行线程间的通信,确保写入对读取线程可见。
怎么想到的:threading.Event 内部提供了必要的内存屏障:event.set() 具有释放语义,event.wait()(或 event.is_set())具有获取语义。这样写入在 set() 后对后续的 wait()/is_set() 可见。
示例代码(Python)
import threading
import time
ready_event = threading.Event()
def setter():
time.sleep(0.1) # 100 ms
ready_event.set() # 写入并发出通知,具有释放语义
print("Setter: event set")
def waiter():
ready_event.wait() # 等待事件,具有获取语义
print("Waiter: saw event set")
def main():
t1 = threading.Thread(target=setter)
t2 = threading.Thread(target=waiter)
t1.start()
t2.start()
t1.join()
t2.join()
if __name__ == "__main__":
main()
运行结果
Setter: event set
Waiter: saw event set
waiter 能够迅速在 setter 完成后看到更新,不再死循环。
要点:
- threading.Event 默认提供了内存屏障,既保证了线程间的可见性又提供了简单的等待/通知机制。
- 如果对性能敏感,可以使用较轻量级的同步原语(如 threading.Condition 或带锁的标志位),但必须确保配对使用获取和释放语义。
例子 3(☼☼☼ 硬骨头):显式使用锁的 acquire/release 来构建内存屏障
任务:展示如何只用 threading.Lock 的 acquire(获取)和 release(释放)来达到同样可见性效果,以理解底层机制。
怎么想到的:写入线程在修改完普通变量后,调用 lock.release();读取线程在读取普通变量前,调用 lock.acquire()。这样即使变量不是原子的,也能通过锁的内部屏障确保写入对读取线程可见。
示例代码(Python)
import threading
import time
lock = threading.Lock()
ready = False # 普通变量
def setter():
time.sleep(0.1) # 100 ms
ready = True # 1. 写入普通变量
lock.release() # 2. 释放锁,具有释放屏障
print("Setter: ready = True")
def waiter():
lock.acquire() # 1. 获取锁,具有获取屏障
try:
if ready: # 2. 读取普通变量
print("Waiter: saw ready = True")
finally:
lock.release() # 确保释放锁
def main():
# 预先获取锁,使得 waiter 开始时会阻塞在 acquire
lock.acquire()
t1 = threading.Thread(target=setter)
t2 = threading.Thread(target=waiter)
t1.start()
t2.start()
t1.join()
t2.join()
if __name__ == "__main__":
main()
运行结果
Setter: ready = True
Waiter: saw ready = True
要点:
- 这展示了锁的内存屏障作用:写入后的 release 确保之前的写入对后续的 acquire 可见;读取前的 acquire 确保后续的读取看到之前的释放写入。
- 在实际代码中,我们很少手动这样使用锁;更常见的是使用 threading.Lock 配合 with 语句(RAII)来保护临界区,这同样提供了必要的内存屏障。
- 推荐使用 threading.Lock, threading.Condition, threading.Event 或 queue.Queue 等同步原语,它们内部已经包含了必要的屏障,确保可见性而无需显式管理。
收尾
这一章要带走的东西
- 可见性问题是指一线程的写入对其他线程何时才能被看到,受到编译器优化、CPU 缓存和重排序的影响。
- 普通(无同步保护)变量在多线程环境下没有可见性保证,依赖运气是不可靠的。
- threading.Lock, threading.Condition, threading.Event, threading.Semaphore 以及 queue.Queue 等同步原语提供了内置的内存屏障(acquire‑release 语义),确保写入对读取线程可见。
- 虽然 Python 没有显式的 volatile 关键字,但我们可以通过将变量放置在这些同步原语的保护下(例如使用 Lock 保护读写)来获得可见性保证。
- 在实际编码中,推荐使用这些同步原语来实现标志位、完成事件、消息传递等轻量级同步原语,而不必只依赖重量级互斥锁(虽然互斥锁也有效)。
- 掌握了可见性问题后,你就能够正确地使用标志位、完成事件、消息传递等轻量级同步原语,而不必只依赖重量级互斥锁。
- 下一章我们将进入实用的同步工具——互斥锁(Mutex):如何用它来保护临界区,使得对共享数据的读取‑修改‑写入操作变得原子且可见。
就这样。 下一章我们来学习“用锁保护共享资源”:互斥锁上岗,以及怎样正确地加锁、解锁,避免常见的陷阱。