R

ReflexKR

Reflex 官方文档

深夜厨房里的并发

·

课程概览

1

第一晚的长队:并发与并行

2

多招几个厨师:threading 基础

3

黑板上的数字错了:竞争条件与 Lock

4

只有三个灶台:同步工具

5

刀与砧板:死锁

6

订单夹:queue

7

外派厨师:concurrent.futures

8

面团为什么快不起来:GIL 与并行

9

一个人跑全场的前厅经理:asyncio 入门

10

团体预订取消了:结构化并发

11

前厅停住了:阻塞调用与混合使用

12

评论家之夜:综合

深夜厨房里的并发
第3课

时间 1:40–2:30

黑板上的数字错了:竞争条件与 Lock

今晚的订单

#03 · 第三晚

黑板上的数字错了

危机

四个厨师各做了 1000 份,每做一份就在厨房黑板的数字上加 1。可黑板上写的不是 4000,而是 1086。

目标

不管有几个厨师,黑板上的数字都要分毫不差。同时,厨房不能因此变慢。

深夜食堂 同时

第三晚,凌晨两点,营业结束了。今天老卓的朋友来帮了一天忙,厨师一共有四个。主厨定了一条规矩:“每出一碗,就在黑板的数字上加 1。”

数一数订单,四个人各出了 1000 碗,一共 4000 碗。可黑板上写着的是 1086。

敏浩挠着头:“我真的每次都加了啊?”秀雅和老卓也这么说。主厨拿起粉笔:“谁也没撒谎。你们四个只是同时站到了黑板前面而已。”

3-1. 4000 变成了 1086(8 分钟)

在黑板上加 1,其实分三步:读黑板,在脑子里加 1,再写回黑板。我们把这三步原样写成代码。

import threading
import time

board = 0                       # 黑板上的数字

def cook(times):
    global board
    for _ in range(times):
        seen = board            # 1. 读黑板
        time.sleep(0)           # 2. 走神一下(给别的厨师插进来的空当)
        board = seen + 1        # 3. 在读到的值上加 1 写回去

threads = [threading.Thread(target=cook, args=(1000,)) for _ in range(4)]
for th in threads:
    th.start()
for th in threads:
    th.join()
print(board)
# 例如:1086  (每次运行都不一样,几乎不会出现 4000)
  • time.sleep(0) 是“休息 0 秒”,实际效果却是把当前的执行机会让给别的线程。我们故意在读和写之间留了个空当,让别的厨师插进来。
  • 在真实的程序里,这个空当不是 sleep,而是打日志、查数据库、调用函数这类再普通不过的代码。空当总是存在的。
  • 每次运行的数字都不一样。在这台电脑的 Python 3.14 上,大约在 1000~1200 之间。

像这样多个线程读取并改写同一个值、结果随执行顺序而变的情况,叫作竞争条件(race condition)。在下面的黑板实验里亲手确认一下。厨师们做的事和上面的代码一模一样:“读 → time.sleep(0) → 写”。

黑板实验

厨师

4

每人次数

1000

使用 Lock

4 个厨师各往黑板上加 1000 次 1。先关着 Lock 写几次,再打开 Lock 写一次。看看丢失的订单会不会变成 0。
  • 关掉 Lock,黑板上的数字比应有的值小得多,而且每次都不一样。“丢失的订单”一栏就是两者的差。
  • 把厨师减到 1 个,没有 Lock 也分毫不差。竞争条件只在两个以上的线程碰同一个值时才会发生。
  • 打开 Lock,不管几个人、写几次,都和应有的值完全一样。这个 Lock 是什么,就是 3-3 的内容。

3-2. 一步一步看竞争条件(8 分钟)

假设黑板上是 41 的时候,敏浩和秀雅几乎同时各出了一碗。把时间一格一格切开来看是这样的:

时刻敏浩秀雅黑板
1读黑板:4141
2(走神)读黑板:4141
3写下 41 + 1 = 4242
4写下 41 + 1 = 4242

