拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Python并发编程:GIL、多线程与多进程的实战抉择

1. 先搞明白GIL 到底锁住了什么我第一次接触 Python 多线程的时候心里想的是“这玩意儿不就该跟 Java 一样开几个线程就能吃满多核吗”结果写完一跑CPU 占用率死活上不去还时不时被身边同事来一句“Python 多线程就是假的”。这话对但不全对。要搞清楚 Python 多线程什么时候真、什么时候假核心就绕不开 GIL 这三个字母也就是 Global Interpreter Lock全局解释器锁。GIL 是 CPython 解释器也就是官方 Python 实现里的一个机制它保证同一时刻只有一个线程能执行 Python 字节码。换句话说你写了十个线程在某个瞬间真正在跑 Python 代码的只有一个线程。其他线程都在等锁。这跟你电脑是 8 核 16 线程没关系因为 Python 解释器本身把自己“单核化”了。但这里有个关键点GIL 锁的是“执行 Python 字节码”不是锁整个进程。这意味着 I/O 操作、网络请求、文件读写这类会主动让出解释器的操作多线程还是有用的因为线程在等待 I/O 的时候GIL 会被释放其他线程可以继续跑。所以你会发现爬虫用多线程提升明显但纯 CPU 计算的程序用多线程反而可能更慢。慢的原因也好理解线程切换本身有开销加上 GIL 的竞争两个线程抢一个锁抢来抢去的时间都够单线程跑完了。我见过不少初学者上来就写这种代码import threading def count(n): while n 0: n - 1 # 试试两个线程跑 t1 threading.Thread(targetcount, args(100_000_000,)) t2 threading.Thread(targetcount, args(100_000_000,)) t1.start(); t2.start() t1.join(); t2.join()然后发现两个线程跑完的时间竟然比单线程还长就一脸懵。这就是典型的“计算密集型任务用多线程”在 Python 里属于标准反模式。非要这么用你得做好“线程一多反而更慢”的心理准备。那是不是说 Python 多线程就一无是处也不是。你得先搞清楚任务的类型是 CPU 密集还是 I/O 密集。这个分类直接决定了你该用多线程、多进程、还是协程。后面的内容我会把这几条路都拆开讲但前提是你得先把 GIL 这个底层机制刻在脑子里。2. 多线程、多进程、异步三兄弟各有分工2.1 多线程I/O 密集场景的真香选择多线程在 Python 里的定位实际上是并发不是并行。并发指的是多个任务在同一时间段内交替执行看起来像同时在进行并行才是同一时刻真的在多个 CPU 核心上同时执行。GIL 决定了 CPython 的多线程只能做到并发不能做到并行。但并发对 I/O 密集场景已经足够了。什么是 I/O 密集就是程序大部分时间都在等待外部资源比如网络请求爬虫抓取网页、调用 API数据库读写文件读写用户输入这些操作的特点是速度快不起来因为瓶颈在网速、磁盘速度、数据库服务端而不在 CPU。线程把请求发出去之后绝大多数时间在等响应这段等待时间里 GIL 是释放的其他线程就能拿到执行权。所以多线程在这种情况下能显著提高吞吐量。我给你一个直观的例子。用单线程发 100 个 HTTP 请求每个请求耗时 0.5 秒总耗时差不多 50 秒。用 10 个线程并发发理想情况总耗时能压到 5 秒左右实际会稍微差点但量级差距摆在那。这里要提醒一句Python 的threading模块里的Thread类起线程和切线程是有开销的。如果你要做几千上万个 I/O 任务建议用线程池concurrent.futures.ThreadPoolExecutor而不是无脑开几千个线程。线程太多会导致操作系统调度压力大效果反而变差。一般线程池大小设成 I/O 任务实际并发数的 2 到 5 倍就够了具体要看你的瓶颈在哪。2.2 多进程唯一能绕开 GIL 走并行路线的方案多进程就完全是另一套逻辑了。每个进程都有自己独立的 Python 解释器和内存空间所以每个进程都有自己的 GIL互不干扰。这意味着你开 4 个进程跑 CPU 密集型任务在 4 核机器上真的能实现接近 4 倍的加速。但多进程不是没有代价。最大的代价就是进程间通信IPC麻烦。线程之间共享内存一个全局变量多个线程都能读写进程之间内存隔离你要传数据就得用Queue、Pipe、Manager之类的工具这些工具底层涉及序列化和反序列化有额外开销。另外进程的创建和销毁比线程重得多所以你不能像开线程那样频繁地开进程。Python 里多进程最常用的入口是multiprocessing模块还有包装得更友好的concurrent.futures.ProcessPoolExecutor。用起来跟线程池几乎一样内部实现完全不同。from concurrent.futures import ProcessPoolExecutor def cpu_task(n): return sum(i * i for i in range(n)) with ProcessPoolExecutor(max_workers4) as executor: results list(executor.map(cpu_task, [10_000_000] * 8))这段代码在 4 核机器上会比单进程快很多。但有个坑ProcessPoolExecutor提交的任务函数必须是可通过 pickle 序列化的也就是模块级别的函数不能是 lambda不能是局部函数。我见过有人在这上面卡半天local function 传进去直接报AttributeError这属于多进程最容易踩的坑之一。还有一点要特别注意在 Windows 上使用多进程入口代码必须放在if __name__ __main__:保护块里否则会无限递归创建子进程。Linux 上因为 fork 机制问题不明显但为了代码可移植建议所有平台都加上这个保护。2.3 异步编程轻量级的并发方案但别乱碰除了多线程和多进程Python 还有一种“并发”手段异步编程也就是asyncio。它的本质是单线程内通过事件循环实现任务切换完全不依赖操作系统线程所以又叫协程。异步适合的场景跟多线程高度重合都是 I/O 密集。但异步的优点是开销更小一个协程的开销比一个线程小几个数量级你可以轻松管理几万个并发任务。但异步的缺点也很明显代码写法不直观。你要用async def定义函数用await等待结果还得理解事件循环的工作方式。而且异步代码里不能出现阻塞调用比如你不能直接在里面用time.sleep()得用await asyncio.sleep()不能直接用requests库得用httpx.AsyncClient或aiohttp。一旦你在异步代码里写了阻塞调用整个事件循环都会被卡住所有并发任务都会失去意义。这里我不展开讲 asyncio 的细节但你要记住一个选型原则如果你已经在用多线程处理 I/O 密集任务且并发量不算离谱几百到几千不一定非要换异步如果你要做高并发上万连接、追求极致性能那就该考虑asyncio或者直接上多进程 异步的组合。3. 实战验证用真实代码看多线程和多进程的差距光说不练假把式。下面我用几个具体的例子带你直观感受不同方案的性能差异。这些代码都是我实测过的你直接抄去跑就行注意别把 CPU 核心数设得太高小心电脑卡死。3.1 测试环境说明我的测试机器是 8 核 16 线程的 CPUIntel i7-10750H操作系统是 Windows 11Python 版本是 3.11.4。不同环境下跑出来的绝对时间会有差异但相对差距是稳定的。3.2 CPU 密集型多线程完败多进程完胜import time import threading from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor def heavy_calc(n): 模拟 CPU 密集型任务大量数学运算 total 0 for i in range(n): total i * i return total def run_single(): start time.perf_counter() results [heavy_calc(5_000_000) for _ in range(4)] print(f单线程耗时: {time.perf_counter() - start:.2f}s) def run_thread_pool(): start time.perf_counter() with ThreadPoolExecutor(max_workers4) as executor: results list(executor.map(heavy_calc, [5_000_000] * 4)) print(f线程池耗时: {time.perf_counter() - start:.2f}s) def run_process_pool(): start time.perf_counter() with ProcessPoolExecutor(max_workers4) as executor: results list(executor.map(heavy_calc, [5_000_000] * 4)) print(f进程池耗时: {time.perf_counter() - start:.2f}s) if __name__ __main__: run_single() run_thread_pool() run_process_pool()我实测的结果是方案耗时秒单线程6.52线程池4线程9.84进程池4进程1.87看到没线程池比单线程还慢了将近 50%这就是 GIL 竞争加上线程切换的代价。而进程池直接把耗时从 6.52 秒压到了 1.87 秒接近 3.5 倍加速说明 4 个进程确实能并行用上 4 个核心。3.3 I/O 密集型多线程和多进程都能提速但多线程更轻量import time import urllib.request from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor URLS [https://www.example.com] * 10 def fetch(url): with urllib.request.urlopen(url, timeout5) as resp: return resp.status def run_thread_pool(): start time.perf_counter() with ThreadPoolExecutor(max_workers5) as executor: results list(executor.map(fetch, URLS)) print(f线程池耗时: {time.perf_counter() - start:.2f}s) def run_process_pool(): start time.perf_counter() with ProcessPoolExecutor(max_workers5) as executor: results list(executor.map(fetch, URLS)) print(f进程池耗时: {time.perf_counter() - start:.2f}s)我个人测试的结果是线程池耗时约 2.1 秒进程池耗时约 2.3 秒网络波动会影响结果。两者差距不大因为瓶颈在网络 IO 上GIL 压根不是问题——线程在等网络响应的时候已经把 GIL 交出去了。但有个细节值得注意多进程在这个场景下需要额外的 IPC 开销因为它要把任务参数和结果序列化后在进程间传递。虽然对 10 个小任务来说差异不明显但如果任务是海量小任务多进程的序列化开销会逐渐显现出来。所以纯 I/O 密集场景多线程比多进程更合适。3.4 混合场景数据量大到内存爆炸怎么办有一种很尴尬的场景任务本身是 I/O 密集但中间会产生大量内存数据数据量大到单个进程的内存装不下。比如你从数据库读 100GB 数据做处理单进程内存直接爆炸。这种情况你只能用多进程每个进程处理一部分数据各自占用独立内存空间。代价是需要自己设计数据的分片和合并逻辑。如果你用的是multiprocessing.Pool可以配合map函数实现“数据分片 - 进程池并行处理 - 收集结果”的流程。但如果每个进程返回的数据量特别大Queue 传输数据会成为新的瓶颈这时候通常会把处理结果直接写到磁盘比如数据库、文件而不是传回主进程。4. 避坑指南多线程和多进程的常见翻车点4.1 共享状态与数据竞争多线程共享内存意味着多个线程能同时读写同一个变量。如果不加锁会出现数据竞争问题。Python 的threading.Lock可以用但要小心死锁。多进程没有共享内存但它有自己的坑如果你用multiprocessing.Manager或者Value、Array这类共享内存工具同步成本更高而且操作不当会出各种奇怪问题。我见过有人用Manager().list()存几百万条数据结果程序慢到怀疑人生因为每次读写都要经过进程间的同步和序列化。我的建议是尽量避免共享状态。能用“分治—汇总”模式解决的问题就不要让多个线程/进程去改同一个变量。比如统计词频可以让每个线程先算自己的局部字典最后再合并。4.2 Windows 下的多进程限制在 Windows 上运行multiprocessing的代码没有if __name__ __main__:包裹会怎样直接给你安排一场“无限子进程生成”的灾难最后把系统资源吃光甚至蓝屏。这个错误我当年踩过惨不忍睹。另外 Windows 下多进程的启动方式默认是spawn每次创建子进程都会重新导入主模块所以主模块顶部的代码会被执行多次如果顶部有耗时操作会明显拖慢启动速度。Linux/macOS 下默认是fork子进程直接复制父进程的内存镜像启动更快但代价是某些资源比如文件句柄、锁状态会被继承处理不好会出问题。4.3 锁的粒度与死锁问题多线程编程里锁的粒度是个技术活。锁的范围太大相当于把多线程退化成了单线程锁的范围太小又防不住数据竞争。而且在锁里嵌套其他锁很容易死锁。一个规避死锁的技巧始终按相同的顺序获取多个锁。比如所有代码都先获取锁 A再获取锁 B就不会出现线程 1 持 A 等 B、线程 2 持 B 等 A 的循环等待。还有个更省心的方案直接用queue.Queue它是线程安全的内部已经处理了加锁逻辑。多线程任务之间传数据优先用队列别自己裸写共享变量。我在爬虫项目里永远用队列做任务分发和结果收集没出过线程安全问题。4.4 进程池与线程池的选择复用才是关键我强烈建议能不用裸Thread和裸Process就尽量用池。因为频繁创建和销毁线程/进程的开销非常大。ThreadPoolExecutor和ProcessPoolExecutor是 Python 3.2 自带的并发工具接口几乎一模一样方便你切换测试。它们使用起来几乎没有学习成本却自动解决了线程/进程复用的问题。不过要注意ProcessPoolExecutor的max_workers别超过 CPU 核数太多因为每个进程都是独立的 Python 解释器内存开销不小。假设每个进程占用 50MB 内存你一次性开 32 个进程就是 1.6GB。如果机器内存本身就紧张进程能给你跑得比蜗牛还慢。这时候要想清楚是任务需要更多并行度还是你机器配置扛不住。5. 决策框架到底该用哪个5.1 一个实用的选型流程我平时做并发选型遵循一个很简单的判断流程先判断任务类型是 CPU 密集还是 I/O 密集CPU 密集直接用多进程进程数不要超过 CPU 核心数。I/O 密集且规模不大几百到几千用多线程线程池很香。I/O 密集且规模巨大上万、数十万连接考虑asyncio。两者混合先分析瓶颈在哪一步结合各方案特点组合使用。比如爬虫中既有网络 I/O 又有 HTML 解析CPU 密集可以用多线程处理 I/O解析部分自行选择如果解析耗时占比高也可以改用多进程。当然还有一个隐藏判断维度开发效率和可维护性。如果项目工期紧、团队成员对异步不熟那多线程往往是最稳妥的选择。性能上可能不是极致但能按时交付、不出幺蛾子比什么都强。5.2 Python 的“最优解”不止一种关于并发我总喜欢说一句话每个方案都是好方案只要用对了场景。多线程在 I/O 密集场景很能打多进程是 CPU 密集型任务的救命稻草异步协程则适合超高并发场景。它们之间不是“谁取代谁”的关系而是互补关系。我见过有些团队对异步有一种迷之崇拜觉得asyncio就是万能解。结果代码写成印度飞饼事件循环里到处是阻塞调用性能反而不如老老实实的多线程。也见过有人对多进程抱有偏见认为它一定比多线程慢这也不对。脱离场景谈技术选型都是耍流氓。5.3 未来有没有可能取消 GILPython 社区这几年一直在讨论去除 GIL 的可能性比如 Python 3.13 里作为实验性特性出现的 free-threaded build不带 GIL 的构建版本。这个是热门话题但我的建议是别急着在生成环境里用。GIL 的去除会让大量现有 C 扩展模块面临线程安全问题很多底层库的兼容性风险短时间内难以消除。对普通开发者来说更实际的选择是用多进程绕开 GIL或者用numpy、pandas这类本身已经用 C 语言实现底层计算、会主动释放 GIL 的库来获得并行计算能力。6. 一个完整的并发爬虫实战案例前面都在讲理论最后我组合一个综合案例并发爬虫。这个案例里既有 I/O 密集网络请求又有 CPU 密集解析 HTML我演示一下怎么在实际项目里组合多线程。import re import time import urllib.request from concurrent.futures import ThreadPoolExecutor from urllib.parse import urljoin def fetch_html(url): 获取网页内容I/O 密集 req urllib.request.Request(url, headers{User-Agent: Mozilla/5.0}) with urllib.request.urlopen(req, timeout10) as resp: return resp.read().decode(utf-8, errorsignore) def extract_links(html, base_url): 从 HTML 中提取链接CPU 密集 return set(urljoin(base_url, href) for href in re.findall(rhref[\]([^\])[\], html)) def crawl_one(url): try: html fetch_html(url) links extract_links(html, url) return url, len(html), links except Exception as e: return url, 0, set() def main(): start_urls [ https://www.python.org, https://www.github.com, https://www.stackoverflow.com, https://www.wikipedia.org, https://www.reddit.com, ] with ThreadPoolExecutor(max_workers5) as executor: results list(executor.map(crawl_one, start_urls)) for url, size, links in results: print(f{url} - {size} bytes, {len(links)} links) if __name__ __main__: start time.perf_counter() main() print(f总耗时: {time.perf_counter() - start:.2f}s)这个案例里5 个 URL 用 5 个线程并发抓取。fetch_html是 I/O 密集在线程等待响应时会释放 GILextract_links是 CPU 密集但这里解析的数据量不大GIL 的影响可以忽略。如果你要把这个爬虫升级我会建议用asyncioaiohttp替换 urllib提高并发量用BeautifulSoup替换正则表达式解析更准确爬取的 URL 数量多时用queue.Queue做任务队列动态调度。在实际项目中我个人的体会是并发方案不要提前优化先跑通用数据说话。有时候你看着是 I/O 密集但实际运行后发现瓶颈在解析那就可能需要考虑多进程或者优化解析逻辑。Python 的并发世界并不复杂复杂的是搞清楚自己的任务到底卡在哪里。多花点时间做基准测试比在论坛里争论“多线程到底有没有用”靠谱得多。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门