目 录CONTENT

文章目录

【Python】深入理解Python并发:进程、线程、协程与操作系统调度机制

EulerBlind
2025-10-16 / 0 评论 / 0 点赞 / 11 阅读 / 0 字

引言

在Python面试中,"进程、线程、协程有什么区别?"是一个经典问题。表面上这是个简单的概念对比,但深入探讨会涉及操作系统调度、内存管理、GIL机制等核心知识。本文将系统性地解析这些概念,并深入操作系统层面理解它们的工作原理。

一、三者的本质区别

1.1 基本概念

进程(Process)

  • 操作系统资源分配的基本单位
  • 拥有独立的内存空间和Python解释器实例
  • 进程间通信需要特殊机制(管道、队列、共享内存)
  • 创建和销毁开销大

线程(Thread)

  • CPU调度的基本单位
  • 同一进程内的线程共享内存空间
  • 轻量级,创建销毁开销较小
  • 受GIL(全局解释器锁)限制

协程(Coroutine)

  • 用户态的轻量级线程,由程序自己调度
  • 在单线程内通过事件循环实现并发
  • 极轻量,可创建成千上万个
  • 通过async/await实现协作式调度

1.2 GIL的影响

GIL是CPython的特性,它确保同一时刻只有一个线程执行Python字节码。这导致:

import threading
import time

def cpu_bound():
    count = 0
    for i in range(100000000):
        count += i

# 单线程
start = time.time()
cpu_bound()
print(f"单线程: {time.time() - start:.2f}秒")

# 多线程(并不会更快!)
start = time.time()
threads = [threading.Thread(target=cpu_bound) for _ in range(2)]
for t in threads: t.start()
for t in threads: t.join()
print(f"多线程: {time.time() - start:.2f}秒")

结论

  • 多线程只在I/O密集型任务中有效
  • CPU密集型任务必须用多进程才能真正并行
  • 协程适合高并发I/O场景

1.3 使用场景对比

场景推荐方案原因
CPU密集计算多进程绕过GIL,真正并行
I/O密集(少量连接)多线程开销适中,实现简单
I/O密集(海量连接)协程极低开销,高并发
混合任务进程+协程进程分配CPU,协程处理I/O

二、进程Fork机制深度解析

2.1 Fork的本质

import os

print(f"父进程开始, PID={os.getpid()}")

pid = os.fork()  # 分叉点!

if pid == 0:
    print(f"子进程, PID={os.getpid()}, 父PID={os.getppid()}")
else:
    print(f"父进程, PID={os.getpid()}, 子PID={pid}")

关键理解

  • fork()调用一次,返回两次
  • 父进程中返回子进程PID,子进程中返回0
  • fork()之后的代码,两个进程都会执行

2.2 Copy-on-Write(写时复制)

很多人误以为fork会立即复制整个内存空间,实际上Linux使用COW机制:

Fork时刻:
父进程和子进程共享同一块物理内存(标记为只读)

当任一进程写入时:
触发缺页中断 → 操作系统复制该内存页 → 两个进程拥有独立副本

核心数据结构

// 页表项(PTE)结构
struct page_table_entry {
    unsigned long pfn;        // 物理页帧号
    unsigned int writable: 1; // 可写标志(COW时设为0)
    // ... 其他标志位
};

// 物理页框结构
struct page {
    atomic_t _refcount;      // 引用计数(核心!)
    // ... 其他元数据
};

2.3 Fork后的内存管理模拟

class Page:
    """模拟物理页"""
    def __init__(self, data):
        self.data = data
        self.refcount = 1
        self.id = id(self)

class PageTableEntry:
    """模拟页表项"""
    def __init__(self, page, writable=True):
        self.page = page
        self.writable = writable