两个人各出一碗,一共两碗,黑板应该是 43。可它是 42。秀雅读到的是敏浩改写之前的值,又用自己的 42 盖掉了敏浩写的 42。敏浩的那一碗不见了。

  • 问题出在“读 → 算 → 写”之间被别的线程插了进来。没有任何保证说这三步会作为一个整体执行。
  • 如果四个人同时读,一下子可能丢三碗。四个人都读到 41、都写下 42,四碗就缩成了一碗。这就是 4000 会掉到 1086 的原因。
  • 运气好、顺序刚好对上时,数字也可能是对的。所以竞争条件是最难抓的 bug:测试时顺利通过,只在客人扎堆的夜里爆发。

3-3. 粉笔只有一支:Lock(10 分钟)

解决办法很简单:黑板前一次只站一个人。只放一支粉笔,只有握着粉笔的人能往黑板上写。Python 里的粉笔就是 threading.Lock。

board = 0
chalk = threading.Lock()        # 粉笔只有一支

def cook(times):
    global board
    for _ in range(times):
        with chalk:             # 握着粉笔的人才能站到黑板前
            seen = board
            time.sleep(0)
            board = seen + 1

threads = [threading.Thread(target=cook, args=(1000,)) for _ in range(4)]
for th in threads:
    th.start()
for th in threads:
    th.join()
print(board)
# 4000
  • 进入 with chalk: 代码块时获取锁(acquire),出来时释放(release)。如果别的线程已经拿着锁,就在原地等到它被释放。
  • 在代码块里用 time.sleep(0) 让出执行机会也没关系。别的厨师就算轮到了,没有粉笔也碰不了黑板。
  • 跑多少次都是 4000。锁里面的“读 → 算 → 写”成了别的线程插不进来的一个整体。

也可以不用 with,自己手动获取和释放。这时一定要用 try/finally 包起来,保证中途出异常也会放下粉笔。

chalk = threading.Lock()
print(chalk.locked())
chalk.acquire()                 # 握住粉笔
try:
    print(chalk.locked())
    print(chalk.acquire(blocking=False))    # 已经有人握着就立刻返回 False
    print(chalk.acquire(timeout=0.1))       # 等 0.1 秒,拿不到就返回 False
finally:
    chalk.release()             # 无论如何都要放下
print(chalk.locked())
chalk.release()
# False
# True
# False
# False
# False
# RuntimeError: release unlocked lock
方法作用
acquire()获取锁。有人拿着就一直等到释放。拿到返回 True
acquire(blocking=False)不等。拿不到立刻返回 False
acquire(timeout=0.1)最多等 0.1 秒。拿不到返回 False
release()释放锁。没上锁却去释放,会抛 RuntimeError
locked()现在是否处于锁定状态

锁的范围要小:只在黑板前

锁会让别人等。所以只该包住真正需要的那几行。如果连做菜都握着粉笔,会怎样?

board = 0
chalk = threading.Lock()

def cook_wide():
    global board
    with chalk:
        time.sleep(0.1)         # 连做菜都握着粉笔
        board += 1

def cook_narrow():
    global board
    time.sleep(0.1)             # 各做各的菜
    with chalk:
        board += 1              # 只在黑板前上锁

for fn in (cook_wide, cook_narrow):
    board = 0
    threads = [threading.Thread(target=fn) for _ in range(4)]
    start = time.perf_counter()
    for th in threads:
        th.start()
    for th in threads:
        th.join()
    print(fn.__name__, board, f"{time.perf_counter() - start:.2f}s")
# cook_wide 4 0.40s
# cook_narrow 4 0.10s

两种写法的数字都是准确的 4。但 cook_wide 要等握粉笔的人把菜做完,其余人只能排队,所以哪怕有四个厨师,也要花和一个人做一样的 0.4 秒。只锁住动共享值的那几行。

主厨的提醒

锁就是一块黑板配一支粉笔。每块黑板用不同的粉笔,或者有人不拿粉笔直接写,那都白搭。碰同一个值的代码,全都得经过同一个 Lock。漏掉一处,数字就从那个窟窿里漏出去。

3-4. 有 GIL 也照样有竞争条件(8 分钟)

