Python 多线程:一个厨房里的多个厨师
引言:如果只有一个厨师,但可以同时切菜和煮汤
上一篇我们讲了多进程——雇多个厨师,每人一个独立厨房,同时做菜。
但有些任务并不需要"多个厨房"。比如:
- 一个厨师煮汤(等水开的间隙),同时切菜;
- 一个服务员同时照看好几桌客人(哪桌招手就去哪桌)。
这些任务的特点是:大部分时间在"等"——等水开、等客人招手、等网络响应、等文件读写。等待的时候,完全可以去做别的事。
这就是**多线程(Threading)**的舞台:在同一个进程(厨房)里,启动多个"分身"(线程),交替执行任务,把等待时间利用起来。
这一篇,我们从线程的基本概念讲起,到 threading 模块的使用,再到线程锁、死锁、GIL 全局锁,最后讲清楚 Python 多线程的适用场景和常见误区。
一、线程是什么?进程和线程的关系
1.1 生活化理解
想象一家餐厅:
| 概念 | 对应现实 | 说明 |
|---|---|---|
| 进程 | 一家独立的餐厅 | 有自己的厨房、设备、员工,互不干扰 |
| 线程 | 餐厅里的一个厨师/服务员 | 共享同一个厨房,但可以同时做不同的事 |
- 一家餐厅(进程)至少有一个厨师(主线程);
- 可以雇多个厨师(多个线程),共享厨房资源;
- 厨师之间要协调:不能同时用同一把刀(共享资源冲突)。
1.2 技术定义
- 进程(Process):操作系统分配资源的最小单位。每个进程有独立的内存空间。
- 线程(Thread):操作系统调度执行的最小单位。线程是进程内的执行单元,共享进程的内存空间。
一个进程可以包含多个线程,这些线程:
- 共享进程的内存、文件句柄、网络连接等资源;
- 各自有独立的执行栈(局部变量互不干扰);
- 由操作系统统一调度,轮流使用 CPU。
1.3 Python 的线程
Python 的线程是真正的操作系统线程(POSIX Thread),不是模拟出来的。
Python 标准库提供了两个线程模块:
| 模块 | 级别 | 说明 |
|---|---|---|
_thread | 低级模块 | 接近操作系统原生 API,功能少,不推荐直接使用 |
threading | 高级模块 | 对 _thread 的封装,功能丰富,推荐使用 |
记住:99% 的情况下,你只需要用
threading。
二、启动线程:把函数交给 Thread
2.1 最简单的线程
启动一个线程分三步:
- 定义一个函数(线程要执行的任务);
- 创建
threading.Thread实例,把函数传进去; - 调用
start()启动。
import time
import threading
def loop():
print('线程 %s 开始运行...' % threading.current_thread().name)
n = 0
while n < 5:
n = n + 1
print('线程 %s >>> %s' % (threading.current_thread().name, n))
time.sleep(1)
print('线程 %s 结束。' % threading.current_thread().name)
print('线程 %s 开始运行...' % threading.current_thread().name)
t = threading.Thread(target=loop, name='LoopThread')
t.start()
t.join()
print('线程 %s 结束。' % threading.current_thread().name)运行结果:
线程 MainThread 开始运行...
线程 LoopThread 开始运行...
线程 LoopThread >>> 1
线程 LoopThread >>> 2
线程 LoopThread >>> 3
线程 LoopThread >>> 4
线程 LoopThread >>> 5
线程 LoopThread 结束。
线程 MainThread 结束。2.2 代码解析
| 代码 | 作用 |
|---|---|
threading.Thread(target=loop, name='LoopThread') | 创建线程,指定要执行的函数和线程名 |
t.start() | 启动线程,线程开始并行执行 |
t.join() | 主线程等待子线程结束后再继续 |
threading.current_thread().name | 获取当前线程的名字 |
2.3 主线程和子线程
- 任何 Python 程序启动时,自动创建一个主线程(
MainThread); - 主线程可以启动子线程;
- 如果不给子线程起名,Python 自动命名为
Thread-1、Thread-2…… join()的作用:主线程"等一等",等子线程干完活再继续。
生活化理解:主线程是餐厅老板,子线程是厨师。老板启动厨师后,如果不 join(),老板就直接下班了(程序退出),厨师干到一半会被强制终止。join() 就是老板说"我等你做完再关门"。
三、多线程共享变量:一把双刃剑
3.1 多进程 vs 多线程的内存差异
| 特性 | 多进程 | 多线程 |
|---|---|---|
| 内存空间 | 每个进程独立 | 所有线程共享 |
| 变量 | 各自有一份拷贝 | 共享同一份 |
| 数据安全 | 天然安全 | 需要小心 |
多线程共享内存是优势(通信方便),也是最大的坑。
3.2 一个银行存款的灾难
来看一个经典例子:
import time
import threading
balance = 0 # 银行存款
def change_it(n):
global balance
balance = balance + n # 存钱
balance = balance - n # 取钱(理论上应该回到 0)
def run_thread(n):
for i in range(1000000):
change_it(n)
t1 = threading.Thread(target=run_thread, args=(5,))
t2 = threading.Thread(target=run_thread, args=(8,))
t1.start()
t2.start()
t1.join()
t2.join()
print(balance) # 理论上应该是 0,但实际可能不是!理论上,两个线程各存各取,余额应该始终是 0。但运行结果可能是:
-8或者:
5为什么?
3.3 问题根源:一条语句 ≠ 一条指令
你以为 balance = balance + n 是一步完成的?错!CPU 执行时分为多步:
# 高级语言:一行代码
balance = balance + n
# CPU 实际执行:多步
x = balance + n # 步骤1:读取 balance,计算,存入临时变量 x
balance = x # 步骤2:把 x 写回 balance两个线程的 x 是各自独立的局部变量,互不干扰。但 balance 是共享的。
看一个出错的执行序列:
初始值 balance = 0
t1: x1 = balance + 5 # x1 = 0 + 5 = 5
# 此时 t1 被操作系统暂停,切换到 t2
t2: x2 = balance + 8 # x2 = 0 + 8 = 8 (t2 读到的还是旧值 0!)
t2: balance = x2 # balance = 8
t2: x2 = balance - 8 # x2 = 8 - 8 = 0
t2: balance = x2 # balance = 0
# t2 执行完毕,切换回 t1
t1: balance = x1 # balance = 5 (t1 把之前算好的 5 写回去)
t1: x1 = balance - 5 # x1 = 5 - 5 = 0
t1: balance = x1 # balance = 0
# 等等,t2 的结果被 t1 覆盖了!生活化理解:两个会计同时改同一本账。会计 A 读到余额 100,正准备写 105;会计 B 也读到余额 100,写 108。A 写完 105,B 的 108 把 A 的结果覆盖了——最终余额不是 105 也不是 108,而是其中一个,另一个操作丢了。
四、线程锁(Lock):给共享资源上把锁
4.1 锁的作用
**锁(Lock)**的核心思想:同一时刻,只允许一个线程修改共享数据。
就像银行柜台的"暂停服务"牌——一个客户办理业务时,其他客户必须排队。
4.2 使用 Lock 修复银行存款
import threading
balance = 0
lock = threading.Lock() # 创建一把锁
def change_it(n):
global balance
balance = balance + n
balance = balance - n
def run_thread(n):
for i in range(1000000):
lock.acquire() # 获取锁(如果别人拿着,就等)
try:
change_it(n) # 安全地修改共享变量
finally:
lock.release() # 释放锁(必须!)
t1 = threading.Thread(target=run_thread, args=(5,))
t2 = threading.Thread(target=run_thread, args=(8,))
t1.start()
t2.start()
t1.join()
t2.join()
print(balance) # 结果一定是 04.3 锁的注意事项
| 要点 | 说明 |
|---|---|
| 必须释放 | 获得锁后一定要 release(),否则其他线程永远等待 |
| try...finally | 用 finally 确保即使出错也能释放锁 |
| 粒度 | 锁的范围越小越好,只锁必要的代码 |
4.4 更优雅的写法:with 语句
Python 的锁支持上下文管理器,可以自动获取和释放:
def run_thread(n):
for i in range(1000000):
with lock: # 自动 acquire(),退出时自动 release()
change_it(n)推荐用 with,再也不怕忘记释放锁。
五、死锁:两个线程互相等对方
5.1 什么是死锁?
两个线程,各拿一把锁,又都想拿对方的锁:
线程 A:拿到锁1,想要锁2
线程 B:拿到锁2,想要锁1
结果:A 等 B 放锁2,B 等 A 放锁1 → 永远等下去生活化理解:两个人过独木桥,一个从东边来,一个从西边来,在桥中间相遇,谁也不肯退,都僵住了。
5.2 死锁代码示例
import threading
import time
lock_a = threading.Lock()
lock_b = threading.Lock()
def thread_1():
with lock_a:
print('线程1拿到锁A')
time.sleep(0.1) # 模拟处理,给线程2机会拿锁B
with lock_b: # 想要锁B,但锁B被线程2拿着
print('线程1拿到锁B')
def thread_2():
with lock_b:
print('线程2拿到锁B')
time.sleep(0.1)
with lock_a: # 想要锁A,但锁A被线程1拿着
print('线程2拿到锁A')
t1 = threading.Thread(target=thread_1)
t2 = threading.Thread(target=thread_2)
t1.start()
t2.start()
t1.join()
t2.join()运行后程序卡死,只能强制终止。
5.3 如何避免死锁?
| 方法 | 说明 |
|---|---|
| 按固定顺序加锁 | 所有线程都按"先锁A后锁B"的顺序,避免循环等待 |
| 减少锁的数量 | 能不用锁就不用,能用一个锁就不用两个 |
| 用超时机制 | acquire(timeout=5),超时自动放弃 |
| 用更高级的同步原语 | 如 threading.RLock、threading.Condition |
六、GIL:Python 多线程的"紧箍咒"
6.1 一个令人困惑的实验
你的电脑是 4 核 CPU,写个死循环试试:
import threading
import multiprocessing
def loop():
x = 0
while True:
x = x ^ 1
# 启动 4 个线程(4核CPU)
for i in range(multiprocessing.cpu_count()):
t = threading.Thread(target=loop)
t.start()打开任务管理器,你会发现:CPU 占用率只有 100%,也就是只用了一个核!
同样的代码,用 C、C++ 或 Java 写,4 核能跑到 400%。为什么 Python 不行?
6.2 GIL 是什么?
**GIL(Global Interpreter Lock,全局解释器锁)**是 CPython 解释器的设计:
- 任何 Python 线程执行前,必须先获得 GIL;
- 每执行 100 条字节码,自动释放 GIL,让其他线程有机会执行;
- 同一时刻,只有一个线程在执行 Python 代码。
生活化理解:GIL 就像厨房里的"总开关"。4 个厨师都想做菜,但同一时刻只能有一个厨师碰灶台,其他人只能等着。即使厨房有 4 个灶台(4 核 CPU),也只能用一个。
6.3 为什么要有 GIL?
这是 CPython 的历史遗留问题:
- CPython 的内存管理不是线程安全的;
- 如果没有 GIL,多线程同时操作引用计数,会导致内存泄漏或崩溃;
- 去掉 GIL 需要重写解释器,工作量巨大,且会降低单线程性能。
6.4 GIL 的影响
| 场景 | 多线程效果 | 原因 |
|---|---|---|
| CPU 密集型(计算、循环) | ❌ 基本无效 | GIL 导致只能用一个核 |
| I/O 密集型(网络、文件、数据库) | ✅ 有效 | 等待 I/O 时 GIL 会释放,其他线程可以执行 |
关键结论:Python 多线程适合 I/O 密集型任务,不适合 CPU 密集型任务。
七、多线程 vs 多进程:如何选择?
| 对比项 | 多线程 | 多进程 |
|---|---|---|
| 内存开销 | 小(共享内存) | 大(独立内存) |
| 创建速度 | 快 | 慢 |
| 数据共享 | 方便(直接共享变量) | 复杂(需要 IPC) |
| 数据安全 | 需要锁 | 天然安全 |
| 多核利用 | ❌ 受 GIL 限制 | ✅ 可以用满多核 |
| 稳定性 | 一个线程崩溃可能拖垮整个进程 | 一个进程崩溃不影响其他 |
选择建议
| 任务类型 | 推荐方案 | 例子 |
|---|---|---|
| I/O 密集型 | 多线程 / 异步IO | 网络爬虫、Web 服务器、文件批量下载 |
| CPU 密集型 | 多进程 | 图像处理、科学计算、视频转码 |
| 混合型 | 多进程 + 每个进程内多线程 | 大型 Web 应用 |
八、常见误区解析
误区 1:多线程一定比单线程快
真相:对于 CPU 密集型任务,Python 多线程可能比单线程更慢!
原因:GIL 导致线程切换有开销,且无法并行计算。
# CPU 密集型任务,多线程反而更慢
import threading
import time
def count(n):
while n > 0:
n -= 1
# 单线程
start = time.time()
count(100000000)
count(100000000)
print('单线程:', time.time() - start)
# 多线程
start = time.time()
t1 = threading.Thread(target=count, args=(100000000,))
t2 = threading.Thread(target=count, args=(100000000,))
t1.start(); t2.start()
t1.join(); t2.join()
print('多线程:', time.time() - start)
# 多线程通常更慢!误区 2:锁越多越好,越安全
真相:锁越多,死锁风险越大,性能越差。
正确做法:锁的粒度尽量小,只锁必要的代码。
误区 3:threading 模块的线程是"假线程"
真相:Python 的线程是真正的操作系统线程(POSIX Thread),只是被 GIL 限制了并行能力。
误区 4:多线程可以随便改全局变量
真相:改全局变量必须加锁,否则数据会错乱。
九、实战案例
案例 1:多线程下载文件
import threading
import requests
def download(url, filename):
print('开始下载:', filename)
r = requests.get(url)
with open(filename, 'wb') as f:
f.write(r.content)
print('下载完成:', filename)
urls = [
('https://example.com/file1.zip', 'file1.zip'),
('https://example.com/file2.zip', 'file2.zip'),
('https://example.com/file3.zip', 'file3.zip'),
]
threads = []
for url, filename in urls:
t = threading.Thread(target=download, args=(url, filename))
t.start()
threads.append(t)
for t in threads:
t.join()
print('全部下载完成')为什么适合多线程? 下载是 I/O 密集型,大部分时间在等网络响应,GIL 会释放,多个线程可以并行下载。
案例 2:带锁的计数器
import threading
class Counter:
def __init__(self):
self._value = 0
self._lock = threading.Lock()
def increment(self):
with self._lock:
self._value += 1
def value(self):
with self._lock:
return self._value
counter = Counter()
def worker():
for _ in range(100000):
counter.increment()
threads = [threading.Thread(target=worker) for _ in range(5)]
for t in threads:
t.start()
for t in threads:
t.join()
print(counter.value()) # 500000十、小结
| 核心知识点 | 一句话总结 |
|---|---|
| 线程 | 进程内的执行单元,共享内存,切换开销小 |
| threading 模块 | Python 多线程的标准库,用 Thread + start() + join() |
| 共享变量 | 多线程共享内存,修改时必须加锁 |
| Lock | 确保同一时刻只有一个线程修改共享数据 |
| 死锁 | 多个线程互相等待对方的锁,程序卡死 |
| GIL | CPython 的全局锁,导致多线程无法利用多核 |
| 适用场景 | I/O 密集型用多线程,CPU 密集型用多进程 |
记住三句话:
- 多线程共享变量,改数据必须加锁;
- Python 多线程有 GIL,CPU 密集型别用多线程;
- I/O 密集型任务,多线程能显著提升效率。