尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Python并发编程全解析:多线程、多进程与协程的选型实战

Python并发编程全解析:多线程、多进程与协程的选型实战 Python并发编程这个话题我从接触Python第二周就踩坑踩到怀疑人生。当时写爬虫循环几百个URL一个个请求跑到一半等IO等到想砸电脑后来换成ThreadPoolExecutor速度直接翻了五六倍但又在处理共享数据时被各种锁折磨到半夜。这篇文章就把这些年整理出来的并发编程思路、工具选型和避坑经验一次性讲清楚。它适合谁刚入门Python、写过一点脚本、想在爬虫、数据处理、Web服务里提速的开发者。不需要你精通底层操作系统但看完之后至少能搞明白多线程、多进程、协程到底什么时候该用哪个各自的坑在哪面试官问GIL时该怎么答。我尽量不堆术语该上代码就上代码让你看完能直接套用到自己的项目里。1. 并发编程先想清楚再动手1.1 并发不等于并行很多人一听到“并发”就以为是在同一时刻做多件事。其实并发concurrency和并行parallelism是两个层次的概念。并发更准确地说是“同时处理多个任务”可能是通过快速切换来实现的“看起来同时”而并行是真正在多个CPU核心上同时执行。用生活化的比喻来讲你一个人在厨房一会儿切菜、一会儿烧水、一会儿炒菜这叫并发叫来三个人一个人专门切菜、一个人专门炒菜、一个人专门洗碗这叫并行。CPU只有一个核的时候Python程序依然可以表现得“同时处理”多个任务这是因为操作系统在极短的时间片里不停切换执行流这就是并发。多核CPU上多个任务才可能真正同时执行这就是并行。Python编程中最常用的三种并发工具是线程threading、进程multiprocessing和协程asyncio。三者各有适用场景也有很多同学把三者混为一谈结果就是代码能跑但性能稀烂或者干脆死锁、崩溃。我自己最开始也是这样什么火用什么最后项目复杂度上来了才明白选型错了后面全是补窟窿。1.2 CPU密集型与IO密集型的判断选择并发方案的第一步不是学API而是看你的任务属于哪一类。这个判断做错了后面用什么库都白搭。CPU密集型CPU-bound任务主要在计算比如循环大量数值、图像处理、数据清洗、机器学习模型推理。IO密集型IO-bound任务主要在等待外部资源比如请求网页、读写数据库、读写本地文件、调用第三方接口。可以把CPU密集想象成你一直在做题IO密集想象成你在“等外卖”。做题时多来几个能真正算数的人多核并行能提升效率等外卖时真正消耗时间的不是处理而是等待所以一个人同时点很多份外卖协程/异步反而效率最高。这个判断是整个并发编程的起点。在我见过的项目里至少有一半的并发方案选型错误就是没做这种基础判断。2. 多线程IO密集型的主力军与GIL的真相2.1 threading与ThreadPoolExecutorPython官方提供的threading模块是最基础的多线程实现。实际工程中我很少直接去new很多个Thread然后手动start/join因为线程多了以后维护状态非常痛苦我一般优先用ThreadPoolExecutor。ThreadPoolExecutor的好处是线程池复用、任务提交简单、返回结果好拿。典型的写法from concurrent.futures import ThreadPoolExecutor, as_completed urls [https://example.com, https://example.org, https://example.net] def fetch(url): return requests.get(url).text[:100] with ThreadPoolExecutor(max_workers8) as executor: future_list [executor.submit(fetch, url) for url in urls] for future in as_completed(future_list): data future.result() print(len(data))只要你不是搞并发框架底层这种写法的可读性和健壮性都远超手写线程。原理上线程池会预先创建一组线程任务来了就从队列里取一个空闲线程执行执行完线程不销毁回到池子里待命避免频繁创建销毁线程的开销。这里还用到了as_completed它会在任何一个任务完成时立即返回这样你能第一时间拿到结果而不是傻等最慢的那个任务。2.2 GIL到底是怎么回事GILGlobal Interpreter Lock全局解释器锁是Python并发话题里绕不开的东西。简单说CPython解释器在同一个进程内同一时刻只允许一个线程执行Python字节码。所以很多人说“Python多线程是假的并发”这个说法对也不对。说它对是因为在多核CPU上多个线程不能真正并行计算CPU密集型任务用多线程不仅没有加速还可能因为上下文切换变慢。说它不对是因为阻塞型IO操作比如网络请求、文件读取在执行时它会释放GIL等待IO返回这段时间其他线程是可以拿到锁执行的。所以IO密集型任务用多线程依然有非常明显的提升。面试时被问到GIL我一般这样回答GIL让CPython的线程在同一时刻只能执行一个线程的字节码目的是简化内存管理和引用计数代价就是多线程无法利用多核并行计算。因此CPU密集型优先考虑多进程或借助NumPy等释放GIL的扩展IO密集型多线程是合理选择。补充一点Python官方其实多次尝试移除GIL但因为兼容性和性能问题一直没能落地所以近几年来CPython还是带着这个锁跑。理解了这一层你才能明白为什么下面要讲锁和线程安全。2.3 锁与线程安全一个典型案例多线程编程里最经典的坑就是共享数据竞争。直接看一个例子import threading counter 0 def add(): global counter for _ in range(1_000_000): counter 1 threads [threading.Thread(targetadd) for _ in range(4)] for t in threads: t.start() for t in threads: t.join() print(counter) # 结果往往远小于4000000原因是 counter 1 在底层并不是原子操作它分为读、加、写三步多个线程交错执行就会丢更新。解决办法是加锁import threading lock threading.Lock() counter 0 def add(): global counter for _ in range(1_000_000): with lock: counter 1用 with lock 的方式可以保证临界区只有一个线程进入也避免手动 acquire/release 忘了释放。但这里也有个反向问题锁加得太粗程序性能反而下降甚至退化成单线程锁加得太细又容易出现逻辑错误和死锁。我的经验是优先让线程之间不共享可变数据而不是想方设法去加锁。提示如果你真的需要线程之间传递数据优先用queue.Queue它是线程安全的自己写列表加锁十有八九要出事。3. 多进程让多核CPU真正干活的姿势3.1 ProcessPoolExecutor与手动多进程如果你的任务是CPU密集型的比如大量数值计算、多媒体处理那么多线程帮不了你因为GIL压着呢此时应使用多进程。多进程下每个进程持有独立的Python解释器所以可以真正利用多核CPU并行计算。实际使用中我优先用ProcessPoolExecutor它继承自Executor接口用法几乎和ThreadPoolExecutor一模一样from concurrent.futures import ProcessPoolExecutor import math def heavy_calc(n): return sum(math.sqrt(i) for i in range(n)) if __name__ __main__: with ProcessPoolExecutor(max_workers4) as executor: results list(executor.map(heavy_calc, [10_000_000] * 4)) print(results)注意ifname main这行不能省。在多进程编程里Windows上子进程的启动方式默认是spawn它会重新导入主模块如果没有main保护子进程会递归地创建新的子进程形成连锁爆炸。我见过很多初学者在Windows上跑多进程一运行就报错或者CPU被占满基本都是少了这个保护。3.2 进程间通信Queue、Pipe与共享内存进程与进程之间的内存空间是隔离的不能像线程那样直接共享全局变量。所以进程间通信IPC是必须要设计的环节。最简单可靠的是multiprocessing.Queuefrom multiprocessing import Process, Queue def worker(q): q.put(来自子进程的数据) if __name__ __main__: q Queue() p Process(targetworker, args(q,)) p.start() p.join() print(q.get())此外还有Pipe管道、Manager管理器可以共享可序列化的高级容器以及共享内存multiprocessing.shared_memory。我在实际项目里普通场景用Queue就够了数据量特别大的时候才考虑共享内存因为它避免了序列化和网络传输的开销。关于Queue内部是怎么工作的可以简单理解为消息队列一个进程往里面放数据另一个进程从里面取数据数据传递的过程会经过序列化pickle和反序列化。所以你在进程间传的对象必须是可序列化的。自定义类如果没有处理好pickle支持就会在这里翻车。3.3 进程池的超时与资源控制进程池虽好但它比线程池“重”得多。每个进程都有自己的解释器和内存空间创建进程的开销远高于创建锁或线程。所以进程池的max_workers不是越大越好一般建议不超过CPU核心数或者稍微高一点因为现代CPU有超线程。我踩过的一个坑是把一个大型DataFrame逐个传给子进程结果每次任务都要pickle序列化传输开销大到比计算本身还慢。正确的做法是把只读的大数据放在模块顶层子进程通过继承拿到它或者用multiprocessing共享内存直接读取而不是每次当作参数传进去。也就是说进程间通信不是免费的数据量大时瓶颈往往不在计算而在数据传输。4. 协程单线程内的“超快速切换”4.1 asyncio的核心机制协程coroutine是单线程内实现高并发的方案。它不需要操作系统介入调度而是由事件循环event loop自己控制什么时候暂停、什么时候恢复。暂停的时候控制权交还给事件循环让它可以去处理别的任务。写一个最简单的asyncio示例import asyncio async def say_hello(): print(start) await asyncio.sleep(1) print(end) async def main(): await asyncio.gather(say_hello(), say_hello(), say_hello()) asyncio.run(main())这里asyncio.gather用于并发地执行多个协程。注意我们在协程内部用的是await asyncio.sleep(1)而不是time.sleep(1)。因为time.sleep是阻塞操作在协程中一旦遇到它整个线程都被卡住了事件循环无法运转其他协程都得等着。理解事件循环可以把它想象成一个超级高效的“前台接待员”。你递过去一句话它记下来说“好的等结果了我叫你”然后立刻去接待下一个人。这种非阻塞的等待方式就是协程高并发的底气。4.2 IO密集型场景为什么协程更强同样是IO密集型任务多线程和协程都能提速但协程的内存开销小得多。线程需要占用系统资源一个线程的栈空间加上内核调度成本哪怕是数千个线程都容易吃紧协程则在用户态切换创建几万个协程都不成问题。做高并发网络爬虫的时候用asyncioaiohttp可以轻松并发几百上千个请求import asyncio import aiohttp async def fetch(session, url): async with session.get(url) as resp: return await resp.text() async def main(): async with aiohttp.ClientSession() as session: tasks [fetch(session, fhttps://example.com/page/{i}) for i in range(100)] pages await asyncio.gather(*tasks) print(len(pages)) asyncio.run(main())这段代码虽然只跑在单个线程里但并发量足以撑起大量IO请求效率远超同样是单线程的普通同步循环。很多对这种写法不熟的人第一反应是想开100个线程其实没必要。只要等待的是网络或磁盘IO协程就是性价比最高的方案因为等待阶段CPU本来就在闲置。注意asyncio里如果调用了非异步的阻塞库比如普通的requests库整个事件循环都会被卡住并发直接归零。这种场景要么换成aiohttp/httpx要么放在线程池中执行。4.3 事件循环与控制并发数量协程并发虽然开销小但也不是完全没有数量上限。当并发请求数过大的时候本地网络连接、文件描述符可能被耗尽因此往往需要做并发限制。有两种常用姿势一是用asyncio.Semaphore控制信号量二是用线程池包裹阻塞任务来控制资源。Semaphore的使用示例import asyncio sem asyncio.Semaphore(10) async def limited_fetch(session, url): async with sem: # 这里每次最多10个任务进入 ...原因很简单如果你的目标是爬5000个页面直接gather创建5000个任务连接数瞬间打满服务器很可能直接拒绝服务甚至本地端口耗尽。限制并发数量是保护自己和保护外部服务的基本功。我在分布式调度里也经常用信号量限制某个下游接口的QPS否则对方一熔断整个任务队列全废。5. 如何选型线程、进程、协程一张表看清5.1 三种方案的对比写代码之前先问自己几个问题是CPU密集还是IO密集任务间共享数据多不多是否需要真正的多核并行下面这张表是我总结出来的最朴素选型参考维度多线程threading多进程multiprocessing协程asyncio适用场景IO密集型阻塞等待多CPU密集型需要多核并行IO密集型超高并发单机函数级切换并行能力受GIL限制无法并行计算真正的并行每个进程独立解释器单线程内并发非并行内存开销较小但创建大量线程有压力大每个进程独立内存空间极小可创建大量任务数据共享容易但需要加锁难需要IPC或共享内存单线程无共享竞争问题编程难度中等锁、死锁易出问题中高IPC、启动方式都要考虑中等需要适应async/await语法适合初学者一般一般稍难需要说明的是这张表是针对CPython实现。如果你用的是PyPy或其他Python实现GIL的表现可能会有差异但工程上绝大部分人还是跑CPython所以这张表基本够用。5.2 典型场景的决策步骤我建议把选型流程固定成这套步骤确认任务类型先写个小脚本或直接凭经验判断是CPU密集还是IO密集。估算并发量如果是几十个并发多线程就够如果是几百上千的并发请求优先考虑协程。看数据共享需求如果有大量共享可写数据尽量重新设计成数据分片与汇总模式避免共享。看运行平台Windows平台的多进程启动方式与Linux不同注意ifname main保护。看团队技术栈有些老项目只用了线程直接上协程会带来大量改造这时候可以先用ThreadPoolExecutor做平滑过渡。这套流程虽然不是绝对真理但至少能帮我避免在选型阶段做无用功。很多时候性能问题不是“语言不够快”而是方案没选对。见过太多人明明是个批量读文件的IO任务非要上多进程结果内存涨得比内存还快最后还得回头改协程。6. 进阶实践并发可视化展示与埋点6.1 通过日志观察并发过程在学习并发编程时建议写代码时打印出线程/进程/协程的运行顺序观察任务调度过程。比如在threading的target函数里打印线程名和当前时刻import threading import time def task(name): print(f{name} started) time.sleep(1) print(f{name} finished) for i in range(3): threading.Thread(targettask, args(fT{i},)).start()你会看到输出的顺序是交错的这说明多个线程在并发切换。很多人以为并发需要“同时打印”才叫并发其实交错打印才是常态因为GIL会不停切换线程执行流。如果你把输出改为在加锁状态下打印又能观察到明显的排队效果这就能直观理解锁的作用。6.2 并发编程中的可视化工具如果想看得更直观可以用tqdm给并发任务加一个进度条。在as_completed中逐个输出进度写代码时和运维时都很爽from concurrent.futures import ThreadPoolExecutor, as_completed from tqdm import tqdm def fake_download(url): import time time.sleep(0.5) return len(url) urls [u] * 100 with ThreadPoolExecutor(max_workers10) as ex: futures [ex.submit(fake_download, u) for u in urls] for _ in tqdm(as_completed(futures), totallen(urls)): pass这只是个简单示例。真实项目里并发任务会涉及大量数据流动进度条能帮你快速定位是不是有任务挂起而不是傻等半天。另一个很实用的技巧是给每个任务开一个唯一ID打到日志里。这样排查问题时你能从时间线上看出任务执行到哪一步卡住了是等待IO还是等待锁。7. 常见问题与排查技巧实录7.1 死锁问题这是并发编程最容易翻车的点。典型场景是多个锁的顺序不一致。比如线程A先拿锁1再拿锁2线程B先拿锁2再拿锁1两个线程就可能各自持有一个锁然后互相等待死循环。排查死锁的经验用faulthandler.dump_traceback_later或者pdb在运行时输出线程栈。尽量保证全项目加锁顺序统一比如统一“小锁在里大锁在外”。能用queue.Queue就直接用不要自己维护一堆锁。使用锁的时候with语句比手动acquire/release安全得多。在asyncio里也有死锁的变种两个协程互相等待对方的信号量不过因为asyncio是单线程一旦事件循环被某处阻塞所有任务都会卡住排查起来更棘手。这时候尤其要注意看日志输出停在哪一行。7.2 任务提交后没有结果ProcessPoolExecutor/ThreadPoolExecutor提交任务后如果调用future.result()一直不返回原因通常有几种任务内部抛了异常但没有被捕获任务里阻塞在不该阻塞的耗时操作上线程池或进程池的工作队列被打满。逐个排查时先打印task开始和结束的日志再用future.exception()查看异常信息。这里我想多说一句future.result()默认会一直阻塞等待结果如果你不想无限等下去可以传入timeout参数比如future.result(timeout3)超时就抛出TimeoutError。工程上这一点非常关键因为很多线上bug就是某个任务意外挂起导致主线程一直阻塞后续任务全部排队。7.3 多进程下的Python对象序列化问题当你往ProcessPoolExecutor里传自定义类实例时如果类没有正确支持pickle会直接报序列化错误。这个错误在Linux的fork模式下不一定出现但Windows的spawn模式下经常遇到。解决办法是尽量传入基础类型数据或者把需要传递的对象定义为顶层可导入的类。还有一个相关问题是进程池里的任务函数必须能被导入不能是lambda或者局部嵌套函数。否则在spawn模式下子进程没法获取到函数定义也会报错。写多进程代码时我一般会把任务函数单独放到一个模块里这样最稳妥。7.4 asyncio常见坑和无头绪的卡顿使用asyncio时最常见的卡顿就是某处代码调用了阻塞库。比如同时用了普通requests和aiohttp网络请求就会把事件循环卡住。排查办法在关键位置打印时间戳或者临时替换为await asyncio.sleep(0)来让出执行权观察调度是否正常。另外一个坑是忘记await。不写await协程不会真正执行它只是创建了一个coroutine对象。如果调用了async函数却没有await控制台通常会出现“coroutine was never awaited”的警告问题就在这里。还有一个容易被忽略的点async函数内部如果调用了普通的同步IO函数也会阻塞事件循环所以协程代码要尽量全链路异步化。8. 生产环境里的并发编程最佳实践8.1 从Demo到生产行为的差别很多并发方案在Demo里跑得飞快一到生产环境就拉胯。原因主要有几个生产环境的任务量大内存和连接数压力更真实生产环境的日志、IO、监控会带来额外开销生产环境往往是长期运行的进程内存泄漏和句柄泄漏会在几天后集中爆发。我在生产环境一般会做这几件事给线程池/进程池设置合理的名字定期打印任务队列长度和活跃线程数加上异常兜底防止个别任务异常导致整个池子卡死。举个具体例子我曾经维护过一个任务调度程序发现内存持续上涨排查几天都没结果最后发现是线程池里的Future对象被列表持有一直没释放。生产环境里的并发问题往往不是语法错误而是资源管理问题。8.2 结合Web服务与数据库的并发建议如果你的并发编程是用在Web服务比如FastAPI、Flask或数据库操作需要注意Web框架本身已经有异步支持如果再用asyncio写业务注意不要把框架的线程池和协程混在一起。数据库并发操作的常见建议是连接池化避免频繁创建关闭连接。线程安全由数据库连接池来保证不是靠你自己加锁。使用SQLAlchemy等ORM时要注意Session对象不是线程安全的不要让多个线程共享同一个Session。很多人写并发操作数据库时遇到“SQLite objects created in a thread can only be used in that same thread”就是典型的跨线程使用数据库连接对象这类问题用连接池就能解决。8.3 单元测试与并发程序的调试技巧并发程序最难的地方不是写出来而是验证对不对。我建议用这几个方式来做测试第一固定随机种子和并发量让问题可复现第二用事件或队列做同步点等待所有任务结束后再断言第三在调试阶段把并发量调小配合日志逐步观察。如果程序偶发死锁或数据错乱最痛苦的是无法稳定复现。这时候我会考虑用stress测试工具跑大量任务同时记录日志到文件跑完之后再用栈转储揪出问题。另外凡是涉及共享状态修改的代码都应该把临界区范围缩到最小宁可多写几行代码也别图省事包一大片。这个原则能解决90%以上的并发数据问题。9. 我的经验与进一步扩展最后说一段我的实际经历。前几年做一个爬虫调度框架一开始所有任务都扔给多线程跑了一段时间后发现上游接口偶尔会超时线程一积压内存飙升。后来我把任务按类型拆分网络请求用协程解析响应和落地DB的操作放在线程池里做计算密集型的数据清洗再丢给多进程。这个分层架构上线以后稳定性好了很多单机吞吐量也比原来高了将近三倍。所以我的体会是并发编程不是选一个技术就一直用而是面向场景做组合。线程、进程、协程可以混合使用关键在于把任务边界划分清楚。另一个心得是先做小规模压测比如限制并发数观察CPU、内存、IO曲线再逐步加量别一上来就all in所有线程数和任务量。如果你正准备上手写第一个并发程序送两句话先跑通小例子再套真实任务日志打到位排查就会轻松一大半。并发编程这东西慢就是快理解清楚再动手比你盲目堆并发数靠谱得多。
返回列表