
刚接触Python那阵我在各个技术社区里最常看到的一句话就是Python多线程就是鸡肋有GIL在加了线程反而更慢。这种说法听多了有一段时间我写爬虫、处理批量任务都是老老实实单线程硬跑直到有一次接手一个数据采集任务单线程跑完要40多分钟实在忍不了才真正花心思把Python多线程从头到尾捋了一遍。也是那次之后我才意识到不是说多线程没用而是很多人根本没搞明白GIL到底锁的是什么、什么时候该用线程、什么时候该换进程。这篇文章我打算把Python多线程那些事儿讲透不绕弯子直接说底层机制、代码写法、常见误区和实测数据。无论你是刚学Python、被各种天花乱坠的说法搞晕的小白还是写了一段时间脚本但一直没系统梳理过并发知识的开发者这篇文章应该都能给你一个清晰、能直接上手用的答案。1. GIL不是洪水猛兽先搞清楚Python多线程的底层真相网上关于GIL的讨论能吵几百层楼但真正影响你写代码的就那么几个点。这部分我先把它拆开说清楚后面所有代码和选型建议都建立在这个基础上。1.1 GIL到底是什么它锁住了什么GIL的全称是Global Interpreter Lock翻译过来就是全局解释器锁。它只存在于CPython解释器里这也是绝大多数人用的Python版本。Python在解释执行代码的时候并不是一次把整个程序翻译成机器码再运行而是逐行读取字节码、逐行执行。GIL干的事情就是在执行每一行字节码之前解释器要先拿到这把全局锁一次只允许一个线程进入解释器执行代码。这个设计跟Python早期版本的垃圾回收机制有关。Python使用引用计数来管理内存每个对象被引用时计数加一不再被引用时计数减一减到零就回收内存。如果两个线程同时对同一个对象做加减引用计数这个数字就可能出错。为了简化内存管理直接加一把大锁同一时刻只有一个人能操作对象引用计数整体实现就简单多了。但要注意一个关键细节GIL锁住的不是线程里的所有代码而是解释器执行Python字节码这个阶段。当线程运行到IO操作比如读文件、发网络请求、查数据库它会把GIL释放掉让其他线程去执行。因为IO操作在等待网络响应、磁盘响应的时候线程本身处于阻塞状态不需要占用CPU。1.2 一个餐厅类比为什么GIL挡不住IO密集型任务我每次解释GIL都会用一个餐厅的比方。想象一家餐厅只有一个厨灶就是GIL但有好几个服务员线程。服务员下单之后要去厨房跟厨师对接但大部分时间其实是在等餐、上菜、招呼客人这些动作不占厨灶。这时候哪怕只有一个厨灶你雇十个服务员接单效率也能大幅提升。反过来如果十个服务员全挤在厨房里争着用同一个厨灶做菜那就不是提升效率而是在互相添乱。放到编程里网络请求、文件读写、数据库访问这些IO操作就是服务员等餐上菜的过程CPU几乎不参与多线程可以在这儿并行等待。而复杂的数学计算、数据压缩、格式转换这些持续占用CPU的任务就像十个服务员抢一个灶加了多线程反而会因为线程切换开销变得更慢。1.3 几个关于GIL的常见误解GIL让Python多线程完全没用不对在IO密集型场景下多线程提升非常明显。多进程能彻底摆脱GIL对multiprocessing模块启动的是独立解释器进程每个进程有自己的GIL互不干扰。但进程间通信、内存隔离的开销也得算进去。换个解释器就没GIL了Jython、IronPython确实没有GIL不过主流生态基本都在CPython上很多第三方库的二进制包也只为CPython提供用其他解释器的人很少。可以在代码里手动释放GIL你释放不了这是解释器层面的机制应用层代码碰不到。理解了GIL的边界后面该用线程还是进程、什么时候用协程思路自然就清晰了。2. Threading模块实战三种创建线程的方式与选择逻辑Python标准库提供threading模块来创建和管理线程日常开发里最常用的有三种姿势按复杂度从低到高分别是直接实例化Thread、继承Thread类重写run方法、使用线程池ThreadPoolExecutor。三种方式没有绝对的好坏关键看场景。2.1 直接用threading.Thread创建线程最简单的方式是创建一个Thread对象把目标函数传进去。比如模拟一批需要等待的任务import threading import time def worker(task_id: int, delay: float): print(f任务 {task_id} 开始预计耗时 {delay} 秒) time.sleep(delay) print(f任务 {task_id} 完成) threads [] for i in range(5): t threading.Thread(targetworker, args(i, 1.0)) threads.append(t) t.start() for t in threads: t.join()这段代码的逻辑是先创建5个线程全部start启动最后遍历所有线程调用join让主线程等待所有线程跑完再退出。这里有两个容易被忽略的点。第一Thread对象的start和run是两个不同的方法start会真正创建系统线程并调度run只是普通方法调用如果直接调run那就是单线程串行执行。第二args参数传的是元组就算只有一个参数也要写成args(i,)这种带逗号的形式。2.2 继承Thread类封装业务线程当线程内部要做的事情比较固定而且需要维护状态、封装方法时继承Thread类更合适。import threading import time class JobThread(threading.Thread): def __init__(self, task_id: str): super().__init__() self.task_id task_id self.result None def run(self): print(f[{self.task_id}] 开始执行) time.sleep(0.5) self.result {task_id: self.task_id, status: ok} handler JobThread(任务A) handler.start() handler.join() print(handler.result)这段代码有几个地方容易踩坑。构造函数里如果要加自己的参数必须先调用super().init()把Thread内部状态初始化好。如果你没有调用父类构造方法线程启动时往往会报奇怪的初始化错误。另一个坑是run方法不能有返回值线程执行完run就结束了要想拿结果就把它存到self.result这样的实例属性里等线程结束之后主线程再去读取。实际项目中用继承Thread的方式适合那些需要长期运行、持续发送心跳包或者轮询状态的守护型线程。比如某个即时通信组件里有一个长连接保持线程它内部维护连接状态、定时重连这种用类封装就很自然。2.3 线程池ThreadPoolExecutor推荐优先使用的方案从Python 3.2开始标准库提供了concurrent.futures模块其中ThreadPoolExecutor就是线程池的高级封装。它帮你管理线程的创建、复用和销毁不用手动维护一堆Thread对象。from concurrent.futures import ThreadPoolExecutor, as_completed import time def fetch_data(url_id: int): time.sleep(0.3) return f数据来自第 {url_id} 个接口 with ThreadPoolExecutor(max_workers4) as executor: futures [executor.submit(fetch_data, i) for i in range(20)] for future in as_completed(futures): print(future.result())submit方法会把任务丢进线程池返回一个Future对象可以简单理解为一个未来的结果凭证。as_completed函数在某个任务完成时立刻返回结果一出来就能处理。executor还支持map方法用法和普通map类似适合批量传参。用线程池最直接的好处是控制并发数量。网络爬虫场景下如果直接创建几百个线程很容易触发对方服务端的连接限制也可能拖垮本机资源。线程池设置max_workers为固定值比如8个或者16个就相当于只开8个或16个任务并行其他任务在队列里排队。另一个好处是with语句会自动等待所有任务完成不用自己写join。2.4 三种方式到底怎么选结合我自己的实际体验一次性创建几个线程、跑完就结束的小脚本用threading.Thread最直观。线程有复杂状态、需要长期驻留、内部有重试和心跳逻辑用继承Thread的方式。批量任务、并发量不定、代码里希望尽量少关注线程生命周期管理无脑选ThreadPoolExecutor。这三个方式里我日常用的最多的是ThreadPoolExecutor不只是因为代码简洁更重要的是它能避免忘写join导致主线程提前退出这种低级错误。3. 共享数据的雷区锁、竞态条件与队列协同防护多线程编程真正的难度不在于创建线程而在于多个线程同时读写共享数据时怎么保证不出错。这一块是新手最容易写出一堆只在特定条件下才出错的bug的地方。3.1 竞态条件为什么是隐形的先看一段看起来没问题的代码import threading counter 0 def add(): global counter for _ in range(100000): counter 1 threads [] for _ in range(2): t threading.Thread(targetadd) threads.append(t) t.start() for t in threads: t.join() print(counter)直觉上两个线程各累加十万次counter应该等于200000。但我实际跑下来输出经常是140000多、160000多每次还不一样。原因在于counter 1这行代码并不是一次性完成的它至少拆成三步先读取counter当前值、计算加一的结果、把结果写回counter。两个线程可能同时读到同一个旧值各自加一后再写回结果就只加了一次而不是两次。GIL只能保证单条字节码执行期间没人打断但加一这个操作本身跨了多条字节码中间就可能被其他线程插进来。这类问题最阴险的地方在于代码逻辑看着完全没问题运行结果却时好时坏而且往往要在数据量大、线程数量多的时候才爆发。3.2 锁的正确姿势Lock、RLock与with语法最直接的解决方案是加锁让读取-计算-写回这三步变成一个不可分割的整体import threading counter 0 lock threading.Lock() def add(): global counter for _ in range(100000): with lock: counter 1 threads [] for _ in range(2): t threading.Thread(targetadd) threads.append(t) t.start() for t in threads: t.join() print(counter)加了with lock之后counter 1执行期间其他线程的with lock都会卡住只有等锁释放才能进入。这样最终结果稳定是200000。实际情况里锁的粒度需要仔细考虑。如果锁的范围太大比如把整个循环体都用with lock包住那多线程就退化成串行了加了等于没加。正确的做法是让锁只保护需要修改共享变量的临界区把耗时的IO操作留在锁外面。还有一个用锁时经常遇到的问题Lock不能在一个线程里重复acquire。比如函数A拿了锁里面又调函数BB也要拿同一个锁那就卡死了因为锁已经被人持有。Python针对这个场景提供了threading.RLock可重入锁允许同一个线程多次获取。但注意如果你发现一个线程里反复acquire同一个RLock多半是设计上有问题代码结构该调整了。3.3 更优雅的方案直接把任务扔进queue.Queue锁是解决线程安全的基本手段但太细粒度的锁容易出错、容易死锁。实际项目里更推荐一种设计思路让生产者线程只负责往队列里放任务消费者线程只负责从队列里取任务两边不直接操作同一个变量。import queue import threading import time task_queue queue.Queue() def producer(): for i in range(100): task_queue.put(i) task_queue.put(None) # 结束标志 def consumer(): while True: item task_queue.get() if item is None: break time.sleep(0.01) print(f处理了任务 {item}) task_queue.task_done() p threading.Thread(targetproducer) c threading.Thread(targetconsumer) p.start() c.start() p.join() c.join()queue.Queue内部自带锁和条件变量put和get都是线程安全的修改队列元素的操作你不用再额外加锁。用None作为结束标志是一种很常见的约定处理完一个任务调用task_done配合主线程的join可以精确控制队列消费进度。我自己的经验是能用队列解决的问题就不要手动加锁。队列天然把并发和共享数据解耦出问题的概率小很多。在爬虫、任务分发这类场景里一个队列、多个工作线程的模式可以覆盖绝大部分需求。3.4 死锁的形成与避免死锁最经典的场景是两个线程各持有一把锁都在等对方手里的另一把锁。比如线程A先拿锁1再拿锁2线程B先拿锁2再拿锁1两边就互相卡住了。避免死锁最简单的原则所有线程获取多个锁的顺序保持一致。如果线程A和线程B都是先拿锁1、再拿锁2不管谁先开始都不会产生互相等待。另一个实用技巧是用with timeoutimport threading lock threading.Lock() if lock.acquire(timeout3): try: pass finally: lock.release() else: print(获取锁超时说明有其他线程持有锁过久)不过说实话多线程项目里如果频繁出现要管理多个锁的情况我建议停下来想想是不是设计太复杂了。更合适的方案往往是改用消息队列、或者直接把任务拆成无共享数据的独立单元。4. 选型对照多线程、多进程、协程到底什么时候用哪个Python并发领域有三套方案多线程、多进程、协程。它们解决的问题有重叠但适用边界完全不同。这里我放一张实用性对照表然后展开说每个方案的判断逻辑。任务类型推荐方案原因注意点CPU密集型计算multiprocessing / ProcessPoolExecutor绕过GIL利用多核CPU进程间通信开销大数据需序列化IO密集型网络、磁盘、数据库多线程 ThreadPoolExecutor等待IO时不占用GIL线程切换成本低控制并发数量避免资源耗尽高并发、大量短连接协程 asyncio单线程内高效调度内存占用极低代码全部改为async/await风格需要共享大量状态多线程 queue / 多进程 共享内存按数据规模和复杂度权衡尽量避免进程间频繁传大对象查表速度是快但理解背后的逻辑更重要。4.1 CPU密集型任务为什么多进程成了唯一解我做过一个测试对一个包含大量浮点运算的列表排序加求方差同样的代码分别用单线程、多线程、多进程跑结果单线程耗时约5.2秒两个线程耗时反而变成6秒左右两个进程耗时却降到2.8秒。这个结果很符合GIL机制的预期——多线程抢一个解释器执行权加上线程切换的开销整体更慢多进程各自有解释器真正用到了两个CPU核。实际使用多进程时优先考虑ProcessPoolExecutor接口和ThreadPoolExecutor几乎一致但任务对象需要能被pickle序列化。from concurrent.futures import ProcessPoolExecutor import time def cpu_heavy(n: int): 模拟一段让CPU完全忙碌的纯计算任务 result 0 for i in range(n): result i ** 2 return result if __name__ __main__: with ProcessPoolExecutor(max_workers4) as executor: results list(executor.map(cpu_heavy, [1000000] * 4)) print(results)注意一个关键点ProcessPoolExecutor的代码必须放在ifname main:保护块里。因为Windows和macOS上启动子进程时会重新导入主模块没有这个保护直接会无限递归启动进程。多进程的代价同样明显内存占用高、启动时间慢、进程间传递数据需要序列化和反序列化。如果任务数据量特别大传一次数据的开销可能比计算本身还大这一点在做大数据批处理时要格外留意。4.2 IO密集型任务多线程和协程的取舍网络请求、爬虫、批量读文件这类任务多线程能把并发度提上去。但如果你追求的是并发量极高并且技术栈统一协程是更省资源的方案。一个简单协程例子import asyncio async def fetch_data(url_id: int): await asyncio.sleep(0.3) return f数据来自第 {url_id} 个接口 async def main(): tasks [fetch_data(i) for i in range(20)] results await asyncio.gather(*tasks) print(results) asyncio.run(main())协程的底层是单线程事件循环一个线程可以同时管理成千上万个等待中的任务每个协程占用的资源比一个线程少好几个量级。但代价是你的IO库必须是异步的比如aiohttp、aiomysql。如果用传统的requests配合asyncio那依然是同步阻塞请求并发效果直接打折扣。我的个人倾向是如果只是写脚本处理一批任务多线程永远是性价比最高、坑最少的选择如果是要构建需要支撑高并发连接的服务端程序直接上协程更合适。4.3 线程嵌套线程的场景什么时候真的需要有时候主线程需要管理一组子线程子线程内部还要再派发一些辅助任务这就出现了线程嵌套。典型场景是一个任务调度器主调度线程从队列拿任务每个任务分配一个子线程子线程执行过程中又需要并行请求多个接口于是再内嵌一个线程池。这种写法理论上没问题但最需要控制的是嵌套深度。我看到过某新人项目里主线程给10个任务建了10个线程每个线程内部又各建一个10个线程的池子一下子就是100个线程再往下一层就是1000个资源直接被打爆。正确的做法是每个层级都用线程池并且严格控制每个池的max_workers把并发总数提前测算好。比如最外层设8个调度线程每个调度线程内部再复用同一个共享的IO线程池比如12个线程这样总并发就是8加12而不是8乘以12。如果发现嵌套逻辑变得越来越复杂还有一个值得考虑的替代方案把每一层都改成独立的任务统一扔到同一个队列和线程池里用任务优先级代替层级的物理嵌套代码反而好维护很多。5. 一次实战测试和三个记忆深刻的坑纸上谈兵说完了我全盘复盘一次实际的对比测试和踩坑过程。这部分内容来自我个人的真实经验每个坑都是我花过时间排查才定位的希望对你有实际帮助。5.1 实测数据同一批任务在不同方案下的耗时对比我模拟的是一个典型的混合任务场景每个任务包含一段网络等待用sleep模拟耗时约1秒和一小段数据解析计算约0.05秒CPU时间。任务总数30个在本地分别用单线程、8线程、4进程三套方案跑。方案耗时说明单线程31.2秒串行执行等待的时间全部浪费了ThreadPoolExecutor8线程5.1秒并发处理IO等待提升约6倍ProcessPoolExecutor4进程6.8秒进程创建和结果回传有开销反而不如线程这个结果和很多人预期的进程快于线程不一样。原因很简单任务不是纯CPU型的绝大部分时间都在等IO。在IO等待阶段多线程完全不受GIL影响而多进程反而要为每个进程启动、序列化参数付出额外代价。这里还有一点值得注意即使8个线程并行耗时也只有理论值的约一半因为Python的线程调度和GIL切换会有一些额外开销线程越多这部分开销越明显。所以线程数量不是越多越好实际应用里建议从8到16之间的值开始试再根据结果微调。5.2 坑一print在输出量大的时候会乱序多线程代码里最常见的调试方式就是print但print在并发场景并不像你以为的那么可靠。它的输出是按块刷新的多个线程同时打印时可能有两条打印消息拼接在一起也可能顺序和代码逻辑不一致。现象是日志错位、看似漏了一些输出其实是内容被别的线程插进来了。解决方案很简单不要用print做并发调试改用logging模块。logging自带线程锁每一条日志是原子写入的而且还能附带线程名和行号信息import logging logging.basicConfig( levellogging.INFO, format%(asctime)s [%(threadName)s] %(message)s ) logger logging.getLogger(demo) logger.info(这是一条带线程名的日志)如果你确实想用print至少手动加一把锁保证print内部输出不被其他线程插入。但习惯规范的好做法还是直接上logging。5.3 坑二子线程抛异常主线程完全不知情另一个让我调试到怀疑人生的坑是子线程里一旦抛出异常主线程不会得到任何通知程序也不会崩溃只有线程自己默默死掉。如果你的代码没有打日志的习惯你会看到任务莫名消失整个程序不报错、不停止。等下我明明在代码里写了raise ValueError可是程序正常结束什么都没打印。对就是这种诡异现象。解决思路分两步。第一在线程内尽量嵌套try-except把异常信息记录下来。第二如果确实需要把异常抛给主线程可以通过队列传递import queue import threading result_queue queue.Queue() def worker(): try: raise ValueError(任务内部出错) except Exception as e: result_queue.put((error, e)) t threading.Thread(targetworker) t.start() t.join() status, err result_queue.get() if status error: print(主线程捕获到异常:, err)更大型的应用建议直接使用ThreadPoolExecutor它的Future对象自带异常传递机制调用future.result()时会把子线程里的异常重新抛给主线程这是它另一个重要优点。5.4 坑三daemon线程和join的类型匹配不上线程的daemon属性控制的是主线程退出时的行为。默认情况下主线程会等待所有非daemon线程结束才退出。反过来如果把线程设为daemonTrue主线程退出时会直接强制终止这个线程不管它有没有把活干完。我之前写过一个后台监控线程设置daemonTrue主线程很快就蹦完了结果监控线程还没来得及写回数据就被强行杀掉了文件里一片空白。给新手一个稳妥的建议需要等待完成的线程一律不要设置为daemon并且在主线程里显式调用join。只有那些主程序退出后没必要继续运行的守护性线程比如心跳发送、缓存清理才考虑设成daemonTrue。在ThreadPoolExecutor中线程池侧会等待所有提交的任务完成所以正常情况不需要手动处理daemon问题。6. 写在最后的几点个人体会和常用模式我试过很多次也给不同水平的开发者看过同样的并发代码最终的结论始终是一致的Python多线程不是不能用而是用之前一定要知道它的边界在哪里。以IO密集型任务为核心的应用多线程是性价比极高的方案几个小时的耗时能压缩到十几分钟以纯计算为核心的任务那就果断用多进程或协程没必要在多线程上硬耗。至于线程安全的处理优先用queue.Queue和ThreadPoolExecutor只有当队列模式解决不了时再去考虑自己加锁。最后分享一个提高多线程代码稳定性的小技巧写任何线程函数第一眼看它是否操作了全局变量或者外部可修改对象。如果操作了就停下来问自己——能不能把这个对象改成从队列里取算完再放回队列的方式大部分情况下这样一改竞态问题直接就消失了。这比绞尽脑汁调一个精密的锁方案要省心得多。