ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Python多线程与GIL解析:从线程基础到线程池高并发实战

Python多线程与GIL解析:从线程基础到线程池高并发实战 很多人刚接触 Python 并发编程时都听过这么一句话“Python 多线程就是假的GIL 锁一锁什么都白搭。”这话对了一半也坑了很多人。Python 的线程确实无法让多个 CPU 核心同时执行纯计算代码但如果你因此觉得线程没用那你大概率还没遇到真正的 I/O 密集场景——读文件、发请求、连数据库、调第三方接口这些场景里线程恰恰是最顺手、最直接的并发工具。这篇内容适合两类人一类是刚学完 Python 基础、想搞明白线程、并发、GIL 到底是什么的新手另一类是已经在跑业务代码被线上卡死、竞态、线程池炸掉等问题折磨过的开发。我不会给你堆教科书定义而是把我在实际项目里对 Python 并发、线程的理解、选型思路、踩坑过程和排查方法完整梳理一遍。从 GIL 到底怎么影响你到线程怎么创建、怎么同步、怎么上线程池最后是高并发场景下的实战套路都能直接拿来用。1. 动手之前先搞懂并发、并行和线程1.1 并发与并行的区别并发concurrency和并行parallelism是一个经常被混用、但本质完全不同的概念。并发指的是系统具备同时处理多个任务的能力重点在于“多任务交错推进”并行指的是同一时刻真的有好几个任务在同时执行重点在于“同一瞬间干多件事”。一个生活化的类比并发就像餐厅门口一个服务员同时接待好几桌客人这桌点完菜、记下来再去那桌倒水客人会觉得服务一直在推进并没有被晾着。并行则是餐厅同时来了好几个服务员一桌一个大家真的在同一时刻各干各的。Python 多线程在 CPython 层面因为 GIL 的存在很难做到真正的并行。但它在并发效果上仍然很有价值当一个线程在等待网络响应时另一个线程可以继续推进CPU 不再干等。实际项目里我们通常用多线程解决的恰恰是“让多个慢任务在同一个时间段里都往前动一动”的问题而不是“让计算速度翻倍”的问题。1.2 进程、线程、协程到底怎么选很多初学者一上来就问“哪个好”其实不存在万能答案。我习惯按任务类型先分类再做技术选型线程创建成本低内存共享方便但受 GIL 限制适合 I/O 密集任务比如爬虫、文件读写、接口调用。进程有独立内存空间可以在多核 CPU 上真正并行适合 CPU 密集任务比如图像处理、数据计算但进程间通信IPC成本和复杂度都比线程高。协程asyncio在单线程内部通过事件循环做协作式调度适合大量 I/O 等待但需要第三方库配合异步生态生态里某些阻塞库会直接卡死整个事件循环。我做过的项目里有一个典型例子一个数据采集服务要从几十个 API 拉数据每个接口慢则两三秒。这种任务如果做成单线程串行一小时也跑不完用多线程并发十几分钟就能收完。反过来一个图像批量处理服务每个任务要 CPU 跑好几秒开线程不仅没提速反而因为 GIL 切换多出额外开销最终改成 multiprocessing 才把多核吃完。1.3 GIL 是怎么来的为什么这么多年还在GILGlobal Interpreter Lock全局解释器锁是 CPython 的“原罪”。它的存在是为了保护解释器内部的内存管理。CPython 使用引用计数来管理对象生命周期每个对象被引用或销毁时都要修改引用计数如果允许多线程同时改引用计数就会错乱轻则内存泄漏重则直接崩溃。GIL 相当于在解释器入口加了一把大锁保证同一时刻只有一个线程在跑 Python 字节码。但要注意GIL 不是所有操作都会一直占着不放。遇到阻塞型系统调用——比如time.sleep、socket读数据、文件读写——线程会主动释放 GIL让其他线程先跑。这就是为什么 I/O 密集场景下Python 多线程能带来接近线性倍数的吞吐提升。我实测过一个模拟“每个任务 sleep 0.1 秒加少量计算”的场景单线程跑 100 个任务约 10 秒开 10 个线程后总耗时稳定在 1 秒多一点。而换成纯浮点运算密集的任务10 个线程跑出来的时间甚至比单线程还慢因为线程切换本身也有成本。理解了这个现象你就不会再去网上争论“Python 多线程到底有没有用”而是能自己判断任务卡在等待上线程有用任务卡在计算上线程帮倒忙。2. 线程的创建与生命周期管理2.1 threading.Thread 的基本用法Python 标准库的threading模块提供了线程对象Thread创建线程有两种常见写法直接传函数或者继承Thread重写run。import threading import time def worker(name, seconds): print(f[{time.strftime(%H:%M:%S)}] 线程 {name} 开始) time.sleep(seconds) print(f[{time.strftime(%H:%M:%S)}] 线程 {name} 结束) t1 threading.Thread(targetworker, args(采集任务A, 2)) t2 threading.Thread(targetworker, args(采集任务B, 3)) t1.start() t2.start() t1.join() t2.join() print(所有任务执行完毕)start()会让线程进入就绪状态等操作系统调度join()则让主线程等待目标线程结束。写代码时最容易犯的错是只调用start()不调用join()如果主线程后面还有什么收尾工作很可能在子线程还没跑完时就被抢先执行了。类继承的写法适合封装一些复用逻辑class ReportWorker(threading.Thread): def __init__(self, task_id): super().__init__(namefReport-{task_id}) self.task_id task_id def run(self): print(f线程 {self.name} 正在处理任务 {self.task_id}) # 业务逻辑这种写法的好处是可以把参数、状态、业务方法都封装在类里尤其是项目里线程粒度和业务对象绑得比较紧的时候。但日常脚本和大多数服务端逻辑直接用target函数更简洁没必要为了“面向对象”而硬套类。2.2 守护线程与线程生命周期线程对象有一个daemon属性设置它为True的线程叫守护线程。守护线程的特点是当主线程结束、所有非守护线程结束之后守护线程会被强制终止。它适合后台心跳、日志刷盘、监控上报这类不要求“有始有终”的任务。这里有个关键坑如果你在程序里启动了一个非守护线程主线程哪怕代码全部执行完也不会退出进程会一直挂在后台等子线程结束。很多人写数据迁移脚本时遇到过“明明打印完退出日志了终端就是不返回”卡住的往往就是某条没设daemonTrue的子线程。线程生命周期比较简单大体是创建Thread对象 →start()进入就绪/运行状态 → 遇到 I/O 或锁进入阻塞状态 → 结束后进入终止状态。Python 不直接暴露“挂起”“恢复”这类系统级原语我们能做到的通常是协作式控制用Event、Lock、Condition让线程在合适的地方等待或继续。实战中还有一点非常重要给线程起个好名字。threading.Thread(targetfn, namedb-cleaner)这行代码看起来无关紧要但在排查问题的时候价值巨大。程序执行py-spy dump或打印线程堆栈时日志里如果全是Thread-5、Thread-23你根本分不清是哪个业务模块出了问题。带上业务名词一眼就能定位。2.3 线程数据隔离threading.local 的用法多线程最方便也最危险的地方是共享内存。全局变量天然可被所有线程访问但很多场景里我们希望“每个线程各持有一份数据”比如数据库连接、HTTP Session、用户上下文。可以用threading.local()实现。import threading thread_local threading.local() class UserContext: def __init__(self, user_id): self.user_id user_id def handle_request(user_id): thread_local.user UserContext(user_id) # 后续从这个线程里取 thread_local.user 就是当前线程设置的threading.local()的内部实现相当于一个由线程 ID 做 key 的字典。同一线程里读到的永远是它自己写入的那份数据不同线程互不干扰。我在写一个多线程爬虫时如果全局共享requests.SessionA 线程设置的 Cookie 可能被 B 线程的请求带上身份全串了。改成每个线程独立 Session 问题就消失了用threading.local()管理比手写字典再手动清理干净得多。3. 线程同步并发程序的分水岭3.1 竞态条件并发下数据为什么乱了并发编程最大的敌人不是线程本身而是竞态条件Race Condition。当多个线程同时读一个变量且至少一个线程要修改它最终结果会依赖线程调度顺序而线程调度是不可预测的数据就乱了。看一个最经典的例子import threading counter 0 def increment(): global counter for _ in range(1_000_000): counter 1 threads [threading.Thread(targetincrement) for _ in range(10)] for t in threads: t.start() for t in threads: t.join() print(counter) # 期望 10000000但大概率小于这个数这里counter 1在底层是“读当前值→加 1→写回”三步操作不是原子的。虽然 GIL 保证了字节码不会同时执行但一个线程执行完“读”之后GIL 可能会切换到另一个线程另一个线程也读了旧值两个人都加 1 写回去结果就丢了一次更新。10 个线程各加一百万次最终值千奇百怪。解决竞态的手段就是加锁让“读-改-写”整体变成一个原子操作。3.2 Lock 与 RLock别用错锁threading.Lock是最基础互斥锁。它只有两个状态已锁定和未锁定。线程调用acquire()拿锁用完后必须release()释放。忘记释放其他线程就会永久等待异常发生时也可能导致锁永远不释放。所以标准写法是配合with语句。import threading lock threading.Lock() counter 0 def safe_increment(): global counter for _ in range(1_000_000): with lock: counter 1Lock是非重入锁同一线程在被锁定状态下再次acquire()会直接把自己卡死因为锁不知道“这个线程已经持有过锁了”。这种场景很容易出现在递归调用或互相调用中。比如 A 方法加锁后调用 B 方法B 方法里又对同一把锁加锁程序就死循环等待了。解决办法是使用threading.RLock可重入锁。它内部维护了持有者线程 ID 和重入计数同一个线程可以多次acquire()每次对应一次release()计数归零后锁才真正释放。在复杂业务里如果你不确定代码路径上会不会嵌套加同一把锁直接用RLock能规避一类非常隐蔽的死锁。但反过来Lock的性能和语义透明度更好能在设计上保证“拿锁代码块很短且不会嵌套”用Lock更合适。3.3 Event线程间的通知机制Event是线程间最简单的通信工具内部维护一个 bool 标志。一个线程等待event.wait()另一个线程在合适时机调用event.set()唤醒所有等待者。我用Event做过一个典型的“所有任务准备就绪再放行”的协调逻辑。主流程先创建 10 个工作线程每个线程在启动后做完自己模块的初始化最后调用ready_event.set()。主线程等待这个事件确保某些需要全部模块就绪才能启动的流程不会太早开始。import threading import time ready_event threading.Event() def worker(name): print(f{name} 正在初始化...) time.sleep(1) print(f{name} 初始化完成) ready_event.set() ready_event.clear() ts [threading.Thread(targetworker, args(f模块{i},)) for i in range(3)] for t in ts: t.start() ready_event.wait(timeout5) print(所有模块就绪主流程继续)注意wait(timeout5)很重要。如果某个子线程初始化失败一直不set()主线程会永久等下去。加上超时可以让流程在异常情况下也能继续配合后续的失败重试逻辑服务健壮性高很多。3.4 Condition、Semaphore遇到复杂协作再上Condition是比Event更底层的条件变量适合“条件满足才能继续”的生产者-消费者模型。经典写法是with cond:里先wait()被notify()唤醒后再检查条件。标准库的queue.Queue已经封装了线程安全的队列和同步机制大多数情况下你根本不需要手写 Condition直接用Queue更不容易出错。Semaphore信号量用来限制并发的数量典型场景是控制连接数。例如同时最多允许 5 个线程访问某个外部系统就创建一个Semaphore(5)每个线程执行前acquire()结束后release()。它本质上是一个计数器适合做“令牌分发”。不过在同一个进程里控制并发量线程池往往更方便信号量的用处更多是在需要“限流但不希望线程池模型改变”的场景。3.5 死锁产生原因与规避方案死锁是并发编程最阴间的 Bug。它需要同时满足四个条件资源互斥、持有资源并等待更多资源、资源不可被抢占、各线程形成循环等待。多线程项目里最常见的形式就是两把锁互相嵌套。比如线程 A 持有锁 1然后尝试拿锁 2线程 B 持有锁 2然后尝试拿锁 1。两人都等对方释放程序永久卡死。我维护过的一个实时数据上报模块就出过这么一次事故新版本里 A 模块为某个缓存加了锁B 模块在回调里又反向调用了 A 模块的方法锁顺序反了线上出现偶发卡顿。因为不是必现日志也看不出异常最后是抱着怀疑心态看线程堆栈才发现两个线程卡在互相等锁的位置。规避死锁的思路有几个尽量把锁的粒度做小能锁一条数据就不要锁整个集合。如果必须用多把锁让所有线程按同一个全局顺序加锁比如按模块 ID、按资源名排序。使用Lock.acquire(timeout3)给拿锁操作加超时拿不到就不等了做补偿处理。优先用更高级的并发抽象Queue、线程池、asyncio从底层减少手动加锁的机会。4. 线程池与常用并发写法4.1 为什么不能无限开线程“用线程”和“无限开线程”是两码事。每个线程在 Python 进程里都占用独立栈空间默认在 Linux 上约为 8MB 虚拟内存大量线程会带来不小的内存压力和调度开销。线程切换也不是免费的——操作系统的调度器要在线程之间保存和恢复寄存器、缓存切得越频繁开销越大。更关键的是线程数一旦超过 CPU 核心数纯计算任务反而会变慢I/O 任务也并非线性增长因为 GIL 切换本身也要时间。我见过有人用threading.Thread逐个创建一万个线程去发 HTTP 请求结果进程内存飙升整体耗时反而比用线程池差。生产环境里绝大多数任务都应该交给线程池管理。4.2 ThreadPoolExecutor 的标准用法concurrent.futures.ThreadPoolExecutor是替代手工管理线程的第一个选择它把任务提交、线程调度、结果收集都封装好了。from concurrent.futures import ThreadPoolExecutor, as_completed import time def fetch_data(url): time.sleep(1) return f{url} 的数据 urls [fhttps://example.com/api/{i} for i in range(20)] with ThreadPoolExecutor(max_workers8) as pool: futures [pool.submit(fetch_data, url) for url in urls] for future in as_completed(futures): result future.result() print(result)submit()返回一个Future对象它代表一个尚未完成的任务。用as_completed()按完成顺序取结果而不是按提交顺序这样处理耗时任务时效率更高。如果任务顺序敏感可以用pool.map(fetch_data, urls)它按输入顺序返回结果但会阻塞等待前面任务完成。取结果时记得future.result()可能会抛出任务里的异常最好包一层 try/except。给result(timeout5)传入超时时间能防止任务卡死时主流程也跟着卡住。4.3 max_workers 到底设多少没有一个万能数字但有几个经验公式可以算I/O 密集任务的并发线程数可以粗略估算为线程数 CPU核心数 × (1 平均等待时间 / 平均计算时间)比如一个任务平均耗时 1 秒其中 0.1 秒在计算、0.9 秒在等待 I/O8 核机器上理论值就是8 × (1 9) 80个线程左右。但这只是起点最终还要靠压测验证。CPU 密集任务则简单很多通常设为 CPU 核心数或者核心数 1。1是为了在一个线程因系统调用临时让出 CPU 时另一个线程能立刻顶上减少空闲。另一个非常有用的公式是 Little 定律并发数 QPS × 平均响应时间如果一个服务平均响应时间是 200ms你想支撑到 1000 QPS那么同一时刻在途请求大约是1000 × 0.2 200个也就意味着至少需要 200 个并发槽位。这个公式在规划线程池大小、估算服务器容量时特别实用。4.4 16C32G 服务器到底能扛多少并发这是社区里高频出现的问题但答案是“看场景”。很多人以为并发是一个固定数字实际上要先搞清楚你的并发概念是连接数、并发线程数、QPS还是在线用户数。不同类型差别巨大。拿一台 16 核 32GB 的 Linux 服务器跑 Python 服务举例纯 I/O 任务比如网关转发、API 聚合线程池开到 100 到 200QPS 到几千并不稀奇。带一点业务计算的接口比如做 JSON 序列化、查数据库线程池 50 到 80QPS 在几百到一千都是正常区间。CPU 密集任务比如实时数据处理、加解密线程池再大也白搭并发几十个就可能是极限必须改用多进程。所以遇到这类问题正确回应方式是先做压测通过 Little 定律反向推算。压测得到平均响应时间和实际 QPS就能换算出现有配置下的并发承载能力。盲目调大线程数只会增加切换成本并不会带来线性收益。5. 实战回归爬虫、IM、库存扣减、接口压测5.1 爬虫场景线程池 请求隔离Python 爬虫是多线程最容易见效的场景。一个耗时 2 秒的接口单线程跑 100 个要 200 秒线程池开 20 个就能压到 10 秒左右前提是网络带宽和目标服务都允许。爬虫项目里最容易踩的坑是共享requests.Session。Session 内部包含 Cookie、Header、连接池等状态多线程并发使用时会互相污染。我踩过之后改用threading.local()给每个线程维护一个独立的 Session问题立刻消失。import threading import requests local threading.local() def get_session(): if not hasattr(local, session): local.session requests.Session() local.session.headers.update({User-Agent: my-robot}) return local.session另一个常见问题是限流控制。有些目标站点并发太高会直接拒绝服务。可以用Semaphore限制最大并发请求数比如同时不超过 10 个避免把自己 IP 送进小黑屋。爬虫结果也不要让线程直接往共享 list 里 append虽然list.append在大多数情况下是安全的但“先判断再追加”这种复合操作就不安全。更稳妥的做法是让每个线程只负责抓取结果通过Future返回由主线程统一汇总或者用queue.Queue做收集管道。5.2 高并发 IM 场景为什么不能“一个连接一个线程”即时通讯应用是典型的高并发长连接场景海量客户端维持着 WebSocket 或 TCP 长连接。如果采用“每个连接分配一个线程”的模型一万个连接就是一万个线程内存、文件描述符、上下文切换成本全部爆炸服务基本撑不住。正确思路是分层处理长连接层放在异步事件循环里负责维护海量连接和收发消息业务逻辑里一旦涉及慢操作——比如查数据库、调用外部推荐服务——就把任务丢给线程池执行避免阻塞事件循环。线程池里的线程数量不需要很大通常几十个就够了因为大部分长连接状态下没有频繁计算。如果你正在用原生socket做长连接服务请优先考虑换用成熟的异步框架来处理连接层。多线程可以留在业务处理层不要让它一旦遇到长连接就无脑开线程。5.3 数据库并发锁与 ERP 库存扣减线程并发写数据库时会遇到比应用层更复杂的问题两个线程同时读到库存为 1都扣成 0但最终实际只成功扣了 0。这类问题不能只靠应用层加锁解决因为应用进程一旦多开、多台机器部署进程内锁完全不管用。ERP 库存扣减场景的常见方案是乐观锁或条件更新。例如UPDATE product SET stock stock - 1 WHERE id %s AND stock 0;这行 SQL 本身就是原子操作。数据库的WHERE stock 0条件相当于给并发更新加了约束如果更新影响行数为 0说明库存已经不足或并发冲突应用层可以重试或提示失败。这种方式比“先 SELECT 再 UPDATE”安全得多因为它在数据库层避免了丢更新。不需要每题都上分布式锁优先考虑能不能把扣减逻辑做成一条原子 SQL。应用层的Lock和 Redis 分布式锁在这种场景里是最后的兜底手段而不是首选。事务则要短平快绝不要在事务里做外部 HTTP 请求否则数据库连接会被拖住并发压测一上来连接池就直接打满。5.4 接口并发压测参数怎么按线程区分用 JMeter 做并发压测时很多人的误区是“所有线程同一个参数”。真实业务每个用户参数都不同压测结果会掩盖很多问题。JMeter 里可以用CSV Data Set Config配置参数文件比如一个 10 行的 CSV每行是不同的 user_id 和商品 id然后让线程按配置读取。这样每个并发请求用的参数都有差异更接近真实场景。如果你用的是 Locust同理可以在任务函数里按当前用户上下文生成不同参数。压测过程中要注意观察几个关键指标平均响应时间、P95/P99 响应时间、错误率、吞吐量。线程数是 10 还是 100 并不是目标QPS 和响应时间才是。压测结束后再用 Little 定律反推一下当前服务器到底能扛多少并发比嘴上争论“16C32G 能扛 1000 并发”靠谱得多。6. 常见问题与排查技巧实录6.1 为什么线程加了速度反而没提升这是新人最容易困惑的问题。如果任务本身是 CPU 密集多线程不仅没有提升还因为线程切换变慢这是 GIL 决定的。如果任务是 I/O 密集但没提速大概率是锁竞争太严重或者线程数量远超合理范围导致切换开销吃掉收益。我之前优化过一批批量导出任务刚开始一上来开 200 个线程效果居然比 50 个线程还差。用cProfile看了一下大量时间花在锁的等待和线程切换上。把线程池缩小到 30配合异步读取整体耗时反而降了一半。所以遇到“加了线程没变快”先别急着加更多线程回头看看任务类型和锁的竞争情况。6.2 哪些操作是线程安全的GIL 只能保护“单条字节码”的原子性比如list.append、dict.get这种单个操作一般没问题。但复合操作就不安全了比如if key not in cache: cache[key] expensive_compute()这段代码在并发下会有两个线程同时发现 key 不存在然后重复计算两次。这种“检查再写入”的复合逻辑必须加锁或者使用带原子语义的数据结构。Python 标准库里的queue.Queue内部用锁保护了put和get是线程安全的。collections.deque的append和popleft也是线程安全的但多个操作组合起来就不能保证。我的建议是共享状态尽量少能用不可变数据就用不可变数据必须变的数据优先考虑Queue或加锁。6.3 死锁和卡死的定位方法程序卡住不可怕怕的是不知道怎么定位。Python 有一个保底利器faulthandler。import faulthandler import sys faulthandler.dump_traceback_later(30, repeatTrue)这样每 30 秒会打印一次所有线程当前执行的函数栈。看到卡住的位置后问题往往就清楚了一半。如果程序已经卡死、无法在代码层面触发可以用py-spy这类工具直接对运行中的进程做线程栈转储py-spy dump --pid PID py-spy top --pid PIDpy-spy能列出所有线程的 Python 级调用栈不打断程序运行特别适合线上排障。这也是为什么要给线程命名的原因——如果日志里显示Thread-7卡在某个锁的acquire上你根本不知道这个线程是干什么的如果命名成inventory-deduction一眼就知道是哪里出问题。6.4 线程池任务堆积导致的内存暴涨ThreadPoolExecutor的默认任务队列是无界的。意思是队列里可以塞无限个任务消费速度跟不上生产速度时待处理任务越来越多内存不断膨胀直到进程 OOM。避免这个问题最好在提交侧加“流量控制”。简单方式是用Semaphore限制在途任务数semaphore threading.Semaphore(500) def submit_with_limit(pool, fn, *args, **kwargs): semaphore.acquire() def wrapper(): try: return fn(*args, **kwargs) finally: semaphore.release() return pool.submit(wrapper)也可以用有界队列自己封装一个生产者-消费者模型。生产环境里任何“任务只会增加不会背压”的设计都是隐患线程池也一样需要背压。最后分享一个我自己的习惯每次写完并发代码不要急着上线先在本地用ThreadPoolExecutor、Lock、Queue组合写一个最小可运行版本故意把线程数调大跑一遍压测观察响应时间、异常、内存变化。并发编程里很多问题都不是逻辑不通而是边界条件、超时处理、资源释放没做好。把最基本的锁、事件、队列用得干净利落比背诵一堆并发理论实在得多。多线程这条路我也踩过不少坑但只要建立了“任务类型决定工具同步逻辑保障安全线程池管理资源”的三层心智模型大部分问题都能在发生之前被拦住。
返回列表