1. 从"程序卡死"说起:为什么单线程思维迟早要翻车
刚学Python那会儿,我写过一个批量下载图片的小脚本。逻辑特别简单:一个循环,挨个请求URL,保存文件,打印进度。跑起来也没毛病,就是慢——慢到我去泡了杯咖啡回来,进度条才走了三分之一。更难受的是,整个窗口是"假死"状态,点啥都没反应,连关都关不掉,只能去任务管理器里强杀进程。
当时我以为是自己代码写得烂,后来才反应过来:这不是代码烂,这是单线程的天然局限。程序从头到尾只有一条执行流,请求网络的时候CPU就在那儿干等着,什么也干不了。就像你去餐厅吃饭,整个餐厅只有一个服务员,他给你点完菜得亲自去后厨盯着厨师做完,端给你之后才能去招呼下一桌。翻台率能高才怪。
多任务学习要解决的,就是这个"一个服务员忙不过来"的问题。而线程,是Python里实现多任务最基础、也最容易被误解的一种手段。说它基础,是因为threading模块用起来确实不复杂,几行代码就能开一个新线程;说它容易被误解,是因为很多人(包括当年的我)以为"开了线程就一定变快",结果写完一跑,发现比单线程还慢,甚至出了各种诡异的数据错乱。
这篇笔记我打算把线程这件事从头捋一遍。不是那种"API手册式"的罗列,而是按照我自己踩坑的顺序,把线程到底是什么、Python的线程为什么特殊、什么时候该用、什么时候千万别用、以及那些文档里不会写的坑,一个一个讲清楚。如果你刚开始接触Python多任务,或者之前用过线程但总觉得"没搞明白",那这篇应该能帮你把脑子里那团浆糊理清楚。
需要先说明一点:这篇是"笔记"性质,我会尽量把原理讲透,但不会堆砌所有API。重点放在理解和避坑上,因为线程这东西,用对了是利器,用错了就是给自己挖坑。
2. 线程的本质:一条执行流,和它共享的那间"公共厨房"
在动手写代码之前,得先把"线程"这个概念本身搞明白。很多人一上来就背"线程是CPU调度的最小单位",背完还是不知道它到底是个啥。我换个说法。
2.1 进程是"独立厨房",线程是厨房里的"厨师"
想象你开了一家餐厅。进程就是你租下的一个独立厨房——它有自己的一套灶台、冰箱、食材、刀具,跟隔壁餐厅完全隔离。隔壁厨房着火了,烧不到你这边;你的食材被人偷了,也赖不到隔壁。这种隔离性很安全,但代价是:如果你想再开一个厨房,就得重新租场地、重新买一整套设备,成本很高。
线程则是同一个厨房里的多个厨师。他们共享这个厨房的所有资源——同一个冰箱、同一批食材、同一套灶台。好处是开一个新厨师几乎不花什么成本,喊一声就来了;坏处也很明显:两个厨师同时伸手去拿同一块肉,就可能撞在一起;一个厨师把盐罐打翻了,整锅菜都遭殃。
对应到程序里:
- 进程有独立的内存空间,进程之间互不干扰,但创建和切换开销大。
- 线程共享同一个进程的内存空间(堆、全局变量、文件句柄等),创建和切换开销小,但正因为共享,才容易出现数据竞争。
这个"共享"是理解线程所有优缺点的钥匙。线程之所以轻量,是因为它不用复制一整套内存;线程之所以危险,也是因为它不复制内存——大家用的是同一份数据。
2.2 每个线程都有自己的"私人抽屉"
那线程之间有没有不共享的东西?有。每个线程都有自己独立的栈(stack),用来存放局部变量和函数调用信息。你可以把栈理解成每个厨师随身带的"私人抽屉",里面放着他自己正在处理的这道菜的调料。这个抽屉别人碰不到。
所以一个关键结论:局部变量是线程安全的,全局变量和共享对象不是。这个结论后面会反复用到。
还有程序计数器和寄存器状态,也是每个线程私有的,它们记录了"这个线程执行到哪一行了"。正因为有这些私有状态,操作系统才能在一个线程被暂停后,准确地从暂停的地方恢复它——这就是所谓的上下文切换。
2.3 并发不等于并行,这是两码事
这里必须澄清一个被无数人混淆的概念:并发(concurrency)和并行(parallelism)不是一回事。
- 并行:两个厨师真的在同一时刻各炒各的菜,需要两个灶台(多核CPU)。
- 并发:只有一个灶台,但厨师A炒两下,趁等油热的间隙让厨师B来切两下菜,再换回来。看起来像"同时进行",实际上是快速轮流。
单核CPU上跑多线程,本质就是并发——CPU切换得足够快,让你产生了"同时"的错觉。多核CPU上,多个线程才可能真正并行。
为什么要区分这个?因为后面讲Python的GIL时会发现,Python的多线程在CPU密集型任务上,连真正的并行都做不到,只能做并发。这个限制直接决定了线程的适用边界。
3. Python线程的"紧箍咒":GIL到底限制了什么
如果你只记住这篇笔记的一件事,那就记住GIL。这是Python线程最特殊、也最容易被忽略的地方。我见过太多人兴冲冲地开了十个线程去算圆周率,结果发现比单线程还慢,然后一脸懵。
3.1 GIL是什么:一把全局的"厨房钥匙"
GIL(Global Interpreter Lock,全局解释器锁),是CPython解释器(也就是我们平时用的那个Python)里的一把全局锁。它的规则很简单粗暴:在任意时刻,一个Python进程里只有一个线程能执行Python字节码。
回到厨房的比喻:GIL就像厨房里唯一的一把"炒菜铲"。不管你有几个厨师(线程),同一时刻只有拿到铲子的人能炒菜,其他人只能干等着。你炒两下,得把铲子交出去,别人才能炒。
这就意味着,哪怕你的电脑是16核的,跑一个纯Python计算的多线程程序,同一时刻也只有一个核在真正干活。多核优势完全发挥不出来。
3.2 为什么要有GIL:一个历史遗留的"省事方案"
你可能会问:这么坑的设计,为什么还要留着?
这得回到Python诞生的年代。GIL最初是为了简化CPython的内存管理——尤其是引用计数这套垃圾回收机制。每个Python对象都有一个引用计数,计数归零就回收。如果多个线程同时修改同一个对象的引用计数,不加锁就会出错。而如果给每个对象都加细粒度的锁,性能开销和实现复杂度都会飙升。
于是设计者选择了一个"偷懒但有效"的方案:干脆用一把大锁,保证同一时刻只有一个线程在操作Python对象。这样引用计数永远不会被并发修改,内存管理就安全了。
这个选择在单核时代几乎没代价——反正单核本来也只能跑一个线程。但到了多核时代,它就成了性能瓶颈。不过由于大量C扩展库都依赖GIL带来的简单性,贸然移除会引发兼容性地震,所以它一直留到了今天。
3.3 GIL的真正影响:CPU密集 vs IO密集
GIL的存在,直接划出了Python多线程的适用边界:
| 任务类型 | 特点 | 多线程效果 | 原因 |
|---|---|---|---|
| CPU密集型 | 大量计算,如数值运算、图像处理 | 几乎无提升,甚至更慢 | GIL让多线程无法并行,切换还有开销 |
| IO密集型 | 大量等待,如网络请求、文件读写 | 显著提升 | 等待时线程会释放GIL,别的线程能干活 |
关键在最后一行:当一个线程执行IO操作(比如等网络响应)时,它会主动释放GIL。这时候别的线程就能拿到"铲子"干活了。所以对于爬虫、批量请求、文件处理这类"大部分时间在等"的任务,多线程效果拔群。
反过来,如果你要算一个超大的矩阵乘法,多线程基本没用,该用多进程(multiprocessing)——每个进程有独立的解释器和独立的GIL,才能真正并行。
注意:GIL是CPython的实现细节,不是Python语言规范的一部分。某些其他实现(如Jython、IronPython)就没有GIL。但日常我们说的"Python",默认指CPython,所以GIL的影响是绕不开的。
3.4 一个反直觉的实测:线程开越多越快吗
不是。线程数量超过某个点之后,性能反而下降。原因有两个:一是上下文切换开销——线程越多,CPU在它们之间切换越频繁,真正干活的时间占比越低;二是GIL争抢——线程越多,抢那把"铲子"的竞争越激烈,抢不到的就白白空转。
我实测过一个IO密集的爬取任务,线程数从1加到20,耗时确实在降;但加到50、100之后,耗时开始回升。经验值是:IO密集型任务,线程数控制在几十以内比较稳妥,具体多少要看IO等待时间和系统资源。这个后面会细讲。
4. threading模块上手:从"能跑"到"跑对"
原理讲完了,该动手了。threading是Python标准库自带的线程模块,不用装任何东西。但它的API设计有几个地方,新手特别容易用错。
4.1 两种创建线程的方式,以及我为什么推荐继承式
创建线程有两种写法。
第一种,传一个函数进去:
import threading import time def worker(name): print(f"线程 {name} 开始工作") time.sleep(2) print(f"线程 {name} 结束工作") t = threading.Thread(target=worker, args=("A",)) t.start() t.join() print("主线程结束")第二种,继承Thread类,重写run方法:
import threading import time class Worker(threading.Thread): def __init__(self, name): super().__init__() self.name = name def run(self): print(f"线程 {self.name} 开始工作") time.sleep(2) print(f"线程 {self.name} 结束工作") t = Worker("A") t.start() t.join()两种都能用。简单任务用第一种,代码短;但如果线程需要维护自己的状态(比如记录处理了多少条数据、持有某个连接对象),我更推荐第二种。原因很实际:继承式把状态封装在对象里,比用闭包或全局变量干净得多,调试的时候也更容易看清每个线程在干什么。
4.2 start()和run()的区别:一个新手必踩的坑
这是新手最容易犯的错,没有之一。
t = threading.Thread(target=worker) t.run() # 错误!这只是普通函数调用 t.start() # 正确!这才是启动新线程run()只是在当前线程里同步执行那个函数,根本不会开新线程。你写t.run(),程序还是单线程跑的,一点并发都没有。只有start()才会真正创建一个新线程,然后在新线程里调用run()。
我当年就栽在这上面,盯着代码看了半天,心想"我明明开了线程啊怎么还是串行的"。后来才发现把start写成了run。这个坑太经典了,值得单独拎出来说。
4.3 join():主线程的"等待指令"
join()的作用是:让当前线程等待目标线程执行完毕。
t.start() t.join() # 主线程在这里卡住,直到t跑完 print("所有工作完成")如果不写join(),主线程会自顾自地往下跑,可能在子线程还没干完的时候就结束了。如果主线程结束导致整个程序退出,子线程可能被强行中断,任务就丢了。
但join()也不是随便加的。如果你有10个线程,正确的做法是先全部start,再全部join:
threads = [] for i in range(10): t = threading.Thread(target=worker, args=(i,)) threads.append(t) t.start() for t in threads: t.join()如果你写成"start一个join一个",那就退化成串行了——每个线程都要等前一个跑完才启动,完全失去了并发的意义。这个顺序问题,也是新手常犯的。
4.4 守护线程:主线程走了,它也得走
有时候你希望某个线程"随主线程一起结束",比如后台的心跳检测、日志刷新。这时候用守护线程(daemon thread):
t = threading.Thread(target=worker, daemon=True) t.start()守护线程的特点是:主线程结束时,它会被强制终止,不管有没有跑完。非守护线程则相反,主线程会等它跑完才退出。
用守护线程要小心:因为它可能在任何时刻被"砍掉",所以不能让它持有需要善后的资源(比如正在写的文件、数据库事务)。否则数据可能写一半就断了。我一般只在"这个线程死了也无所谓"的场景下才用daemon。
5. 线程安全:共享数据是怎么"打架"的
前面反复强调"线程共享内存",现在到了它真正咬人的地方。这一段是整篇笔记里最需要打起精神的部分,因为线程安全问题往往不会立刻暴露,而是在某个偶然的时刻突然炸出来,极难排查。
5.1 一个经典的"数丢了"实验
看这段代码:
import threading counter = 0 def increment(): global counter for _ in range(1000000): counter += 1 threads = [threading.Thread(target=increment) for _ in range(5)] for t in threads: t.start() for t in threads: t.join() print(counter) # 期望 5000000,实际往往小于它理论上5个线程各加100万次,结果应该是500万。但你跑一下会发现,结果经常是480万、490万这种"少了"的数字。为什么?
因为counter += 1这行代码,在底层不是原子操作。它其实分三步:
- 读取
counter的当前值(比如读到100) - 计算100 + 1 = 101
- 把101写回
counter
如果两个线程同时执行到第1步,都读到了100,然后各自加1写回101,那么这两次加法实际上只让counter增加了1,另一次"丢了"。这就是竞态条件(race condition)。
5.2 用锁(Lock)保护临界区
解决办法是加锁。threading.Lock是最基本的互斥锁:
import threading counter = 0 lock = threading.Lock() def increment(): global counter for _ in range(1000000): with lock: # 进入临界区前加锁 counter += 1 # 离开时自动释放 threads = [threading.Thread(target=increment) for _ in range(5)] for t in threads: t.start() for t in threads: t.join() print(counter) # 稳定输出 5000000with lock:会自动获取锁,代码块结束后自动释放。这比手动lock.acquire()和lock.release()安全得多——因为如果临界区里抛了异常,手动release可能被跳过,锁就永远释放不了了,其他线程全部卡死。用with就没这个问题。
5.3 锁的代价:别把整个程序都锁上
加锁能解决问题,但锁本身有成本。被锁保护的代码段,同一时刻只能有一个线程执行,等于把这段代码变回了串行。如果你把一大段逻辑全塞进锁里,那多线程就白开了。
原则是:临界区越小越好。只锁真正涉及共享数据的那几行,其他计算、IO都放到锁外面。
# 不好的写法:整个循环都锁上 with lock: for _ in range(1000000): counter += 1 # 好的写法:只锁关键的一行 for _ in range(1000000): with lock: counter += 1当然,第二种写法因为频繁加解锁,开销也不小。实际中更常见的做法是减少共享——比如每个线程各自累加到一个局部变量,最后再合并。这往往比加锁更高效。
5.4 死锁:两个线程互相等对方
锁用不好会死锁。最典型的场景:线程A持有锁1,等锁2;线程B持有锁2,等锁1。两边都不肯放手,程序就永远卡住了。
# 危险示例 lock1 = threading.Lock() lock2 = threading.Lock() def task_a(): with lock1: with lock2: pass def task_b(): with lock2: with lock1: pass避免死锁的实用原则:
- 统一加锁顺序:所有线程都按"先锁1再锁2"的顺序获取,就不会互相等。
- 设置超时:用
lock.acquire(timeout=5),超时就放弃,避免无限等待。 - 尽量少用嵌套锁:能用一个锁解决就别用两个。
死锁最恶心的地方是它不一定每次都出现,取决于线程调度的时机。可能测试一百次都没事,上线第一次就卡死。所以涉及多把锁的代码,一定要在脑子里过一遍加锁顺序。
6. 线程间通信:queue才是那个"靠谱的传菜口"
线程之间要交换数据,最直接的想法是用全局变量。但前面说了,全局变量加锁很麻烦,稍不注意就出竞态。Python标准库给了一个更省心的工具:queue.Queue。
6.1 为什么queue天生线程安全
queue.Queue内部已经帮你加好了锁。多个线程同时往队列里放数据、取数据,它自己会保证不出错。你完全不用操心锁的事。
这就像厨房里那个"传菜口"——厨师把做好的菜放上去,服务员从上面取走。放和取的动作由传菜口本身协调,厨师和服务员不用互相喊话,也不用担心撞手。
import threading import queue import time q = queue.Queue() def producer(): for i in range(5): q.put(f"菜品{i}") print(f"厨师做好了 菜品{i}") time.sleep(0.5) def consumer(): while True: item = q.get() if item is None: # 收到结束信号就退出 break print(f"服务员端走了 {item}") q.task_done() p = threading.Thread(target=producer) c = threading.Thread(target=consumer) p.start() c.start() p.join() q.put(None) # 发送结束信号 c.join()6.2 生产者-消费者模式:线程协作的标准套路
上面这段代码就是经典的生产者-消费者模式。它的价值在于解耦:生产者只管往队列里塞任务,消费者只管从队列里取任务,两边互不关心对方是谁、有几个。你可以随时加一个消费者线程来加速处理,生产者代码一行都不用改。
这个模式在实际项目里用得极多。比如爬虫:一个线程负责生成待爬URL放进队列,多个线程从队列取URL去爬。又比如日志处理:业务线程往队列写日志,单独的日志线程负责落盘。
6.3 队列的几种变体,以及怎么选
queue模块提供了几种队列:
| 类型 | 特点 | 适用场景 |
|---|---|---|
Queue | 先进先出(FIFO) | 绝大多数任务分发场景 |
LifoQueue | 后进先出(LIFO),像栈 | 需要"最新优先"的场景 |
PriorityQueue | 按优先级出队 | 任务有轻重缓急之分 |
选哪个取决于你的业务逻辑。大部分情况用默认的Queue就够了。如果任务有优先级(比如VIP用户的请求要先处理),就用PriorityQueue,往里面放(优先级数字, 数据)的元组,数字小的先出。
6.4 一个容易忽略的细节:task_done和join的配合
Queue有个join()方法,它会阻塞直到队列里所有任务都被处理完。但它怎么知道任务处理完了?靠的是每次get()之后调用task_done()。
q = queue.Queue() # ... 放入若干任务 ... q.join() # 等所有任务被处理如果消费者取了数据却忘了调task_done(),q.join()就会永远卡住。这个坑我在一个项目里踩过——消费者代码里有个分支提前continue了,跳过了task_done(),结果主线程一直等不到结束。排查了半天才发现是漏了一行。
提示:用
q.join()时,务必确保每一条get()路径最终都会走到task_done(),包括异常分支。
7. 线程池:别再手动管理一堆线程了
手动Thread对象管理起来其实挺烦的:要维护一个列表、要处理异常、要控制并发数量。Python从3.2开始提供了concurrent.futures模块,里面的ThreadPoolExecutor把这些问题都封装好了。
7.1 为什么推荐用线程池
线程池的核心思想是复用。它预先创建(或按需创建)一批线程,任务来了就分配一个空闲线程去执行,执行完线程不销毁,继续接下一个任务。好处:
- 控制并发数量:不会因为任务太多而创建成千上万个线程把系统拖垮。
- 自动管理生命周期:不用手动start和join。
- 方便获取结果:通过
Future对象拿返回值、捕获异常。
from concurrent.futures import ThreadPoolExecutor import time def fetch(url): time.sleep(1) return f"{url} 的内容" urls = [f"http://example.com/{i}" for i in range(10)] with ThreadPoolExecutor(max_workers=5) as executor: results = executor.map(fetch, urls) for r in results: print(r)max_workers=5表示最多同时跑5个线程。executor.map会把urls里的每个元素依次传给fetch,并保持结果顺序。
7.2 submit和map:两种提交任务的方式
ThreadPoolExecutor有两种提交任务的方式:
submit(fn, *args):提交单个任务,返回一个Future对象。适合任务参数不统一、需要单独处理结果的场景。map(fn, iterable):批量提交,返回结果迭代器。适合"对一批数据做同样处理"的场景,代码更简洁。
Future对象有几个好用的方法:
future = executor.submit(fetch, "http://example.com") result = future.result() # 阻塞直到拿到结果 done = future.done() # 是否已完成 future.cancel() # 尝试取消(未开始的任务才能取消)future.result()如果任务里抛了异常,会在这里重新抛出。所以用submit时,一定要在result()外面包try-except,否则异常会被吞掉或在不该出现的地方炸出来。
7.3 max_workers设多少合适
这是最常被问的问题。没有标准答案,但有几个参考:
- IO密集型:可以设大一些,比如CPU核数的5到10倍。因为线程大部分时间在等待,多开点能提高吞吐。
- CPU密集型:设成CPU核数就行,再多也没用(GIL限制),反而增加切换开销。
- 拿不准:从
min(32, os.cpu_count() + 4)这个经验值起步,然后压测调整。
我一般会先跑一个小规模测试,观察CPU利用率和任务耗时,再决定最终值。盲目设个100,很可能适得其反。
7.4 线程池的异常处理:别让一个任务炸掉整个池
线程池里某个任务抛异常,不会影响其他任务,异常会被封装在对应的Future里。但如果你用map且不检查结果,异常可能被静默忽略。所以:
with ThreadPoolExecutor(max_workers=5) as executor: futures = [executor.submit(fetch, url) for url in urls] for f in futures: try: print(f.result()) except Exception as e: print(f"任务失败: {e}")这样每个任务的异常都能被单独捕获,不会互相干扰,也不会被吞掉。
8. 那些文档不会告诉你的实战经验
前面讲的都是"应该怎么做",这一段讲"实际做的时候会遇到什么"。这些都是我在真实项目里踩出来的,文档里基本不会写。
8.1 线程数量不是越多越好,找到那个"甜点"
我做过一个批量请求接口的任务,一开始图省事开了200个线程,结果目标服务器直接把我限流了,大量请求失败。后来降到20个,成功率反而上去了。
线程数的"甜点"取决于三个因素:IO等待时间、目标服务的承受能力、本机资源。等待时间越长,可以开的线程越多;但目标服务扛不住的话,开再多也是白搭,还会触发限流甚至封禁。
我的经验做法是:从较小的值(比如10)开始,逐步增加,观察成功率和总耗时。当增加线程数不再带来明显提速,甚至成功率下降时,就说明到顶了。
8.2 线程里的异常默认会被"吞掉"
这是个特别隐蔽的坑。看这段代码:
def worker(): raise ValueError("出错了") t = threading.Thread(target=worker) t.start() t.join() print("程序继续")子线程里抛了异常,但主线程完全不知道,程序照常打印"程序继续"。异常信息可能会被打印到stderr,也可能因为线程结束而丢失。
解决办法:在run方法或worker函数里自己包一层try-except,把异常记录下来。
def worker(): try: # 实际逻辑 pass except Exception as e: print(f"线程出错: {e}") # 或者写进日志、放进队列用线程池的话,异常会存在Future里,通过result()能拿到,相对安全一些。
8.3 别在线程里做"需要顺序"的事
线程的执行顺序是不确定的,由操作系统调度决定。你start了A、B、C三个线程,它们谁先跑完完全看运气。所以:
- 不要依赖线程的执行顺序。
- 如果任务之间有依赖(B必须等A完成),要么用
join()显式等待,要么用队列传递信号,要么干脆别用多线程。
我见过有人写了个多线程程序,然后靠time.sleep来"保证"执行顺序,这种代码极其脆弱,换个机器、换个负载就崩。
8.4 调试多线程程序,print比断点好用
多线程程序用调试器打断点特别难受——你停在一个线程上,其他线程还在跑,状态一直在变,根本看不清。我的习惯是用带线程名的日志来追踪:
import threading import logging logging.basicConfig( format='%(asctime)s [%(threadName)s] %(message)s', level=logging.INFO ) def worker(): logging.info("开始处理") # ... logging.info("处理完成")日志里带上threadName,就能清楚看到每个线程在什么时间做了什么,比断点直观得多。排查竞态问题时,这种时间线尤其有用。
8.5 全局解释器锁之外,还有"库级别的锁"
有些第三方库内部有自己的锁,可能成为隐藏的瓶颈。比如某些数据库驱动、某些C扩展,它们可能在整个调用期间持有锁,导致你的多线程实际上还是串行的。遇到"开了线程没提速"的情况,除了怀疑GIL,也要查查用的库是不是线程安全的、有没有内部串行化。
一个简单的验证方法:把线程数从1加到N,看耗时有没有下降。如果完全没变化,那大概率是某个环节被串行化了。
9. 什么时候该用线程,什么时候该换别的
学到这里,你应该对线程有了比较完整的认识。最后我想聊聊选型——因为线程不是万能的,用错场景就是白费力气。
9.1 线程的主场:IO密集型任务
线程最擅长的就是"等待型"任务:
- 网络请求(爬虫、调API)
- 文件读写
- 数据库操作
- 等待用户输入
这些任务的共同点是大部分时间在等,CPU闲着。多线程能让CPU在等待的间隙去处理别的任务,吞吐量成倍提升。这也是为什么爬虫是线程最经典的应用场景。
9.2 线程的禁区:CPU密集型任务
反过来,纯计算任务(数值运算、加密解密、图像处理)用线程基本没收益,因为GIL让它们无法并行。这种场景应该用多进程:
from multiprocessing import Pool def heavy_compute(n): return sum(i * i for i in range(n)) with Pool(processes=4) as pool: results = pool.map(heavy_compute, [1000000] * 4)每个进程有独立的GIL,能真正跑满多核。代价是进程间通信比线程麻烦,内存开销也大。
9.3 异步IO:另一种"等待"的解法
对于高并发的IO任务,还有第三条路:异步IO(asyncio)。它用单线程+事件循环来处理大量并发连接,没有线程切换开销,在超高并发场景下比线程更高效。
但异步的代码写起来是另一套思维(async/await),而且一旦用了异步,整条调用链都得是异步的,改造老代码成本高。所以我的建议是:
- 任务量不大、代码已经写好了:用线程,改造成本低。
- 需要极高并发(成千上万连接):考虑asyncio。
- 纯计算:用多进程。
9.4 一张选型对照表
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 几十个网络请求 | 线程池 | 简单、够用、改造成本低 |
| 大量文件处理 | 线程池 | IO等待为主,线程收益明显 |
| 数值计算/图像处理 | 多进程 | 绕开GIL,真正并行 |
| 上万并发连接 | asyncio | 无线程切换开销,资源占用低 |
| 简单定时任务 | 单线程+定时器 | 没必要上多线程 |
选型的核心就一句话:先判断任务是"等"还是"算"。等为主用线程或异步,算为主用多进程。
10. 我踩过的三个真实坑,以及它们教会我的事
理论讲再多,不如几个真实的坑来得深刻。这三个是我印象最深的,分享出来给你避避雷。
第一个坑:把start写成了run。前面提过,但值得再说一次。当时我写了个多线程下载器,跑起来发现速度跟单线程一模一样,还以为是网络问题。查了半天才发现是t.run()。这个错误的隐蔽性在于——程序不报错,只是默默退化成单线程。后来我养成了一个习惯:写完多线程代码,先打印一下threading.active_count(),确认线程真的开起来了。
第二个坑:忘了task_done导致主线程卡死。在一个生产者-消费者项目里,主线程用q.join()等所有任务完成,结果程序永远不退出。排查了很久才发现,消费者代码里有个异常分支提前return了,没走到task_done()。教训是:用队列的join机制时,一定要保证每条路径都会调用task_done,最好用try/finally包起来。
第三个坑:共享了一个"看起来没问题"的对象。我让多个线程共用一个requests.Session对象来复用连接,结果偶尔出现诡异的报错。后来才知道,虽然Session在多数情况下能用,但某些内部状态在并发下并不完全安全。换成每个线程独立创建Session后,问题消失。这教会我:第三方库的"线程安全性"不能想当然,拿不准就每个线程一份独立实例。
这三个坑的共同点是:它们都不会在开发阶段稳定复现,往往在压力大、并发高的时候才冒出来。所以多线程代码的测试,一定要在接近真实的并发条件下跑,别只测单线程逻辑。
线程这块内容其实还有不少可以展开的,比如Condition、Semaphore、Event这些同步原语,以及threading.local这种线程局部存储。但那些属于进阶话题,等把基础打牢了再看会更顺。下一篇我打算聊聊多进程,把"CPU密集型任务怎么提速"这件事讲透——毕竟线程搞不定的计算任务,还得靠它。