ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Python GIL 详解:多线程为何无法利用多核,以及该用多进程还是多线程

Python GIL 详解:多线程为何无法利用多核,以及该用多进程还是多线程 先给结论GIL 是 CPython 解释器里的一把全局锁它限制的不是“Python 不能并发”而是“同一个解释器进程在同一时刻只能有一个线程执行 Python 字节码”。所以你在 8 核机器上开 4 个 Python 线程去算 CPU 密集型任务看到的往往是 CPU 只跑了一个核多一点甚至 4 线程比单线程更慢。这个问题不是代码写得差而是 CPython 的内存安全和执行模型决定的。这篇文章会直接摆现象、拆原因、给代码。你可以照着跑一遍确认自己手上的任务是该继续用多线程还是应该换成多进程。重点放在“怎么判断”和“换完之后怎么验证效果”。1. 直接看现象4 个线程为什么没跑满 4 个核1.1 先跑一个最小复现脚本我建议你直接在本地建一个gil_demo.py把下面的代码放进去。它做的事情很简单用一个纯 Python 函数做大量数值循环分别用单线程和 4 个线程跑同样的总量。import threading import time def heavy(rounds): total 0 for i in range(rounds): total i * i total - i % 7 return total def run_single(total_rounds): start time.perf_counter() heavy(total_rounds) print(f单线程耗时: {time.perf_counter() - start:.3f}s) def run_threads(total_rounds, n4): threads [] start time.perf_counter() part total_rounds // n for i in range(n): t threading.Thread(targetheavy, args(part,)) threads.append(t) t.start() for t in threads: t.join() print(f{n} 线程耗时: {time.perf_counter() - start:.3f}s) if __name__ __main__: run_single(20_000_000) run_threads(20_000_000, 4)这是我跑过多次的经典现象。单线程跑完 2000 万次循环耗时大约 0.7 到 0.9 秒你以为 4 个线程并行能跑到 0.2 秒左右但结果往往和下面这张表接近。方案耗时CPU 核占用情况直观结论单线程0.78s 左右1 个核正常4 线程0.86s 左右1 个多一点的核没有跑满 4 核4 进程0.21s 左右4 个核接近线性加速不同机器波动会有差异但趋势非常一致纯 Python 数值计算多线程不仅没加速还因为抢锁开了倒车。1.2 GIL 限制的是并行执行不是并发很多人把 GIL 理解成“Python 多线程是假的完全不能并发”这个说法不准确。准确的理解是CPython 的解释器在执行 Python 字节码时会要求当前线程先持有 GIL。这把锁保证同一时刻只有一个线程能运行字节码所以纯 Python 计算无法在多核 CPU 上并行。但线程并不等于完全死掉当线程遇到 I/O 等待、sleep、网络请求阻塞时会主动释放 GIL让其他线程继续跑。也就是说Python 多线程做 I/O 密集型任务时依然有并发能力。我经常用一个比喻来解释GIL 像公司里唯一一间会议室。有人进去做计算时其他人只能在门口等但如果里面的人是在等电话、等快递他会先出来让另一个人进去等。1.3 为什么 CPU 密集多线程反而更慢多线程跑 CPU 密集任务本质上是在做“多人轮流抢一把锁”。每个线程拿到 GIL 后执行一小段时间然后被切走下一个线程再抢。这个切换不是免费的。每轮切换至少包括几类成本线程上下文切换。在等待 GIL 时锁的争抢。调度器相关的开销。不同线程同时运行可能触发缓存失效。CPython 默认的线程切换间隔可以通过sys.getswitchinterval()查看通常是 5 毫秒。也就是说一个线程最多持续持有 GIL 大约 5 毫秒就会被提醒让出。对纯计算任务来说这种频繁让出没有意义只会让总时长增加。结论先放在这里如果你的任务是“绝大部分时间在运行 Python 循环和运算”多线程的方向大概率是错的。2. GIL 为什么存在历史包袱还是设计必然2.1 引用计数是 GIL 出现的重要原因CPython 的内存管理机制里每个 Python 对象都有一个引用计数记录当前有多少个地方引用它。当一个对象的引用计数变成 0解释器就会立即回收它占用的内存。这个机制本身很快而且简单直接。但问题来了如果两个线程同时操作同一个对象的引用计数就可能出现计数错乱。最典型的情况是一个线程正在使用对象 A另一个线程误把引用计数减到了 0导致对象被回收第一个线程就炸了。为了避免这种竞争CPython 选择用一把全局锁让同一时刻只有一个线程执行 Python 字节码。只要字节码执行是串行的引用计数的增删就不会互相干扰。你可能会问为什么不给每个对象单独加锁理论上可以但会导致两个问题锁本身占用大量内存而且对象之间互相引用时很容易出现死锁。相比来说GIL 是一种更简单、短期内更不容易出错的方案。2.2 C 扩展的兼容性也是重要原因CPython 除了运行 Python 代码还经常调用 C 扩展库。很多底层库、科学计算库、图像处理库都依赖 GIL 来保证线程安全。如果直接移除 GIL这些 C 扩展的线程安全模型可能需要重写。现实中很多第三方库几十年没有按“无 GIL”来设计这是 Python 官方在推进“自由线程”时非常谨慎的原因。所以GIL 不是一个“当初拍脑袋留下的 bug”而是一个为了内存安全和扩展生态而做的历史设计。当然它带来的副作用也很明显Python 纯代码在多核 CPU 上的并行能力被严重限制。2.3 C 扩展里有一个例外某些计算会自动释放 GIL这里有个容易混淆的点。你在 Python 里调用numpy的大矩阵运算时有时候能看到多核占用升高但这不表示 GIL 消失了。原因是许多 C 扩展在执行真正耗时的计算时会主动释放 GIL。因为这时候 C 代码不碰 Python 对象不需要锁保护。计算完成之后再重新获取 GIL把结果转回 Python 对象。这也是为什么很多实际项目里多线程跑numpy、pandas、PIL的部分操作时性能并没有想象中那么差。需要注意释放 GIL 是第三方扩展自己的行为不是 Python 字节码自动做到的事情。3. 多线程真正的价值I/O 密集场景GIL 不会拖后腿3.1 什么是 I/O 密集任务I/O 密集任务指的是“大量时间花在等待外部输入输出上”的任务。典型特征如下等待网络接口返回数据。等待数据库查询完成。等待文件读写。等待消息队列消费。等待外部服务响应。这类任务的特点是真正消耗 CPU 的时间很短大部分时间线程都在阻塞等待。在等待期间线程不使用 CPU也不需要执行字节码所以 GIL 的负面影响不明显。3.2 用一个模拟脚本实测 I/O 场景我一般不用真实接口做首次测试因为网络不稳定会影响判断。先用time.sleep模拟一次外部等待效果更可控。import threading import time def remote_request(task_id): # 模拟一次网络请求或数据库等待 time.sleep(0.5) return task_id def run_single(total8): start time.perf_counter() for i in range(total): remote_request(i) print(f单线程耗时: {time.perf_counter() - start:.3f}s) def run_threads(total8, n4): start time.perf_counter() threads [] for i in range(total): t threading.Thread(targetremote_request, args(i,)) threads.append(t) t.start() if len(threads) n: for t in threads: t.join() threads [] for t in threads: t.join() print(f多线程耗时: {time.perf_counter() - start:.3f}s) if __name__ __main__: run_single(8) run_threads(8, 4)单线程跑 8 次每次等待 0.5 秒总耗时约 4 秒。改成 4 个线程后4 个等待可以重叠总耗时明显下降通常在 1 到 2 秒之间。如果你的任务实际是网络请求差距会更明显。3.3 多线程适合的应用场景Python 多线程在实际项目里不是没有价值关键是找对场景。安全且常见的用法包括批量调用第三方 HTTP 接口。并发读取多个文件。并发查询多个数据库表。对接多个消息队列。需要同时监听多个 socket 或端口。在这些场景里多线程代码比异步代码更容易理解也能获得明显收益。我的建议是先把任务分成“等待型”和“计算型”两类。等待型的优先考虑多线程或异步计算型的优先考虑多进程或底层库。4. 需要多核改用多进程并把效果测出来4.1 用 multiprocessing.Pool 直接替换如果确认任务是 CPU 密集型正确路线是换多进程。每个进程都有独立的 Python 解释器和独立的内存空间也各自持有一把 GIL所以 4 个进程可以在 4 个核上并行跑。把刚才的例子改成多进程代码并不复杂from multiprocessing import Pool import time def heavy(rounds): total 0 for i in range(rounds): total i * i total - i % 7 return total def run_processes(total_rounds20_000_000, workers4): start time.perf_counter() part total_rounds // workers with Pool(workers) as pool: result pool.map(heavy, [part] * workers) print(f{workers} 进程耗时: {time.perf_counter() - start:.3f}s) print(f结果: {result}) if __name__ __main__: run_processes()你留意到if __name__ __main__了吗多进程在启动子进程时会重新导入当前脚本。如果没有这个保护Windows 环境或 spawn 模式下会无限递归创建子进程。Linux 默认 fork 模式通常不报错但保持这个写法是稳妥的。4.2 怎么确认 4 个核真的跑满了只看耗时还不够建议同时打开系统监控工具。Linux 下用top或htop按1查看每个核的占用率。macOS 下用Activity Monitor。Windows 下用任务管理器或者直接在 Python 里用psutil查。用psutil的优点是能直接拿到每个核心的占用百分比方便后续做成性能基线。import psutil # 每隔 1 秒打印一次每个 CPU 核心的占用 for cpu in psutil.cpu_percent(interval1, percpuTrue): print(fCPU 核心占用: {cpu}%)多进程运行阶段你大概率能看到 4 个核心同时接近 100%。这才是“多核并行”的正常表现。4.3 更现代的方式ProcessPoolExecutor除了multiprocessing.Poolconcurrent.futures.ProcessPoolExecutor也是常见选择。它的接口更统一如果你以后想在线程池和进程池之间切换只需要改一行导入。from concurrent.futures import ProcessPoolExecutor import time def heavy(rounds): total 0 for i in range(rounds): total i * i total - i % 7 return total def run_processes(total_rounds20_000_000, workers4): start time.perf_counter() part total_rounds // workers with ProcessPoolExecutor(max_workersworkers) as executor: results list(executor.map(heavy, [part] * workers)) print(f{workers} 进程耗时: {time.perf_counter() - start:.3f}s) print(f结果: {results}) if __name__ __main__: run_processes()executor.map会自动帮你把任务分配到进程池。这里有一个容易被忽略的点返回值必须能被 pickle 序列化。如果你的任务返回的是 lambda 函数、本地类实例或其他不能序列化的对象就必须先做一次转换否则会报错。4.4 多进程的注意事项多进程不是简单的“把 Thread 换成 Process”就完事了有几个点必须提前想清楚。第一主进程和子进程之间不能直接共享普通 Python 变量。它们的内存空间是隔离的需要通过multiprocessing.Queue、Pipe、Manager或共享内存来传递数据。第二进程启动时间比线程长。线程创建成本低进程创建需要加载一个新的解释器可能还需要重新导入依赖包。因此不要在一个循环里反复创建和销毁进程。正确做法是提前创建进程池或执行器复用里面的 worker。第三注意任务粒度。如果单个任务本身只需要几毫秒而你把上万个这样的小任务丢给进程池进程通信和序列化的开销会超过计算本身最终反而更慢。遇到这种情况尽量在任务内部做合并比如一次处理一批数据。第四multiprocessing在不同平台的默认启动方式不同。Linux 默认是 fork 模式Windows 和 macOS 默认是 spawn 模式Python 3.14 起 spawn 会进一步成为更多平台的默认选项。你写代码时不要依赖 fork 独有的行为尽量保证代码同时兼容两种模式。5. 多进程不能无脑上开销、序列化和数据同步5.1 进程创建成本比线程高一到两个数量级线程是在同一个进程里创建的共享内存空间切换时开销相对小。进程则完全独立每个进程都需要加载解释器状态、初始化模块、分配内存。如果你在循环里频繁创建进程比如处理 10 万个文件时每个文件都开一个新的Process那程序大部分时间会花在创建和销毁上而不是执行任务。正确做法是使用固定大小的进程池让多个任务复用已有的 worker。5.2 数据传递才是真正的隐藏瓶颈多进程并行计算本身可能只需要 0.1 秒但如果每个任务要从主进程传 100MB 数据过去传数据的时间可能会是计算时间的几十倍。Pool.map会把输入列表拆成多个元素分发给各个进程然后把结果传回主进程。这个过程中数据需要序列化和反序列化。如果输入数据很大或者结果里包含复杂对象瓶颈就很明显。遇到大数据量场景我建议先思考几个问题数据能不能在子进程里各自生成而不是主进程传过去数据能不能用只读共享内存而不是复制结果能不能只传汇总值而不是把完整对象传回如果确实需要在多个进程之间共享大数据可以了解multiprocessing.shared_memory。但也要注意共享内存本身有同步和生命周期管理成本不是所有场景都值得。5.3 什么时候该谨慎使用多进程并不是所有 CPU 密集任务都能获得理想加速。下面这些情况多进程实际收益可能很小任务本身非常小调用开销占比高。任务数量远大于核数导致频繁排队和切换。任务之间依赖关系很强必须频繁通信。每个任务需要加载大量前置数据加载时间远大于计算时间。子进程需要访问复杂的不易序列化的对象。我之前遇到一个案例用多进程处理 1 万个小文件的格式转换结果比单线程还慢。后来把 100 个文件合并成一个批次再交给进程速度才稳定下来。原因是单个文件处理只需要几毫秒但进程池分发和结果回收的开销比任务本身还大。6. 接到任务时如何做技术选型从判断类型到小样本验证6.1 第一步判断任务是 CPU 密集还是 I/O 密集一个很简单的判断方法把任务里所有等待外部响应的部分去掉看剩下的计算量到底大不大。如果你想跑 100 个 HTTP 请求等响应的时间占 90%那这是 I/O 密集适合多线程或异步。如果你要对 100 万个字符串做复杂解析解析本身就要消耗大量 CPU那这是 CPU 密集适合多进程。更细一点说可以看任务的“阻塞等待时间”和“CPU 运算时间”的比例。等待时间明显大于运算时间优先考虑多线程运算时间明显大于等待时间优先考虑多进程。6.2 第二步先看底层库能不能自己释放 GIL如果你的计算任务用的是numpy、pandas、PIL、opencv这类带 C 扩展的库情况会不一样。它们内部在耗时计算时可能会释放 GIL所以多线程有时也能跑出不错的效果。这并不意味着你不需要了解 GIL。相反你更需要先做实验。我见过不少项目看到多线程没加速就断定是 GIL 问题结果换了多进程后反而更慢。真正原因可能是任务太小进程间通信成为瓶颈。技术选型不要靠猜测。你至少要跑一个最小实验记录单线程、多线程、多进程三种方案的耗时和资源占用再决定用哪一套。6.3 第三步组合使用线程和进程实际项目中任务常常混合了 I/O 和计算。比如一个文件处理流程先读取文件再对内容做复杂计算最后写回结果。这时候可以这样设计外层用多线程处理文件读取和写入因为 I/O 等待适合线程并发内层用一个进程池处理复杂计算因为计算需要真正的多核并行。二者通过队列或执行器串起来就能同时吃满 I/O 吞吐和多核算力。6.4 第四步用小样本先压测不管选哪个方案我都建议先拿一个小样本跑通流程再逐步扩大到完整数据量。所谓小样本不是让你只跑一次而是跑一个包含 5 到 10 个典型任务的小批次分别记录单条任务的平均耗时。并发数设置下总耗时。CPU 核心占用。内存峰值。失败任务数量和对应原因。把这些数据记录下来再决定要不要加并发。不要一开始就把 worker 数开到最大。比如机器有 16 个核进程数不是只能开 16但也不是越大越好。超过物理核心数后进程切换反而会拖慢速度。对纯计算任务通常先按物理核心数设置 worker再根据实测结果微调。7. 调试和观测并发任务到底跑在哪一个环节7.1 用统一的时间测量写并发程序时我习惯给测试脚本加一个简单计时器保证每次对比都在相同条件下进行。import time def timing(func): def wrapper(*args, **kwargs): start time.perf_counter() result func(*args, **kwargs) print(f{func.__name__}: {time.perf_counter() - start:.3f}s) return result return wrappertime.perf_counter比time.time更适合做精确耗时统计因为它不受系统时钟调整影响。7.2 观察日志和任务进度多进程的日志容易混在一起建议每个 worker 在日志里带上进程 ID 或任务编号。Python 的multiprocessing模块可以直接看到当前进程名称。import multiprocessing import os def work(task_id): pid os.getpid() print(ftask {task_id} 正在进程 {pid} 中执行) return task_id如果任务长时间没有输出优先排查三个位置输入数据是否被正确分发worker 是否在等待主进程数据以及是否有进程直接崩溃但没有留下异常。7.3 区分“功能正常”和“性能正常”并发程序最常见的问题是功能上任务都跑完了没有报错但整体性能没有提升。这时候不要急着改并发数。先看 CPU 占用和各阶段日志如果 CPU 占用一直很低说明任务大部分时间在等待 I/O 或数据传递。如果 CPU 占用已经很高但总耗时还是很长说明任务量本身太大并发模型没有问题需要从算法和库层面优化。如果多个核心占用不均衡说明任务分配不均需要调整任务切分粒度。我见过一个团队多进程跑批处理任务发现 16 个核里只有 2 个核在满负荷工作。最后排查发现他们把一个大任务分成了不均匀的片段大部分 worker 很快跑完只有一小部分 worker 在长时间计算。解决方案是改成动态任务队列让空闲进程可以从队列里取新的小任务。7.4 常见错误排查顺序如果你遇到“并发后反而更慢”的问题我建议按这个顺序排查先确认任务类型。是 CPU 密集还是 I/O 密集。再确认数据量。单个任务太多还是太少。再看序列化成本。传输的数据是不是过大。再看进程池配置。worker 数是否超过物理核心数很多。最后看第三方库。它是否已经释放 GIL还是必须用多进程。查看日志和系统监控确认资源占用发生在哪个阶段。很多时候问题不是出在“GIL 没有移除”而是出在“任务粒度不合适”或“数据传递太慢”。8. 关于 GIL 的未来现在该怎么选8.1 Python 3.13 的自由线程构建是怎么回事Python 3.13 引入了实验性的自由线程构建也就是允许关闭 GIL 的版本。这个方向确实值得关注但目前它仍然是一个需要单独选择编译方式的功能并非所有第三方包都验证过在新模式下的稳定性。如果你使用的是常见数据分析库、Web 框架或深度学习框架建议先关注这些库对自由线程的兼容性声明。不要在没有验证的情况下把生产环境切到实验性构建上。8.2 现在该不该等 GIL 移除我的观点是可以用新特性做实验和研究但正式项目不要因为“以后可能没有 GIL”而改变当前方案。如果你现在需要处理 CPU 密集型任务多进程依然是最稳妥的选择。它不依赖解释器内部实现不依赖第三方库是否适配自由线程在任何主流 CPython 版本上都能工作。如果你的任务主要是 I/O 密集多线程和异步依然有效。即使未来 GIL 被移除多线程在 I/O 场景下的收益也不会消失。8.3 我用下来最稳妥的几条经验最后整理几条实际项目里反复验证过的经验。第一先写单线程版本。单线程跑通且结果正确这是所有优化的大前提。很多并发问题其实是在“没有基线”的情况下叠加出来的结果出了问题都不知道找谁。第二不要迷信技术方案。4 进程不一定比 4 线程快。如果你的任务本身很小或数据传递成本很高多进程可能更慢。判断依据是小样本压测不是什么先进方法论。第三把耗时、CPU 占用、内存峰值、失败率记录下来。每次调整并发数或任务粒度时用同样的测试样本对比这样才能看到真正的变化。第四I/O 和计算混合的场景可以采用“多线程处理 I/O 进程池处理计算”的组合方式。不要只盯着某一种并发模型。第五学习阶段可以从threading的简单例子入手理解 GIL 的行为生产阶段再根据任务类型选择ProcessPoolExecutor或asyncio。理解 GIL 不是目的目的是搞清楚你手头的任务为什么慢以及应该把优化资源放到哪里。GIL 不是一道需要“破解”的墙更像是一块必须绕开的石墩。清楚它的边界之后你会发现 Python 的多线程和多进程各自都有明确用途。CPU 稠密计算走多进程I/O 等待走多线程复杂管线就组合着用关键是把每次调整都建立在可重复的实测结果上。
返回列表