Python(准确说是 CPython)里有一把叫 GIL(Global Interpreter Lock,全局解释器锁)的大锁。它的规矩是:同一时刻只有一个线程在执行 Python 代码。详细内容放在第 8 课,但这里常有一个误解:“既然一次只执行一个,那不就没有竞争条件了吗?”

不对。GIL 只是以字节码指令为单位切换线程,并不会把你的“读 → 算 → 写”当作一个整体护住。就连 board += 1 这一行,拆开来看也是好几步。

import dis

count = 0

def add():
    global count
    count += 1

dis.dis(add)
# 部分结果(3.14):
#   LOAD_GLOBAL              0 (count)     ← 读
#   LOAD_SMALL_INT           1
#   BINARY_OP               13 (+=)        ← 算
#   STORE_GLOBAL             0 (count)     ← 写

只要在读和写之间切换了线程,就会发生和 3-2 那张表一模一样的事。什么时候切换线程由解释器决定,切换间隔可以用 sys.getswitchinterval() 查看。

import sys
print(sys.getswitchinterval())
# 0.005

默认值是 0.005 秒。即使一个线程一直在跑,大约每隔这么久,也会收到“把执行机会让给别的线程”的请求。遇到 time.sleep 或 I/O 这类需要等待的操作,还会更早让出。

如果不加 time.sleep(0),只做 400 万次 count += 1,会怎样?

count = 0

def cook(times):
    global count
    for _ in range(times):
        count += 1

threads = [threading.Thread(target=cook, args=(1_000_000,)) for _ in range(4)]
for th in threads:
    th.start()
for th in threads:
    th.join()
print(count, sys._is_gil_enabled())
# Python 3.14:   4000000 True
# Python 3.14t:  例如:1127695 False  (每次运行都不一样)
  • 在这台电脑的 3.14(有 GIL)上,跑了好几次都是 4000000。但这只是碰巧,线程切换点恰好没落在那四步之间而已。Python 从来没有承诺过 += 是原子的(不可分割的整体)。换个版本,或者循环里多一行代码,都可能出错。
  • 在没有 GIL 的自由线程构建 3.14t 上,线程真的在同时运行,400 万里只剩下 100 万出头。在有 GIL 的构建上碰巧算对的代码,到了 3.14t 立刻出错。
  • 3-1 的黑板代码放到 3.14t 上跑,4000 次里比如只剩 2410。数字不一样,错是一样的。
  • 结论只有一个:多个线程改写同一个值时,不管有没有 GIL,都要用 Lock 包起来。打算迁移到 3.14t 的代码更是如此。

3-5. 同一个人再锁一次:RLock(6 分钟)

Lock 不在乎是谁拿着它。所以已经握着粉笔的人再去拿粉笔时,就会自己等自己放下,永远卡住。真卡住就麻烦了,所以我们只等 0.5 秒试试。

def probe(name, lock):
    with lock:                              # 先锁一次
        start = time.perf_counter()
        ok = lock.acquire(timeout=0.5)      # 同一个线程再锁一次
        waited = time.perf_counter() - start
        print(name, ok, f"{waited:.1f}s")
        if ok:
            lock.release()

probe("Lock", threading.Lock())
probe("RLock", threading.RLock())
# Lock False 0.5s
# RLock True 0.0s
  • Lock 整整等了 0.5 秒,返回 False。要是没有 timeout,这个线程就会永远卡在这里。
  • RLock(可重入锁,reentrant lock)会记住是哪个线程拿着它。同一个线程可以重复获取任意多次,释放同样的次数才算完全释放。别的线程照常等待。

这种情况常出现在一个加锁的方法调用另一个加锁的方法时。

class Board:
    def __init__(self):
        self.lock = threading.RLock()
        self.count = 0

    def add(self):
        with self.lock:
            self.count += 1

    def add_table(self, dishes):
        with self.lock:             # 一桌整体处理
            for _ in range(dishes):
                self.add()          # 里面又获取了同一把锁

board = Board()
threads = [threading.Thread(target=board.add_table, args=(250,)) for _ in range(4)]
for th in threads:
    th.start()
for th in threads:
    th.join()
print(board.count)
# 1000