class Process:
    """模拟进程"""
    def __init__(self, name):
        self.name = name
        self.page_table = {}
    
    def write(self, vaddr, data):
        """写入内存 - 触发COW"""
        pte = self.page_table[vaddr]
        
        if not pte.writable:
            print(f"[{self.name}] 触发COW! 虚拟地址 {vaddr}")
            
            # 1. 减少旧页引用计数
            old_page = pte.page
            old_page.refcount -= 1
            
            # 2. 分配新物理页
            new_page = Page(old_page.data.copy())
            
            # 3. 更新页表项
            pte.page = new_page
            pte.writable = True
            
            print(f"  分配新物理页: {new_page.id}")
        
        pte.page.data = data

关键点

  • Fork后,页表被标记为只读
  • 任何写操作触发缺页中断
  • 操作系统按需复制内存页
  • 通过引用计数管理物理页生命周期

2.4 Fork的陷阱:多线程+Fork

import os
import threading
import time

lock = threading.Lock()

def thread_func():
    with lock:
        time.sleep(10)

# 启动线程(获取锁)
t = threading.Thread(target=thread_func)
t.start()
time.sleep(0.1)

pid = os.fork()

if pid == 0:
    # 子进程中,锁仍是锁定状态,但持有锁的线程不存在!
    with lock:  # 死锁!
        print("永远不会执行")

问题根源

  • Fork只复制调用fork的线程
  • 其他线程在子进程中消失
  • 但锁、信号量的状态被保留
  • 可能导致死锁或资源泄露

解决方案

  • Fork后立即exec替换进程
  • 使用multiprocessing模块而非直接fork

三、操作系统调度机制

3.1 Linux线程模型:线程即进程

在Linux中,线程和进程在内核层面是统一的,都是task_struct

struct task_struct {
    pid_t pid;                        // TID(线程ID)
    pid_t tgid;                       // TGID(线程组ID = 进程ID)
    
    struct sched_entity se;           // 调度实体(独立!)
    unsigned int policy;              // 调度策略
    int prio;                         // 优先级
    
    struct mm_struct *mm;             // 内存描述符(线程共享)
    cpumask_t cpus_allowed;          // CPU亲和性
};

关键发现

  • 每个线程都有独立的TID和调度实体
  • 线程和进程的唯一区别是是否共享mm_struct
  • 调度器统一对待所有task_struct

3.2 时间片分配:CFS调度器

Linux使用CFS(完全公平调度器),基于虚拟运行时间(vruntime)调度:

class Task:
    def __init__(self, tid, name):
        self.tid = tid
        self.name = name
        self.vruntime = 0
        self.weight = 1024  # nice=0时的权重
    
    def calculate_timeslice(self, nr_running):
        """计算时间片"""
        SCHED_LATENCY = 6  # 调度延迟 6ms
        MIN_GRANULARITY = 0.75  # 最小粒度
        
        if nr_running <= 8:
            period = SCHED_LATENCY
        else:
            period = nr_running * MIN_GRANULARITY
        
        # 时间片 = period * (任务权重 / 总权重)
        total_weight = 1024 * nr_running
        return period * (self.weight / total_weight)

时间片分配示例

任务数每个任务时间片调度周期
16.000ms6.00ms
41.500ms6.00ms
80.750ms6.00ms
160.750ms12.00ms

3.3 多核调度:Per-CPU运行队列

// 每个CPU有独立的运行队列
struct rq {
    unsigned int nr_running;        // 运行任务数
    struct cfs_rq cfs;              // CFS运行队列(红黑树)
    struct task_struct *curr;       // 当前运行任务
};

DEFINE_PER_CPU(struct rq, runqueues);

调度流程

1. 任务加入某个CPU的运行队列(最小负载)
2. 调度器选择vruntime最小的任务
3. 任务运行一个时间片
4. vruntime增长,时间片耗尽
5. 任务重新入队,选择下一个任务
6. 负载均衡器定期平衡各CPU负载

3.4 父子进程不共享时间片

import os
import time
import psutil

def measure_scheduling():
    start = time.time()
    
    pid = os.fork()
    
    if pid == 0:
        # 子进程
        for i in range(100000000):
            pass
        elapsed = time.time() - start
        print(f"子进程耗时: {elapsed:.3f}s")
        os._exit(0)
    else:
        # 父进程
        for i in range(100000000):
            pass
        elapsed = time.time() - start
        print(f"父进程耗时: {elapsed:.3f}s")
        os.wait()

