Python线程详解
前言多线程multithreading是 Python 里最容易被高估、也最容易被误解的特性的组件之一。常见的第一层误解是「多线程等于多核并行、所以一定更快」第二层误解是「既然 CPython 有 GIL那多线程干脆没用」。这两句话都不成立。线程真正解决的问题是并发concurrency让程序在等待某件事网络响应、磁盘读写、用户输入的时候能腾出手去干别的。它从来不是并行parallelism意义上的「同时算」。I/O 密集I/O-bound任务用线程很划算CPU 密集CPU-bound任务用线程几乎没有加速。本文按「线程是什么 → GIL 为什么存在 → 生命周期与 API → 竞态与同步」的顺序把机制讲清最后给一段可直接复制的完整示例。示例以 CPython 3.8 及以上为基准涉及更高版本才有的行为时会单独标注。一、进程与线程的边界进程process是操作系统分配资源的单位每个进程有独立的地址空间、文件描述符表、内存。线程thread是操作系统调度的单位同一个进程内的多个线程共享该进程的地址空间和绝大多数资源只各自持有一份独立的调用栈和寄存器状态。共享内存带来两个直接后果恰好一好一坏好消息线程之间交换数据极快不需要序列化直接读同一个对象就行。坏消息多个线程同时改同一个对象时如果不做同步就会互相覆盖这叫竞态条件race condition。线程比进程「轻」是相对的创建一个进程要复制地址空间表、创建线程只多一个栈线程切换也比进程切换便宜。但「轻」不等于「免费」更不等于「能绕开 GIL」。维度进程线程地址空间各自独立同进程内共享创建/切换开销较大较小数据交换需序列化pickle 等直接共享对象崩溃影响一般不影响其他进程一个线程崩溃可能带走整个进程能否绕开 GIL可以各自一个解释器不能二、GIL为什么多线程加速不了 CPU 密集任务CPython 的实现里有一把全局解释器锁GILGlobal Interpreter Lock。它的效果是同一进程内同一时刻只有一个线程在执行 Python 字节码。注意三个限定词——「同一进程内」「同一时刻」「Python 字节码」。由此推出两条结论CPU 密集任务线程大部分时间都在抢这把锁多开线程并不能让多核一起算反而多出锁竞争和切换开销。官方文档在threading模块页明确写着想让程序更好地利用多核机器的计算资源应当改用multiprocessing或concurrent.futures.ProcessPoolExecutor。I/O 密集任务线程在等待 I/O 时会释放 GIL别的线程就能趁这段时间跑。所以爬虫、批量请求、读写文件、等数据库返回这类场景多线程是真有效的。必须说清楚的一点CPython 3.13 起提供了实验性的自由线程free-threaded即 no-GIL构建可以在运行时关掉 GIL实现真正的并行执行。但官方文档同时写明「该特性是实验性的因此默认不启用」——它需要单独的可执行文件安装时选 free-threaded 版本或自行用--disable-gil从源码编译。所以不要在任何场合说「Python 已经没有 GIL 了」默认构建里 GIL 依然在。顺便记住GIL 是CPython 的实现细节不是 Python 语言规范的一部分。JPython、PyPy部分版本没有同样的限制。三、线程的生命周期与核心 API一个线程对象从创建到结束大致经历创建 →start()→run()执行 → 结束正常返回或抛未捕获异常。is_alive()用来判断它是否还活着。# 适用于 Python 3.8import threadingimport timedef worker(name, n):for i in range(n):print(f[{name}] 第 {i} 次工作)time.sleep(0.1) # 模拟 I/O 等待sleep 期间线程会让出 GILthreads []for name in (A, B, C):t threading.Thread(targetworker, args(name, 3), namefworker-{name})threads.append(t)t.start() # 只负责“启动”立刻返回不阻塞for t in threads:t.join() # 主线程在此等待每一个子线程结束print(全部完成)几个必须记牢的语义t.start()只能对一个线程对象调用一次第二次会抛RuntimeError。想再跑一遍要新建对象。线程对象构造出来不会自动跑必须显式start()。join()阻塞调用它的那个线程这里是主线程直到被 join 的线程结束join永远返回None想判断是不是超时了得在join(timeout)之后再看is_alive()。默认线程是非守护non-daemon的主线程会等所有非守护线程结束程序才退出。设daemonTrue则相反——整个 Python 程序在只剩守护线程时就会退出守护线程会被突然掐断它占用的文件、数据库事务等资源可能来不及正确释放。想让它优雅收尾就设成非守护并用Event之类的机制通知它退出。还有一个容易忽略的点isDaemon()/setDaemon()是旧式写法从 Python 3.10 起已废弃请直接用daemon属性或构造参数。Python 2 时代的thread模块在 Python 3 里也已被改名为_thread带下划线的低层模块日常编程不该直接用它。四、竞态条件与 Lock看看下面这段代码。counter 1看起来是一步实际上在字节码层面是「读全局变量 → 加一 → 写回全局变量」三步。GIL 保证单条字节码不被中途抢占但不保证这三条字节码连续执行——解释器会按切换间隔sys.getswitchinterval()默认值约几毫秒在不同线程间轮转。# 适用于 Python 3.8import threadingcounter 0def add_one():global counterfor _ in range(100_000):counter 1 # 非原子操作读-改-写三步可被切换打断threads [threading.Thread(targetadd_one) for _ in range(5)]for t in threads:t.start()for t in threads:t.join()print(counter) # 期望 500000实际往往偏小修法是用锁把「读-改-写」包成一个临界区。推荐用with语句它能保证即使临界区里抛异常锁也会被释放# 适用于 Python 3.8import threadingcounter 0lock threading.Lock()def add_one():global counterfor _ in range(100_000):with lock: # 进入时 acquire()退出时 release()counter 1threading.Lock与threading.RLock的区别Lock不可重入同一线程重复acquire()会自己把自己锁死RLock允许同一线程多次acquire()但必须release()同样多次才能解锁因此它必须由加锁的那个线程来释放。同步原语作用典型场景Lock互斥保护临界区计数器、共享字典RLock可重入互斥同一个锁被同一线程嵌套获取Condition等待某个条件成立再继续生产者-消费者Event一个线程等另一个线程的「开始」信号优雅停止、启动栅栏Semaphore限制同时访问的线程数限速、连接池queue.Queue自带锁的线程安全队列任务分发其中queue.Queue值得单独提一句queue模块文档明确写着它实现了「所有必需的加锁语义」多生产者多消费者场景下直接用 Queue 传数据比手动加锁安全得多也更不容易写出死锁。五、完整实战线程池并发抓取生产代码里很少直接Thread(target...)手工管理一批线程更常用concurrent.futures.ThreadPoolExecutor它会复用线程、统一收集结果、自动传播异常。# 适用于 Python 3.8import concurrent.futuresimport timeimport urllib.errorimport urllib.requestURLS [https://www.python.org/,https://docs.python.org/3/,https://peps.python.org/,]def fetch(url, timeout10):下载一个页面返回 (url, 字节数)。try:with urllib.request.urlopen(url, timeouttimeout) as resp:return url, len(resp.read())except (urllib.error.URLError, TimeoutError) as exc:return url, f下载失败: {exc}def main():start time.perf_counter()with concurrent.futures.ThreadPoolExecutor(max_workers4) as pool:# submit 立即返回 Future不阻塞futures [pool.submit(fetch, url) for url in URLS]# as_completed 谁先完成先产出而不是按提交顺序for fut in concurrent.futures.as_completed(futures):url, info fut.result()print(f{url} - {info})print(f耗时 {time.perf_counter() - start:.2f} 秒)if __name__ __main__:main()max_workers的默认值经历过两次调整3.8 起是min(32, os.cpu_count() 4)3.13 起改为min(32, (os.process_cpu_count() or 1) 4)。写业务代码时显式指定一个值更稳妥不要在正文里相信「开 N 倍线程就快 N 倍」这类说法——实际耗时受目标站响应速度、网络带宽、对方的限流策略共同影响想比较就自己用time.perf_counter()实测。另外这段代码只演示技术用法。真实爬取前请阅读目标站的robots.txt与使用条款控制请求频率、加合理的延迟与超时不要对单个站点发起高并发冲击。常见坑点1. 以为多线程能让 CPU 密集任务变快❌for _ in range(1000): Thread(targetheavy_math).start()指望多核一起算。 ✅ CPU 密集请用multiprocessing或concurrent.futures.ProcessPoolExecutor线程留给 I/O 密集任务。2. 线程对象调了两次start()❌t Thread(targetf); t.start(); t.join(); t.start()—— 第二次抛RuntimeError。 ✅ 每个线程对象只能启动一次要重跑就新建一个对象。3. 把守护线程当成「后台任务」随意使用❌Thread(targetsave_data, daemonTrue)然后主程序直接结束导致数据没写完。 ✅ 守护线程会在程序退出时被突然掐断关键写操作请设非守护并join()或用Event通知其优雅退出。4. 用x 1累加共享变量❌ 多线程里直接global counter; counter 1结果总是偏小。 ✅ 用with lock:包住读-改-写或改用queue.Queue汇总结果。5.join()的返回值当成了执行结果❌result t.join()然后print(result)打印出None。 ✅join()只负责等待且恒返回None线程的产出要通过共享容器、Queue或Future.result()取回。6. 忘记join()就检查结果❌ 主线程start()完立刻读共享变量读到的是半成品。 ✅ 对所有非守护线程显式join()或用ThreadPoolExecutor的with块自动等待。7. 用废弃的setDaemon()/isDaemon()❌t.setDaemon(True)在 3.10 会收到DeprecationWarning。 ✅ 用构造参数Thread(targetf, daemonTrue)或改t.daemon True必须在start()之前设置。8. 用time.sleep之外的方式「让出」CPU❌ 在 CPU 密集的循环里指望 GIL 自动公平调度导致 I/O 线程迟迟得不到执行。 ✅ 长循环里主动time.sleep(0)或合理切分任务也可以调sys.setswitchinterval()但这是全局副作用慎用。总结结论说明线程共享地址空间通信快但必须同步GIL 制约 CPU 密集同进程内同一时刻只有一个线程跑字节码I/O 等待会释放 GIL因此 I/O 密集任务用线程有效3.13 有实验性 no-GIL 构建非默认、需单独安装别说过头保护共享可变状态优先with lock:或queue.Queue优先用线程池ThreadPoolExecutor管生命周期更省心选型口诀等 I/O 用线程算数据用进程要细粒度协作就用锁和队列把共享状态管起来。GIL 不是「Python 太慢」的借口它只是划定了线程能力的那条边界——知道边界在哪才能把线程用在刀刃上。