公告薛鑫鹏的博客 —— 内容持续更新,欢迎指正。

Python多线程编程常见问题怎么解决?,多线程死锁怎么解决

2026-11-06 问题记录 0 阅读

Python多线程编程的绝大多数问题,都绕不开GIL、共享变量竞争和线程池配置这三件事,把这三处理顺,剩下的坑基本是细节。下面这些内容来自我自己写爬虫、写任务调度脚本时踩过的坑,按“为什么会出问题—怎么改—怎么验证”的顺序讲。

GIL不是玄学:Python多线程到底在什么场景下才有意义

新手最常问的一句是“Python多线程是不是假的”。这个说法不算错,但也不完整。要判断它有没有用,得先看清楚GIL在管什么。

GIL到底锁住了什么

CPython解释器在执行字节码时,同一时刻只允许一个线程持有GIL。也就是说,纯Python的计算型代码,你开8个线程,本质上还是一个一个排队跑,切换还要额外开销。CPython官方文档对这个机制有明确说明,这不是实现缺陷,而是早期为简化内存管理和引用计数做的取舍。

但关键在于:GIL在遇到I/O阻塞时会主动释放。文件读写、网络请求、数据库查询、time.sleep,这些操作等待期间线程会让出GIL,其他线程就能接着干活。所以多线程真正的战场是I/O密集型任务,不是算力密集型任务。

什么时候多线程真能提速

  • 01
    网络请求

    批量调接口、抓页面,几十个线程并发,总耗时能压到原来的几分之一。

  • 02
    文件与磁盘操作

    批量读写小文件、图片转码前的读取阶段。

  • 03
    等待型任务

    轮询第三方状态、定时探测服务可用性。

  • 04
    不适合

    大矩阵运算、图像像素处理、加密解密这类纯CPU循环。

顺便提一句,Python 3.13开始提供了实验性的free-threaded构建,可以编译出不带GIL的解释器,但生态兼容性还在推进中。据Python官方文档的说明,这个模式目前不建议直接用于生产环境,短期内GIL仍然是默认行为。

Python多线程为什么比单线程还慢

这个问题我在一个日志清洗脚本上真实遇到过:改成20个线程后,跑得比原来慢了一倍多。原因不复杂,通常是下面几种叠加。

常见原因清单

  1. 任务本身是CPU密集型。线程切换加上抢GIL的开销,白忙一场。
  2. 线程数开太大。系统调度、上下文切换的成本会吃掉并发收益。I/O任务一般从8到32个线程起步试探,不是越多越好。
  3. 锁粒度过粗。一个全局锁把所有操作串起来,等于人为造了个单线程。
  4. 频繁创建销毁线程。每次new一个Thread都要走一遍系统调用,短任务尤其亏。
  5. 共享数据结构选错。用普通list做多线程队列,加锁逻辑写得不好就会反复等待。

验证方法很直接:用cProfile看耗时分布,或者在线程函数里打时间戳,统计每个线程真正执行的时间片占比。如果大量时间花在等待锁上,问题就在并发模型而不在代码效率。

Python多线程和多进程该怎么选

这是我在技术群里被问得最多的对比问题。判断标准其实只有一条:任务卡在等,还是卡在算。

维度 多线程(threading) 多进程(multiprocessing)
适用任务 I/O密集:网络、文件、数据库 CPU密集:计算、编解码、图像处理
内存占用 共享同一进程空间,占用小 每个进程独立内存,占用大
数据传递 直接读写,需加锁 需要队列或管道序列化
启动开销 小 较大,Windows下更明显
调试难度 竞争问题隐蔽,难复现 相对好定位

行业共识认为,超过七成的后台脚本场景属于I/O等待,先用线程池跑起来再说;只有当单机CPU占用稳定跑满、且数据可以分片时,才值得换成多进程或者干脆上异步。

threading模块的三个实操要点

理论说完了,落到代码上。这部分是我自己反复改过几版的写法。

要点一:用线程池,不要手动new

from concurrent.futures import ThreadPoolExecutor

def fetch(url):
    # 这里放requests请求
    return url

with ThreadPoolExecutor(max_workers=16) as pool:
    results = list(pool.map(fetch, url_list))

