线程
前言
- 这一篇讲
threading:怎么起线程、线程间为什么天然共享数据、竞态与锁、Event/Timer/local这些同步与工具类、子线程异常怎么处理、GIL 到底影响什么,以及现在更推荐的ThreadPoolExecutor。 - 所有示例在本机 Python 3.14.10(Windows)上实跑过,输出原样贴在代码块下面。为了输出可复现,涉及并发时序的地方只打印布尔结论或排序后的结果,不打印会漂移的数字和顺序。
线程简介
- 一个进程里可以跑多个执行流,这些执行流就是线程,常被称作「轻量级进程」。
- 进程是资源分配的基本单位,线程是 CPU 调度的基本单位:同一进程内的线程共享地址空间(全局变量、打开的文件、堆上的对象),所以线程间传数据不用序列化。
- 一个进程默认只有一个线程,即主线程(
threading.main_thread())。进程退出时的规矩是:非守护线程(默认)会被等——解释器等它跑完才退出;守护线程(daemon=True)不等——主线程一走它就被直接带走,剩下的代码不再执行。下面「怎么起一个线程」里有两组实测对照。 - 与进程的对比:
维度 进程 线程 内存 独立地址空间 同进程内共享 创建开销 大(要复制 / 重新解释器) 小 通信 Pipe / Queue / Manager / 共享内存 直接读写共享对象(要加锁) 崩溃影响 一般只崩自己 一个线程踩坏内存可能带崩整个进程 吃多核 能 受 GIL 限制(CPU 密集不划算,IO 密集没问题) - 两个模块:
_thread是低级接口(start_new_thread()返回线程 id,没有对象、没有 join),threading是对它的高级封装。日常一律用threading;_thread只需要知道它存在。
怎么起一个线程
1 | import threading |
输出:
1 | 主线程名: MainThread |
start() 和 join() 各自到底做了什么:
start():创建线程并让它从target那个函数开始跑。它是「开始」不是「跑完」——调用完立刻返回,主线程继续往下走,于是两个线程并发,谁先打印不固定。同一个对象只能start()一次,重复调用报RuntimeError: threads can only be started once。join():让调用它的那个线程停在原地等,等被join的那个线程结束才往下走;不是「把两个线程合并」。返回值永远是None,join(0.2)表示最多等 0.2 秒、到点就返回(可能还没等完,要配is_alive()判断);没start()就join()报RuntimeError: cannot join thread before it is started;重复join()不报错;线程join自己会报RuntimeError: cannot join current thread(死锁自己没意义)。join()等的是它那一个,不是「最先跑完的那个」:下面 B 先结束,t1.join()仍然要等 A 跑完才返回。- 等待期间当前线程是挂起的(不是空转轮询),不占 CPU,也不会因此霸着 GIL——这正是
join能让 IO 型线程「同时等」的原因。
1 | import threading |
输出:
1 | 两个 start 都调用完了,主线程立刻走到下一行 |
要点:
-
start()之前ident是None;join()之后is_alive()变False,但ident仍留着旧值(它只是记录,不代表线程还在)。 -
enumerate()/active_count()会把主线程也算进去。 -
线程共享内存,所以「收集结果」最直接的方式就是往一个共享列表里写:
1
2
3
4
5
6
7
8
9
10
11
12
13
14import threading
results = []
def worker(i):
results.append(i * i) # 线程共享进程内存,直接写列表就行
if __name__ == '__main__':
ts = [threading.Thread(target=worker, args=(i,)) for i in range(5)]
for t in ts:
t.start()
for t in ts:
t.join()
print('共享列表拿到全部结果:', sorted(results))输出:
1
共享列表拿到全部结果: [0, 1, 4, 9, 16]
这里不用加锁是因为
list.append在 CPython 里是原子的(GIL 保护);换成x = d[k]; d[k] = x + 1这种「读-改-写」就不安全了,见下一节。 -
守护线程:主线程走的时候,它要不要跟着走
先分清两件事:主线程的语句跑完 ≠ 进程退出。
threading.Thread(...)建出来的线程默认是非守护的,进程必须等它们全部结束才退出;写成daemon=True的就是守护线程(后台服务线程),进程一走它们就被直接带走。非守护版(全程没有
join):1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19import threading
import time
ready = threading.Event() # 用来固定「子线程先打印」这件事,否则两线程同时输出会串行成一行
def job():
print('子线程 开始', flush=True)
ready.set()
time.sleep(0.6)
print('子线程 结束', flush=True)
if __name__ == '__main__':
t = threading.Thread(target=job) # daemon 默认 False,即非守护
t.start()
ready.wait()
print('主线程自己的代码跑完了(全程没有 join)', flush=True)
print('主线程已经走到最后一行', flush=True)输出:
1
2
3
4子线程 开始
主线程自己的代码跑完了(全程没有 join)
主线程已经走到最后一行
子线程 结束守护版(只多一个
daemon=True):1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19import threading
import time
ready = threading.Event() # 用来固定「子线程先打印」这件事,否则两线程同时输出会串行成一行
def job():
print('子线程 开始', flush=True)
ready.set()
time.sleep(0.6)
print('子线程 结束', flush=True)
if __name__ == '__main__':
t = threading.Thread(target=job, daemon=True) # 守护线程
t.start()
ready.wait()
print('主线程自己的代码跑完了(全程没有 join)', flush=True)
print('主线程已经走到最后一行', flush=True)输出:
1
2
3子线程 开始
主线程自己的代码跑完了(全程没有 join)
主线程已经走到最后一行两份输出只差最后一行「子线程 结束」:非守护的被解释器等到跑完了,守护的还没睡醒进程就退了。把子线程的睡眠改成 3 秒再跑,非守护版进程要多活约 3 秒才退出,守护版 0 秒就退(本机实测)。
三个容易混的点:
- 「等不等」说的是进程退出这件事,跟谁先执行、跟
join都没关系;join对守护线程照样有效——实测t.join()会一直等到那个守护线程跑完,只是没人join时进程不会替它等。 - 主线程自己也是非守护的(
threading.current_thread().daemon是False),所以交互式环境里起了非守护线程又忘了收尾,解释器会卡着不退。 - 守护线程随时可能被掐断,别让它做「必须写完」的事(落盘、发完最后一个请求);真要收尾就用非守护 +
join,或者用with ThreadPoolExecutor(...)(退出with块时自动等)。
顺着这个名字还有三条实测出来的规则:
daemon标志会被继承:守护线程里再Thread(...)且不明说时,新线程的daemon也是True。- 继承只是默认值,不是锁死:守护线程里显式写
daemon=False开的线程,进程退出时照样会被等(实测那行的输出仍然打印了出来)。 - 被带走时是硬终止:守护线程里的
finally不保证执行(实测主线程一结束,那段finally就没打印);非守护线程的finally会正常执行。
别把「守护」理解反:它不是「主线程退出后保护子线程继续跑完」,规则恰好相反——守护线程跟着进程一起没。想要「脱离上级继续执行」是另一件事(Unix 里叫 detach / daemon 化,
nohup、setsid),在 Python 里对应的不是线程标志,而是用subprocess.Popen起一个独立进程(实测父进程退出后它照样跑完),细节见《进程》的「守护进程与子进程异常」。线程本身做不到这件事:同一个进程里的线程没有「脱离进程单独活着」的可能,进程一停,所有线程一起停。 - 「等不等」说的是进程退出这件事,跟谁先执行、跟
数据共享与竞态
-
共享是默认行为
线程之间共享进程的所有数据,全局变量不需要任何机制就能互相看到——这和《进程》里「全局变量改不动对方」正好相反。
-
竞态是怎么发生的
用两个事件把「读-改-写」强制拆开,就能稳定复现一次丢失更新:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30import threading
balance = 0
step1 = threading.Event()
step2 = threading.Event()
def reader_first():
global balance
cur = balance # 读到 0
step1.set() # 告诉对方「我已经读完了」
step2.wait() # 等对方也写回
balance = cur + 1 # 再写回,把对方的修改覆盖掉
def reader_second():
global balance
step1.wait() # 等第一个线程先读
cur = balance # 同样读到 0
balance = cur + 1
step2.set()
step2.wait() # 等对方写回,保证输出稳定
if __name__ == '__main__':
t1 = threading.Thread(target=reader_first)
t2 = threading.Thread(target=reader_second)
t1.start()
t2.start()
t1.join()
t2.join()
print('两个线程各加 1,结果却是:', balance)
print('原因:读-改-写 被拆开,后写的覆盖了先写的')输出:
1
2两个线程各加 1,结果却是: 1
原因:读-改-写 被拆开,后写的覆盖了先写的 -
为什么
money += 1不是原子的1
2
3
4
5
6
7
8
9
10
11
12
13
14
15import dis
import threading
money = 0
def add():
global money
money += 1
if __name__ == '__main__':
ops = [i.opname for i in dis.get_instructions(add)
if i.opname not in ('RESUME', 'RETURN_CONST', 'CACHE')]
print('money += 1 的字节码:', ops)
print('读和写是两条不同指令:', 'LOAD_GLOBAL' in ops and 'STORE_GLOBAL' in ops)
print('中间被抢走就会丢更新,所以 GIL 不等于线程安全')输出:
1
2
3money += 1 的字节码: ['LOAD_GLOBAL', 'LOAD_SMALL_INT', 'BINARY_OP', 'STORE_GLOBAL', 'LOAD_CONST', 'RETURN_VALUE']
读和写是两条不同指令: True
中间被抢走就会丢更新,所以 GIL 不等于线程安全字节码里
LOAD_GLOBAL和STORE_GLOBAL是两条指令,GIL 只保证「一条字节码执行期间不被抢走」,不保证「这两条之间没人插队」。所以 GIL 不等于线程安全。 -
加锁修正
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19import threading
money = 0
lock = threading.Lock()
def add(n, times):
global money
for _ in range(times):
with lock: # 自动 acquire / release
money += 1
if __name__ == '__main__':
ts = [threading.Thread(target=add, args=(1, 20000)) for _ in range(4)]
for t in ts:
t.start()
for t in ts:
t.join()
print('4 个线程各加 20000 次,结果 =', money, '| 期望 =', 4 * 20000)
print('一致吗:', money == 80000)输出:
1
24 个线程各加 20000 次,结果 = 80000 | 期望 = 80000
一致吗: Truewith lock:会自动acquire/release;手写lock.acquire()时务必try/finally,否则中途抛异常就永久死锁。
同步原语
-
Lock与RLockLock不可重入:同一线程二次acquire()会自锁死。RLock(可重入锁)允许同一线程嵌套获取,常用于「公开方法加锁、内部方法也加锁」的类。先把方法逐个说清(Lock/RLock是同一套接口):acquire():抢锁。空闲就抢到并返回True;锁被别人占着就就地等。两个参数可以改这个行为:acquire(blocking=False)不等待,抢不到立刻返回False;acquire(timeout=0.1)最多等 0.1 秒,等不到返回False(实测确实只等了 0.11 秒)。release():归还锁。锁本来没人拿着还去 release,会报RuntimeError: release unlocked lock,所以别自己手写配平,用with lock:让它在退出时自动释放(中途抛异常也能释放)。locked():问一句「现在被占着吗」,返回布尔值。只能拿来调试——「先判断没被占、再去抢」这两步之间随时可能被人抢走,不能当互斥用。RLock(可重入锁):同一线程可以嵌套acquire,套几层就要release几次,计数回到 0 才真正放开;对不同线程之间它和Lock一样互斥。适合「公开方法已经加锁、它调用的内部方法也加同一把锁」的类。- 为什么要有锁:
now += 1其实是「读、算、写」三步,两个线程交错执行就会丢更新(上一节有实测)。锁的作用是把那段代码变成「同一时刻只有一个线程在里面」。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16import threading
rlock = threading.RLock()
def outer():
with rlock:
inner()
return 'ok'
def inner():
with rlock: # 同一把锁再进一次,普通 Lock 会自锁死
return 'inner'
if __name__ == '__main__':
print('可重入锁嵌套获取:', outer())
print('当前持有者:', threading.get_ident() == rlock._owner if hasattr(rlock, '_owner') else '释放后无持有者')输出:
1
2可重入锁嵌套获取: ok
当前持有者: 释放后无持有者 -
Semaphore:限制并发数Semaphore(n)不是锁,是一个许可计数器(签名Semaphore(value=1)):acquire():拿一个许可,计数减 1;已经是 0 就阻塞等别人归还。同样支持acquire(blocking=False)(拿不到返回False)和acquire(timeout=...)。release():归还一个许可,计数加 1。它不检查你有没有拿过——凭空release()会把计数加到超过初始值(实测Semaphore(1)先 release 一次,后面能连拿两个)。- 要严格不许超发就用
BoundedSemaphore(n),多 release 会报ValueError: Semaphore released too many times。 - 和
Lock的区别:锁讲「谁持有」,信号量讲「还剩几个名额」;Semaphore(1)行为上像一把锁,但用错语义(多 release)不会被拦住。 - 下面这段里的
lock只是为了安全地改now/peak两个计数器,限流是sem干的,两个对象各司其职。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25import threading
import time
sem = threading.Semaphore(2) # 最多 2 个线程同时进入
lock = threading.Lock()
now = 0
peak = 0
def job(i):
global now, peak
with sem:
with lock:
now += 1
peak = max(peak, now)
time.sleep(0.05)
with lock:
now -= 1
if __name__ == '__main__':
ts = [threading.Thread(target=job, args=(i,)) for i in range(6)]
for t in ts:
t.start()
for t in ts:
t.join()
print('6 个任务,实际最大并发 =', peak, '| 上限 =', 2)输出:
1
6 个任务,实际最大并发 = 2 | 上限 = 2
典型用途:同时最多 N 个线程访问数据库 / 调外部接口。
-
Event:跨线程发信号Event是一个开关,让线程「等一个条件成立」,它不保护数据(签名Event(),没有参数):- 新建时默认是关的:
is_set()返回False。 set():打开开关,所有正在wait()的线程立刻被放行;已经是开的再 set 没有副作用。clear():关上开关,下一批wait()又要重新等。wait(timeout=None):开关是开的就立刻返回True;关着就阻塞。超时也会返回,返回值就是「有没有等到」——实测关着时wait(0.1)等 0.11 秒返回False,所以别把wait()当无返回值的 sleep 用。- 和
Condition的分工:Event是「广播一个状态」,谁都能看、谁都能等;Condition要配锁用(with cond:里wait()/notify()),适合「等到某个条件为真再继续」。没持锁就notify()会报RuntimeError: cannot notify on un-acquired lock(实测)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21import threading
import time
e = threading.Event()
def worker():
for i in range(3):
print('worker 等待指令...')
ok = e.wait(timeout=2) # 超时也会返回,返回值就是是否等到
print('等到信号:', ok, '-> 执行第', i + 1, '步')
e.clear() # 复位,下一轮还要等
if __name__ == '__main__':
t = threading.Thread(target=worker)
t.start()
for i in range(3):
time.sleep(0.05)
print('主线程发出第', i + 1, '次信号')
e.set()
t.join()
print('结束')输出:
1
2
3
4
5
6
7
8
9
10worker 等待指令...
主线程发出第 1 次信号
等到信号: True -> 执行第 1 步
worker 等待指令...
主线程发出第 2 次信号
等到信号: True -> 执行第 2 步
worker 等待指令...
主线程发出第 3 次信号
等到信号: True -> 执行第 3 步
结束set()唤醒所有wait()的线程,clear()之后下一轮要重新等;wait(timeout=)会返回布尔值,别写无限等待。还有两个同族:Condition(配合wait/notify做「等待某个条件成立」,生产者-消费者经典写法)、Barrier(N 个线程互相等齐再一起走)。 - 新建时默认是关的:
定时线程与线程局部数据
-
Timer:延迟执行Timer(interval, function, args=None, kwargs=None)是延时执行的线程:它是Thread的子类,所以start()/join()/is_alive()那套全都在(实测isinstance(t, threading.Thread)为True)。- 建完不会自己跑,必须
start();没 start 时is_alive()是False。 cancel():在时间到之前调用可以取消,函数就不会执行(实测取消后那次回调没发生);已经跑起来了就取消不了。- 默认不是守护线程(
t.daemon是False,3.14 实测),所以主线程退出前会等它——这点和很多人印象里的「Timer 是 daemon」不同,别靠记忆下结论。 - 它只保证「不早于 interval 执行」,具体晚多少看调度;要周期执行得在函数里再建一个 Timer,或者用
schedule这类库。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17import threading
import time
def greet(name):
print('定时器到点,你好', name)
if __name__ == '__main__':
t = threading.Timer(0.2, greet, args=('小明',))
start = time.perf_counter()
t.start()
print('start 之后主线程不用等:', time.perf_counter() - start < 0.1)
t.join()
print('任务只执行一次:', not t.is_alive())
cancel_timer = threading.Timer(10, greet, args=('不会被打印',))
cancel_timer.start()
cancel_timer.cancel() # 到点前取消
print('cancel 之后 is_alive:', cancel_timer.is_alive())输出:
1
2
3
4start 之后主线程不用等: True
定时器到点,你好 小明
任务只执行一次: True
cancel 之后 is_alive: TrueTimer是Thread的子类,start()后在后台等 N 秒再执行;cancel()只能在到点之前取消——所以取消之后is_alive()仍然是True(线程还在那儿等超时),只是任务不会执行了。 - 建完不会自己跑,必须
-
threading.local:每个线程一份threading.local()造出来的是一个按线程分格子的对象:同一个属性名,每个线程各存各的,互不干扰。- 每个线程第一次访问时都是空的:读没写过的属性报
AttributeError: '_thread._local' object has no attribute 'v'(实测)。 - 子线程写的值,主线程读不到,另一个子线程也读不到——这正是它的全部用途:给每个线程一份私有上下文。
- 常见场景:每线程一个数据库连接、请求上下文、线程内计数器。
- 它解决的是「隔离」,不是「并发安全」:同一线程内不需要锁,跨线程共享的那份还是要
Lock。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24import threading
import time
local = threading.local()
results = {}
lock = threading.Lock()
def show(tag):
local.value = tag # 每个线程各有一份,互不覆盖
time.sleep(0.02) # 让三个线程真正同时在跑
with lock:
results[tag] = local.value
if __name__ == '__main__':
ts = [threading.Thread(target=show, args=('线程%d' % i,)) for i in range(3)]
for t in ts:
t.start()
for t in ts:
t.join()
for tag in sorted(results): # 排序后再打印,输出才稳定
print(tag, '->', results[tag])
print('主线程里读 local:', getattr(local, 'value', '没设置过'))输出:
1
2
3
4线程0 -> 线程0
线程1 -> 线程1
线程2 -> 线程2
主线程里读 local: 没设置过同一个对象,不同线程读写互不干扰,常用来放「当前请求上下文」「数据库连接」。注意它不是「自动按线程清理」:线程结束后其数据随线程对象回收,线程池里复用线程时要自己重置。
- 每个线程第一次访问时都是空的:读没写过的属性报
自定义线程类与子线程异常
继承 Thread 重写 run(),start() 会自动调用它:
1 | import threading |
输出:
1 | 子线程中... |
子线程里抛异常不会让主线程崩,默认只在 stderr(专门输出错误的那条通道,和 print 走的 stdout 分开,终端里看起来是同一个地方)打一段 traceback(就是报错时那一大段「从哪层调用进来」的记录)。想统一处理,换 threading.excepthook:
1 | import threading |
输出:
1 | excepthook 抓到异常: ValueError 子线程炸了 |
GIL 到底影响什么
CPython 的全局解释器锁(GIL)保证同一时刻只有一个线程在执行 Python 字节码。结论分两类:
- CPU 密集:多线程不会变快(甚至更慢,因为切换开销),要吃多核请用
multiprocessing或ProcessPoolExecutor。 - IO 密集:线程在等待(网络、磁盘、
sleep)时会释放 GIL,所以多线程照样有效。
1 | import threading |
输出:
1 | CPU 密集:多线程并没有更快 -> True |
补充两点:调用 C 扩展(用 C 语言写的模块,比如 numpy 的底层,执行时不跑 Python 字节码)时若显式释放 GIL 也能并行;3.13 起有实验性的 free-threaded 构建(彻底去掉 GIL),但生态还在跟进,生产慎用。
现代写法:ThreadPoolExecutor
手搓 Thread + Lock + 结果列表的活,交给线程池更省事:
1 | import concurrent.futures as cf |
输出:
1 | map 保序: [0, 2, 4, 6, 8, 10] |
map保序返回;submit给Future,.result(timeout=...)取值,子线程里的异常会在这里原样抛出——这是它比裸Thread好用的最大一点。with退出时自动shutdown(wait=True)。- 想「谁先完成先处理」用
concurrent.futures.as_completed(futures)。 - 同一个
Executor换ProcessPoolExecutor就能从线程切到进程,接口一致,这也是判断「该用线程还是进程」最省事的实验方式。
整体示例:并发抓取 + 计数
把线程、锁、共享字典串起来:6 个线程各自「抓取」,成功失败分开计数,共享结构全部在锁里改。
1 | import threading |
输出:
1 | 抓取成功(排序后): ['a', 'c', 'e'] |
done 的追加顺序取决于调度,所以打印前先 sorted()——并发程序里凡是顺序相关的输出,要么排序、要么带序号,否则日志没法比对。