今晚的订单
#03 · 第三晚
危机
四个厨师各做了 1000 份,每做一份就在厨房黑板的数字上加 1。可黑板上写的不是 4000,而是 1086。
目标
不管有几个厨师,黑板上的数字都要分毫不差。同时,厨房不能因此变慢。
深夜食堂 同时
第三晚,凌晨两点,营业结束了。今天老卓的朋友来帮了一天忙,厨师一共有四个。主厨定了一条规矩:“每出一碗,就在黑板的数字上加 1。”
数一数订单,四个人各出了 1000 碗,一共 4000 碗。可黑板上写着的是 1086。
敏浩挠着头:“我真的每次都加了啊?”秀雅和老卓也这么说。主厨拿起粉笔:“谁也没撒谎。你们四个只是同时站到了黑板前面而已。”
在黑板上加 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,而是打日志、查数据库、调用函数这类再普通不过的代码。空当总是存在的。像这样多个线程读取并改写同一个值、结果随执行顺序而变的情况,叫作竞争条件(race condition)。在下面的黑板实验里亲手确认一下。厨师们做的事和上面的代码一模一样:“读 → time.sleep(0) → 写”。
黑板实验
厨师
4
每人次数
1000
使用 Lock
假设黑板上是 41 的时候,敏浩和秀雅几乎同时各出了一碗。把时间一格一格切开来看是这样的:
| 时刻 | 敏浩 | 秀雅 | 黑板 |
|---|---|---|---|
| 1 | 读黑板:41 | 41 | |
| 2 | (走神) | 读黑板:41 | 41 |
| 3 | 写下 41 + 1 = 42 | 42 | |
| 4 | 写下 41 + 1 = 42 | 42 |
两个人各出一碗,一共两碗,黑板应该是 43。可它是 42。秀雅读到的是敏浩改写之前的值,又用自己的 42 盖掉了敏浩写的 42。敏浩的那一碗不见了。
解决办法很简单:黑板前一次只站一个人。只放一支粉笔,只有握着粉笔的人能往黑板上写。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)
# 4000with chalk: 代码块时获取锁(acquire),出来时释放(release)。如果别的线程已经拿着锁,就在原地等到它被释放。time.sleep(0) 让出执行机会也没关系。别的厨师就算轮到了,没有粉笔也碰不了黑板。也可以不用 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。漏掉一处,数字就从那个窟窿里漏出去。
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 (每次运行都不一样)+= 是原子的(不可分割的整体)。换个版本,或者循环里多一行代码,都可能出错。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.0sLock 整整等了 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),也是个好办法。
今晚的目标是:不管有几个厨师,黑板上的数字都分毫不差,厨房也不变慢。把黑板做成一个类,只锁改写黑板的那一行。做菜(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,时间是大约值)count)的途径只有 add() 这一个。把锁藏在类里面,就能减少有人不小心不拿粉笔直接写的情况。主厨擦掉黑板,重新写上 400。“黑板一次一个人就够了。可明天灶台有三个,厨师有六个。一次只放一个人进去,就有两个灶台闲着。有没有能一次放三个人进去的粉笔?”
营业日志 — 第三晚。黑板上的 4000 缩成了 1086。原因是读和写之间被别的厨师插了进来。黑板前只放一支粉笔(Lock),只锁改写黑板的那一行,问题解决。
time.sleep(0) 再跑一遍。就算数字对了,为什么也不能说它是安全的?以 3-4 为依据解释一下。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:大约每隔这么久,线程就可能被切换。
订阅新课时和新课程通知
本课程新增课时或推出后续课程时,我们会发邮件通知你。
问答
关于本课有任何疑问都可以在这里留言。所有语言版本页面的提问会汇集在一起。