关键观察

  • 父子进程几乎同时完成
  • 它们获得相近的CPU时间
  • 说明它们是独立调度

结论:每个进程/线程都有独立的调度实体和时间片,不存在"父进程分配时间片给子进程"的概念。

四、多核并行的真相

4.1 为什么看到多核都在工作?

import threading
import psutil

def cpu_bound_task(thread_id):
    count = 0
    for i in range(50000000):
        count += i

threads = []
for i in range(4):
    t = threading.Thread(target=cpu_bound_task, args=(i,))
    t.start()
    threads.append(t)

for t in threads:
    t.join()

观察CPU使用率:每个核心都有负载,但总CPU使用率约100%(单核)。

原因分析

时间轴(GIL在线程间传递):

CPU 0: [T0] [T2]     [T0] [T3]     [T1]
CPU 1:     [T1] [T3]     [T1] [T0]
CPU 2:         [T0] [T2]         [T2]
CPU 3:             [T1]     [T3]

↑ 任意时刻只有一个线程持有GIL

看起来多核都在工作的原因:
1. 操作系统负载均衡导致线程在核心间迁移
2. 监控工具显示的是时间累积,非瞬时状态
3. 上下文切换和内核态操作分散在多核
4. 线程等待GIL时仍占用调度队列

4.2 真正的多核并行

C扩展或多进程可以实现真正的并行:

from multiprocessing import Process

def cpu_work():
    count = 0
    for i in range(50000000):
        count += i

processes = []
for i in range(4):
    p = Process(target=cpu_work)
    p.start()
    processes.append(p)

for p in processes:
    p.join()

此时4个核心都是100%使用率,总CPU使用率约400%。

五、进程阻塞与协程

5.1 阻塞的传递链

进程阻塞 → 所有线程阻塞 → 所有事件循环停止 → 所有协程阻塞

协程运行在线程之上,线程运行在进程之上。进程阻塞意味着:

  • 进程进入TASK_INTERRUPTIBLETASK_UNINTERRUPTIBLE状态
  • 操作系统将其移出运行队列
  • 事件循环无法运行
  • 所有协程无法继续执行

5.2 经典错误:协程中的阻塞调用

import asyncio
import time
import requests

async def bad_coroutine():
    print("发起HTTP请求...")
    # ❌ 阻塞调用!整个事件循环被卡住
    response = requests.get('https://httpbin.org/delay/3')
    print(f"完成,状态码: {response.status_code}")

async def other_coroutine():
    for i in range(5):
        print(f"计数: {i}")
        await asyncio.sleep(0.5)

# 运行结果:other_coroutine在bad_coroutine完成后才开始执行

问题根源

事件循环运行过程:

正常(await asyncio.sleep()):
1. 运行协程1 → await → 协程1暂停,让出控制权 ✓
2. 运行协程2 → await → 协程2暂停,让出控制权 ✓
3. 检查定时器,唤醒到期的协程
→ 并发执行

阻塞(requests.get()):
1. 运行协程1 → requests.get() → 系统调用阻塞 ❌
   → 线程进入等待状态
   → 事件循环卡住
   → 无法运行其他协程
[等待3秒...]
2. 协程1返回,终于可以运行协程2
→ 串行执行

5.3 正确做法

方案1:使用异步库

import aiohttp

async def good_coroutine():
    async with aiohttp.ClientSession() as session:
        async with session.get('https://httpbin.org/delay/3') as response:
            return response.status

方案2:使用executor包装阻塞操作

async def good_executor_coroutine():
    def blocking_operation():
        time.sleep(2)
        return "完成"
    
    loop = asyncio.get_event_loop()
    result = await loop.run_in_executor(None, blocking_operation)
    return result

5.4 常见阻塞操作清单

