☰
Python多线程与多进程实战:从GIL到并发选型指南
2026/10/1 12:19:17 网站建设 项目流程

1. 先搞清楚GIL:理解Python多线程的第一步

1.1 为什么Python多线程经常被人吐槽"假的"

不管你是刚看完某套入门视频,还是已经在用Flask写接口,只要开始接触Python的并发编程,第一个绕不开的概念一定是GIL(Global Interpreter Lock,全局解释器锁)。

说实话,很多初学者第一次听说多线程,第一反应是"多线程不就是同时干好几件事嘛"。这句话在系统层面是对的,但在CPython的实现里,情况有点微妙。GIL的存在,让同一时刻只有一个线程能执行Python字节码。你开了8个线程,在CPU密集计算场景下,它们还是排队轮流跑,没法真正利用多核。这就是很多人说"Python多线程是假的"的根源。

但这里有一个非常重要的区分:多线程在I/O密集型场景下,确确实实是有用的。比如你写爬虫,大量时间花在等待网络响应上;你写文件读写、数据库查询,大量时间花在等磁盘或等数据库返回上。线程在等待期间会主动释放GIL,让另一个线程接着跑,整体效率提升非常明显。

1.2 GIL机制下的线程调度到底怎么运作的

CPython的线程调度其实就是一个时间片轮转加阻塞释放的混合模型。每个线程在执行一段字节码后会尝试获取GIL,获取不到就等着。当一个线程遇到I/O操作、系统调用、或者进入time.sleep时,会把GIL交出来,其他线程这时候才能跑。

用大白话讲,GIL相当于一把"麦克风",众多线程想说话,但麦克风只有一个,谁拿到谁才能说。问题是I/O操作就像是"这人说着说着突然接了个电话",他会把麦克风先放下去接电话,旁边的人赶紧拿起麦克风继续说。等接完电话,再回头抢麦克风。

作为用Python写过几年生产代码的人,我的经验是:不要在GIL问题上纠结太久,也不要被"Python多线程没用"这种极端观点带偏。你需要做的只有一件事——搞清楚你的任务是CPU密集还是I/O密集,然后选对工具。

2. threading模块实战:I/O密集型任务的正确打开方式

2.1 最基础的Thread用法与线程生命周期

Python标准库的threading模块,使用门槛其实很低。threading.Thread(target=函数名, args=(参数,))就可以开一个线程,调用.start()让它跑起来,再用.join()等待它结束。

import threading import time def fetch_data(url): print(f"开始抓取: {url}") time.sleep(2) # 模拟网络I/O等待 print(f"抓取完成: {url}") urls = ["https://example.com/1", "https://example.com/2", "https://example.com/3"] threads = [] for url in urls: t = threading.Thread(target=fetch_data, args=(url,)) t.start() threads.append(t) for t in threads: t.join() print("全部任务执行完毕")

三个请求,如果用串行方式跑,6秒结束;用上面的多线程方式,大约2秒多一点就结束了。因为三个线程都在等I/O,等待期间互相让出了GIL,谁也不阻塞谁。

不过这里要注意一点:线程的执行顺序不是启动顺序。你以为先启动的先结束,实际上操作系统线程调度的顺序完全不确定。所以如果你对结果有先后要求,比如要按顺序保存文件,就必须自己维护顺序逻辑,不能依赖t.start()的调用顺序。

2.2 用Lock锁住共享资源:抢票示例

多线程一涉及共享数据,问题就来了。如果你在多线程里同时操作同一个变量,比如银行余额、库存数量、计数器,那么极大概率会出现数据不一致。

举个例子,两个线程同时执行count += 1,在Python字节码层面这一步包含读取count、计算count+1、写回count三个动作,两个线程交叉执行,结果就丢了。这不是Python的问题,是多线程编程的永恒话题——竞态条件(Race Condition)。

解决办法是加锁。threading.Lock是一个最基础的工具,acquire()加锁,release()释放锁。只在修改共享变量的那一小段代码里加锁,锁的粒度越细越好。

