
做Python开发的几乎都绕不开“并发”这两个字。我用Python写了十年后端从监控系统、爬虫平台到API网关都碰过GIL、多线程、多进程、协程这套东西算是踩坑踩出来的经验。很多人一提到Python高并发第一反应就是“Python有GIL多线程就是废的只能上多进程”这个结论对了一半但也会误导很多方案选择。你真正把GIL的切换机制、线程和进程各自的适用边界、协程在I/O密集场景下的优势摸清了会发现Python在并发这件事情上能玩的招数非常多完全不输其他语言。这篇文章我会从GIL的原理讲起再一步步拆解线程、进程、协程三种并发手段的选型逻辑然后给出几套可以直接抄的高并发实战方案包括线程池参数怎么定、异步框架怎么搭、压测时怎么定位瓶颈最后整理我这些年踩过的高并发坑。适合刚入门并发编程的新手也适合已经在项目里写了ThreadPoolExecutor或者asyncio、但总觉得性能不对的朋友参考。1. GIL原理挖掘为什么会锁锁在哪影响边界在哪1.1 GIL到底是什么为什么CPython非要有它GIL全称Global Interpreter Lock也就是全局解释器锁是CPython官方解释器里的一个互斥锁。它的核心约束就一句话同一个进程内的多个线程同一时刻只能有一个线程在执行Python字节码。换句话说CPython的多线程并不能真正利用多核CPU并行执行Python代码。这里必须先强调一个前提GIL是CPython的实现细节它不是Python语言本身的特性。JPython、IronPython这些实现没有GILPyPy官方也一直在围绕这方面做改进。但我们99%的线上环境跑的都是CPython所以讨论Python并发问题时GIL是绕不开的话题。那CPython为什么非要给自己戴一个这样的枷锁答案在内存管理机制里。CPython使用引用计数来管理对象的生命周期。每个Python对象都有一个引用计数器一旦计数器归零这个对象的内存就会被立刻回收。这套机制运行在单线程下非常高效、非常直观可一旦多个线程同时操作同一个对象问题就来了两个线程同时读取一个对象的引用计数同时判断它是不是0同时尝试释放内存极大概率会崩溃轻则内存泄漏重则double free直接进程崩溃。如果要在解释器层面给每个对象都加上细粒度的锁那设计难度和性能开销都太大了。于是CPython选了一条务实的路子在解释器入口加一把全局锁把整个解释器内部的多线程并发访问串行化。相当于一个房间里只有一个令牌谁拿到令牌谁才能干活其他人在外面等着。这样实现起来足够简单单线程的Python性能也不受任何影响代价就是多线程的并行能力被牺牲掉了。1.2 GIL的切换机制5毫秒的交替游戏既然GIL把多线程执行串行化了那多线程到底是怎么工作的答案就是“快速交替”。Python 3.2之后切换逻辑改为基于时间片每个线程大约执行5毫秒就会自动释放GIL给其他线程机会。于是宏观上看多个线程像是在同时跑微观上它们只是在轮流占用解释器。这里有个非常容易被忽略的细节如果线程在等待I/O操作比如网络请求正在等响应、文件正在读写它会主动释放GIL。这一点决定了GIL在实际场景中影响面的巨大差异——I/O密集场景下线程等待网络或磁盘的时间占比很高这些时间GIL处于空闲状态其他线程完全可以插进来执行所以多线程在I/O密集任务里效果很明显而纯CPU密集场景下线程压根不会主动让出切换完全靠5毫秒时间片硬切加上切换本身的上下文开销多线程不仅不加速反而可能更慢。我见过不少人喜欢调整sys.setswitchinterval想把时间片调小来“优化”但实测下来把这个值从0.005改到0.001线程切换频率确实高了但所有线程的上下文切换总开销也上去了整体吞吐反而下降。除非你有非常特殊的交互需求否则别动这个参数。1.3 GIL影响的实际边界哪些代码真的被锁住搞清楚GIL的切换机制之后我们就能画出它真正的影响范围了纯Python写的CPU密集计算比如大段循环、正则匹配、字符串处理被GIL死死锁住多线程几乎无法加速。很多C扩展会在执行底层计算时释放GIL。典型代表就是numpy、pandas、hashlib、zlib这些库重量级的数学计算发生在C语言层底层执行时GIL完全放开从而支持与Python线程并行执行。I/O密集任务GIL影响非常有限因为线程的大量时间都在等待外部资源GIL形同虚设。我自己最深刻的体会来自一个文本清洗服务。当时用16个线程跑同一批日志清洗任务全是正则和字符串替换CPU占用率死活上不到20%处理几十万条数据要40秒。后来改成四个进程并行处理同样数据量只需要8秒。五倍的差异就是GIL那个锁带来的。所以当你遇到“Python多线程不好使”的抱怨时第一反应要先区分场景是CPU密集的纯Python计算是C扩展内部的计算还是I/O等待这三个情况的正确答案完全不同。2. 并发与并行的工具箱线程、进程、协程到底怎么选2.1 先分清并发和并行一个厨师轮炒菜三个厨师同时炒聊工具之前必须先把两个基础概念理清楚。并发concurrency是指系统有能力同时处理多个任务但不一定同时执行更多是交错处理的意思并行parallelism是指多个任务真正同时执行依赖的是多核CPU同时开工。用一个餐厅的比喻并发就是一位厨师在三道菜之间来回切换每道菜炒几秒钟再去炒另一道到最后所有菜都能上桌但任何时刻锅台上只有一个锅在工作。并行就是三位厨师同时各炒一道菜硬件上有三个锅台同时运转。放到Python里threading多线程实现的是并发——因为GIL同一时刻只有一个线程能执行字节码multiprocessing多进程实现的是并行——每个进程有独立的Python解释器各自拥有一个GIL多个进程可以分别跑在不同CPU核心上真正同时执行。理解了这层差异选型逻辑就清晰多了。下面的表格说明我的常用选型思路任务类型典型场景推荐方案原因CPU密集纯Python日志清洗、规则引擎、加密算法多进程ProcessPoolExecutor绕开GIL真正利用多核CPU密集C扩展库numpy矩阵运算、哈希计算多线程即可或配合多进程C扩展执行时释放GIL线程已可并行I/O密集网络等待爬虫、接口调用、数据库访问协程asyncio或线程池等待阶段让出资源并发量极高密集长连接IM、WebSocket、消息推送协程异步框架单机可支撑数万连接内存消耗低2.2 线程轻量但别滥用I/O密集场景的真香工具threading是很多人第一接触的并发工具它最大的优势是创建成本低、线程之间可以直接共享内存变量程序写起来直观。在I/O密集场景下比如要同时请求一百个HTTP接口、批量查询数据库多线程的表现是完全够用的。但线程的坑不少。首先是数量问题虽然线程比进程轻量但它依然占用系统资源Linux上每个线程默认的栈空间就有8MB虚拟内存如果代码里无限创建线程到达几千个之后就可能出现内存暴涨甚至崩溃。我自己实测过在默认配置的容器里几千个线程同时存活就会出现无法创建新线程的错误。正确做法是用线程池把线程数量控制在一个合理的范围内。另一个坑是共享内存的竞争。多线程共享变量确实方便但那个变量如果不加锁保护两个线程同时读改写就会产出乱七八糟的结果。Python里用threading.Lock可以解决但要小心死锁两个线程各自持有一把锁又互相等待对方手里的锁程序就卡死了。一般我会在加锁的地方设置超时或者重组锁顺序避免这种问题。2.3 进程绕开GIL的正道但要付出序列化的代价multiprocessing是CPU密集场景的正解每个进程拥有独立的Python解释器和自己的GIL可以同时使用多个CPU核心。如果你的任务是纯Python写的大循环计算这种方案基本能实现线性加速。代价是进程之间不能共享内存数据传递必须序列化。你用multiprocessing.Queue向子进程派发任务时要小心Python的序列化包含把对象变成字节流的开销大量数据结构被反复拷贝来拷过去性能不一定理想甚至可能因为序列化太慢变成了瓶颈。对于大批量数据我的经验是尽量减少跨进程传输的粒度能一次传一批就绝不一条一条传能用文件共享或Redis中转就尽量用外部存储做交换。还有个经典的跨平台坑。multiprocessing在Linux上默认使用fork方式启动子进程就是复制父进程的整个内存空间在Windows和macOS上默认使用spawn方式会重新导入主模块执行。所以你的代码里如果有业务逻辑写在模块顶层在spawn模式下每个子进程启动时都会重新执行一遍这把业务逻辑轻则重复运行任务重则递归创建进程把自己的机器搞炸。解决办法就是彻底贯彻ifname main:这一行把所有入口代码都关进去。2.4 协程I/O密集型高并发的终极答案如果I/O密集场景里线程池是够用的方案那协程就是好得多的方案。asyncio基于事件循环核心思路是一个线程内部维护一个任务队列遇到I/O等待就把当前任务挂起、让出CPU给其他任务。因为I/O等待是物理层面的等待挂起后不占用任何CPU所以协程可以把并发量拉到几万而不带来内存压力这在长连接、高频调用的场景里优势尤其明显。协程最大的禁忌是阻塞事件循环。asyncio的运行机制是协作式多任务一个任务在等待期间让出控制权但如果你在协程里调用了同步阻塞的函数比如time.sleep()、requests.get()整个事件循环都会被卡住所有处于等待状态的任务都会受影响。我在项目里见过有人用FastAPI写了async接口里面却调了同步的数据库驱动结果并发一高就全面超时。这种问题不是简单换库就能解决的需要明确哪些库支持异步再彻底把调用链换成异步版本。3. 高并发实战从参数调整到架构落地3.1 线程池和进程池的参数怎么定从默认值到压测调优Python标准库的concurrent.futures是个好东西ThreadPoolExecutor和ProcessPoolExecutor封装了底层细节。但要注意它们各自的默认参数并不通用ThreadPoolExecutor的默认线程数是min(32, os.cpu_count() 4)ProcessPoolExecutor默认进程数是os.cpu_count()。这些值在轻量场景下能用在高并发生产环境里基本都要手动调整。线程池参数的黄金法则I/O密集任务线程数可以大于CPU核心数。因为线程大部分时间在等待一个CPU核心就可以轮流跑多个线程。我常用的起步值是cpu_count * 5但这个值仅供参考。比如目标接口平均耗时200ms你希望每秒完成200个请求那至少需要同时运行40个请求线程数就得按这个来纯靠公式算是不准确的。所以我一般先定一个合理的下限比如200并发能匹配压测目标再往上加到资源快扛不住为止。进程池参数的逻辑完全不同。进程数建议不超过CPU核心数因为每个进程都消耗大量内存和CPU切换资源超过核心数反而会因为上下文切换导致整体性能下降。如果任务是内存密集型的还要先算好内存账一个进程跑一次数据切片需要占用500MB内存机器总共16GB可用内存留10GB那最多只能开5个进程否则扛不到任务结束就OOM了。我见过太多人只盯着CPU忽略了内存预算直接把机器干挂。3.2 线程池小实战一个能抗压的爬虫并发框架拿最常用的爬虫场景来演示线程池怎么落地。很多人从网上抄的爬虫代码都是用for循环一个请求一个请求发速度实在太慢改成线程池之后效果立竿见影。下面是一个我实际在项目里用的精简版本import requests from concurrent.futures import ThreadPoolExecutor, as_completed from queue import Queue # 全局复用Session这是重点Session内部维护HTTP连接池能显著降低握手开销 session requests.Session() session.headers.update({User-Agent: Mozilla/5.0}) MAX_WORKERS 12 RETRY_QUEUE Queue() def fetch_one(url): for attempt in range(3): # 最多重试三次 try: resp session.get(url, timeout5) if resp.status_code 200: return url, resp.text elif resp.status_code in (429, 500, 502, 503, 504): # 遇到限流或服务端错误重试前加一点退避 raise RuntimeError(fstatus{resp.status_code}) except Exception as exc: if attempt 2: return url, ffailed: {exc} time.sleep(0.5 * (attempt 1)) return url, failed: retry exhausted def main(urls): results [] with ThreadPoolExecutor(max_workersMAX_WORKERS) as pool: future_map {pool.submit(fetch_one, url): url for url in urls} for future in as_completed(future_map): url, content future.result() results.append((url, content)) # 如果你想把失败的重试队列单独处理可以在这里push if content.startswith(failed): RETRY_QUEUE.put(url) return results这个框架里有三个关键点。第一是Session全局复用如果不复用一个连接池每个请求都重新建立TCP连接和TLS握手性能至少慢三倍。第二是显式设置timeout不设超时的话遇到慢接口整个线程池都被拖住。第三是失败的请求进重试队列而不是反复刷屏。这个框架在我实际爬取大量列表页的时候稳定的并发能力从原来一个线程的每秒几个请求提升到每秒上百次足够应对大多数公开接口了。3.3 异步高并发实战FastAPI httpx构建不阻塞的接口层如果你要做的不是批量任务而是对外提供高并发API服务那个把异步框架用好的收益比线程池还大。目前的方案我推荐FastAPI加异步客户端来构建整条调用链。下面是一个典型的异步HTTP接口模式import asyncio import httpx from fastapi import FastAPI from contextlib import asynccontextmanager app FastAPI() # 全局共享一个异步Client既能复用连接池又能统一管理超时 asynccontextmanager async def lifespan(app): client httpx.AsyncClient(timeout10.0, limitshttpx.Limits(max_connections200)) app.state.client client yield await client.aclose() app FastAPI(lifespanlifespan) app.get(/fetch) async def fetch_url(url: str): # 这里必须是async函数httpx.AsyncClient也是异步的整个链路不阻塞事件循环 resp await app.state.client.get(url) return {status: resp.status_code, body: resp.text[:200]}这个接口看起来简单但如果把内部换成同步调用比如在async函数里写requests.get(url)并发压到50以上就会出现请求大量排队、CPU飙升、响应超时的“全血崩塌”。因为一个同步调用阻塞住了整个事件循环所有其他连接都在等待这一个I/O完成。实际生产环境里处理外部依赖的调用要再加一层并发隔离具体来说有三种手段用asyncio.Semaphore限制最大并发请求数防止上游服务被打爆。把耗时的外部调用丢到后台任务里执行调用方先返回任务ID事后异步查询结果。给每个上游服务单独设置超时和重试策略避免一个慢服务拖垮整个接口。这套方案我在一个数据聚合服务里用过单机承载几千路并发调用多个第三方API整体表现很稳定内存占用比多进程方案少了一大截。3.4 高并发场景设计IM长连接和AI Agent并发最近的热搜词里“高并发im”“ai agent怎么扛并发”出现频率很高这确实是很多团队在从脚本开发转向服务化时碰到的棘手问题。IM场景本质上是海量长连接加高频消息推送属于I/O密集服务。这种服务用协程做承载非常合适因为每个连接在事件循环里只占一块很小的状态空闲时几乎不消耗资源。关键要处理的是三件事WebSocket连接数指标监控、心跳超时踢掉死连接、广播消息尽量使用单播模式避免全员遍历。AI Agent扛并发则是另一种典型困境。Agent处理一个请求往往要等待模型API返回一次交互可能耗时十几秒甚至几十秒这个等待如果全部阻塞在请求线程里并发量会很惨。正确方案跟上面异步接口的思路一致把Agent推理过程做成异步任务前端请求进来后立即返回后台用任务队列消化模型API响应后通过WebSocket或者轮询通知前端。同时用令牌桶限制对模型API的并发请求数量避免触发上游的速率限制。这套架构里Python的作用是把任务调度、连接管理、状态流转都承接起来支撑几千路并发Agent对话没有什么问题。4. 常见问题与排查技巧实录4.1 线程数量涨不上去文件描述符和内存双重耗尽典型现象是并发压测刚开始表现不错到达某个临界点后突然大量报错日志里出现“cant start new thread”。排查下来原因通常是两个一个是线程栈虚拟内存耗尽另一个是文件描述符达到上限。前者可以调低线程数量上限比如从32降到16看性能是否仍然达标后者可以临时调大ulimit -n限制比如1024提升大后重试。但最终的正解是估算你单机所需的并发任务数再把worker数量按这个来配置不要滥用无边界线程创建。4.2 多进程数据结果重复或者丢失fork带来的幽灵如果你在用multiprocessing时发现同样的任务被跑了遍或者结果不对先检查你的代码是不是有模块级别的业务逻辑。在fork模式下子进程会继承父进程已经创建的所有对象在spawn模式下子进程会重新执行模块导入。如果你把创建锁、初始化连接池这类代码直接写在模块顶层子进程启动时就会重复初始化极可能导致连接数爆炸或者数据污染。所有初始化逻辑必须写进ifname main:或者放进每个子进程内探作的函数里。4.3 asyncio代码没加速反而是负优化如果你把代码改成了asyncio却发现性能反而更差最常见的原因就是事件循环里混入了同步阻塞操作。检查方法其实很简单用py-spy dump对运行中的进程做一次线程栈快照如果看到多个协程都卡在time.sleep或者requests.get这些同步调用上问题就一目了然。另外一个隐蔽问题是不恰当的等待并发控制比如把asyncio.gather包裹了大量任务每个任务内部又因为信号量限制导致大部分时间在排队整体效率反而不如直接限制任务数量。4.4 压测定位瓶颈观察队列积压和系统指标高并发排查切忌拍脑袋建议按固定流程走。压测时周期性记录四个指标任务队列积压数、线程池/事件循环耗时分布、系统CPU使用率、内存增长曲线。如果CPU使用率已经接近100%但队列积压还在增长说明计算资源成了瓶颈如果CPU只有20%但队列积压严重说明GIL或者外部I/O卡住了执行如果内存曲线持续上升且不回落那基本可以确定是任务积压导致的对象堆积。我习惯把压测脚本写成最终结果和指标自动写入日志的形式连续压测几十轮再对比不同并发参数下的数据而不是凭感觉拍板。5. 并发选型决策指南三个步骤定位最优方案根据我这些年的项目经验遇到一个并发需求判断方案并不复杂按三步走就行了第一步先区分任务类型。CPU密集型至少纯Python代码占比很高的任务直接考虑多进程I/O密集型比如请求外部服务、等待数据库、网络传输优先考虑协程如果团队对asyncio掌控度不够也可以用线程池过渡。第二步估算单机并发量级。几百以下线程池和协程都能胜任选择更顺手的那套几千到几万协程几乎是唯一合适的技术如果任务要求低延迟CPU计算那就上多进程并配套合理的任务分发框架。第三步做一次完整的压测验证。不要上了线才发现参数不对。压测时要仔细关注队列积压和资源使用情况再根据结果调整worker数量和超时时间。按照这个流程走绝大多数并发方案的选型都不会跑偏。最后分享一个我个人的习惯所有并发代码上线前都必须跑一轮故障演练。比如把第三方接口强制设为不可用看任务队列会不会爆掉故意调低上游限流阈值看重试策略会不会引起雪崩。并发编程的难点从来不是把代码写出来而是让服务在异常场景下还能保持可控。你在高并发的世界里待久了就会明白真正可靠的技术方案永远不是靠运气撑住的。