CPU 只能看到线程

27 阅读8分钟

本文翻译自我的英文博客,最新修订内容可随时参考:CPU can only see the threads

在 Python 中,由于 GIL(全局解释器锁)的存在——这是一个互斥锁,确保同一时刻只有一个线程能执行——因此在 CPython 解释器下不支持多线程并行执行。但多进程呢?它们之间的区别是什么?如何选择合适的方法?你了解协程吗?让我们一起探讨。

前置知识

首先,你需要了解以下基本概念:

  1. 进程(Process):进程是资源分配的基本单位。
  2. 线程(Thread):线程是 CPU 调度的最小单位。

对于每个进程,实际的执行单元是进程中的主线程。因此,即使在不同进程中,CPU 实际看到的仍然是线程

计算机的核心是可同时并行的物理核心数量(CPU 只能“看到”线程)。由于超线程技术(Hyper-Threading),实际可并行的线程数通常是物理核心数的 两倍(这也是操作系统看到的核心数)。我们只关心可并行的线程数,因此 后文提到的核心数均指操作系统看到的核心数(即包含超线程后的逻辑核心,非物理核心)。

  • 若计算机有多个 CPU 核心,且系统中线程总数少于核心数,线程可并行运行在不同核心上。
  • 若为单核多线程,多线程并非并行,而是 并发(CPU 调度器在单个核心上切换不同线程以平衡负载)。
  • 若为多核多线程且线程数超过核心数,部分线程会持续切换(并发执行),但 实际最大并行执行数等于当前核心数。因此,盲目增加线程数不会提升程序速度,反而会增加额外开销。

进程(Process)

  1. 资源分配单位:每个进程拥有独立的运行空间(包括文本段、数据段、栈段)。
  2. 独立内存空间:进程间内存地址空间隔离,确保安全性。
  3. 进程组成:包含程序、数据集和进程控制块(PCB),PCB 记录进程占用的资源信息。
  4. 调度单位:进程切换需保存和恢复 CPU 状态(如寄存器值、程序计数器等)。
  5. 进程间通信(IPC):无法直接通信,需通过 管道(父子进程)、命名管道、信号、消息队列、共享内存(效率最高)、套接字(跨机器) 等机制。

缺点

  • 进程频繁切换开销大(内存占用 GB 级别)。

线程(Thread)

  1. CPU 调度最小单位:线程是轻量化的进程,又称“轻量级进程”。
  2. 共享内存空间:同一进程内的线程共享进程资源(如内存、文件句柄),可直接通信。
  3. 调度开销小:线程切换仅涉及栈(KB 级别)和寄存器,远小于进程切换。
  4. 资源依赖:从属于进程,不独立分配资源。线程由 栈(系统栈/用户栈)、寄存器、线程控制块(TCB) 组成,寄存器仅存储本线程局部变量,无法访问其他线程数据。

其他对比

  1. 独立性
    • 进程间相互独立,一个进程崩溃不影响其他进程。
    • 线程间不独立,一个线程崩溃会导致整个进程崩溃。
  2. 内存安全
    • 进程使用可锁定的内存地址(如互斥锁),当一个线程占用共享内存时,其他线程必须等待。

选择策略

  1. CPU 密集型任务:推荐使用多进程(规避 GIL 限制,充分利用多核)。
  2. IO 密集型任务:推荐使用多线程(如网络爬虫,IO 阻塞时线程释放 GIL,允许其他线程运行)。
  3. 常见场景
    • Web 服务器(频繁创建/关闭连接):多线程更合适。
    • 数据强关联场景(如共享缓存):多线程更高效(无需跨进程通信)。

上下文切换(Context Switching)

类型

  1. 进程上下文切换:不同进程间的切换,任务调度采用 时间片轮转抢占式策略
  2. 线程上下文切换:同一进程内不同线程间的切换。
  3. 用户态与内核态切换:用户程序调用硬件设备时,需从用户态切换到内核态执行系统调用。

步骤

  1. 切换地址空间:仅进程切换需更换页表(虚拟内存空间),线程切换共享同一地址空间(因此进程切换开销最大)。
  2. 切换内核栈与硬件上下文:最耗时的是寄存器内容的保存与恢复。

性能瓶颈判断

若 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()  # 等待子进程完成

进程间通信

  1. 队列(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())  # 从队列中取出数据(阻塞直到有数据)
    
  2. 管道(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())  # 接收数据
    
  3. 进程池(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
超高频调度任务协程无内核上下文切换,调度成本极低
跨机器通信多进程+套接字进程隔离性强,套接字支持分布式通信

参考资料