如果 self.lock 是 Lock,add_table 调用 add 的那一刻就卡住了。RLock 稍重一些,也容易让人搞不清锁到底套了几层。换个设计,单独准备一个“不加锁的内部方法”(_add),也是个好办法。

打烊(10 分钟)

今晚的目标是:不管有几个厨师,黑板上的数字都分毫不差,厨房也不变慢。把黑板做成一个类,只锁改写黑板的那一行。做菜(time.sleep)放在锁外面,各做各的。

class Chalkboard:
    def __init__(self):
        self._lock = threading.Lock()
        self.count = 0

    def add(self, n=1):
        with self._lock:            # 只在黑板前
            self.count += n

board = Chalkboard()

def cook(dishes):
    for _ in range(dishes):
        time.sleep(0.001)           # 做菜放在锁外面
        board.add()

threads = [threading.Thread(target=cook, args=(100,)) for _ in range(4)]
start = time.perf_counter()
for th in threads:
    th.start()
for th in threads:
    th.join()
print(board.count, f"{time.perf_counter() - start:.2f}s")
# 例如:400 0.15s  (数字永远是 400,时间是大约值)
  • 四个厨师各 100 碗,黑板永远是 400。在 3.14t 上跑也是 400。
  • 耗时只比一个人做 100 碗的时间(这台电脑上约 0.11 秒)稍多一点。因为四个人一起做菜,只在黑板前短暂排一下队。
  • 改写黑板(count)的途径只有 add() 这一个。把锁藏在类里面,就能减少有人不小心不拿粉笔直接写的情况。

主厨擦掉黑板,重新写上 400。“黑板一次一个人就够了。可明天灶台有三个,厨师有六个。一次只放一个人进去,就有两个灶台闲着。有没有能一次放三个人进去的粉笔?”

营业日志 — 第三晚。黑板上的 4000 缩成了 1086。原因是读和写之间被别的厨师插了进来。黑板前只放一支粉笔(Lock),只锁改写黑板的那一行,问题解决。

课后练习

  1. 在黑板实验里关掉 Lock,比较一下 2 个厨师 × 2000 次和 8 个厨师 × 500 次。总次数都是 4000,哪边丢的订单多?为什么?
  2. 删掉 3-1 代码里的 time.sleep(0) 再跑一遍。就算数字对了,为什么也不能说它是安全的?以 3-4 为依据解释一下。
  3. 把 3-5 的 Board 改用 Lock,再加一个不加锁的内部方法 _add(),让 add() 和 add_table() 都不会卡住。

主厨的配方卡

  • 竞争条件:“读 → 算 → 写”之间被别的线程插入,值就丢了。每次运行结果都不同。

  • lock = threading.Lock() / with lock: 只包住改写共享值的那几行。手动写法:acquire() … finally: release()

  • acquire(blocking=False)、acquire(timeout=秒数) 拿不到就返回 False。用 locked() 查看状态。

  • RLock:同一个线程可以再次加锁。Lock 被同一线程获取两次会把自己卡死。

  • 有 GIL,count += 1 也不是原子的。3.14t 上错得更厉害。共享值一律用 Lock。

  • sys.getswitchinterval() = 0.005:大约每隔这么久,线程就可能被切换。

← 上一课

第2课 · 多招几个厨师:threading 基础

下一课 →

第4课 · 只有三个灶台:同步工具

订阅新课时和新课程通知

本课程新增课时或推出后续课程时,我们会发邮件通知你。

收集项目:电子邮箱 · 用途:本课程的新课时和上线通知 · 保存期限:直到你要求删除。不会用于其他用途,也不会提供给他人。回复任意通知邮件,我们会立即删除。

问答

关于本课有任何疑问都可以在这里留言。所有语言版本页面的提问会汇集在一起。

以纯文本显示,不支持链接和格式。

R

ReflexKR

动手学 Python 和 Reflex。所有示例均已在 Reflex 0.10.0 上实际运行验证。

ReflexKR 免费,没有横幅广告。如果对你有帮助,欢迎请我们喝杯咖啡。 ☕ 请我们喝杯咖啡

联盟链接与赞助说明
한국어English日本語简体中文EspañolDeutsch

© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0

Built with Reflex