本文翻译自我的英文博客,最新修订内容可随时参考:CPU can only see the threads
在 Python 中,由于 GIL(全局解释器锁)的存在——这是一个互斥锁,确保同一时刻只有一个线程能执行——因此在 CPython 解释器下不支持多线程并行执行。但多进程呢?它们之间的区别是什么?如何选择合适的方法?你了解协程吗?让我们一起探讨。
前置知识
首先,你需要了解以下基本概念:
- 进程(Process):进程是资源分配的基本单位。
- 线程(Thread):线程是 CPU 调度的最小单位。
对于每个进程,实际的执行单元是进程中的主线程。因此,即使在不同进程中,CPU 实际看到的仍然是线程。
计算机的核心是可同时并行的物理核心数量(CPU 只能“看到”线程)。由于超线程技术(Hyper-Threading),实际可并行的线程数通常是物理核心数的 两倍(这也是操作系统看到的核心数)。我们只关心可并行的线程数,因此 后文提到的核心数均指操作系统看到的核心数(即包含超线程后的逻辑核心,非物理核心)。
- 若计算机有多个 CPU 核心,且系统中线程总数少于核心数,线程可并行运行在不同核心上。
- 若为单核多线程,多线程并非并行,而是 并发(CPU 调度器在单个核心上切换不同线程以平衡负载)。
- 若为多核多线程且线程数超过核心数,部分线程会持续切换(并发执行),但 实际最大并行执行数等于当前核心数。因此,盲目增加线程数不会提升程序速度,反而会增加额外开销。
进程(Process)
- 资源分配单位:每个进程拥有独立的运行空间(包括文本段、数据段、栈段)。
- 独立内存空间:进程间内存地址空间隔离,确保安全性。
- 进程组成:包含程序、数据集和进程控制块(PCB),PCB 记录进程占用的资源信息。
- 调度单位:进程切换需保存和恢复 CPU 状态(如寄存器值、程序计数器等)。
- 进程间通信(IPC):无法直接通信,需通过 管道(父子进程)、命名管道、信号、消息队列、共享内存(效率最高)、套接字(跨机器) 等机制。
缺点:
- 进程频繁切换开销大(内存占用 GB 级别)。
线程(Thread)
- CPU 调度最小单位:线程是轻量化的进程,又称“轻量级进程”。
- 共享内存空间:同一进程内的线程共享进程资源(如内存、文件句柄),可直接通信。
- 调度开销小:线程切换仅涉及栈(KB 级别)和寄存器,远小于进程切换。
- 资源依赖:从属于进程,不独立分配资源。线程由 栈(系统栈/用户栈)、寄存器、线程控制块(TCB) 组成,寄存器仅存储本线程局部变量,无法访问其他线程数据。
其他对比
- 独立性:
- 进程间相互独立,一个进程崩溃不影响其他进程。
- 线程间不独立,一个线程崩溃会导致整个进程崩溃。
- 内存安全:
- 进程使用可锁定的内存地址(如互斥锁),当一个线程占用共享内存时,其他线程必须等待。
选择策略
- CPU 密集型任务:推荐使用多进程(规避 GIL 限制,充分利用多核)。
- IO 密集型任务:推荐使用多线程(如网络爬虫,IO 阻塞时线程释放 GIL,允许其他线程运行)。
- 常见场景:
- Web 服务器(频繁创建/关闭连接):多线程更合适。
- 数据强关联场景(如共享缓存):多线程更高效(无需跨进程通信)。
上下文切换(Context Switching)
类型
- 进程上下文切换:不同进程间的切换,任务调度采用 时间片轮转抢占式策略。
- 线程上下文切换:同一进程内不同线程间的切换。
- 用户态与内核态切换:用户程序调用硬件设备时,需从用户态切换到内核态执行系统调用。
步骤
- 切换地址空间:仅进程切换需更换页表(虚拟内存空间),线程切换共享同一地址空间(因此进程切换开销最大)。
- 切换内核栈与硬件上下文:最耗时的是寄存器内容的保存与恢复。
性能瓶颈判断
若 CPU 满负载,合理的时间分配应为:
- 用户态时间(User Time):65%~70%
- 系统态时间(System Time):30%~35%(过高则说明上下文切换频繁)
- 空闲时间(Idle):0%~5%
Python 中的多线程
其他语言中,多线程可并行运行在多个核心上,但在 Python 中,同一时刻只有一个线程能获取 CPU。
Python 的 GIL(全局解释器锁)确保同一时刻只有一个线程执行字节码。Python 有多种解释器:
- CPython:官方实现(用 C 编写),存在 GIL。
- Jython:将 Python 代码编译为 Java 字节码,运行于 JVM(无 GIL)。
- IronPython:.NET 平台实现(无 GIL)。
- PyPy:用 RPython 实现(GIL 限制较弱)。
为什么只有 CPython 有 GIL?
CPython 的内存管理(如垃圾回收)并非线程安全。若无 GIL,多线程同时操作对象时可能导致内存泄漏或程序崩溃。GIL 确保同一时刻只有一个线程访问 CPython 解释器,从而保证线程级安全。
线程安全
线程安全主要涉及内存安全。进程内所有线程共享堆内存,若无保护机制,数据可能被其他线程意外修改。常见解决方案包括互斥锁(Mutex)、信号量(Semaphore)等。
Python 线程使用示例
# 直接使用 threading 模块
import threading
def run(n):
print("当前任务:", n)
if __name__ == "__main__":
t1 = threading.Thread(target=run, args=("线程 1",))
t2 = threading.Thread(target=run, args=("线程 2",))
t1.start()
t2.start()
# 自定义线程类
class MyThread(threading.Thread):
def __init__(self, name):
super().__init__()
self.name = name
def run(self):
print("当前任务:", self.name)
# 主线程等待子线程完成
t1.start()
t2.start()
t1.join() # 阻塞主线程,直至 t1 完成
t2.join(timeout=5) # 带超时的等待
# 守护线程(Daemon Thread):随主线程退出而终止
t1.setDaemon(True)
t1.start()
定时器与线程局部存储
# 定时器:延迟执行任务
t = threading.Timer(1, show) # 1 秒后执行 show 函数
t.start()
# 线程局部存储(每个线程独立数据)
local_school = threading.local() # 创建线程局部变量
local_school.student = "Alice" # 仅当前线程可见
线程同步与锁
# 互斥锁(Lock)
mutex = threading.Lock()
mutex.acquire() # 加锁
# 临界区代码
mutex.release() # 解锁
# 可重入锁(RLock):允许同一线程多次获取锁
reentrant_lock = threading.RLock()
# 信号量(Semaphore):限制同时访问资源的线程数
semaphore = threading.BoundedSemaphore(5) # 最多 5 个线程同时获取锁
Python 中的多进程
多进程可绕过 GIL 限制,充分利用多核 CPU。
# 基本用法
from multiprocessing import Process
def show(name):
print("子进程名称:", name)
if __name__ == "__main__":
proc = Process(target=show, args=("子进程",))
proc.start()
proc.join() # 等待子进程完成
进程间通信
-
队列(Queue):
from multiprocessing import Queue def put_data(queue): queue.put("数据") # 向队列中放入数据 queue = Queue() proc = Process(target=put_data, args=(queue,)) proc.start() print(queue.get()) # 从队列中取出数据(阻塞直到有数据)
-
管道(Pipe):
from multiprocessing import Pipe parent_conn, child_conn = Pipe() # 创建双向管道 def send_data(conn): conn.send("管道数据") conn.close() proc = Process(target=send_data, args=(child_conn,)) proc.start() print(parent_conn.recv()) # 接收数据
-
进程池(Pool):
from multiprocessing import Pool import time def task(msg): print("任务:", msg) time.sleep(1) if __name__ == "__main__": pool = Pool(processes=3) # 最多 3 个进程并行 for i in range(5): pool.apply_async(task, args=(f"任务{i}",)) # 异步非阻塞提交任务 pool.close() # 关闭进程池,不再接受新任务 pool.join() # 等待所有任务完成
协程(Coroutine)
协程是用户态的轻量级线程,基于事件循环(Event Loop)和 await
主动切换,无需操作系统调度。
- 特点:单线程执行,无线程切换开销,栈内存仅 KB 级别。
- 安全性:单线程环境下无资源竞争问题(除非主动使用
await
切换)。
import asyncio
balance = 0
async def update_balance(n):
global balance
balance += n
await asyncio.sleep(1) # 主动让出控制权(模拟 IO 阻塞)
balance -= n
print(balance)
# 运行协程
loop = asyncio.get_event_loop()
loop.run_until_complete(asyncio.gather(
update_balance(10),
update_balance(20)
))
print("最终余额:", balance) # 输出:0
注意:
- 若协程中无
await
,则顺序执行,结果确定。 - 若有
await
,需通过锁(如asyncio.Lock
)保证数据一致性,此时异步会退化为同步。
总结
场景 | 推荐方案 | 理由 |
---|---|---|
CPU 密集型任务 | 多进程 | 规避 GIL,利用多核并行 |
IO 密集型任务 | 多线程/协程 | 线程在 IO 阻塞时释放 GIL,协程轻量级适合高并发 IO |
超高频调度任务 | 协程 | 无内核上下文切换,调度成本极低 |
跨机器通信 | 多进程+套接字 | 进程隔离性强,套接字支持分布式通信 |
参考资料: