
如果你在搜索框里敲下“python 多线程”大概率会滑到各种论坛的热帖标题十有八九长这样“【python多线程编程】请问这是怎么回事”再往下翻代码就那几行——线程开了一堆结果要么没输出要么数据乱了要么速度反而变慢。这不是个例Python的多线程几乎是每个新手撞上的第一堵墙而且这堵墙的名字叫GIL。这篇不打算照本宣科地给你复述一遍“什么是线程、什么是进程”而是直接把那些你大概率见过的“灵异现象”搬出来一个个拆开讲清楚为什么多线程不等于更快为什么数据会乱多线程到底该怎么写才稳以及什么时候你压根就不该用多线程。你看完以后至少下次再遇到“请问这是怎么回事”自己能拍板了。1. 先从根子上说清为什么Python多线程跟你想的不一样1.1 GIL到底是个什么东西为什么它会拖后腿在很多人的直觉里多线程就是“同时做几件事”四核CPU开四个线程速度直接翻四倍。这个直觉在C、Java里基本成立但在Python的官方解释器CPython里有个叫GIL的全局锁把这条路给焊死了。GIL全称Global Interpreter Lock全局解释器锁。它做的事情其实很粗暴CPython解释器在执行代码时同一时刻只允许一个线程进入解释器执行字节码。也就是说哪怕你的机器有128个核线程切来切去但任意时刻真正在“跑”Python代码的只有一条线程。打个比方GIL就像一个单车道收费站你开了四辆车上路但收费窗口只有一个车还是一辆一辆过排队是免不了的。这跟有没有四车道没关系因为收费站设计就把并发限制死了。所以“Python多线程能利用多核”这个想法在纯CPU计算场景下从一开始就站不住脚。那为什么Python官方不干脆把GIL去掉这里面的历史包袱很重。CPython的内存管理用了大量的全局状态很多C扩展库也默认“同一时刻只有一条线程在执行”要改GIL意味着整个解释器的内存模型都要重写牵一发动全身。虽然社区这些年一直在推进“去除GIL”的尝试比如Python 3.13里出现了自由线程的实验性特性但在绝大多数生产环境你还是跟GIL共存。1.2 什么时候多线程有用什么时候它只是摆设很多人一看GIL就误以为“Python多线程完全没用”这也是另一个极端的误解。GIL锁的是“执行Python字节码”但它锁不住“等待外部资源”的时间。当一个线程在做I/O操作时——比如网络请求、读取文件、访问数据库——它的本质是发出去一个请求然后等结果返回。在等待期间解释器其实没什么代码可跑GIL会被释放让其他线程继续干活。这正是Python多线程的主场I/O密集型任务。举个例子你要下载100个网页单线程是“下载一个等它返回再下载下一个”大部分时间都耗在等待上。多线程则是“同时发出去10个下载请求谁先返回都可以接着发下一个”等待时间被重叠利用整体耗时就压下来了。这也是为什么Requests加ThreadPoolExecutor是爬虫场景里的经典配置。反过来如果你开10个线程去疯狂计算斐波那契数列、解析超大JSON、处理视频帧那所有线程都在抢同一个CPU执行权线程切换本身还有开销结果往往是开了线程比单线程还慢。这种CPU密集任务正确的赛道是用多进程multiprocessing或者干脆换一门更适合的语言。一句话总结I/O密集用多线程CPU密集用多进程不确定就先看瓶颈到底在哪里。2. 多线程常见“灵异事件”真相与排查2.1 速度没提上去反而变慢了这个问题在论坛里几乎是日经帖写了多线程去跑一段CPU密集的计算结果跑了半天发现比单线程还慢。下面一堆回复说“因为有GIL”然后提问者还是一脸懵有GIL就不该用多线程了吗事情比一句话要复杂一点。GIL导致多线程不能并行执行CPU任务但线程并没有闲着它在不停争抢解释器使用权。Python 3.2以后解释器默认每隔5毫秒由sys.setswitchinterval控制就要切换一次线程这个切换过程本身是有代价的要保存上下文、要重新唤醒线程、要触发信号检查。你把一个大任务切成10个线程分头算由于GIL的存在这10个线程根本没法同时算反而是把任务切成几百段来回换着算每一段切换都白花时间。更惨的是多线程还会带来额外的缓存不命中问题所以表现就是线程越多越慢。我在早期写过一段多线程算素数的代码8个线程跑下来比单线程慢了接近一倍。当时还天真地以为代码写错了后来用timeit一测才发现是CPU密集任务根本不配用多线程。如果你的代码场景里没有等待、没有I/O只是因为“想要快”就开多线程请立刻停下来。2.2 为什么你的多线程看起来像单线程甚至直接没有输出还有个高频现象写了一个多线程程序运行完屏幕上什么都没有或者只有主线程的输出自己创建的线程连个泡都没冒。这个问题的原因通常非常低级但极其常见——主线程跑完了程序直接退出根本没给子线程执行的机会。看个例子import threading import time def worker(): time.sleep(1) print(线程执行完毕) t threading.Thread(targetworker) t.start() print(主线程结束)运行这个程序你可能会看到“主线程结束”两秒后才有“线程执行完毕”甚至如果主线程后面没有等待程序直接退出子线程可能压根没来得及打印。因为Python主线程结束的时候不会自动等待所有子线程它会把整个进程带走。解决办法就是在主线程里调用t.join()让主线程“等一等你”。t threading.Thread(targetworker) t.start() t.join() # 等子线程结束再往下走 print(主线程结束)另外一个原因是daemon线程的坑。如果你在创建线程时设置了daemonTrue表示这个线程是“守护线程”它会在主线程结束时无条件被杀掉。用redis订阅、后台心跳这类场景里你可能确实需要daemon线程但如果只是想让它干活默认的非daemon线程会更符合直觉。2.3 共享数据“随机错乱”count不是我们想要的count这是多线程里最有迷惑性的问题之一两个线程各执行100万次count 1理论上结果是200万可实际跑出来只有一百九十几万、一百八十万都算正常有时候每次结果还不一样。而且运行次数越多偏差越大看起来像“随机数”。问题的根源在于count 1根本不是一步操作。在Python字节码层面它要经历三步读取count的值、把值加1、把新值写回count。线程A读到了100正要写101结果解释器切换到线程BB也读到了100加了1写回101。等你切回AA把101写回这下本来应该增加两次的次数实际只增加了一次。import threading count 0 def add(): global count for _ in range(1_000_000): count 1 threads [threading.Thread(targetadd) for _ in range(2)] for t in threads: t.start() for t in threads: t.join() print(count) # 不是2000000而是比它小虽然GIL保证了任意时刻只有一条线程执行字节码但它并不能保证count 1这一整串操作不被中断。这就像你去银行取钱走出柜台刚拿到钱又被另一个人把钱包里那份拿走了。要解决这个问题就得引入同步机制锁也就是下一章的重点。你先记住一个结论对共享可变数据的修改一定要加锁别指望GIL帮你挡住所有风险。3. 正确的多线程代码长什么样3.1 入门用法Thread、start、join三件套先用一个最简单的场景把基础打牢。现在有10个下载任务你要用4条线程去跑最基本的多线程代码长这样import threading import time from concurrent.futures import ThreadPoolExecutor def download(url): print(f开始下载: {url}) time.sleep(1) # 模拟网络等待 print(f下载完成: {url}) urls [fhttps://example.com/{i} for i in range(10)] # Python 3.2之后推荐直接用线程池 with ThreadPoolExecutor(max_workers4) as executor: executor.map(download, urls)这段代码就是多线程里最贴合实战的形态你要处理的任务列表是一批URL把任务交给线程池池子里最多同时跑4个线程谁跑完了就能领下一个任务。executor.map会等待所有任务结束然后统一返回结果。如果你手动管理Thread对象别忘了两件事一是每个线程都要调start()才能真正启动二是主线程要在合适的地方调join()让主线程等所有子线程完成再退出。如果你写的是脚本入口处加一个遍历所有线程并join()的逻辑能避免很多“为什么没输出”的尴尬。3.2 锁的正确使用与死锁预防回到上一章的计数器问题解决方式很简单给临界区加锁。比如import threading count 0 lock threading.Lock() def add(): global count for _ in range(1_000_000): with lock: count 1with lock是Python里最推荐的写法它等价于lock.acquire()和lock.release()的成对使用但即使代码抛了异常锁也会自动释放不会把自己锁死。锁的使用有个非常关键的原则锁的粒度要尽量小。你只想保护“修改count”这个临界区就别把整个for循环都锁住否则又变成单线程了。但如果锁得太细频繁获取释放也有开销。实际场景中你要评估临界区的代码到底有多耗时。死锁是另一个绕不开的坑。最常见的死锁模型是线程A持有一把锁等待另一把锁线程B持有了另一把锁正在等A手里的锁。两边谁都不松手程序就卡死了。# 经典死锁两个锁以不同顺序获取 lock1 threading.Lock() lock2 threading.Lock() def func1(): with lock1: with lock2: pass def func2(): with lock2: # 顺序反了 with lock1: pass解决方案其实很简单只要保证所有线程都“按同样的顺序获取锁”死锁就能基本避免。比如规定先拿lock1再拿lock2所有线程都遵守这个顺序。另外如果你的代码里要用到可重入锁也就是同一个线程可以多次获取同一把锁那就用threading.RLock()不要用Lock否则自己都能把自己锁死。3.3 用queue.Queue解耦生产消费多线程里最实用的设计模式之一就是生产者和消费者模型。生产者线程负责制造任务消费者线程负责处理任务中间用队列做缓冲。我自己在做爬虫的时候这个模式救了我很多次。queue.Queue是Python标准库里线程安全的队列它的put和get方法内部已经加了锁你不需要自己处理线程安全问题直接用就行。一个典型的任务分发场景import threading import queue import time task_queue queue.Queue() def producer(): for i in range(10): task_queue.put(f任务{i}) time.sleep(0.1) task_queue.put(None) # 结束信号 def consumer(): while True: task task_queue.get() if task is None: break print(f处理 {task}) time.sleep(0.2) producer_thread threading.Thread(targetproducer) consumer_threads [threading.Thread(targetconsumer) for _ in range(2)] producer_thread.start() for t in consumer_threads: t.start() producer_thread.join() for t in consumer_threads: t.join()这里的巧妙之处在于队列天然起到了“削峰填谷”的作用。哪怕生产者瞬间产生了100个任务消费者只需要按自己的节奏处理队列会暂存多余的任务两个线程不会互相阻塞。结束信号用None是最常见的做法但有多个消费者线程的时候要小心你发送一个None只有一个消费者会收到它其他消费者还在傻傻地等任务。要么往队列里放入和消费者数量一样的None要么使用task_done()和join()配合控制结束时机。后者逻辑更复杂新手阶段用“发多个None”的方式更直观。3.4 ThreadPoolExecutor能不用自己管理线程就别自己管理很多教程还在教手动创建Thread、逐个start、join这个教法没有问题但工作里写的话我更推荐concurrent.futures.ThreadPoolExecutor。原因很简单线程池可以复用线程不反复创建销毁而且接口封装得特别好。from concurrent.futures import ThreadPoolExecutor, as_completed def fetch_url(url): # 模拟耗时任务 return len(url) urls [https://a.com, https://b.com, https://c.com] with ThreadPoolExecutor(max_workers4) as executor: future_to_url {executor.submit(fetch_url, url): url for url in urls} for future in as_completed(future_to_url): url future_to_url[future] try: result future.result() print(f{url} 的结果: {result}) except Exception as e: print(f{url} 出错: {e})submit和map的区别submit返回一个Future对象你可以逐个检查结果、捕捉异常也可以配合as_completed在任务完成时立刻处理不要求所有任务全部跑完再统一拿结果。map用法更简洁但它按输入顺序返回结果如果某个任务卡住了后面的结果也得一起等。线程池另一个好处是上下文管理器with块。离开with块时会自动调用线程池的shutdown(waitTrue)也就是会等你所有线程跑完再退出这就避免了主线程提前退出的经典问题。4. 该换赛道时别死磕多进程与asyncio的选型4.1 multiprocessing如何绕过GIL聊了半天多线程的局限性你肯定想知道CPU密集任务该怎么写。答案是用multiprocessing。多进程和多线程的本质区别是每个进程都有自己独立的内存空间和独立的Python解释器也就有独立的GIL。两个进程可以真正在两个CPU核上同时跑不再抢同一把锁。from concurrent.futures import ProcessPoolExecutor def cpu_task(n): total 0 for i in range(n): total i ** 2 return total if __name__ __main__: nums [10_000_000] * 4 with ProcessPoolExecutor(max_workers4) as executor: results list(executor.map(cpu_task, nums)) print(results)这里有个久经考验的注意事项多进程代码必须放在if __name__ __main__:保护块里尤其是在Windows上否则会启动子进程时无限递归报错。这是因为Windows没有fork机制新进程需要重新导入模块如果没有保护导入一次就创建一个新进程新进程又导入无限循环。多进程的代价是进程启动开销大进程间通信和传参都要经过序列化和反序列化pickle所以在任务拆得特别细碎的场景下多进程的通信成本可能比收益还大。4.2 asyncio同样是I/O密集它可能更轻如果你处理的是成千上万个I/O任务比如爬几万个页面再开几百个线程就有点撑不住了。每个线程都有固定开销栈空间、上下文切换都会占资源。这个场景下用asyncio做协程可能是更好的选择。asyncio的思想是“协作式调度”它不需要操作系统去切换线程而是程序自己在遇到I/O等待时主动让出控制权等I/O回来了再继续执行。协程数量可以开到上万而不像线程那样有系统资源限制。一个使用asyncio做并发请求的典型结构import asyncio async def fetch(url): print(f开始 {url}) await asyncio.sleep(1) # 模拟I/O等待 print(f完成 {url}) return url async def main(): urls [fhttps://example.com/{i} for i in range(10)] tasks [asyncio.create_task(fetch(url)) for url in urls] results await asyncio.gather(*tasks) print(results) asyncio.run(main())注意真实世界里发起HTTP请求时你需要用aiohttp或者httpx这样的异步库而不是阻塞式的requests否则整个事件循环会被卡住I/O等待没法重叠。4.3 选型表什么时候该用哪个我根据实际经验整理了一张选型表可以作为你写代码时的快速参考方案适合的任务能否真正并行资源开销典型场景threadingI/O密集型不能受GIL限制低网络请求、文件读写、轻量数据采集multiprocessingCPU密集型能高图像处理、数值计算、解析大文件asyncio高并发I/O密集型不能单线程协作极低上万级网络请求、WebSocket服务、API网关结论就是先看你的任务是等别人还是算自己。如果是“等”threading和asyncio都行量大优先asyncio如果是“算”直接上multiprocessing。5. 多线程问题排查实录与避坑心得5.1 高发报错与现场案例速查我自己这些年看过无数帖子和真实代码多线程相关的报错翻来覆去就那么几个每种背后都有比较固定的原因。第一个是RuntimeError: threads can only be started once。意思是同一个Thread对象不能被start()两次。原因通常是你在循环里把同一个线程对象反复调用start解决办法是每次循环都创建新的线程对象。第二个是UnboundLocalError: local variable x referenced before assignment。这个报错特别坑因为单线程时完全没有问题。原因是线程函数里对全局变量做了赋值操作Python默认把赋值语句左侧的变量当成局部变量你在赋值前又读了它就会报这个错。解决办法是在函数最开始声明global x或者用可变对象比如list或dict来存数据。第三个是程序迟迟不退出。这往往是某个线程没有加daemonTrue又在等一个永远不会来的I/O响应。排查时先用threading.enumerate()列出所有存活线程看到底是哪条线程卡住了。第四个是“子线程抛异常主线程不知道”。子线程里的异常不会自动传回主线程它只会打印到stderr偶尔还会把整个程序搞挂。如果你用ThreadPoolExecutor可以通过future.result()捕捉异常如果是自己管理Thread记得在子线程函数内部做好try/except把异常信息放到队列或日志里别让异常静默消失。5.2 调试工具怎么把“看不见的线程”揪出来多线程调试最烦人的地方就是“它随机失败”因为线程调度顺序不可控。单靠print大法往往只能看到结果乱成一团却看不清到底哪一步出了问题。我推荐几个实用手段。第一个是threading.enumerate()。在程序卡住或者异常时打印当前所有存活的线程对象你能立刻看到是不是有线程一直没退出或者线程数量超出了预期。第二个是Python自带的faulthandler。在程序最前面启用import faulthandler faulthandler.dump_traceback_later(10, exitTrue)这样如果程序运行10秒还卡着会自动把所有线程的当前堆栈打印出来。你会看到每条线程停在哪一行代码死锁在哪个锁上一清二楚。第三个是最笨但最有效的给每个线程起名字。在线程函数里把线程名放进日志输出里比如用threading.current_thread().name你就能区分日志是哪条线程打的排查数据错乱问题时特别有用。别小看这个细节绝大多数数据竞争问题靠日志里多打印一个线程名就能定位。5.3 几则来自实战的注意事项第一注意第三方库的线程安全性。requests库的Session对象是线程安全的可以放心在多线程里共享但普通的sqlite3连接、文件句柄这类东西往往不是线程安全的。你在多线程里用它们必须主动加锁或者每个线程单独创建不能指望库自己处理。第二控制线程数量不是越多越好。即便是I/O密集型任务线程数超过某个阈值后收益也会递减因为上下文切换和线程调度会开始占资源。我自己调试下来网络请求类任务线程数通常在20到50之间比较稳再往上涨反而会踩到目标网站的限制。如果有条件做一个小规模压测看线程数和耗时之间的曲线比凭感觉设一个数要靠谱得多。第三不要在子线程里直接操作GUI界面。无论是Tkinter、PyQt还是PySideUI组件必须在主线程里更新。你子线程跑去改文本框内容轻则没反应重则直接崩溃。正确做法是子线程把数据通过队列传给主线程主线程的事件循环再去刷新界面。最后锁的“修炼层次”也值得说一嘴。新手阶段喜欢用全局的Lock老手则更倾向于把共享数据和锁封装成一个类让锁作为这个类的一部分外部调用者不需要关心锁的细节。如果你的多线程代码越来越复杂建议往这个方向重构。锁用得越集中出问题的概率越低。结合我自己的成长过程这些坑是几乎每个人都会踩的。多线程本身不难难的是改变“多线程就一定快”的直觉。先判断任务类型再选工具最后再考虑要不要用锁——把每一步都想清楚“这是怎么回事”的问号就会越来越少。