YAOTU INSIGHTS

Python并发选型与避坑指南:进程、线程、GIL、线程池/进程池实战

Python并发选型与避坑指南:进程、线程、GIL、线程池/进程池实战
经常有人在群里问我Python多线程是不是假的为什么我开了8个线程CPU还是只跑一个核爬虫到底该用线程还是进程这些问题背后其实都指向同一个主题——Python进程与线程。今天我就把自己踩过坑、翻过车之后总结的东西好好梳理一遍从原理到实战从选型到排错一次性讲透。这篇内容适合刚接触Python并发、或者写爬虫/脚本总感觉性能上不去的人也适合那些已经在用threading和multiprocessing但一直没搞明白为什么我这么写反而更慢的朋友。我会尽量用最直白的语言把进程和线程的区别、GIL的影响、进程池/线程池的用法以及实际项目中常见的一堆坑全部摊开来讲。1. 先搞懂进程和线程的本质区别1.1 进程和线程到底是什么很多教程喜欢画那种嵌套图——一个进程里面装着几个线程看得人头晕。我用大白话解释一次。进程是操作系统分配资源的最小单位。你在任务管理器里看到的每一个程序就是一个进程它有自己的内存空间、文件句柄、环境变量等资源。两个进程之间默认老死不相往来你改你的我改我的互不干扰。线程是CPU调度的最小单位它是进程内部的执行路径。同一个进程里的多个线程共享这块进程的内存空间可以访问同一个全局变量。所以线程的创建成本很低切换也快但要命的是数据共享的时候容易打架。打个比方进程就像一栋楼一个公司每个公司有自己的办公楼、桌椅、水电——资源独立。线程就像楼里的员工共用楼里的一切资源但大家干活的时候容易争抢一个会议室。Python里创建进程和线程分别对应multiprocessing和threading两个标准库。实际上你写代码的时候大部分场景根本不用直接面对这俩底层库concurrent.futures已经封装得很好这点后面细说。1.2 一个绕不开的问题GIL到底锁了什么所有学Python并发的人都会撞上GIL。它的全称是 Global Interpreter Lock全局解释器锁。网上说法很多有人干脆说Python多线程是废物这种判断太片面了但原理必须讲清楚。CPython 解释器不是线程安全的。解释器自己内部维护了很多全局状态比如对象引用计数。如果多个线程同时在Python解释器层面执行字节码就可能出现两个线程同时操作同一个对象状态、导致引用计数错乱的严重bug。为了让解释器自己不出错GIL 给整个解释器加了一把大锁同一时刻只有一个线程能执行 Python 字节码。注意关键词字节码。这意味着如果你的代码主要是CPU计算比如循环100万次做运算多线程并不能带来加速因为GIL只让一个线程在CPU上跑但如果你的代码是IO密集型比如爬虫等待网络响应、读写文件、查数据库线程在等待IO的时候会主动释放GIL让其他线程去执行。所以IO密集场景多线程依然很有用。Python 3.13 引入了自由线程的实验性特性可以部分去掉GIL但目前还远没到生产环境普及的阶段。主流3.8-3.12版本该有的GIL还是有。1.3 什么时候该用线程什么时候该用进程这是最核心的选型问题我用一张表直接给结论后面再逐一展开。场景类型特征推荐方案原因IO密集型爬虫、文件读写、网络请求、数据库查询多线程或协程等待IO时释放GIL线程开销小并发量大CPU密集型复杂计算、图像处理、数据分析、密码hash多进程每个进程有独立GIL能利用多核CPU两者混合先算后等边算边等进程池线程池组合 或 asyncio按阶段拆分别混用实际项目中我见过最多的错误就是一个人用ThreadPoolExecutor去跑一堆CPU密集任务结果发现8个核只占用1个性能还不如直接sync写。反过来有人用ProcessPoolExecutor去调第三方HTTP接口进程启动的开销比请求还大最后慢得离谱。判断方法很简单看你的瓶颈在CPU还是IO。打开任务管理器跑任务的时候CPU占用率如果一直上不去但任务就是卡着多半是IO如果CPU直接100%但速度还是不行多半是CPU密集。前者用线程后者用进程。2. Python多线程实战threading模块的正确打开方式2.1 最简单的线程写法先看一段基础代码import threading import time def worker(name): print(f线程 {name} 开始工作) time.sleep(2) print(f线程 {name} 工作结束) t1 threading.Thread(targetworker, args(A,)) t2 threading.Thread(targetworker, args(B,)) t1.start() t2.start() t1.join() t2.join() print(主线程结束)这里有三个关键点。start()是启动线程执行到这里的瞬间线程才开始跑不是调用run()。join()是等待线程结束。没有join()的话主线程可能先跑完子线程还没执行完进程就退出了。Python解释器退出时会等待所有非守护线程结束但行为会变乱所以最好显式join()。线程的执行顺序是不确定的先start()不等于先运行。不要依赖代码顺序。这段代码本身很好理解但工作中千万别手写一堆Thread对象管理下面说为什么。2.2 线程池别再手动创建线程了手写threading.Thread管理线程你得手动维护列表、join、处理异常、控制并发数量……麻烦且容易出问题。更优的做法是用concurrent.futures.ThreadPoolExecutor。from concurrent.futures import ThreadPoolExecutor, as_completed import requests urls [...] # 一批要爬的URL def fetch_one(url): resp requests.get(url, timeout10) return url, len(resp.content) with ThreadPoolExecutor(max_workers8) as executor: future_map {executor.submit(fetch_one, url): url for url in urls} for future in as_completed(future_map): url future_map[future] try: result future.result() print(url, result[1]) except Exception as e: print(url, 出错, e)这里的核心是submit()提交任务返回一个Future对象再通过as_completed按完成顺序拿到结果。with块退出的时候自动调用shutdown(waitTrue)线程池里的线程会被妥善回收。参数max_workers怎么定经验值是网络IO类任务可以开到CPU核数的4-8倍甚至更高因为线程大部分时间在等网络。如果是文件IO建议不要太多磁盘也是资源开太多线程反而增加切换开销。实际爬虫场景8-16个是比较舒服的范围。2.3 线程锁与线程安全线程之间共享全局变量这件事是万恶之源。看这个经典例子import threading counter 0 def increment(): global counter for _ in range(100000): counter 1 threads [] for _ in range(4): t threading.Thread(targetincrement) t.start() threads.append(t) for t in threads: t.join() print(counter)你猜最后输出是400000吗答案是不确定经常少于400000。原因是counter 1在Python字节码层面不是一条指令它会被拆分成读取当前值→计算新值→写回两个线程可能同时读到同一个旧值导致加的次数丢失。解决办法是加锁lock threading.Lock() def increment(): global counter for _ in range(100000): with lock: counter 1with lock保证同一时刻只有一个线程能进入这段代码其他线程必须等锁释放。不过加锁是有代价的锁的获取/释放本身有开销而且锁竞争激烈时线程会阻塞。所以另一个思路是尽量别共享可变状态。实在要共享优先用queue.Queue这种线程安全的容器而不是自己加锁。2.4 守护线程到底是个什么鬼threading.Thread有个参数daemon中文叫守护线程。很多教程说守护线程就是后台线程主线程退出它就没了这说法不算错但不够准确。准确的说法是Python程序在退出前会等待所有非守护线程执行完毕。如果某个线程是守护线程那么主线程结束时不会等它进程直接退出线程随之被强杀。所以守护线程适合那些可有可无的后台任务比如心跳上报、日志清理。注意守护线程里如果正在写文件进程退出可能导致数据写一半丢失所以需要可靠落地的任务别用守护线程。3. Python多进程实战multiprocessing与进程池3.1 多进程的最基本写法先明确一点multiprocessing模块的用法和threading很像但底层机制完全不同。进程通过forkLinux/mac或者spawnWindows创建子进程每个子进程有独立的Python解释器和内存空间。import multiprocessing def calculate(n): return n * n if __name__ __main__: with multiprocessing.Pool(processes4) as pool: results pool.map(calculate, range(100)) print(results)注意if __name__ __main__:这一行。在Windows上创建子进程时会重新导入主模块如果没有这行保护程序会陷入无穷递归创建子进程的死循环。实际工作中就算你在Linux上开发也建议写上这一行保证跨平台不炸。3.2 进程池的用法与参数选择multiprocessing.Pool是老牌进程池常见用法有三种pool.apply(func, args)同步调用阻塞等待结果。pool.apply_async(func, args)异步提交立即返回AsyncResult对象。pool.map(func, iterable)把可迭代对象的每个元素交给进程池处理返回结果列表。适合同样的函数批量处理一堆数据的场景。还有一个容易被忽略但很重要的参数chunksize。with multiprocessing.Pool(processes8) as pool: results pool.map(process_item, big_list, chunksize100)chunksize表示每个进程一次性从任务队列拿取多少项任务。默认情况下如果不设置map的chunksize会有自动计算逻辑但那不是最优的。任务粒度很小时每个任务计算时间很短设置一个较大的chunksize能减少进程间通信和任务分发的开销。任务粒度大时chunksize小一点让多进程更均衡。实际项目里如果big_list有10万条数据每条计算只要几毫秒我会把chunksize调到500-1000如果每条计算要几秒chunksize用50左右就够了。这个参数需要根据任务粒度调试不是越大越好。3.3 进程间通信IPC的几种方式进程内存独立共享受限。所以进程间通信靠的是专门的机制Python最常碰到的有四种Queue队列multiprocessing.Queue是线程和进程安全的FIFO队列。生产者往队列放数据消费者从队列取数据。适合任务分发/结果收集。Pipe管道multiprocessing.Pipe返回连接对象的两端双工通信。适合两个进程之间一对一通信数据量小、频繁交互的场景。Manager管理器multiprocessing.Manager可以在进程间共享列表、字典等数据结构用法像普通对象但性能较差适合低频共享元数据。共享内存multiprocessing.Value和Array可以直接在进程间共享内存性能好但不适合复杂数据结构。import multiprocessing def worker(q): q.put(来自子进程的消息) if __name__ __main__: q multiprocessing.Queue() p multiprocessing.Process(targetworker, args(q,)) p.start() print(q.get()) p.join()新手最常犯的错是想把一个大对象比如DataFrame通过Queue传给子进程。然后发现内存暴涨、文件锁冲突程序莫名其妙崩溃。我一般建议进程间只传小数据和控制消息大数据走磁盘或独立数据库别试图在进程间倒腾大对象太容易出问题。3.4 Windows下必须注意的坑Windows和Linux在multiprocessing上的行为差异非常大这里集中说几个我踩过的雷。第一创建进程的方式不同。Linux默认fork子进程直接复制父进程内存Windows默认spawn子进程从头导入模块。这意味着Windows上每个子进程都要重新加载一遍所有import启动开销大而且全局变量在子进程里是初始状态不是你修改后的状态。第二交互式环境容易卡死。在Jupyter Notebook或交互式Python里直接跑multiprocessing经常遇到无限重启或进程卡死的问题。这是因为交互式环境没法干净的spawn新进程。建议把多进程代码写进.py文件再运行或者改用ProcessPoolExecutor并固定方式。第三进程无法干净退出。如果子进程里开启了额外的线程或加载了某些DLLWindows下直接用terminate()强杀可能导致句柄泄漏程序虽然结束了但端口/文件被占住。这时候在finally里调用pool.close()和pool.join()是必须的。4. 更高层的选择concurrent.futures让你少写很多代码4.1 ThreadPoolExecutor 和 ProcessPoolExecutor 的对比concurrent.futures这个模块最大的特点是对线程和进程提供了统一的接口。你只需要换一个Executor类其他代码几乎不用改。from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor def heavy_calc(x): return x ** 2 # 换成 ProcessPoolExecutor 就是多进程版本 with ThreadPoolExecutor(max_workers8) as executor: results list(executor.map(heavy_calc, range(100)))这就有一个非常优雅的用法先用一个抽象函数做测试分别用线程池和进程池跑一遍测一下耗时再决定线上用哪种。不用改业务逻辑只换一行。但要注意ProcessPoolExecutor执行的任务必须是可以被pickle序列化的。如果任务函数是lambda、嵌套函数或者绑定方法进程池直接跑不起来会抛PicklingError。这是进程池最常见的坑之一。解决办法是把任务函数定义成模块级函数放在顶层别套在里面。4.2 提交任务的几种方式对比executor.submit(fn, *args)和executor.map(fn, *iterables)是最常用的两个入口。submit返回Future对象适合需要拿单个任务结果做后续处理、或者需要按完成顺序收集结果的场景。map返回迭代器适合批量处理、结果顺序和输入顺序一致的场景。还有一个as_completed方法它接收一个Future列表按照完成顺序返回结果。爬虫场景下特别实用先提交100个URL哪个先下载完就先处理哪个不用干等最慢的那个。from concurrent.futures import ThreadPoolExecutor, as_completed with ThreadPoolExecutor(max_workers16) as executor: futures [executor.submit(download, url) for url in urls] for future in as_completed(futures): result future.result() process_result(result)4.3 实际案例爬虫里怎么组合用爬虫应该是Python并发最常见的应用场景。我自己常用的组合是多线程负责并发请求队列负责解耦单线程或多线程负责解析入库。import queue import threading import requests from bs4 import BeautifulSoup q queue.Queue() results [] def producer(urls): for url in urls: q.put(url) def worker(): while True: try: url q.get(timeout3) except queue.Empty: break try: text requests.get(url, timeout10).text soup BeautifulSoup(text, html.parser) title soup.title.string results.append((url, title)) except Exception as e: print(url, e) finally: q.task_done() producer(url_list) threads [threading.Thread(targetworker) for _ in range(8)] for t in threads: t.start() for t in threads: t.join()用queue.Queue而不是直接共享列表是因为Queue内部实现了锁机制多线程put/get不会丢数据。task_done()告诉队列这个任务处理完了配合主线程的q.join()可以等所有任务处理完毕再往下走。这套写法虽然老但非常稳。实测8个线程在普通家用宽带上爬一个中小型资讯站几千个页面大概十几分钟能跑完比单线程快将近一个数量级。5. 常见问题与排查技巧实录5.1 线程死锁怎么排查死锁的经典场景是两个线程各持有一把锁又互相等着对方释放。比如线程A持有锁L1想获取L2线程B持有L2想获取L1两边都卡着不动。排查死锁我第一反应不是看代码而是先抓线程栈。Linux下用py-spy非常方便py-spy dump --pid 12345它会输出所有线程当前的调用栈能看到线程卡在哪个文件的哪一行。Windows下可以用 Visual Studio 的调试工具或者干脆在代码里加faulthandlerimport faulthandler faulthandler.dump_traceback_later(30, repeatTrue)这样程序运行30秒后自动打印所有线程的栈直接定位卡点。加了这段代码再复现一次基本就能找到死锁位置。预防死锁的最好手段是不要嵌套加锁。我自己的原则是每个线程最多持有一把锁持锁时间不超过必要的代码行数能不用Lock就尽量用queue.Queue代替。5.2 进程卡住或无法结束Windows下运行 Python 多进程祖传问题就是进程关不掉。在任务管理器里看到python.exe还赖着不走点结束进程又提示拒绝访问多半是子进程还在运行。一个比较隐藏的原因是子进程里创建了非守护线程并且这个子进程收到了终止信号但无法响应。Python子进程在主进程退出时如果没被正确join就处于僵尸状态Windows下特别难清理。我的建议是任务结束后显式调用pool.close()再pool.join()。给子进程任务设置超时在apply_async里传timeout参数超时强制放弃。实在卡住用pool.terminate()强杀——它会立即终止所有子进程但保证不了业务数据的完整性属于止损操作。Linux下相对好办直接kill -9Windows下就得靠任务管理器配合某个小工具强杀进程树没有系统级的完美方案所以提前写好清理逻辑更重要。5.3 CPU跑满却不出活有时候CPU占用率看着100%但任务就是不出结果。我遇到过几次这种情况排查下来原因各不相同进程数量开的比CPU核心数还多很多。比如机器8核你开了32个ProcessPoolExecutor虽然每个进程能占一个核但进程切换开销巨大实际吞吐量反而下降。进程数建议最初从multiprocessing.cpu_count()开始纯计算任务 核数混合任务 核数x2左右。pickle序列化开销太大。任务数据体积巨大进程间传一次数据要序列化很久表现出来的就是CPU飙高但业务结果没产出。解决方法是减少传参体积或者考虑用共享内存。GIL 外部库的锅。如果底层是C扩展库有的库会释放GIL有的不会前者在线程并发下能提升后者反而更慢。遇到这种情况要考虑换进程或者换库。5.4 实战经验总结我踩过的坑和你的避坑清单最后把这几年的经验浓缩成几条血泪教训希望对你有直接帮助。第一先判断任务类型再动手。很多人一上来就搜Python并发其实99%的场景用concurrent.futures一把梭就够了。IO密集用线程池CPU密集用进程池不要为了炫技去手写复杂的多进程代码。第二不要共享可变状态。这句话再怎么强调都不过分。线程之间共享全局列表结果混乱进程之间共享大对象内存爆炸。优先用队列传数据传递可pickle的小对象。第三写代码永远带上if __name__ __main__:。即使你现在只用Linux哪天项目搬到Windows上跑就直接炸。一行代码省一个通宵改bug不亏。第四加超时、加异常捕获。future.result()如果不加超时和异常处理一个无限循环的子任务能让你整个程序挂死。result(timeout5)是防御性编程的基本素养。第五先用小型任务验证结果再上量。我经常先把任务列表缩小到10条分别用线程/进程跑一遍对比时间和结果确认OK再全量跑。这个习惯救了我无数次。我个人在实际调试中体会最深的一件事并发程序的bug不是写出来的是你没有预料到而造成的。每次写并发代码前先问自己三个问题——任务会抛什么异常超时了怎么办进程/线程挂了会影响主流程吗把这三个问题的答案写进代码里绝大多数坑根本不会出现。