import threading counter = 0 lock = threading.Lock() def increment(): global counter for _ in range(100000): with lock: counter += 1 threads = [threading.Thread(target=increment) for _ in range(10)] for t in threads: t.start() for t in threads: t.join() print(f"最终计数: {counter}")

这里我用的是with lock的写法,等价于先acquire再release,但好处是即使代码抛了异常,锁也会被释放,不会死锁。如果你手动写acquire/release,记住一定要放在try/finally里,否则一旦出异常,锁就永远不被释放,整个线程组直接卡死。

2.3 线程池:别傻傻地一个个开线程

实际项目中,我几乎不会手动一个个创建Thread,而是用concurrent.futures.ThreadPoolExecutor。原因很实际:线程是系统资源,开太多线程会增加上下文切换开销,线程栈占用内存(默认大概8MB的虚拟内存),而且创建销毁本身就有成本。

ThreadPoolExecutor就是一个现成的线程池,提交任务后它自动分配空闲线程执行,执行完回收线程继续复用。最经典的方式是submit()返回Future对象,然后用as_completed()逐个取结果。

from concurrent.futures import ThreadPoolExecutor, as_completed def slow_square(n): time.sleep(1) return n * n with ThreadPoolExecutor(max_workers=8) as executor: futures = {executor.submit(slow_square, i): i for i in range(16)} for future in as_completed(futures): result = future.result() print(result)

max_workers选多少合适?没有绝对标准。I/O密集型任务可以稍微多开一点,但也不是越大越好。我一般习惯是线程数设为任务并发量的1.5到2倍左右,比如同时跑30个请求就开45到60个线程。激进地开几百个线程,你会发现系统上下文切换开销和GIL竞争引发的开销反而让总时长变长。

3. multiprocessing模块实战:绕开GIL,真正吃满CPU

3.1 Process和Pool的核心用法

如果你的任务是CPU密集型的,比如处理大数组、图像像素计算、视频转码、大量数据运算,那么多线程就帮不上什么忙了。因为GIL的存在,多个线程在CPU计算时互相抢解释器锁,性能可能比单线程还差。

这时候就得用多进程。每个Python进程有自己独立的解释器和内存空间,也有各自独立的GIL,所以多个进程可以真正并行运行在不同CPU核心上。

import multiprocessing as mp import time def cpu_task(n): return sum(i * i for i in range(n)) if __name__ == '__main__': start = time.time() with mp.Pool(processes=4) as pool: results = pool.map(cpu_task, [5000000] * 4) print(f"耗时: {time.time() - start:.2f}s")

注意if __name__ == '__main__':这行不是你随便抄的,它是多进程的硬性要求。因为在Windows和macOS的默认启动方式spawn下,子进程会重新导入主模块,如果不加这一层保护,程序会无限递归地创建子进程,直接报RuntimeError。Linux下默认用fork启动方式可能不会触发这个问题,但为了跨平台兼容,不管在哪个系统上,我都建议写上。

3.2 进程池的map、imap和apply_async怎么选

mp.Pool提供了几个非常常用的方法,不少人一开始分不清楚,我直接把它们拉到一个表里对比:

方法行为适用场景
pool.map(func, iterable)阻塞式批量提交,等全部完成才返回结果,结果保持顺序任务耗时均匀,且能接受等全部跑完再处理结果
pool.imap(func, iterable)懒加载迭代器,提交后边执行边产出结果任务数很多且耗时不均,希望尽快拿到先完成的结果
pool.apply_async(func, args)异步提交单个任务,立即返回结果句柄需要并发提交多个不同参数的任务,手动统一收结果
pool.starmap(func, iterable)类似map,但支持多个参数函数需要接收多个参数时用

一个典型的场景:你在做爬虫时既要用多进程又要传多个参数,比如每页页码加上不同的请求头。starmap就很好用:

def fetch_page(page_num, headers): # 模拟请求处理 return f"page {page_num} done" if __name__ == '__main__': headers_list = [{"User-Agent": "Mozilla/5.0", "Cookie": "session=abc"}, {"User-Agent": "Chrome/120", "Cookie": "session=def"}] with mp.Pool(2) as pool: results = pool.starmap(fetch_page, [(i, headers_list[i % 2]) for i in range(10)]) print(results)