ThreadPoolExecutor省掉了手动join和异常收集,map会按顺序返回结果。max_workers不要超过32,除非明确知道对方接口能扛住。

要点二:共享变量必须加锁,累加尤其危险

counter += 1看着是一行,实际是读取、加一、写回三步,线程一交错就丢数据。正确做法是threading.Lock()包住临界区,或者直接用queue.Queue把结果收回主线程统一处理。后者更快也更少出错,我个人更推荐。

要点三:异常不会自动冒出来

线程里抛的异常,默认不会中断主程序,也不会打印到你能看见的地方。用线程池时,异常会在取结果的那一刻才抛出;手动起线程时,记得在函数最外层套try/except并写日志,否则排查起来会怀疑人生。

Python多线程爬虫最容易翻车的地方

爬虫是多线程最典型的落地场景,也是最容易把对方服务器打挂、把自己IP封掉的场景。我的经验是三个约束必须提前定死。

并发数、限速、去重

  • 01
    并发数

    单站点建议不超过10,跨站点可以到30以上。

  • 02
    限速

    用time.sleep或者信号量控制每秒请求数,别裸奔。

  • 03
    去重与任务队列

    用queue.Queue存放待抓链接,配合一个已访问集合,避免线程重复抓同一页。

  • 04
    会话复用

    每个线程复用requests.Session,减少握手开销。

还有一个容易被忽略的点:线程安全地写文件。多个线程同时open同一个文件写日志,很容易出现内容错行。稳妥做法是让工作线程只往队列放数据,单独开一个写线程负责落盘。

出问题怎么查:三条可验证的排查路径

线程问题最烦的是“偶发”。下面几个手段我用得最多。

  1. 打线程名和时间戳。用threading.current_thread().name配合time.time(),能看出是哪个线程卡住、卡了多久。
  2. 看线程栈。程序疑似挂死时,用faulthandler.dump_traceback()或者py-spy dump --pid直接导出所有线程的调用栈,锁等待一眼就能看出来。
  3. 降并发复现。把线程数降到2,如果问题消失,基本可以锁定是竞争条件而不是逻辑错误。

据近年来一些线上事故复盘的经验看,多数“多线程导致数据错乱”的根因,落在共享可变对象没有隔离上,而不是Python本身的问题。

学习路径与一点现实观察

想系统补这块,我的建议是先读CPython官方文档的threading和concurrent.futures两章,再动手改写一个自己旧的串行脚本,比看视频有效得多。

顺带说两个现实情况:市面上的Python并发编程线上课程价格跨度很大,从几十元的录播到上千元的训练营都有,内容重合度不低,先看免费文档再决定要不要付费更划算。另外从招聘侧看,北京、杭州、深圳等地后端和数据开发岗位的JD里,“熟悉多线程与异步编程”几乎是标配要求,面试常考的就是GIL、线程池参数和锁的粒度这三类问题。

回到开头那句话:把GIL的适用边界、共享变量的隔离方式、线程池的数量控制搞清楚,Python多线程的坑其实就那么几处。

Python多线程编程常见问题Q&A

Q1:多线程里用print会不会有问题?

会有。多个线程同时print可能输出交错,尤其是长字符串。建议改成logging模块,它内部有锁,输出更规整,还能带线程名。

Q2:daemon线程和普通线程有什么区别?

设成daemon的线程会随主线程退出而强制结束,适合后台心跳、监控这类可随时中断的任务;普通线程则会阻塞主线程退出,直到执行完毕。数据落盘、事务提交这类工作不要放在daemon线程里,可能来不及完成。

Q3:Python多线程能利用多核CPU吗?

不能。受GIL限制,同一进程内的多个Python线程在任一时刻只有一个在执行字节码,多核无法被纯Python计算型代码同时占满。要吃到多核,需要用multiprocessing、concurrent.futures的ProcessPoolExecutor,或把计算下沉到NumPy这类释放GIL的C扩展里;仅就I/O等待场景而言,多线程依然能显著缩短总耗时。

推荐阅读

RELATED
重庆通星亿科技有限公司 渝ICP备2026025661号-3