前言
学 Python 多线程,第一步要先把预期摆正:多线程不是让程序算得更快的万能药。它擅长的是「一边等、一边干活」——比如同时下载几十个页面、同时读写多个文件。如果你的瓶颈是一大堆计算,多线程帮不上忙,原因在 GIL(见下文第四节)。
新手最常见的两个错误认知:
- 「开 8 个线程就等于 8 核并行」——在默认的 CPython 里不是这样。
- 「线程越多越快」——线程一多,锁竞争和上下文切换的开销反而吃掉收益。
这篇教程从零开始:先写出第一个多线程程序,再讲清线程的创建、等待、参数传递,然后补上绕不开的同步问题,最后给一个可以照抄的线程池示例。示例以 CPython 3.8 及以上为基准。
一、第一个多线程程序
不使用任何库,标准库自带的threading就够了。
# 适用于 Python 3.8+
import threading
import time
def say_hello(name):
print(f"线程 {name} 开始")
time.sleep(1) # 模拟一件需要等待的事
print(f"线程 {name} 结束")
t1 = threading.Thread(target=say_hello, args=("A",))
t2 = threading.Thread(target=say_hello, args=("B",))
t1.start() # 启动,立刻返回
t2.start()
t1.join() # 主线程在这里等 t1 结束
t2.join()
print("主线程结束")运行这段代码,会先看到两个「开始」,大约 1 秒后再看到两个「结束」,最后才是「主线程结束」。总耗时大约 1 秒而不是 2 秒——因为两次sleep是重叠发生的。这就是并发带来的收益。
如果改成先t1.join()再t2.start(),两次等待就会串起来,耗时翻倍。start()要先把所有线程都点着,最后统一join(),这是新手最容易写反的地方。
二、Thread的构造参数
threading.Thread的签名(来自官方文档):
Thread(group=None, target=None, name=None, args=(), kwargs={}, *, daemon=None, context=None)常用的几个参数:
| 参数 | 含义 | 备注 |
|---|
target | 线程要执行的可调用对象 | 不传就什么也不做 |
args | 传给target的位置参数 | 必须是元组;单元素别忘逗号 |
kwargs | 传给target的关键字参数 | 字典 |
name | 线程名,仅供标识 | 省略时自动生成 |
daemon | 是否为守护线程 | 必须在start()前确定 |
一个高频踩坑点:args是元组,只传一个参数时必须写成args=("A",),逗号不能丢。写成args=("A")只是字符串,Thread会把它按字符拆开当成多个参数——或者干脆报参数个数不对。
# 适用于 Python 3.8+
import threading
def show(a, b):
print(f"a={a!r}, b={b!r}")
# 正确:元组里带逗号
threading.Thread(target=show, args=(1, 2)).start()
# 正确:传关键字参数
threading.Thread(target=show, kwargs={"a": 1, "b": 2}).start()三、两种创建方式
方式一:函数式。把现成的函数交给target,最省事,也最推荐。
# 适用于 Python 3.8+
import threading
def task(n):
print(f"处理 {n}")
threading.Thread(target=task, args=(5,)).start()方式二:继承式。定义一个类继承threading.Thread,重写run()。
# 适用于 Python 3.8+
import threading
class MyTask(threading.Thread):
def __init__(self, n, **kwargs):
super().__init__(**kwargs) # 必须先调用父类构造
self.n = n
def run(self): # 线程体写在这里
print(f"处理 {self.n},线程名 {self.name}")
t = MyTask(5)
t.start() # start() 内部会调用 run()官方文档有一句关键提醒:继承Thread时只应重写__init__()和run(),不要重写其他方法。并且要直接调用t.start()而不是t.run()——直接调run()只是在当前线程里跑一个普通函数,根本没有新线程。
四、绕不开的 GIL
CPython 里有一把全局解释器锁(GIL,Global Interpreter Lock)。官方文档的说法是:同一时刻只有一个线程能执行 Python 代码。
由此得到两条实用结论:
- I/O 密集任务用线程有效:线程在等网络、等磁盘时会释放 GIL,别的线程能接着跑。
- CPU 密集任务用线程无效:大家抢同一把锁,多核用不上,官方建议改用
multiprocessing或concurrent.futures.ProcessPoolExecutor。
补充一句事实:CPython 3.13 起有实验性的自由线程(no-GIL)构建,可以真正并行跑线程,但它不是默认构建、需要单独安装,所以日常说到「Python 的多线程」时,GIL 依然存在。
五、共享数据与锁
线程共享同一个进程的内存,多个线程同时改一个变量就会出问题。
# 适用于 Python 3.8+
import threading
count = 0
def increase():
global count
for _ in range(100_000):
count += 1 # 读-改-写,三步之间可能被切换
ts = [threading.Thread(target=increase) for _ in range(4)]
for t in ts:
t.start()
for t in ts:
t.join()
print(count) # 期望 400000,实际通常偏小原因:count += 1在字节码层面是「取值 → 加一 → 写回」三步,解释器会在三步之间切换线程,两个线程可能都读到同一个旧值,各自加一后写回,于是丢了一次累加。
用threading.Lock把这三步锁成一个整体:
# 适用于 Python 3.8+
import threading
count = 0
lock = threading.Lock()
def increase():
global count
for _ in range(100_000):
with lock: # 自动 acquire / release,异常也会释放
count += 1要点:锁保护的是「不可分割的一整段操作」,不是单个变量。加锁范围过大会让线程排队,等于变回单线程;范围过小则保护不住。
如果只是多个线程往一个地方塞数据、再由别人取走,更省心的做法是用queue.Queue——它是线程安全的,内部自带锁。
六、完整示例:线程池批量探测
手工start()/join()一批线程,写起来啰嗦且容易漏。生产代码更常用concurrent.futures.ThreadPoolExecutor。
# 适用于 Python 3.8+
import concurrent.futures
import time
def probe(name, delay):
time.sleep(delay) # 模拟一次耗时的 I/O
return f"{name} 完成(用时 {delay}s)"
def main():
jobs = [("任务1", 1.0), ("任务2", 1.2), ("任务3", 0.4)]
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as pool:
futures = [pool.submit(probe, name, d) for name, d in jobs]
for fut in concurrent.futures.as_completed(futures):
print(fut.result())
if __name__ == "__main__":
main()三个任务串行要 2.6 秒左右,用线程池并发后,总耗时由最慢的那个决定,约 1.2 秒。with块退出时会自动shutdown(wait=True),不必手动收尾。
map()与submit()的区别值得记一下:submit()一次提交一个任务、返回Future;map()一次提交一批、按提交顺序返回结果迭代器。
| 需求 | 用法 |
|---|
| 提交单个任务、逐个取结果 | pool.submit(fn, *args) |
| 一批任务、只要最终结果 | pool.map(fn, iterable) |
| 一批任务、按完成先后处理 | submit+as_completed |
| 需要限制并发数 | 构造时给max_workers赋值 |
常见坑点
1. 先join()再start(),写成串行
❌t1.start(); t1.join(); t2.start(); t2.join()✅t1.start(); t2.start(); t1.join(); t2.join()—— 先全部启动,再统一等待。
2. 单参数忘了逗号
❌Thread(target=f, args=("abc")),"abc"被当作三个字符拆开传。 ✅args=("abc",)或args=["abc"]。
3. 调用run()而不是start()
❌t.run()—— 在当前线程里同步执行,白写了。 ✅t.start(),由解释器另开线程调用run()。
4. 对同一个线程对象重复start()
❌t.start(); t.join(); t.start()抛RuntimeError。 ✅ 一个线程对象只能启动一次;要重跑请新建对象。
5. 用setDaemon()设置守护线程
❌t.setDaemon(True),3.10 起已废弃。 ✅Thread(target=f, daemon=True),或start()之前写t.daemon = True。
6. 主线程没join()就读结果
❌for t in ts: t.start()之后马上print(count)。 ✅ 先对所有线程join(),或用ThreadPoolExecutor的with块。
7. 用全局列表直接 append 之外的结构做共享
❌ 用普通字典一边读一边删,抛RuntimeError: dictionary changed size during iteration。 ✅ 用queue.Queue传递数据,或全程with lock:保护读改。
8. 在 CPU 密集任务上堆线程
❌ 用 8 个线程做纯计算,结果和单线程一样慢、甚至更慢。 ✅ 纯计算任务改用ProcessPoolExecutor;线程只留给 I/O。
总结
| 主题 | 关键结论 |
|---|
| 创建线程 | Thread(target=..., args=(...)),别忘了start() |
| 等待线程 | join()阻塞直到结束,恒返回None |
| 并发收益 | 只体现在 I/O 密集任务上 |
| GIL | 默认 CPython 同一时刻只跑一个线程的字节码 |
| 共享数据 | 必须加锁或改用queue.Queue |
| 批量任务 | 优先ThreadPoolExecutor,别再手工管理一堆线程 |
入门阶段的正确顺序是:先学会「启动 + 等待」这两个动作,再理解「为什么需要锁」,最后形成「I/O 用线程、计算用进程」的直觉。把线程当成 I/O 的工具而不是提速的魔法,就不会走进死胡同。