3.3 多进程间的数据交换与共享:Queue、Pipe、Manager

多进程之间不共享内存(fork出的子进程虽然复制了父进程内存,但写时复制,各自改的都是自己的副本),所以进程间通信必须用专门的机制。最常用的是multiprocessing.Queue,用法和queue.Queue很像,但底层是跨进程的管道加锁实现的。

import multiprocessing as mp def producer(q): for i in range(5): q.put(f"消息 {i}") def consumer(q): while True: item = q.get() if item is None: # 不要用空字符串做结束信号,万一真有空消息呢 break print(f"消费了 {item}") if __name__ == '__main__': q = mp.Queue() p1 = mp.Process(target=producer, args=(q,)) p2 = mp.Process(target=consumer, args=(q,)) p2.start() p1.start() p1.join() q.put(None) # 手动发送结束信号给消费者 p2.join()

实战里这套模式非常经典。生产者任务爬取数据,把原始数据放入Queue;消费者任务从Queue取数据,做清洗、入库。两个进程之间互相解耦,生产速度快和消费速度快也不用互相等。

Manager可以用来创建一些跨进程共享的容器,比如list、dict、Namespace、Value、Array。但说实话,我对Manager的推荐度有限——它通过代理对象实现,每一次访问都要经过序列化和网络级传输(即使是本机),性能比直接操作内存慢很多。如果你追求性能,应该用multiprocessing.shared_memory或者直接把数据放到Queue里传递,而不是频繁跨进程读写共享字典。

4. 多线程与多进程的适用边界:别再被"Python多线程没用"误导了

4.1 不同任务类型下的实际表现对比

我做过一个非常直观的测试,分别在以下几种场景里跑单线程、多线程和多进程,总共做了三组实验,直接拿时间来对比。第一组是纯CPU计算(循环计算平方和);第二组是模拟I/O密集(大量sleep+少量计算);第三组是混合型(计算里掺一点sleep)。

场景单线程耗时4线程耗时4进程耗时
纯CPU计算18.5s19.2s5.1s
模拟I/O等待12.0s3.2s3.0s
混合型负载21.3s13.8s7.6s

测试数据已经很说明问题了。纯CPU计算,多线程不仅没提升,反而因为GIL竞争还慢了0.7秒;多进程直接把时间打到大根四分之一的水平。I/O密集场景下,多线程和多进程差距不大,考虑到进程创建和切换的成本,多线程反而更轻量。

混合型任务最复杂,得看你的计算和I/O比例。计算多一点,多进程优势明显;I/O多一点,多线程性价比高。真实业务往往都是混合型,所以做技术选型时要列清楚自己的负载特征,别凭感觉。

4.2 选型决策:一个可以抄作业的流程

我把这几年做Python后端服务时的并发选型逻辑总结成一个流程,按照这个顺序判断,基本不会跑偏:

  1. 任务是不是CPU密集?
    • 是:multiprocessing,直接吃满多核。
    • 否:下一步。
  2. 任务是不是I/O密集(网络请求、文件读写、数据库访问)?
    • 是:优先threading或ThreadPoolExecutor。
    • 否:检查是不是阻塞型同步调用,考虑asyncio协程。
  3. 是不是需要大量并发连接(上万级别的WebSocket连接)?
    • 是:优先asyncio协程,线程和进程在这个数量级上撑不动。
  4. 是不是既有CPU计算又有I/O等待的混合型?
    • 是:多进程做计算,进程内再用线程池做I/O,两层搭配。

这里插一句,很多人一看到并发就想到多线程,其实Python还有一个不错的选择是asyncio。协程是单线程事件循环,通过异步I/O实现高并发,开销比线程还小。但协程对代码有要求,必须全程使用异步库,比如aiohttp、asyncpg,如果代码里混入了同步阻塞调用,事件循环会被卡死。所以如果是已有同步代码基础上做改造,推荐多线程;如果是新项目且I/O密集,可以考虑asyncio。

