news 2026/10/13 5:27:00

Python多线程编程入门:从GIL原理到线程安全实战避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Python多线程编程入门:从GIL原理到线程安全实战避坑指南

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这行代码,在底层不是原子操作。它其实分三步:

  1. 读取counter的当前值(比如读到100)
  2. 计算100 + 1 = 101
  3. 把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) # 稳定输出 5000000

with 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密集型任务怎么提速"这件事讲透——毕竟线程搞不定的计算任务,还得靠它。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/13 5:23:45

计算机组成原理:存储器容量扩充详解与实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/13 5:23:42

Pentaho Kettle 8.2 在 Java 8 环境下的 ETL 部署与踩坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/13 5:22:36

Jenkins 与 GitLab 集成实战:从 Webhook 触发到流水线回写全链路

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/13 5:20:32

litellm实战:构建统一模型接入层,告别多平台API切换痛点

做Agent开发这一两年,我最大的感受就是接口满天飞,造轮子的人也满天飞。直到我把模型接入层统一改造成litellm,才真正从不同模型平台来回切换的if-else地狱里爬出来。今天这篇文章不写概念科普,就讲清楚litellm到底帮我解决了哪些…

作者头像 李华