更新页面 Python 并发语法
页面 kb/python-concurrency · 修改(rev 10) · · 2026-10-25 19:45
body
@@ -7,5 +7,8 @@ ## 线程:`Thread` -一个线程就是一条独立往下跑的执行流。建一个 `Thread`,告诉它要跑哪个函数、带什么参数。调 `start()` 之后它开始跑,`join()` 让当前线程停下来等它跑完。 +> [!term] 线程(thread) +> 程序里一条独立往下跑的执行流。同一个程序的几个线程共用同一份内存,能读写同样的变量,所以会互相干扰。 + +在 Python 里建一个 `Thread`,告诉它要跑哪个函数、带什么参数。调 `start()` 之后它开始跑,`join()` 让当前线程停下来等它跑完。 ```python @@ -33,4 +36,7 @@ 两个线程同时读写同一个变量,结果就取决于它们的指令怎么交错。最简单的例子是计数器:`counter += 1` 其实是「读出来、加一、写回去」三步,如果线程 A 读完还没写回,线程 B 也读了同一个旧值,两个线程各写回一次,结果只加了一,丢了一次更新。 +> [!term] 竞态条件(race condition) +> 几个线程同时读写同一份数据,至少有一个在写,最后的结果取决于它们的指令恰好怎么交错。上面计数器丢更新就是一个例子。 + ```python counter = 0 @@ -53,5 +59,9 @@ ### 怎么写 -锁(lock,也叫 mutex)是一个对象,只有两个操作。`acquire()` 是「拿锁」:没人拿着就立刻拿到、继续往下跑。别的线程正拿着,就停在这一行等,直到那个线程放锁。`release()` 是「放锁」:放掉以后,正在等的线程里有一个会拿到并继续。所以同一时刻最多一个线程处在「拿着锁」的状态,从拿到放之间的那段代码叫临界区(critical section),两个线程的临界区不会同时跑。 +> [!term] 锁(lock,也叫互斥锁 mutex) +> 一个对象,只有两个操作。`acquire()` 是「拿锁」:没人拿着就立刻拿到、继续往下跑。别的线程正拿着,就停在这一行等,直到那个线程放锁。`release()` 是「放锁」:放掉以后,正在等的线程里有一个会拿到并继续。所以同一时刻最多一个线程拿着锁。 + +> [!term] 临界区(critical section) +> 从拿到锁到放锁之间执行的那段代码。拿同一把锁的两个线程,临界区不会同时跑。 ```python @@ -78,4 +88,7 @@ `Lock` 不认线程:一个线程拿着锁再调一次 `acquire()`,它会等「拿锁的人」放锁,而拿锁的人就是它自己,于是永远等下去。这叫不可重入(non-reentrant)。典型场景是一个加了锁的方法里调用另一个也加了同一把锁的方法。 +> [!term] 死锁(deadlock) +> 几个线程互相等对方手里的东西,谁都等不到,全部永远停住。这里是最简单的一种:一个线程等的是它自己手里的锁。 + ```python lock = threading.Lock() @@ -91,5 +104,8 @@ ``` -`RLock`(reentrant lock,可重入锁)记住是哪个线程拿着、拿了几次,同一个线程可以反复拿,放够同样次数才真正释放。面试里拿它解决「锁里调用自己」的问题可以,但要说出代价:它没解决「拿着锁去调别人的代码」带来的其他问题(慢、抛异常),只是不死锁了。 +> [!term] 可重入锁(reentrant lock) +> 记得自己被哪个线程拿着、拿了几次的锁。同一个线程可以反复拿,每拿一次计数加 1,每放一次减 1,减到 0 才真正放开。Python 里是 `threading.RLock`。 + +面试里拿 `RLock` 解决「锁里调用自己」的问题可以,但要说出代价:它没解决「拿着锁去调别人的代码」带来的其他问题(慢、抛异常),只是不死锁了。 「可重入」只对拿着锁的那个线程成立,这个线程叫锁的主人(owner)。别的线程来 `acquire()`,和普通的 `Lock` 一样要等,一直等到主人把锁完全放开:主人拿了几次,就要放几次,计数归零才算放开。别的线程也不能替主人放锁。 @@ -109,5 +125,8 @@ ## 信号量:`Semaphore` -信号量(semaphore)是一个许可计数。`Semaphore(N)` 一开始有 N 个许可。`acquire()` 拿走一个,许可是 0 就停住等。`release()` 还回一个,正在等的线程里有一个会拿到。它不记得许可被谁拿走了,所以任何线程都可以 `release()`。它有两种用法。 +> [!term] 信号量(semaphore) +> 一个许可计数。`Semaphore(N)` 一开始有 N 个许可。`acquire()` 拿走一个,许可是 0 就停住等。`release()` 还回一个,正在等的线程里有一个会拿到。它不记得许可被谁拿走了,所以任何线程都可以 `release()`。 + +信号量有两种用法。 **限制同时做某件事的线程数**,比如最多 3 个线程同时下载。用来限流时写 `BoundedSemaphore`,原因见下面「用错会怎样」。 @@ -168,5 +187,8 @@ ### 为什么需要 -锁解决的是「别同时改」。另一类需求是「等某件事发生再继续」:消费者要等队列里有东西,Trigger 题里 `wait()` 要等有人 `fire()`。最笨的办法是忙等(busy wait):`while not flag: pass` 一直空转查标志,白白占满一个 CPU 核。条件变量(condition variable)让线程真正睡下去,等别人叫醒。 +锁解决的是「别同时改」。另一类需求是「等某件事发生再继续」:消费者要等队列里有东西,Trigger 题里 `wait()` 要等有人 `fire()`。最笨的办法是忙等(busy wait):`while not flag: pass` 一直空转查标志,白白占满一个 CPU 核。条件变量能让线程睡下去、不占 CPU,等别人叫醒。 + +> [!term] 条件变量(condition variable) +> 一个和锁绑在一起用的等待工具。线程在它上面 `wait()`,放锁并睡下。别的线程改完条件以后 `notify()` 或 `notify_all()`,把等着的线程叫醒。它只负责「睡」和「叫醒」,条件成没成立要另用一个变量记下来。 ### 怎么写 @@ -174,6 +196,9 @@ `Condition` 内部自带一把锁,`with cond:` 拿的就是这把锁。它提供: -- `wait()`:必须在拿着锁的时候调用。它**在一步之内**放锁并让线程睡下。被叫醒以后,要重新拿到锁才返回。「放锁」和「睡下」必须是一步,否则线程刚放锁、还没睡着,别人就改了条件并发了通知,这个通知没人收到,线程随后睡下去就再也醒不过来(这叫丢失唤醒,lost wakeup)。 +- `wait()`:必须在拿着锁的时候调用。它**在一步之内**放锁并让线程睡下。被叫醒以后,要重新拿到锁才返回。「放锁」和「睡下」必须是一步,否则线程刚放锁、还没睡着,别人就改了条件并发了通知,这个通知没人收到,线程随后睡下去就再也醒不过来(这叫丢失唤醒,见下面的名词解释)。 - `notify()`:叫醒一个正在 `wait()` 的线程,`notify_all()` 叫醒全部。都必须在拿着锁的时候调用,不拿会直接抛 `RuntimeError: cannot notify on un-acquired lock`。它们不留任何记录,只叫醒发通知那一刻正在等的线程:没人在等时这次通知就落空了,之后才来 `wait()` 的线程照样睡下,等的是下一次通知(实测先 `notify_all()`、再 `wait(timeout=0.3)`,睡满 0.3 秒超时返回 False)。所以「发生过什么」要另用一个变量记下来,比如下面例子里的 `items`,等的一方先查它,再决定要不要睡。前面的 `Semaphore` 和后面的 `Event` 会记住之前的 `release()`、`set()`,之后再来的线程立刻返回。 + +> [!term] 丢失唤醒(lost wakeup) +> 通知发出的时候,要等的线程还没开始等,这次通知就落空了。那个线程随后开始等,可再也没有人通知它,于是永远睡下去。 ```python @@ -195,5 +220,8 @@ 实测:消费者先进入 `wait()`,生产者放入一个元素并 `notify()` 后,消费者拿到了它。 -**`wait()` 外面用 `while` 不用 `if`**,两个原因。第一,虚假唤醒(spurious wakeup):Python、Java、C++ 的规范都允许 `wait()` 在没人通知的情况下返回,醒来不代表条件成立。第二,从被叫醒到重新拿到锁之间有一段空档,别的线程可能在这段时间里把条件又改回去了(比如另一个消费者先一步把元素拿走了)。所以醒来后一律重新检查,不成立就接着睡。 +> [!term] 虚假唤醒(spurious wakeup) +> 等待的线程在没有任何人通知的情况下,从 `wait()` 里返回了。POSIX 线程库和 C++ 标准都明确允许这种情况,所以从 `wait()` 返回只说明「该醒来再看一眼了」,不说明条件已经成立。 + +**`wait()` 外面用 `while` 不用 `if`**,两个原因。第一,可能是虚假唤醒,醒来不代表条件成立。第二,从被叫醒到重新拿到锁之间有一段空档,别的线程可能在这段时间里把条件又改回去了(比如另一个消费者先一步把元素拿走了)。所以醒来后一律重新检查,不成立就接着睡。 **先改条件、再通知,两步都在锁里。** 顺序反过来(先通知、再在锁外改条件),被叫醒的线程重新拿到锁时查到的还是旧条件,又睡回去,之后再也没人叫它。[[q:everpure-fire-event]] 思路第 8 节有这个错误的逐步表。 @@ -288,7 +316,13 @@ ## 线程池:`ThreadPoolExecutor` -线程池(thread pool)是一组事先建好的工作线程(worker thread)。你把任务提交给它,它从内部的队列里按提交顺序取出任务,交给空闲的工作线程执行。和每个任务新建一个 `Thread` 相比,线程只建一次、反复使用,同时执行的任务数也有上限。标准库的线程池在 `concurrent.futures` 模块里,叫 `ThreadPoolExecutor`。 - -`submit(f, *args)` 把一个任务交给线程池,马上返回一个 `Future` 对象,代表「这个任务将来的结果」。之后用 `result()` 取返回值,任务还没执行完就停住等。`wait(futures)` 等一组任务全部执行完。 +> [!term] 线程池(thread pool) +> 一组事先建好、反复使用的工作线程(worker thread)。你把任务提交给它,它从内部的队列里按提交顺序取出任务,交给空闲的工作线程执行。 + +和每个任务新建一个 `Thread` 相比,线程只建一次、反复使用,同时执行的任务数也有上限。标准库的线程池在 `concurrent.futures` 模块里,叫 `ThreadPoolExecutor`。 + +> [!term] Future 对象 +> 代表一个已经提交、可能还没执行完的任务。任务执行完以后,返回值或者抛出的异常都存在它里面。用 `result()` 取返回值,任务还没执行完就停住等。用 `exception()` 取异常对象。 + +`submit(f, *args)` 把一个任务交给线程池,马上返回一个 `Future`。`wait(futures)` 等一组任务全部执行完。 ```python @@ -318,5 +352,8 @@ ## GIL:多线程什么时候有用 -CPython 有一把全局解释器锁(GIL),同一时刻只有一个线程在执行 Python 字节码。它带来两个后果。一是 Python 线程做纯计算不会并行,只是轮流。二是一条赋值不会写到一半被打断,别的线程也马上能看到。但它**不**保证两条语句之间不被切开,上面计数器的例子就是证明。所以 GIL 不能代替锁,面试里也别说「Python 有 GIL 所以不用加锁」。 +> [!term] 全局解释器锁(GIL,global interpreter lock) +> 标准的 Python 解释器 CPython 里的一把锁:同一时刻只有一个线程能执行 Python 字节码。 + +GIL 带来两个后果。一是 Python 线程做纯计算不会并行,只是轮流。二是一条赋值不会写到一半被打断,别的线程也马上能看到。但它**不**保证两条语句之间不被切开,上面计数器的例子就是证明。所以 GIL 不能代替锁,面试里也别说「Python 有 GIL 所以不用加锁」。 GIL 只限制「执行 Python 字节码」这一件事。线程在等 I/O 的时候不执行字节码,C 代码放掉 GIL 以后在里面算,也不受它限制。所以多线程在三种情况下有用,只有纯 Python 计算帮不上忙。实测(2026-10-25,Python 3.12.10,16 核的 Windows 机器,每行 4 个任务):