ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python线程到底怎么用?说透GIL、线程池与避坑指南

Python线程到底怎么用?说透GIL、线程池与避坑指南 每次聊到 Python 并发几乎都会听到同一句话——Python 有 GIL线程没用。这句话对一半错一半错的那一半恰好让很多人直接跳过线程去上进程、协程那套更重的方案最后代码复杂度翻倍、坑踩得更多性能却没见提升多少。真实场景里只要你在做网络请求、数据库读写、爬虫、文件处理这一类 IO 密集型的事情Python 线程就是性价比最高的并发方案之一而且它足够简单简单到你能把精力留在真正的业务问题上。这篇文章会从一个写过多年代码的人的角度把 Python 线程这件事从头到尾捋一遍GIL 到底卡在哪、threading 模块怎么用才顺手、线程池怎么配置、同步和死锁怎么处理、线上问题怎么排查。适合刚接触并发的初学者也适合写了一阵子但没深究过锁和线程池细节的开发者——看完至少能让你的多线程代码不再靠玄学。2. 先搞清楚 Python 线程到底解决什么问题2.1 线程解决的是等待问题不是计算问题很多人一开始接触线程时会有一个错觉线程越多程序越快。严格说这个想法只在特定条件下成立。Python 线程擅长处理的是“大部分时间在等”的任务而不是“大部分时间在算”的任务。打个比方。你去快餐店点餐把订单交给柜台发一个网络请求然后站在旁边等出餐等响应返回这个过程叫 IO 等待后厨拿到订单后开始切菜炒菜CPU 计算这个过程才是真正的干活。单线程程序的问题是一份订单交进去必须等它完全出餐才开始接下一份订单。如果出餐要 3 秒而你只花了 0.1 秒下单剩下 2.9 秒全在无所事事地等。线程的意义就在于把这段时间利用起来。一个线程发出请求后在等响应另一个线程可以同时发出自己的请求。于是 10 个请求分别要等 3 秒串行处理需要 30 秒用 10 个线程并发处理理想情况下 3 秒出头就全部完成。这就是为什么爬虫、API 调用、数据库批量查询这类场景用线程效果立竿见影——它们本质上是“把等待时间重叠起来”。我实测过一个小例子用 requests 串行请求 3 个接口每个接口耗时约 0.8 秒总耗时 2.4 秒改成 3 个线程并发总耗时降到 0.9 秒。代码几乎不用动脑子只要把三个请求丢进线程池就行。线程解决的从来不是“算得快”而是“不等白不等”。2.2 GIL 是绕不开的第一课聊 Python 线程不聊 GIL等于聊汽车不聊发动机。GIL 全称 Global Interpreter Lock是 CPython也就是大多数人用的官方 Python 解释器为了实现内存管理安全而加的一把全局锁。它保证同一时刻只有一个线程在执行 Python 字节码其他线程只能排队等锁。这个设计带来的后果非常直接如果你的任务是纯 CPU 计算比如循环做几百万次数学运算、图像处理、数据压缩那么开多线程不仅不会变快反而可能因为线程切换和锁竞争变得比单线程还慢。这就是“Python 线程没用”这句偏见的主要来源。但 GIL 真的让线程一无是处吗不是。关键点在于线程在遇到 IO 操作等待网络响应、读写磁盘时会主动释放 GIL让给其他线程执行。所以前面说的那种 3 秒网络等待场景线程之间能很好地交替运行整体吞吐量可以成倍提升。GIL 影响的是“同时计算”不影响“同时等待”。顺带提一句Python 官方已经在 3.13 版本里发布了实验性的自由线程free-threaded模式也就是说未来 CPython 有可能不再强制一把大锁。但那是以后的事现阶段生产环境里你依然要按照“IO 密集用线程、CPU 密集用进程”的思路来选择。理解 GIL你就不会被网上的极端言论带着走也不会反过来把 GIL 当成万能借口。2.3 线程、进程、协程怎么选这是个老生常谈的话题但每次面试和项目评审都会遇到。我的建议很简单先看你的任务是等得多还是算得多。方案实现成本切换开销适用场景注意事项线程低低IO 密集任务、并发请求聚合受 GIL 限制别用于纯计算进程中高CPU 密集任务、多核并行进程间通信麻烦资源占用大协程低极低超高频 IO、长连接、事件驱动需要异步框架支持生态割裂线程适合“一个人同时盯很多事”的场景比如同时等 10 个接口返回、同时处理多个数据库查询、批量抓取网页。进程适合“一块活要多人同时干并且真正并行计算”的场景比如数据处理、算法训练。协程则适合连接数特别多、每个连接又不太说话的场景比如聊天服务器、网关转发。实际项目里这三个方案常常混用。比如你可以在主进程里用线程池处理数据库 IO再用 threading.Event 通知其他线程做后续流程。没必要追求单一技术也不用一上来就搞异步全家桶。记住一句话能用简单方案解决就不上复杂方案线程往往就是那个简单方案。3. threading 核心 API 实操拆解3.1 创建线程的三种姿势Python 的 threading 模块创建线程非常直白有三种常见的写法我按个人推荐程度来说。第一种直接传可调用对象这是最常用也最推荐的方式import threading import time def worker(name): print(f线程 {name} 开始运行) time.sleep(2) print(f线程 {name} 结束) t1 threading.Thread(targetworker, args(A,)) t1.start() print(主线程继续做别的事情) t1.join()第二种继承 Thread 类适合需要在线程里维护额外状态的场景class Worker(threading.Thread): def __init__(self, name, delay): super().__init__(namename) self.delay delay def run(self): print(f{self.name} 开始延迟 {self.delay} 秒) time.sleep(self.delay)第三种用线程池后面专门讲它其实不算创建线程的姿势而是管理线程的方式生产环境里我强烈建议以它为主。这里有两个容易踩的坑。第一个是 join 别忘了主线程代码跑完整个进程就退了不会等子线程。如果你想让主线程等所有子线程完成必须在每个线程对象上调用 join()。第二个是 daemon 线程如果某个线程设置为 daemonTrue那么主线程结束时它会被直接杀掉适合后台日志、监控心跳这类不用善后的任务但如果有重要数据要落盘千万别设 daemon。3.2 线程间通信队列与事件线程之间不能直接共享局部变量而全局变量又容易引起竞态条件所以实践里最稳妥的通信方式是使用 queue.Queue。这个类内部实现了锁机制put 和 get 都是线程安全的天然适合生产者消费者模型import queue import threading import time q queue.Queue(maxsize10) def producer(): for i in range(5): q.put(f任务 {i}) print(f生产了任务 {i}) time.sleep(1) def consumer(): while True: item q.get() if item is None: # 发送结束信号 break print(f消费了 {item}) q.task_done() t1 threading.Thread(targetproducer) t2 threading.Thread(targetconsumer) t1.start() t2.start() t1.join() q.put(None) # 通知消费者退出 t2.join()这个例子里有个很重要的设计通过往队列里放一个 None 作为“毒丸”让消费者线程可以正常退出。否则消费者会一直在 q.get() 上阻塞程序就没办法干净收尾。Event 则是另一种常用的线程间通知机制。一个线程可以调用 event.wait() 等待另一个线程调用 event.set() 触发放行。适合“等某个条件满足再继续”的场景比如等待配置加载完成、等待服务预热结束。如果你的线程之间需要传递的是“信号”而不是“数据”优先用 Event别自己写轮询循环又费 CPU 又容易有延迟。3.3 锁的正确用法锁是线程同步的基石但也最容易被滥用。threading.Lock 的典型使用方式是这样import threading lock threading.Lock() counter 0 def increment(): global counter with lock: # 等价于 lock.acquire() try/finally lock.release() counter 1一定要记得用 with 语句。我见过不少老代码用裸 acquire() 和 release()一旦中间抛异常锁就永远不释放其他线程全卡死这是最经典的死锁来源之一。with 语句会自动释放锁哪怕中间出错也一样。锁还有一个重要概念叫粒度。如果你把一大堆业务逻辑都塞在锁里其他线程全都在门外等着跟串行没区别如果你只锁一行关键赋值又可能漏掉别的共享资源。我的经验是锁只保护真正会并发修改的“临界区”并且尽量把耗时操作网络请求、文件读写移出锁外这样锁的竞争压力会小很多。另一个容易忽略的是 RLock可重入锁同一个线程可以多次 acquire 而不死锁适合递归函数里加锁的场景普通 Lock 遇到这种情况会立刻把自己锁死请务必分清。4. 线程池生产环境最常用的形态4.1 为什么不要裸写线程很多新手学会 threading.Thread 之后遇到并发需求就疯狂循环创建线程比如每来一个请求就 new 一个线程。短期内没问题但到生产环境压力一大就会出各种奇怪的状况。频繁创建和销毁线程本身就是开销。线程的创建要申请系统资源、注册内核对象销毁时还要回收。在高并发 IM、高吞吐网关这类场景下如果每秒来几千个请求每个请求都开线程系统资源会迅速被耗尽甚至把进程搞到崩溃。线程数一旦膨胀到几千上万线程切换本身就会把 CPU 打满业务反而被拖垮。另一个问题是并发数不可控。你有没有想过16C32G 的服务器到底能支持多少并发这个问题的答案不是死数字它取决于线程的内存占用、每线程栈大小、下游服务的承载能力等多种因素。Python 线程每个默认占用的内存不算小无限创建必然导致 OOM。手动管理线程还有个麻烦你很难对线程做统一的超时控制、异常处理和状态监控。所以生产环境里永远优先用线程池而不是自己管理 Thread 对象。4.2 ThreadPoolExecutor 的实际用法concurrent.futures.ThreadPoolExecutor 是 Python 标准库提供的线程池用法非常优雅。我推荐把线程池当作所有 Python 并发任务的默认入口from concurrent.futures import ThreadPoolExecutor, as_completed import requests urls [https://example.com/api for _ in range(20)] def fetch(url): r requests.get(url, timeout5) return r.status_code with ThreadPoolExecutor(max_workers10) as executor: futures [executor.submit(fetch, url) for url in urls] for future in as_completed(futures): print(future.result())用 with 语句包住线程池代码块结束时它会自动调用 shutdown(waitTrue)等待所有任务完成线程池的资源被妥善回收不会留下孤儿线程。executor.submit 把任务提交进去返回一个 Future 对象Future 代表“将来某个时刻的结果”调用 result() 会阻塞到任务完成并拿到返回值。as_completed 则允许你按完成顺序处理结果先做完的先处理适合批量请求场景。executor.map 也是一个常用方法它会把一个可迭代对象里的每个元素交给线程池处理并且保持结果的顺序。如果你的任务都是同一种操作map 写起来更简洁但如果任务的参数不统一或者需要按完成顺序处理submit 更灵活。顺便说一句很多人在需要并发请求时喜欢自己写一堆 Thread Queue其实 ThreadPoolExecutor 内部就封装好了队列和线程管理直接用它省心得多。4.3 线程池大小到底怎么定这是面试和实战中出现频率极高的问题。网上流传最广的公式是CPU 密集型任务用 cpu_count 1IO 密集型任务用 cpu_count * 5。我只能说这是给新手兜底的真实情况要复杂得多更准确的 IO 密集模型是线程数 核心数 * (1 平均等待时间 / 平均计算时间)如果一次请求等 300 毫秒、只算 10 毫秒那么一个核心就能支撑大约 31 个线程等待。8 核机器轻松就能开上百个线程而不会明显影响性能。问题的关键是你不能无限开要考虑三个方面第一每个线程有栈内存和对象开销线程多了内存会涨第二GIL 在 IO 密集时会释放但不是 100% 不排队线程过多会导致锁竞争加剧第三下游服务能不能扛住这个并发量。你开 1000 个线程打一个接口不是把你自己打死而是把对方服务打死。我实际的做法是先按经验估算一个数再跑压测观察 CPU、内存、响应延迟三个指标。比如一个 16C32G 的网关服务处理外部 API 转发我先给线程池配 32 个线程压测后发现 CPU 只用了 40%但平均延迟偏高于是逐步调大到 64、128直到延迟曲线达到拐点再往回退一点留余量。线程池不是越大越好而是“够用 留裕度”最好的配置一定来自你的业务实测而不是某个公式。5. 线程间协作与同步别让竞态条件毁了你5.1 一个经典竞态条件的复盘很多人以为 Python 的赋值和自增是原子的实际上不是。看一个经典的例子import threading counter 0 def increment(): global counter for _ in range(100000): counter 1 threads [threading.Thread(targetincrement) for _ in range(10)] for t in threads: t.start() for t in threads: t.join() print(counter) # 期望 1000000实际往往不是每次 counter 1 在 Python 字节码层面其实是三步读取 counter 的值、加 1、写回 counter。三个线程同时做这组动作时可能两个线程都读到了旧值各写各的最终只加了一次。这是典型的竞态条件。10 个线程各加 10 万次理论上应该是 100 万实测下来经常是 99 万出头甚至更少。这个问题的本质是多个线程操作共享状态时需要互斥mutex否则结果就会取决于线程调度的运气。所以只要你的线程之间共享了可变数据并且存在“读-改-写”这类复合操作就必须加锁或者改用后面要讲的原子数据结构。不要心存侥幸你第一次遇到竞态条件时是查不出 bug 的因为它可能几千次运行才触发一次。5.2 常用同步原语一览Python 的 threading 模块提供了多种同步工具我整理了一份速查表工具核心方法典型场景注意点Lockacquire / release保护临界区注意顺序防止死锁RLockacquire / release递归函数内加锁同一线程可重入Semaphoreacquire / release限流、控制并发数有初始计数上限Eventwait / set / clear线程间发信号适合“万事俱备再开工”Conditionwait / notify复杂协作、有条件的阻塞多和队列配合Barrierwait多个线程同步到同一位置适合分阶段任务Semaphore 是我特别喜欢的一个工具它可以用来做并发限流。比如你要同时下载 1000 个文件但不想一次开 1000 个线程把带宽打满可以定义一个 Semaphore(10)每个线程在下载前 acquire 一下下载完释放。它本质上是个计数器不限制你能开多少线程但限制了同时干活的线程数非常实用。Condition 用得相对少但遇到“多个线程要等同一个条件满足时再一起干活”的场景它比锁加轮询更优雅。真实项目中队列 锁的组合已经能覆盖绝大多数需求不需要把每个同步原语都堆上去。工具用得越少代码越容易推理并发 bug 就越难藏身。5.3 死锁的产生与规避死锁是并发编程里最让人头疼的问题之一一旦出现进程就卡死在原地线程之间互相等待对方释放资源谁也没法继续。最常见的死锁模型是 ABBA线程 1 先拿锁 A 想拿锁 B线程 2 先拿锁 B 想拿锁 A两边都互不相让于是双双卡死。lock_a threading.Lock() lock_b threading.Lock() def task1(): with lock_a: time.sleep(0.01) with lock_b: pass def task2(): with lock_b: time.sleep(0.01) with lock_a: pass这两个线程同时运行大概率会卡死。避免死锁有几个实用策略。第一是锁顺序一致所有线程都按相同的顺序去获取多个锁就不会出现循环等待。第二是尽量使用超时获取lock.acquire(timeout3)拿不到锁就放弃重试而不是无限等下去。第三也是最根本的尽量减少同时持有多个锁能把多个资源组合成一个数据结构然后用一把锁保护就别拆成多把锁。我的实践经验是死锁问题靠“写代码时的小心”是不够的排查起来极其痛苦。线上如果怀疑死锁可以用 faulthandler.dump_traceback_later(10) 让程序在运行 10 秒后自动打印所有线程的堆栈这样就能看到每个线程到底卡在哪一把锁上。这不是理论我排查过的线上事故里用这个工具定位死锁位置的准确率几乎百分之百。6. 生产环境常见的坑与排查实录6.1 “线程安全”的假象很多人用 Python 的 list 和 dict 写并发代码时听说“append 是原子操作”就放开了手脚。这个说法在某些实现细节上是对的列如 cPython 里单个 list.append 确实不会在中途被打断但它的“线程安全”程度比你想的小得多。问题出在复合操作。假设你有个 dict一个线程检查 key 是否不存在然后往里面赋值另一个线程同时做同样的操作。你写出了“先检查后赋值”的逻辑哪怕单个 if 和单个赋值都是原子的这两步之间依然可能被另一个线程插进来。于是两个线程同时判断 key 不存在然后同时写入最后只有一个值保留。这种事用锁能解决但根源在于你误解了“线程安全”的含义。线程安全说的是“单个操作不会破坏容器内部结构”不等于“多个线程随便操作都能得到正确业务结果”。判断一个操作是不是安全要看它是不是由多个步骤拼起来的。如果代码里出现“读一个值、算一下、写回去”这种三连那就是竞态条件的温床不管底层容器号称多安全都要加锁或改用更合适的数据结构。6.2 线程泄漏与无限增长排查线上服务时我见过最典型的问题是线程数只增不减。每次请求进来都 new 一个 Thread请求结束了既不 join 也不关闭线程线程对象就一直挂在内存里。时间一长进程的线程数量从几十涨到几千系统资源耗尽服务开始频繁 GC 甚至直接卡死。最讽刺的是这类代码在测试环境通常跑得好好的因为流量小线程增长得慢根本暴露不出来。线程泄漏的本质是资源管理缺失。你自己 new 出来的线程必须明确由谁回收、什么时候回收、异常时怎么处理。生产环境的做法就是前面反复强调的线程池线程池维护固定数量的 worker任务做完线程回池复用不会无限增长。如果你确实需要动态创建线程比如按用户隔离也务必加一个上限控制并且提供强制回收机制绝不能放任线程自然堆积。判断线程是否泄漏最简单的办法是在服务运行一段时间后到服务器上执行 ps -eLf | wc -l 看线程总数或者用 py-spy 这类工具 dump 线程栈看看线程都在干什么。如果线程数随着请求量不断增加、却从不回落那基本可以断定存在泄漏赶紧查代码里的 Thread 创建位置。6.3 排查工具与调试技巧我给自己的线程代码加了两条强制纪律排查线上问题时特别有用。第一创建线程时一定要起一个容易识别的名字threading.Thread(..., namerequest-worker-1)。第二在日志里打上线程 ID 和时间戳。这样线上出问题时你能从日志里一眼看出哪个线程在处理哪个请求而不至于面对一坨匿名线程干瞪眼。实用工具方面首推 py-spy。它是一个第三方命令行工具不需要修改代码就能打印运行中 Python 进程的线程栈py-spy dump --pid 进程ID。当进程卡死、死锁、线程池耗尽时它能在不重启服务的情况下告诉你每个线程正在执行哪一行代码这对定位问题有决定性帮助。faulthandler.dump_traceback_later 则是代码内的定时快照适合你想让程序运行一段时间后自动输出堆栈的场景。内存和线程监控也有很多现成方案。用 cProfile 可以看函数级耗时用 tracemalloc 可以看内存分配而运维层面的线程数、内存占用则可以通过监控系统持续采集。说实话排查并发问题的关键不是工具多高级而是你脑子里有没有一个排查路径先确认是死锁还是线程失控再定位到具体线程最后看那一行代码在干什么。遵守这条路径90% 的并发问题都能在半小时内找到根因。最后分享一个我实际项目中一直在用的小习惯所有在线程池里执行的任务函数写的时候就把“允许失败”考虑进去函数内部捕获所有异常并记录日志绝不让异常偷偷吃掉一个任务。这样线程池里的任务不会因为一次报错就断掉整条流程后续排查也能从日志里直接看到失败原因而不是面对一个悄无声息消失的任务发呆。并发编程没有银弹但先把这些基础工作做扎实就能躲开大多数生产事故。
返回列表