类型❌ 阻塞操作✓ 异步替代
网络I/Orequests.get()aiohttp.ClientSession.get()
文件I/Oopen().read()aiofiles.open().read()
数据库psycopg2.connect()aiopg.create_pool()
睡眠time.sleep()await asyncio.sleep()
CPU密集大循环计算await loop.run_in_executor()

六、OOM与进程管理

6.1 内存过量分配(Overcommit)

Linux允许承诺比实际拥有的更多内存:

# 查看overcommit模式
cat /proc/sys/vm/overcommit_memory

# 0 = 启发式(默认)
# 1 = 总是允许
# 2 = 严格计账

关键概念

  • 虚拟内存分配malloc()成功,但未分配物理内存
  • 物理内存分配:访问内存时触发缺页中断,真正分配物理页
  • OOM触发:物理内存耗尽时,OOM Killer选择进程杀死

6.2 OOM Killer选择逻辑

// 计算OOM分数
long oom_badness(struct task_struct *p) {
    // 1. 基于RSS计算基础分数
    points = (RSS / 总内存) * 1000;
    
    // 2. 加上用户调整值
    points += oom_score_adj;  // 范围:-1000 到 1000
    
    // 3. 选择分数最高的进程杀死
    return points;
}

示例

# 父进程:重要服务,设置保护
with open(f'/proc/{os.getpid()}/oom_score_adj', 'w') as f:
    f.write('-500')

pid = os.fork()

if pid == 0:
    # 子进程:内存泄漏
    leaked = []
    while True:
        chunk = bytearray(100 * 1024 * 1024)
        leaked.append(chunk)

结果

  • 子进程RSS增长,OOM分数高
  • 父进程有oom_score_adj=-500保护
  • OOM Killer杀死子进程,父进程幸存

6.3 子进程与父进程的独立性

关键点

  • 子进程OOM通常不会影响父进程
  • 父进程可以通过oom_score_adj保护自己
  • 但如果父进程内存使用也很高,也可能被杀

七、实践总结

7.1 选择决策树

需要并发?
├─ 否 → 单线程
└─ 是 → 什么类型?
    ├─ CPU密集 → 多进程
    ├─ I/O密集(少量) → 多线程
    ├─ I/O密集(海量) → 协程
    └─ 混合 → 多进程 + 协程

7.2 关键要点

进程与线程

  • Linux中线程是轻量级进程(LWP)
  • 调度器统一对待,每个都有独立的调度实体
  • GIL限制Python多线程的并行能力

Fork机制

  • COW机制延迟内存复制,提高效率
  • 引用计数管理物理页生命周期
  • 避免多线程+Fork的死锁陷阱

多核调度

  • Per-CPU运行队列减少锁竞争
  • CFS基于vruntime公平调度
  • 父子进程/线程独立调度,不共享时间片

协程与阻塞

  • 协程依赖事件循环,进程阻塞导致协程阻塞
  • 避免在协程中使用阻塞调用
  • 使用异步库或executor包装阻塞操作

内存管理

  • Linux使用overcommit策略
  • OOM Killer基于RSS和oom_score_adj选择牺牲进程
  • 通过调整oom_score_adj保护关键进程

7.3 最佳实践

# 1. CPU密集:多进程
from multiprocessing import Pool
with Pool(4) as pool:
    results = pool.map(cpu_intensive_func, data)

# 2. I/O密集(少量):多线程
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=10) as executor:
    futures = [executor.submit(io_task, url) for url in urls]

# 3. I/O密集(海量):协程
async def main():
    async with aiohttp.ClientSession() as session:
        tasks = [fetch(session, url) for url in urls]
        await asyncio.gather(*tasks)

# 4. 混合:多进程+协程
def worker_process():
    asyncio.run(async_io_tasks())

with Pool(4) as pool:
    pool.map(worker_process, range(4))

结语

理解进程、线程、协程的本质区别,需要深入操作系统层面:

  • 它们在内核中的表示(task_struct)
  • 调度器如何选择和分配CPU时间
  • 内存管理的COW机制
  • GIL对并行的限制
  • 阻塞的传递关系
0
博主关闭了所有页面的评论