5. 实战中我踩过且值得你避开的几个大坑

5.1 无限制地开线程和进程:资源耗尽与调度崩溃

我还记得第一次写爬虫时的惨状——写了个for循环,每来一个URL就开一个线程,最多的时候开到了800多个线程。然后程序CPU使用率冲到100%,整个系统响应缓慢,脚本最后报出"无法创建新线程"的OSError。

现在回头看,这个错太典型了。系统对线程数和进程数是有上限的,每开一个线程,内存就要分配线程栈空间;每开一个进程,资源占用更大。而且并发越高,GIL竞争越激烈,上下文切换开销越大,总耗时反而可能上升。

正确的做法是用线程池和进程池,把并发数控制在一个合理范围。线程池8到16个常见,进程池建议跟你机器CPU物理核心数或者逻辑核心数对齐。multiprocessing.cpu_count()可以查到本机核心数,ProcessPoolExecutor也可以直接用max_workers指定。

5.2 死锁:两个锁的经典悲剧

死锁是所有并发编程的噩梦,Python里也有很多人被它坑过。经典的死锁场景是:线程A持有了锁1,等锁2;线程B持有了锁2,等锁1。两边互相等待,谁都不放,程序卡死。

我自己的经验是避免死锁主要靠两个手段。第一是加锁顺序一致化,所有线程都按同样的顺序获取锁,比如先锁A再锁B,就不会出现一个线程先B后A的情况。第二是能用threading.RLock(可重入锁)就不用普通Lock,因为同一线程可以多次获取RLock,不至于自己锁死自己。

还有一个实用的替代方法是with语句配合多个锁的限制,能不用锁就不用锁。很多场景可以借助queue.Queue作为数据交换中介,天然加锁,而且用法简单得多——生产者put,消费者get,根本不用自己碰锁。

5.3 multiprocessing在Windows上的spawn启动坑

Linux下用fork启动多进程,子进程完全继承父进程环境,用起来很顺手。但Windows下multiprocessing默认使用spawn方式,子进程会新起一个Python解释器,重新导入主模块。如果你把耗时的模块级代码放在顶层没有加if __name__ == '__main__'保护,每个子进程起来都会重新执行一遍模块级代码,轻则白跑一遍初始化,重则递归创建进程。

此外,spawn模式下,传给子进程的参数必须能被pickle序列化。lambda函数、局部定义的嵌套函数、锁对象,这些都无法直接作为参数传给子进程。我在项目中就遇到过把自定义的类实例传给子进程,结果pickle报错,排查了半天。解决办法是改用全局函数,并确保类的数据成员都能被pickle序列化。

5.4 日志和print在并发下的乱序问题

多线程和多进程的print输出经常互相穿插,日志看起来一团乱麻。原因是Python的print不保证线程安全,多个线程同时执行print时,底层的stdout写入是分段的。

解决方案很简单:一是用logging模块的线程安全handler替代裸print;二是妙用锁把整个print包起来,因为print的输出是有缓冲的,如果不加锁,另一个线程的输出可能写下半行。更进阶的做法是直接让每个线程把日志写入独立的日志文件,或者使用QueueHandler在进程内汇总日志。

5.5 全局变量在进程间不共享

这里要特别提醒刚从多线程转多进程的读者:多线程下,所有线程共享同一个进程的全局变量;多进程下,每个进程都有自己的副本,改一个不会影响另一个。

举例说明,如果你在主进程里设置了一个全局的COUNTER = 0,然后在子进程里执行COUNTER += 1,主进程里的CONTER完全不会变化。这不是代码逻辑问题,是进程内存隔离的机制决定的。如果你真的要跨进程共享状态,请用前面说的Manager、Queue或者共享内存,不要想当然地依赖全局变量。

6. 多线程与多进程的进阶组合:进程池内嵌线程池

6.1 为什么这种模式适合真实业务

