缝隙在指令之间、await 之间和语句之间
一句话总结
竞态条件不是在“有多个线程”时产生的,而是在“读取的值与写入的值之间,别人有机会插进来的缝隙”中产生的。在线程里,这个缝隙在字节码指令之间;在 asyncio 里,在每个 await 处;在数据库里,在 SELECT 与 UPDATE 之间。必须消除缝隙(原子操作),或者让整个缝隙每次只允许一个任务通过(锁)。
为什么需要它
“操作系统”课程的并发模块,会让你亲手做出四个线程给同一个值加一而丢失更新的场景,以及死锁。即使做完了那个实验,在服务代码中也常常搞错三件事:Python 有 GIL,所以 x += 1 是安全的;asyncio 只有一个线程,所以不存在竞争;数据库会自动帮忙挡住。三个都是错的,本模块用数字来确认这三点。
工作原理
GIL 保证什么,不保证什么。 术语表把 GIL 定义为让 CPython 每次只允许一个线程执行字节码的机制。库 FAQ解释说,Python 通常只在字节码指令之间切换线程,所以单条指令是原子的,并写道 L.append(x) 是原子的,而 i = i+1 或 D[x] = D[x] + 1 不是。用 dis 拆解 counter += 1,在本实验镜像(3.12)中会得到 LOAD_GLOBAL、LOAD_CONST、BINARY_OP、STORE_GLOBAL 四条指令。读取和写入是不同的指令,所以如果线程在它们之间切换,别人加上去的值就会被覆盖。切换有多频繁,由 sys.setswitchinterval 决定,文档写道,这个值只是“理想的”时间片,由哪个线程接手要由操作系统决定。所以竞争在测试中不太容易看到,到了负载升高时才会出现。实验在读取和写入之间插入 time.sleep(0),故意把缝隙拉大。这不是制造 bug,而是让原本就存在的 bug 每次都显现出来的装置。
锁必须罩住整个缝隙。 threading 的 Lock 在进入 with 语句时 acquire,离开时 release。常见的只改一半的修复,是只把写入用锁罩住。读到的值已经过时,即使让写入排队,也只是把过时的值依次写进去。像座位预订这样“确认是否空闲 → 预订”的先检查后操作(check-then-act)也是一样,只把检查用锁罩住的话,两个请求会同时拿着“是空的”这个答案走出来。
在 asyncio 中,await 就是缝隙。 事件循环在一个线程里运行,任务只在 await 处交出控制权。没有 await 的区段实际上是原子的,夹着 await 的区段则不是。先确认余额 → 为了记录而 await → 扣减,那么在这次 await 期间,同一账户的另一笔取款会确认同样的余额。asyncio 同步原语文档建议用 async with 来使用 asyncio.Lock,并写道获取是公平的,先等待的协程先进入。同一份文档还强调,这些工具不是线程安全的,OS 线程的同步要用 threading。反过来,如果在协程里持有 threading.Lock 的同时 await,下一个任务会在等那把锁的过程中,让事件循环线程本身停住。
阻塞事件循环的调用。 asyncio 开发指南解释说,如果直接调用一个占用 CPU 1 秒的函数,所有任务和 IO 都会被推迟 1 秒,并写道调试模式会把耗时超过 100ms 的回调记到日志里。time.sleep 或同步驱动的调用同样会让事件循环停住。实验把每隔 10ms 醒来一次的任务比预定时间晚醒了多久,作为“事件循环延迟”来测量。asyncio.to_thread 会把函数放到另一个线程里运行,让出事件循环,但正如文档所说,由于 GIL,通常只对 IO 型函数有效。
数据库里,语句之间也是缝隙。 SQLite 的事务文档写道,读事务进行期间如果有写入到来,只有在可能时才会升级为写事务;如果别的连接已经修改过或正在修改,就会以 SQLITE_BUSY 失败(锁定阶段)。相反,不开事务,用 SELECT 读出、计算之后再用 UPDATE 写入,会毫无报错地丢失更新。Python 的 sqlite3 模块在默认设置下,只在 INSERT、UPDATE、DELETE、REPLACE 之前隐式开启事务,所以 SELECT 得不到保护。UPDATE t SET n = n + 1 把读取和写入放进一条语句,消除了缝隙。
| 位置 | 缝隙 | 封堵方法 |
|---|---|---|
| 线程 | 字节码指令之间 | 用 threading.Lock 罩住从读取到写入的全过程 |
| asyncio | await | 用 asyncio.Lock 罩住从检查到扣减的全过程 |
| DB | 语句之间 | 原子的 UPDATE,或者会让失败显现出来的事务 + 重试 |
GIL 与速度。 threading 文档写道,在 CPython 中每次只有一个线程执行 Python 代码,所以要使用多个核心,应该使用 multiprocessing 或 ProcessPoolExecutor,而要同时运行多个 IO 任务,线程仍然合适。术语表说,在 IO 期间 GIL 总是会被释放。自 3.13 起可以构建关闭 GIL 的 free-threaded 构建,但它不是默认构建,而且那份指南也建议不要依赖内置类型的内部锁,应使用 threading.Lock。这意味着,无论有没有 GIL,读取、修改、写入之间的缝隙都必须由代码自己封住。
在现场相遇的样子
Node 的结构也是一样的。“慢的不是一个请求,而是全部”课程把事件循环延迟一直讲到观测和告警,所以本模块的循环延迟测量,就是它的 Python 版。行锁与 SELECT FOR UPDATE、死锁,由“并发 — 两个人碰同一行时”课程讲;增加线程但 CPU 任务并没有变快,用性能分析器来看这件事,则由“CPU 与内存泄漏的判定”课程讲。本模块位于它们之间,测量缝隙在哪里。
下一项实验要做什么
把 counter += 1 拆成字节码看一看,用线程丢失更新,再用锁封住。做出座位重复预订和 await 之间的超额取款,再修好;测量阻塞调用让事件循环停住多久,并改用 to_thread。用 SQLite 的两个连接比较静默丢失和明确失败,最后测量在 GIL 之下线程与进程的速度。