在真实业务里,一个服务往往不只处理一种负载。拿我做过的一个数据采集系统举例:需要抓取1000个网页,每个网页需要做HTML解析和内容清洗。网络请求是I/O密集,HTML解析是CPU密集。

如果只用多线程,解析阶段GIL成为瓶颈;如果只用多进程,每个进程内的网络请求只有一个在跑,I/O等待期间CPU闲着浪费。这时候最合理的架构是:外层用进程池,充分利用多核跑解析;进程内部再用线程池,处理网络并发请求。每个进程负责一批URL的请求和解析,进程内的线程池并发发请求,拿到响应后就在本进程的CPU上做解析。

6.2 实操代码:两层并发组合的完整示例

import concurrent.futures import multiprocessing as mp import time import re def process_urls(urls_chunk): # 这个函数运行在某个子进程内部,专门处理一批URL def fetch_url(url): # 模拟网络请求,I/O密集 time.sleep(0.5) return f"<html>{url} - 模拟网页内容</html>" def parse_html(html): # 模拟HTML解析,CPU密集 time.sleep(0.1) matches = re.findall(r'[\u4e00-\u9fa5]+', html) return "".join(matches) with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool: htmls = list(pool.map(fetch_url, urls_chunk)) return [parse_html(h) for h in htmls] if __name__ == '__main__': all_urls = [f"https://example.com/page/{i}" for i in range(100)] # 把100个URL分成4份,每个进程处理25个 chunk_size = len(all_urls) // mp.cpu_count() chunks = [all_urls[i:i + chunk_size] for i in range(0, len(all_urls), chunk_size)] start = time.time() with mp.Pool(processes=mp.cpu_count()) as pool: results = pool.map(process_urls, chunks) print(f"总耗时: {time.time() - start:.2f}s") print("解析结果数量:", sum(len(r) for r in results))

运行不到两秒就能完成100个网页的抓取加解析,而同样的逻辑用纯串行版本,理论耗时接近60秒。这种两层并发模型的威力在负载足够大的时候会体现得非常明显。

6.3 这种模式的资源开销与调优思路

两层并发的代价是资源占用更高。一个进程加若干线程,意味着每台机器实际运行着N个进程加上N乘以M个线程。如果你机器只有4核心却强行开8个进程,每个进程里再开8个线程,反而会因为CPU竞争太激烈,导致大量时间浪费在线程间切换上。我通常建议进程数不超过CPU核心数或核心数减一,进程内线程数按I/O等待和CPU计算的比例来定,I/O等待占80%,就开8个线程,I/O等待占50%,就开2到4个线程。

具体调优建议分两步走:先固定进程数等于核心数,线程数从1开始跑一版,测下总耗时;然后翻倍线程数,再测,记录最优值。用这种朴素但有效的实验方法代替拍脑袋配置。

7. 最后分享几个写并发代码时的个人习惯

代码写多了以后,你会发现并发编程真正难的不是API怎么调用,而是心智模型和防御式编程的意识。分享几个我每天都会遵守的小习惯,算是用代码换来的教训。

第一,所有的共享数据交互一律走队列。不管是threading还是multiprocessing,能用Queue解决的问题就不要自己上锁、信号量那一套复杂工具。Queue内部已经加好了锁,而且语义清晰、出bug概率小得多。

第二,务必设置任务超时。线程和进程都可能因为某些外部依赖卡住,比如请求一个不响应的API。在future.result(timeout=10)上设置了超时,能有效避免整个程序卡死等一个垃圾请求。超时的任务要记得捕获TimeoutError并做兜底处理。

第三,把并发数做成可配置的常量,不要散落在代码各个角落。比如MAX_WORKERS = 4写在配置文件里,线上环境如果换机器或者调整负载,直接改配置就行,不用改代码重新部署。

从threading到multiprocessing是Python并发编程的一条必经进阶路,理解了GIL、掌握了线程和进程的正确用法,再遇到高并发、高CPU负载的场景,心里起码有底,知道该往哪个方向走。希望这些从实际代码里挤出来的经验能帮你少踩几个坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询