Skip to content

Python 并发编程的核心是理解 GIL 的限制,根据场景选择多线程、多进程或协程。并发不是银弹,选对工具才能提升性能。

Q1: Python中实现并发的方式有哪些?各有什么优缺点? 「🟡 中级」

考察点:考察候选人对 Python 并发编程全貌的理解,是否清楚多线程、多进程、协程各自的适用场景和局限性,筛选只会用某一种方式而不会根据场景选型的人。

参考答案

Python 中实现并发主要有三种方式:多线程多进程协程,三者各有侧重。

1. 多线程(threading)

  • 原理:在同一进程内创建多个线程,由操作系统调度,线程间共享进程的内存空间。
  • 优点
    • 轻量级,创建和切换开销小
    • 共享内存,线程间通信方便(直接读写共享变量)
    • 编程模型相对简单
  • 缺点
    • GIL(全局解释器锁) 限制,同一时刻只有一个线程执行 Python 字节码,无法利用多核 CPU 进行计算密集型任务
    • 线程安全问题,需要锁等同步机制
    • 线程数量有限(几千级别),太多会导致频繁上下文切换
  • 适用场景IO 密集型任务(网络请求、文件读写、数据库查询等)

2. 多进程(multiprocessing)

  • 原理:创建多个独立的进程,每个进程有自己独立的内存空间和 GIL,由操作系统调度。
  • 优点
    • 绕过 GIL,可以真正利用多核 CPU 并行计算
    • 进程间相互独立,一个进程崩溃不会影响其他进程
    • 适合 CPU 密集型任务
  • 缺点
    • 进程创建和销毁开销大
    • 进程间通信复杂(需要 IPC 机制,如队列、管道、共享内存等)
    • 内存占用大(每个进程有独立的内存空间)
  • 适用场景CPU 密集型任务(数值计算、图像处理、数据处理等)

3. 协程(asyncio)

  • 原理:在单线程内,由程序(事件循环)协作式调度,遇到 IO 操作时主动挂起,切换到其他协程执行。
  • 优点
    • 极低的切换开销(用户态切换,几纳秒级别)
    • 可以创建大量协程(几万甚至几十万级别)
    • 没有线程安全问题(单线程内顺序执行)
  • 缺点
    • 需要异步库支持(同步库会阻塞整个事件循环)
    • 编程模型复杂,需要理解 async/await、事件循环等概念
    • 无法利用多核 CPU(单线程)
    • 调试困难(异常堆栈不直观)
  • 适用场景高并发 IO 密集型任务(Web 服务器、爬虫、实时聊天等)

对比表

对比维度多线程多进程协程
适用场景IO 密集型CPU 密集型高并发 IO 密集型
资源开销中等极小
切换开销中等(内核态)大(内核态)极小(用户态)
通信方式共享内存(需锁)IPC(队列/管道/共享内存)共享变量(单线程)
GIL 限制受限制不受限受限制(单线程)
编程复杂度中等较高
并发数量几千几十到几百几万到几十万
稳定性一个线程崩溃影响进程进程独立,稳定性高一个协程崩溃影响整个事件循环

选型建议

python
# CPU 密集型 → 多进程
from multiprocessing import Pool

def cpu_bound_task(n):
    return sum(i * i for i in range(n))

if __name__ == "__main__":
    with Pool(4) as p:
        results = p.map(cpu_bound_task, [10**6] * 10)

# IO 密集型 → 多线程 或 协程
import threading
import requests

def fetch(url):
    return requests.get(url)

threads = [threading.Thread(target=fetch, args=(url,)) for url in urls]
for t in threads:
    t.start()
for t in threads:
    t.join()

# 高并发 IO 密集型 → 协程(asyncio + aiohttp)
import asyncio
import aiohttp

async def fetch(session, url):
    async with session.get(url) as resp:
        return await resp.text()

async def main():
    async with aiohttp.ClientSession() as session:
        tasks = [fetch(session, url) for url in urls]
        await asyncio.gather(*tasks)

asyncio.run(main())

追问延伸

  • GIL 具体是怎么工作的?在什么情况下会释放?
  • 如果一个任务既有 CPU 计算又有 IO 操作,你会怎么选型?
  • 协程和线程的本质区别是什么?为什么协程切换更快?
  • 你在项目中是如何做并发选型的?举一个实际案例。

Q2: 多线程和多进程的区别?怎么选择? 「🟡 中级」

考察点:考察对线程和进程本质区别的理解,以及根据实际场景进行技术选型的能力,筛选那些只会死记硬背概念而不会应用的候选人。

参考答案

核心区别

维度多线程多进程
内存空间线程共享进程的堆内存,有自己独立的栈空间每个进程有独立的内存空间(地址空间)
创建开销小(只需分配栈和寄存器等上下文)大(需要分配独立的地址空间、复制父进程资源等)
切换开销较小(只需保存和恢复寄存器、程序计数器等)较大(需要切换页表、刷新 TLB、保存整个进程上下文)
GIL 影响受 GIL 限制,同一时刻只有一个线程执行 Python 字节码每个进程有独立的 GIL,不受限制,可真正并行
通信方式共享变量(需加锁保证线程安全)IPC 机制(Queue、Pipe、共享内存、Manager 等)
通信速度快(直接读写内存)慢(需要内核参与或数据拷贝)
稳定性一个线程崩溃可能导致整个进程崩溃进程间相互独立,一个崩溃不影响其他进程
资源占用
并发数量几千级别(受栈空间和调度开销限制)几十到几百级别(受内存和 PID 限制)

深入理解

1. 内存共享的利与弊

线程共享进程的内存空间,这既是优点也是缺点:

  • 优点:线程间通信简单高效,可以直接读写共享变量
  • 缺点:容易出现竞态条件(Race Condition),需要使用锁等同步机制,增加了编程复杂度
python
# 线程安全问题示例
import threading

count = 0

def increment():
    global count
    for _ in range(100000):
        count += 1  # 非原子操作,存在竞态条件

threads = [threading.Thread(target=increment) for _ in range(10)]
for t in threads:
    t.start()
for t in threads:
    t.join()

print(count)  # 结果通常小于 1,000,000,而不是等于

进程有独立的内存空间,所以不存在线程安全问题,但通信成本高:

python
# 多进程通信示例
from multiprocessing import Process, Queue

def worker(q):
    q.put("hello from child")

if __name__ == "__main__":
    q = Queue()
    p = Process(target=worker, args=(q,))
    p.start()
    print(q.get())  # 从队列中获取数据
    p.join()

2. GIL 的影响

  • 线程:GIL 使得 Python 多线程在 CPU 密集型任务上无法真正并行,性能甚至可能不如单线程(因为有线程切换开销)
  • 进程:每个进程有独立的 Python 解释器和 GIL,可以在多核 CPU 上真正并行执行

3. 稳定性差异

  • 线程共享进程地址空间,一个线程如果出现内存越界等严重错误,可能导致整个进程崩溃
  • 进程有独立的地址空间,一个进程崩溃不会影响其他进程,这也是为什么浏览器等软件使用多进程架构

选型原则

场景推荐方案原因
CPU 密集型(数值计算、图像处理等)多进程绕过 GIL,真正利用多核
IO 密集型(网络请求、文件读写等)多线程 或 协程IO 时 GIL 会释放,线程切换开销可以接受
高并发 IO 密集型(Web 服务、爬虫等)协程极高的并发能力和极低的切换开销
任务需要高隔离度(稳定性优先)多进程进程间独立,不会互相影响
任务间需要频繁共享大量数据多线程共享内存,通信效率高

混合使用

实际项目中也可以混合使用多进程和多线程/协程:

架构示例:多进程 + 协程
├── 进程1(CPU 核1):事件循环 + 多个协程处理 IO
├── 进程2(CPU 核2):事件循环 + 多个协程处理 IO
├── 进程3(CPU 核3):事件循环 + 多个协程处理 IO
└── 进程4(CPU 核4):事件循环 + 多个协程处理 IO

这种架构结合了多进程的多核利用和协程的高并发 IO 能力,是高性能 Web 服务器(如 Uvicorn、Gunicorn + Gevent)的常用模式。

追问延伸

  • 进程间通信有哪些方式?各有什么优缺点?
  • 线程安全问题有哪些解决方法?
  • Python 中为什么有了 GIL 还需要线程锁?
  • 你在项目中遇到过死锁吗?怎么排查和解决的?

Q3: threading模块的常用方法?线程同步方式有哪些? 「🟢 校招/初级」

考察点:考察对 Python 标准库 threading 模块的熟悉程度,以及对各种线程同步原语的理解和使用场景,筛选基础不扎实的初级候选人。

参考答案

一、Thread 类的常用方法

threading.Thread 是创建线程的核心类:

方法/属性说明
Thread(target, args, kwargs, daemon)创建线程对象
start()启动线程(调用 run 方法)
run()线程执行的逻辑(可以被子类重写)
join(timeout)等待线程结束
is_alive()检查线程是否存活
name线程名称
daemon是否为守护线程
ident线程标识符
python
import threading
import time

# 方式一:传入 target 函数
def worker(name, delay):
    print(f"Worker {name} started")
    time.sleep(delay)
    print(f"Worker {name} finished")

t = threading.Thread(target=worker, args=("A", 2), daemon=True)
t.start()
t.join()  # 等待线程结束

# 方式二:继承 Thread 类
class MyThread(threading.Thread):
    def __init__(self, name):
        super().__init__()
        self.name = name

    def run(self):
        print(f"Thread {self.name} running")

t = MyThread("B")
t.start()

守护线程(daemon)

  • 守护线程是后台线程,当所有非守护线程结束后,守护线程会自动终止
  • 常用于后台任务,如日志收集、心跳检测等
  • 设置方式:t.daemon = TrueThread(daemon=True)

二、线程同步方式

当多个线程访问共享资源时,需要同步机制来保证数据一致性。

1. Lock(互斥锁)

最基本的同步原语,同一时刻只有一个线程能持有锁。

python
import threading

lock = threading.Lock()
count = 0

def increment():
    global count
    for _ in range(100000):
        with lock:  # 自动 acquire/release
            count += 1

# 也可以手动调用:
# lock.acquire()
# count += 1
# lock.release()

特点

  • 不可重入(同一线程重复 acquire 会导致死锁)
  • 是最常用的同步原语

2. RLock(可重入锁)

可重入锁,同一线程可以多次 acquire,不会死锁,但需要对应次数的 release。

python
rlock = threading.RLock()

def func1():
    with rlock:
        func2()  # 同一线程再次 acquire,不会死锁

def func2():
    with rlock:
        print("func2")

适用场景:递归函数、嵌套调用中需要加锁的情况。

3. Condition(条件变量)

在 Lock 的基础上增加了等待/通知机制,用于线程间协作。

python
import threading
import time

cond = threading.Condition()
items = []
MAX_SIZE = 5

def producer():
    for i in range(10):
        with cond:
            while len(items) >= MAX_SIZE:
                cond.wait()  # 队列满,等待消费者消费
            items.append(i)
            print(f"Produced: {i}")
            cond.notify()  # 通知消费者

def consumer():
    for i in range(10):
        with cond:
            while len(items) == 0:
                cond.wait()  # 队列空,等待生产者生产
            item = items.pop(0)
            print(f"Consumed: {item}")
            cond.notify()  # 通知生产者

常用方法

  • wait(timeout):释放锁并等待,被 notify 后重新获取锁
  • notify():唤醒一个等待的线程
  • notify_all():唤醒所有等待的线程

适用场景:生产者-消费者模式。

4. Semaphore(信号量)

控制并发数量,允许多个线程同时访问资源。

python
sem = threading.Semaphore(3)  # 最多 3 个线程同时执行

def worker(id):
    with sem:
        print(f"Worker {id} is working")
        time.sleep(1)

特殊情况Semaphore(1) 等价于 Lock

适用场景:连接池、限流等需要控制并发数的场景。

5. Event(事件)

用于线程间简单的信号通知,一个线程发出事件,其他线程等待。

python
event = threading.Event()

def waiter():
    print("Waiting for event...")
    event.wait()  # 阻塞等待事件被设置
    print("Event received!")

def setter():
    time.sleep(2)
    event.set()  # 设置事件,唤醒所有等待的线程

常用方法

  • set():设置事件(内部标志为 True)
  • clear():清除事件(内部标志为 False)
  • wait(timeout):等待事件被设置
  • is_set():检查事件是否被设置

适用场景:线程间的简单通知、启动/停止信号。

6. Barrier(屏障)

等待多个线程都到达屏障点后,再一起继续执行。

python
barrier = threading.Barrier(3)  # 需要 3 个线程都到达

def worker(id):
    print(f"Worker {id} before barrier")
    barrier.wait()  # 等待其他线程
    print(f"Worker {id} after barrier")

适用场景:多线程分阶段执行,需要等待所有线程准备好后再开始下一阶段。

同步原语对比

同步原语用途关键方法适用场景
Lock互斥访问共享资源acquire()/release()最常用,保护临界区
RLock可重入的互斥锁acquire()/release()递归/嵌套加锁
Condition线程间协作等待wait()/notify()/notify_all()生产者消费者
Semaphore控制并发数量acquire()/release()连接池、限流
Event简单事件通知set()/clear()/wait()启动/停止信号
Barrier多线程同步点wait()分阶段并行计算

追问延伸

  • Lock 和 RLock 的区别?什么场景下需要用 RLock?
  • 生产者消费者模式用什么同步原语实现?为什么不用 Event?
  • Semaphore 和 Lock 的关系?
  • 为什么 Condition 需要配合 Lock 使用?

Q4: 进程间通信(IPC)的方式有哪些?Python中怎么实现? 「🟡 中级」

考察点:考察对进程间通信机制的理解,以及在 Python 中实际使用的经验,筛选只会用多线程、对多进程编程缺乏实践的候选人。

参考答案

进程间通信(Inter-Process Communication, IPC)是多进程编程中的核心问题。由于进程有独立的内存空间,进程间不能直接共享数据,需要通过操作系统提供的机制进行通信。

Python 中常见的 IPC 方式

1. Queue(队列)

multiprocessing.Queue 是最常用的 IPC 方式,基于管道和锁实现,线程/进程安全。

python
from multiprocessing import Process, Queue

def producer(q):
    for i in range(5):
        q.put(i)
        print(f"Produced: {i}")

def consumer(q):
    while True:
        item = q.get()
        if item is None:  # 哨兵值,表示结束
            break
        print(f"Consumed: {item}")

if __name__ == "__main__":
    q = Queue()
    p1 = Process(target=producer, args=(q,))
    p2 = Process(target=consumer, args=(q,))
    p1.start()
    p2.start()
    p1.join()
    q.put(None)  # 发送结束信号
    p2.join()

特点

  • 简单易用,进程安全
  • 先进先出(FIFO)
  • 数据需要序列化(pickle),有性能开销
  • 适合大多数场景

2. Pipe(管道)

Pipe 是双向的,可以用于两个进程之间的通信,比 Queue 更轻量。

python
from multiprocessing import Process, Pipe

def worker(conn):
    conn.send("Hello from child")
    data = conn.recv()
    print(f"Child received: {data}")
    conn.close()

if __name__ == "__main__":
    parent_conn, child_conn = Pipe()
    p = Process(target=worker, args=(child_conn,))
    p.start()
    data = parent_conn.recv()
    print(f"Parent received: {data}")
    parent_conn.send("Hello from parent")
    p.join()

特点

  • 双向通信(默认全双工)
  • 比 Queue 更快(实现更简单)
  • 适合两个进程间的点对点通信
  • 多进程使用时需要注意锁的问题

3. 共享内存(Shared Memory)

多个进程可以访问同一块内存区域,速度最快。

方式一:Value / Array

python
from multiprocessing import Process, Value, Array

def worker(n, arr):
    n.value = 3.14
    for i in range(len(arr)):
        arr[i] = arr[i] * 2

if __name__ == "__main__":
    num = Value('d', 0.0)  # 'd' 表示 double
    arr = Array('i', range(10))  # 'i' 表示 int
    
    p = Process(target=worker, args=(num, arr))
    p.start()
    p.join()
    
    print(num.value)  # 3.14
    print(arr[:])     # [0, 2, 4, 6, 8, 10, 12, 14, 16, 18]

方式二:SharedMemory(Python 3.8+)

python
from multiprocessing import shared_memory
import numpy as np

# 创建共享内存
shm = shared_memory.SharedMemory(create=True, size=100)

# 在子进程中访问同一块共享内存
# 通过名称连接:shm = shared_memory.SharedMemory(name="shm_name")

# 使用 numpy 操作共享内存
arr = np.ndarray((10,), dtype=np.int32, buffer=shm.buf)
arr[:] = range(10)

# 用完释放
shm.close()
shm.unlink()

特点

  • 速度最快(直接内存访问,无需数据拷贝)
  • 需要手动同步(配合 Lock 使用)
  • 适合大数据量的共享
  • SharedMemory 比 Value/Array 更灵活

4. Manager(管理器)

Manager 提供了一种更高层的 IPC 方式,支持多种数据类型,还可以跨网络。

python
from multiprocessing import Process, Manager

def worker(d, l):
    d['key'] = 'value'
    d['count'] = 42
    l.append('new item')

if __name__ == "__main__":
    with Manager() as manager:
        d = manager.dict()
        l = manager.list([1, 2, 3])
        
        p = Process(target=worker, args=(d, l))
        p.start()
        p.join()
        
        print(dict(d))  # {'key': 'value', 'count': 42}
        print(list(l))  # [1, 2, 3, 'new item']

特点

  • 支持 dict、list、Lock、Condition 等多种类型
  • 使用简单,像普通 Python 对象一样操作
  • 可以跨网络通信(BaseManager)
  • 速度较慢(通过进程间的代理对象通信,有序列化开销)

5. 其他同步原语

multiprocessing 模块也提供了类似 threading 的同步原语:

  • Lock / RLock:进程间互斥锁
  • Semaphore:进程间信号量
  • Event:进程间事件
  • Condition:进程间条件变量

这些同步原语可以配合共享内存使用,保证数据一致性。

6. Socket / RPC(跨机器)

如果需要跨机器通信,可以使用:

  • Socket:最底层的网络通信
  • RPC:如 gRPC、XML-RPC(标准库 xmlrpc
  • 消息队列:如 RabbitMQ、Kafka、Redis 等

各种 IPC 方式对比

方式速度复杂度适用场景数据量是否需要同步
Queue中等通用消息传递中小不需要(内置锁)
Pipe两进程双向通信中小不需要
共享内存最快大数据共享需要(手动加锁)
Manager灵活数据结构,跨网络不需要(内置同步)
Socket/RPC跨机器通信不定视协议而定

选型建议

  1. 简单消息传递 → Queue(最常用,最安全)
  2. 两进程点对点 → Pipe(比 Queue 更快)
  3. 大数据量共享 → 共享内存(配合 Lock)
  4. 需要复杂数据结构 → Manager(使用方便)
  5. 跨机器通信 → Socket / RPC / 消息队列

追问延伸

  • Queue 和 Pipe 的底层实现有什么区别?
  • 共享内存为什么快?有什么缺点?
  • Manager 的实现原理是什么?为什么比共享内存慢?
  • 你在项目中用过哪种 IPC 方式?为什么选它?
  • multiprocessing.Queue 和 queue.Queue 有什么区别?

Q5: concurrent.futures 模块是什么?ThreadPoolExecutor 和 ProcessPoolExecutor? 「🟡 中级」

考察点:考察对 Python 高层并发抽象的理解,是否熟悉线程池/进程池的统一接口,筛选只会用底层 threading/multiprocessing 而不会用更现代接口的候选人。

参考答案

concurrent.futures 是 Python 3.2 引入的高层并发模块,提供了统一的接口来管理线程池和进程池,大大简化了并发编程的代码。

核心概念

1. Executor(执行器)

Executor 是抽象基类,有两个具体实现:

  • ThreadPoolExecutor:线程池执行器
  • ProcessPoolExecutor:进程池执行器

两者接口完全一致,可以方便地切换。

2. Future(未来对象)

Future 表示一个异步计算的结果,可以用它来查询任务状态、获取结果或异常。

基本用法

ThreadPoolExecutor 示例

python
from concurrent.futures import ThreadPoolExecutor
import requests

def fetch(url):
    resp = requests.get(url)
    return resp.status_code

urls = ["https://example.com"] * 10

# 方式一:submit + result(逐个提交)
with ThreadPoolExecutor(max_workers=5) as executor:
    futures = [executor.submit(fetch, url) for url in urls]
    for future in futures:
        print(future.result())  # 阻塞等待结果

# 方式二:map(批量映射,按输入顺序返回)
with ThreadPoolExecutor(max_workers=5) as executor:
    results = executor.map(fetch, urls)
    for result in results:
        print(result)

ProcessPoolExecutor 示例

python
from concurrent.futures import ProcessPoolExecutor
import math

def is_prime(n):
    if n < 2:
        return False
    for i in range(2, int(math.isqrt(n)) + 1):
        if n % i == 0:
            return False
    return True

numbers = [999999937, 999999929, 99999989, 99999983]

if __name__ == "__main__":
    with ProcessPoolExecutor(max_workers=4) as executor:
        results = list(executor.map(is_prime, numbers))
    print(results)

注意:ProcessPoolExecutor 中传递的函数和参数必须可序列化(pickle)。

重要 API 详解

1. submit() vs map()

特性submit()map()
返回值Future 对象列表结果迭代器
顺序提交顺序(获取结果顺序不定)严格按输入顺序
异常处理调用 result() 时抛出迭代时抛出
灵活性高(可以单独取消、添加回调)低(批量处理)
超时控制result(timeout=)不直接支持

2. as_completed() — 按完成顺序获取结果

python
from concurrent.futures import as_completed

with ThreadPoolExecutor(max_workers=5) as executor:
    futures = {executor.submit(fetch, url): url for url in urls}
    for future in as_completed(futures):
        url = futures[future]
        try:
            result = future.result()
            print(f"{url}: {result}")
        except Exception as e:
            print(f"{url} failed: {e}")

as_completed() 会在任务完成时立即返回对应的 Future,不按提交顺序,适合需要尽早处理结果的场景。

3. wait() — 等待任务完成

python
from concurrent.futures import wait, FIRST_COMPLETED, ALL_COMPLETED

with ThreadPoolExecutor(max_workers=5) as executor:
    futures = [executor.submit(fetch, url) for url in urls]
    
    # 等待所有任务完成
    done, not_done = wait(futures, return_when=ALL_COMPLETED)
    
    # 等待第一个任务完成
    # done, not_done = wait(futures, return_when=FIRST_COMPLETED)
    
    # 等待超时
    # done, not_done = wait(futures, timeout=5)

4. Future 对象的方法

方法说明
result(timeout=None)获取结果(阻塞),超时抛 TimeoutError
exception(timeout=None)获取异常(阻塞)
done()是否完成
cancelled()是否被取消
cancel()尝试取消任务(未开始才可能成功)
add_done_callback(fn)添加完成回调
python
# 回调示例
def on_complete(future):
    print(f"Task completed: {future.result()}")

future = executor.submit(fetch, url)
future.add_done_callback(on_complete)

线程池/进程池的优势

  1. 简化代码:统一的高层 API,不用手动管理线程/进程的创建和生命周期
  2. 资源复用:线程/进程预先创建,任务来了直接用,避免频繁创建销毁的开销
  3. 并发控制:通过 max_workers 控制最大并发数,防止资源耗尽
  4. Future 模式:方便的异步编程模型,支持回调、超时、取消等操作
  5. 接口统一:线程池和进程池接口一致,可以根据场景灵活切换

注意事项

ProcessPoolExecutor 的限制

  1. 必须可序列化:传递给进程池的函数和参数必须支持 pickle 序列化
  2. 不能用 lambda:lambda 函数不可序列化,不能直接传递给 ProcessPoolExecutor
  3. 全局变量不共享:每个进程有独立的内存空间,全局变量不共享
  4. if name == "main":Windows 下必须在主模块中使用,否则会无限递归创建子进程

线程池大小的选择

  • IO 密集型:可以设置较大,如 2 * CPU核数 或更多(因为线程大部分时间在等待)
  • CPU 密集型:建议设置为 CPU 核数(避免频繁上下文切换)
  • 默认值:min(32, os.cpu_count() + 4)(Python 3.8+)

对比:底层 API vs concurrent.futures

维度threading / multiprocessingconcurrent.futures
抽象层级底层,手动管理高层,自动管理
代码复杂度
灵活性高(可以精细控制)中(常用场景足够)
结果获取需手动实现Future 对象,方便
推荐程度复杂场景用大多数场景优先用

追问延伸

  • submit 和 map 的区别?各自适用什么场景?
  • as_completed 和 wait 的区别?
  • ProcessPoolExecutor 有什么限制?为什么函数必须可序列化?
  • 线程池的大小怎么设置?有什么依据?
  • Future 对象和协程有什么关系?
  • 你在项目中用过 concurrent.futures 吗?解决了什么问题?

Q6: asyncio 的事件循环原理?核心概念有哪些? 「🔴 高级」

考察点:考察对 asyncio 底层原理的深入理解,是否清楚事件循环的工作机制和核心组件,筛选只会用 async/await 语法而不理解原理的候选人。

参考答案

asyncio 是 Python 的异步编程框架,核心是事件循环(Event Loop),它是一个单线程的循环,负责监听和分发事件、调度协程执行。

一、事件循环的工作原理

事件循环本质上是一个 while True 循环,不断地轮询事件并处理:

┌─────────────────────────────────────────┐
│           事件循环 (Event Loop)          │
│  ┌───────┐    ┌────────┐    ┌────────┐ │
│  │ 等待IO │ →  │ 就绪队列 │ →  │ 执行回调 │ │
│  └───────┘    └────────┘    └────────┘ │
│                  ↑                      │
│                  └──────────────────────┘
└─────────────────────────────────────────┘

基本流程

  1. 事件循环维护一个待处理的任务队列
  2. 循环检查哪些 IO 操作已经就绪(通过 selectors 模块)
  3. 将就绪的任务/回调加入执行队列
  4. 依次执行回调,回调中可能创建新的任务
  5. 重复上述过程,直到所有任务完成

二、核心概念

1. 协程(Coroutine)

使用 async def 定义的函数,调用时不会立即执行,而是返回一个协程对象。

python
import asyncio

async def hello():
    print("Hello")
    await asyncio.sleep(1)
    print("World")

coro = hello()  # 协程对象,不会执行
asyncio.run(coro)  # 交给事件循环执行

协程本身只是一个函数对象,需要被包装成 Task 加入事件循环才能被调度执行。

2. Task(任务)

Task 是协程的包装器,将协程提交给事件循环调度。Task 是 Future 的子类。

python
async def main():
    # 创建 Task(立即被调度)
    task1 = asyncio.create_task(hello())
    task2 = asyncio.create_task(hello())
    
    # 等待任务完成
    await task1
    await task2

asyncio.run(main())

Task vs 协程

  • 协程:只是一个函数对象,调用不执行
  • Task:协程的调度单元,加入事件循环后会自动调度执行
  • await 协程:只是串行等待,不会并发
  • await asyncio.gather(task1, task2):并发执行多个任务

3. Future(期程)

Future 是一个底层概念,表示一个异步操作的结果(占位符)。Task 是 Future 的子类。

  • Future 有状态:PENDINGRUNNINGDONE(正常完成或异常)
  • 可以设置结果:set_result()
  • 可以添加回调:add_done_callback()
python
async def main():
    loop = asyncio.get_running_loop()
    future = loop.create_future()
    
    # 模拟异步操作完成后设置结果
    loop.call_later(1, lambda: future.set_result("done"))
    
    result = await future  # 等待 future 完成
    print(result)

大多数情况下不需要直接操作 Future,用 Task 即可。

4. 事件循环(Event Loop)

事件循环是 asyncio 的核心,负责:

  • 注册和监听 IO 事件
  • 调度协程/任务执行
  • 处理定时器(call_later、call_at)
  • 处理回调(call_soon)
python
# 获取事件循环
loop = asyncio.get_event_loop()  # 旧方式
loop = asyncio.get_running_loop()  # 新方式(在协程中使用)

# 运行协程
loop.run_until_complete(coro)

# 调度回调
loop.call_soon(callback)  # 尽快执行
loop.call_later(1, callback)  # 1秒后执行
loop.call_at(when, callback)  # 指定时间执行

三、事件循环的执行流程

asyncio.gather(task1, task2) 为例:

1. 创建两个 Task,加入事件循环的就绪队列
2. 事件循环取出 task1 执行
3. task1 执行到 await asyncio.sleep(1),挂起自己
   → 注册一个 1 秒后的定时器回调
   → 让出控制权给事件循环
4. 事件循环取出 task2 执行
5. task2 执行到 await asyncio.sleep(2),挂起自己
   → 注册一个 2 秒后的定时器回调
   → 让出控制权给事件循环
6. 事件循环进入等待状态(没有就绪任务)
7. 1 秒后,task1 的定时器触发,task1 恢复执行
8. task1 执行完成,标记为 DONE
9. 又过了 1 秒,task2 的定时器触发,task2 恢复执行
10. task2 执行完成,标记为 DONE
11. gather 发现所有任务都完成了,返回结果

四、底层实现:selectors 模块

asyncio 的 IO 多路复用底层依赖 selectors 模块,它封装了操作系统提供的 IO 多路复用机制:

系统机制说明
Linuxepoll高效,支持大量连接
macOS/BSDkqueue类似 epoll
Solaris/dev/poll类似 epoll
WindowsIOCP完成端口
通用select兼容性好,但效率低

事件循环通过 selectors 监听文件描述符(socket 等)的可读/可写事件,当事件就绪时,唤醒对应的协程继续执行。

五、事件循环的类型

Python 提供了不同的事件循环实现:

  1. SelectorEventLoop:默认实现,基于 selectors,适合大多数场景
  2. ProactorEventLoop:Windows 专用,基于 IOCP(Windows 上性能更好)
  3. uvloop:第三方实现,基于 libuv,性能比默认实现高 2-4 倍
python
# 使用 uvloop(需要安装)
import uvloop
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())

六、为什么是单线程?

asyncio 采用单线程事件循环模型,原因是:

  1. IO 密集型任务的瓶颈在等待,而不是 CPU 计算
  2. 单线程避免了线程切换的开销和线程安全问题
  3. 协程切换的开销远小于线程切换
  4. 编程模型相对简单(没有多线程的竞态条件)

如果需要利用多核 CPU,可以结合多进程使用(每个进程运行一个事件循环)。

追问延伸

  • async/await 的底层原理是什么?和生成器有什么关系?
  • 事件循环是单线程的,为什么能处理高并发?
  • Task 和 Future 的区别和联系?
  • 什么是 uvloop?为什么更快?
  • 事件循环在什么时候会切换协程?
  • 如果在协程中执行了阻塞操作会怎么样?

Q7: async/await 的底层原理是什么? 「🔴 高级」

考察点:考察对 Python 异步编程底层机制的深度理解,是否清楚 async/await 语法糖背后的实现原理,筛选只会用语法而不懂原理的候选人。

参考答案

async/await 是 Python 3.5 引入的语法糖,底层基于**生成器(generator)**实现。理解其原理需要从生成器和 yield from 讲起。

一、从生成器到协程

1. 生成器基础

生成器是可以暂停和恢复的函数,使用 yield 关键字:

python
def gen():
    yield 1
    yield 2
    yield 3

g = gen()
print(next(g))  # 1
print(next(g))  # 2
print(next(g))  # 3

生成器可以通过 yield 暂停执行,通过 next()send() 恢复执行。这是协程的基础。

2. yield from:生成器的委托

Python 3.3 引入了 yield from,可以在生成器中调用另一个生成器:

python
def inner():
    yield 1
    yield 2

def outer():
    yield from inner()  # 委托给 inner 生成器
    yield 3

for x in outer():
    print(x)  # 1, 2, 3

yield from 不仅可以委托,还可以在调用者和子生成器之间建立双向通道:

  • 值可以从调用者直接传到子生成器
  • 异常可以从子生成器直接传到调用者
  • 返回值可以通过 return 从子生成器传回

3. 基于生成器的协程

async/await 出现之前,Python 就有基于生成器的协程(使用 @asyncio.coroutine 装饰器 + yield from):

python
# Python 3.4 风格的协程(已废弃)
import asyncio

@asyncio.coroutine
def old_style_coro():
    yield from asyncio.sleep(1)
    return "done"

这本质上是把生成器当作协程来用。async/await 就是这种模式的语法糖。

二、async/await 的本质

1. async def:定义协程函数

async def 定义的函数,调用时返回一个协程对象,而不是立即执行函数体。

python
async def foo():
    return 42

coro = foo()
print(type(coro))  # <class 'coroutine'>

协程对象和生成器对象非常相似,都是可以暂停和恢复的执行单元。实际上,协程在 CPython 内部的实现和生成器共享很多代码。

2. await:挂起和恢复

awaityield from 的升级版,用于挂起当前协程,等待另一个 awaitable 对象完成。

python
async def bar():
    result = await foo()  # 挂起 bar,等待 foo 完成
    return result

await 的执行过程:

  1. 遇到 await 时,当前协程挂起(暂停执行)
  2. 控制权返回给事件循环
  3. 事件循环去执行其他就绪的协程
  4. 当 await 的对象完成后,事件循环将该协程重新加入就绪队列
  5. 协程从 await 处恢复执行,拿到结果

三、协程的状态

协程有四种状态(类似生成器):

状态说明
GEN_CREATED协程已创建,尚未开始执行
GEN_RUNNING协程正在执行
GEN_SUSPENDED协程在 await 处挂起
GEN_CLOSED协程执行完成或被关闭

可以用 inspect.getcoroutinestate() 查看协程状态:

python
import inspect

async def foo():
    await asyncio.sleep(1)

coro = foo()
print(inspect.getcoroutinestate(coro))  # CORO_CREATED

四、事件循环如何驱动协程

事件循环通过不断地 send() 来推进协程的执行:

python
# 简化的事件循环驱动协程的伪代码
def run_coroutine(coro):
    try:
        # 启动协程,执行到第一个 await
        future = coro.send(None)
        
        # 等待 future 完成
        while not future.done():
            # ... 事件循环做其他事情 ...
            pass
        
        # future 完成后,把结果发送回协程,继续执行
        result = future.result()
        coro.send(result)
        
    except StopIteration as e:
        # 协程执行完毕,返回值在 e.value 中
        return e.value

实际的事件循环要复杂得多,它需要:

  • 管理大量的协程/任务
  • 监听 IO 事件
  • 处理定时器
  • 处理异常和取消

五、Task 的作用

协程本身不能被事件循环直接调度,需要包装成 Task:

协程 (Coroutine) → 包装 → Task → 加入事件循环调度

Task 的作用:

  1. 将协程注册到事件循环
  2. 驱动协程执行(不断 send)
  3. 保存协程的状态和结果
  4. 支持取消、回调等操作

可以把 Task 理解为"协程的管理者",它负责协程的生命周期。

六、awaitable 对象

await 后面可以跟什么?答案是 awaitable 对象,包括:

  1. 协程(coroutine)async def 定义的函数返回的对象
  2. Task:协程的包装
  3. Future:异步结果的占位符
  4. 实现了 __await__ 方法的对象:自定义的 awaitable
python
# 自定义 awaitable 对象
class MyAwaitable:
    def __await__(self):
        # __await__ 必须返回一个生成器
        yield "suspend"
        return 42

async def main():
    result = await MyAwaitable()
    print(result)  # 42

七、与生成器的区别

虽然协程底层基于生成器,但两者有本质区别:

特性生成器协程
目的生成数据序列异步协作
关键字yield / yield fromasync / await
驱动者调用者(next/send)事件循环
返回值通过 yield 产出通过 return 返回
类型名generatorcoroutine
能否 await不能(除非加装饰器)

八、总结

  1. async def 定义的函数返回协程对象,本质上是一种特殊的生成器
  2. awaityield from 的升级版,用于挂起协程并等待另一个 awaitable
  3. 协程由事件循环驱动,通过类似 send() 的机制推进执行
  4. Task 是协程的包装,负责协程的调度和管理
  5. 整个异步体系建立在"单线程 + 协作式调度"的基础上

追问延伸

  • 生成器和协程有什么区别和联系?
  • 为什么说 async/await 是语法糖?没有它能不能实现异步?
  • 事件循环是如何知道什么时候该恢复一个挂起的协程的?
  • await 后面可以跟什么?什么是 awaitable?
  • 协程的栈帧存在哪里?和函数调用栈有什么不同?
  • 你能自己实现一个简单的事件循环吗?

Q8: 异步编程中有哪些常见的陷阱? 「🔴 高级」

考察点:考察候选人的实战经验,是否踩过异步编程的坑,筛选只会写简单异步代码而缺乏生产环境经验的候选人。

参考答案

异步编程虽然能带来高并发,但也有很多容易踩的坑。以下是常见的陷阱:

1. 阻塞调用阻塞整个事件循环

这是最常见也是最严重的问题。在 asyncio 中,任何阻塞调用(同步 IO、CPU 密集型计算)都会阻塞整个事件循环,导致所有协程都被卡住。

python
import asyncio
import requests  # 同步库

async def fetch(url):
    # 错误:requests 是同步的,会阻塞整个事件循环
    resp = requests.get(url)  # ❌ 阻塞!
    return resp.text

async def other_task():
    while True:
        print("Working...")
        await asyncio.sleep(1)

async def main():
    # fetch 中的阻塞会导致 other_task 也无法执行
    await asyncio.gather(
        fetch("https://example.com"),
        other_task()
    )

解决方法

  1. 使用对应的异步库(如 aiohttp 替代 requests
  2. 使用 run_in_executor 将阻塞调用放到线程池中执行
python
async def fetch(url):
    # 方法一:使用异步库
    # async with aiohttp.ClientSession() as session:
    #     async with session.get(url) as resp:
    #         return await resp.text()
    
    # 方法二:run_in_executor 把阻塞调用放到线程池
    loop = asyncio.get_running_loop()
    resp = await loop.run_in_executor(None, requests.get, url)
    return resp.text

2. 未 await 的协程不会执行

调用 async def 函数只是创建了协程对象,不会执行。如果忘记 await,协程永远不会运行,还会产生 RuntimeWarning

python
async def do_something():
    print("Doing something")
    await asyncio.sleep(1)
    print("Done")

async def main():
    do_something()  # ❌ 只创建了协程对象,不会执行!
    # 正确:await do_something()
    # 或者:asyncio.create_task(do_something())

运行时会警告:

RuntimeWarning: coroutine 'do_something' was never awaited

3. 异常吞噬

Task 的异常如果不主动获取,会被"吞掉",导致问题难以排查。

python
async def buggy_task():
    await asyncio.sleep(1)
    raise ValueError("something went wrong")

async def main():
    task = asyncio.create_task(buggy_task())
    await asyncio.sleep(2)
    # 任务已经异常结束了,但这里不会有任何报错
    print("Main finished")

解决方法

  • await 任务(异常会被抛出)
  • 使用 add_done_callback 检查异常
  • 配置全局异常处理器
python
# 方法一:await
try:
    await task
except Exception as e:
    print(f"Error: {e}")

# 方法二:回调
def on_done(future):
    if future.exception():
        print(f"Task failed: {future.exception()}")

task.add_done_callback(on_done)

# 方法三:全局异常处理器
def exception_handler(loop, context):
    print(f"Exception: {context['exception']}")

loop = asyncio.get_event_loop()
loop.set_exception_handler(exception_handler)

4. 无限制创建任务导致资源耗尽

协程虽然轻量,但也不是无限的。无限制地创建任务可能导致:

  • 内存耗尽
  • 连接数耗尽(数据库连接、文件描述符等)
  • 事件循环负担过重
python
async def process(url):
    await asyncio.sleep(0.1)

async def main():
    urls = [f"https://example.com/{i}" for i in range(100000)]
    # ❌ 一次性创建 10 万个任务,可能导致资源耗尽
    tasks = [asyncio.create_task(process(url)) for url in urls]
    await asyncio.gather(*tasks)

解决方法:使用 asyncio.Semaphore 控制并发数

python
async def process(url, sem):
    async with sem:
        await asyncio.sleep(0.1)

async def main():
    sem = asyncio.Semaphore(100)  # 最多 100 个并发
    urls = [f"https://example.com/{i}" for i in range(100000)]
    tasks = [asyncio.create_task(process(url, sem)) for url in urls]
    await asyncio.gather(*tasks)

5. 取消任务不一定立即生效

task.cancel() 只是请求取消,任务不一定会立即停止,取决于协程的实现。

python
async def long_task():
    try:
        # 计算密集型操作,不会响应取消
        for i in range(10000000):
            pass
        await asyncio.sleep(10)  # 只有 await 点才能响应取消
    except asyncio.CancelledError:
        print("Cancelled!")
        raise

async def main():
    task = asyncio.create_task(long_task())
    await asyncio.sleep(1)
    task.cancel()  # 请求取消
    try:
        await task
    except asyncio.CancelledError:
        print("Task was cancelled")

要点

  • 取消是通过在 await 点抛出 CancelledError 实现的
  • 如果协程在执行同步代码(没有 await),取消不会立即生效
  • 应该在合适的地方处理 CancelledError,做好清理工作

6. 线程安全问题

asyncio 不是线程安全的。不要在其他线程中直接操作事件循环或调用 asyncio 的 API。

python
# 错误:在另一个线程中调用 asyncio 的方法
def worker_thread(loop):
    # ❌ 不安全!
    asyncio.run_coroutine_threadsafe(coro(), loop)  # 这个是安全的
    # 但很多操作不是

解决方法:使用 call_soon_threadsaferun_coroutine_threadsafe

python
# 正确:线程安全的方式
def worker_thread(loop):
    # 从其他线程向事件循环提交协程
    asyncio.run_coroutine_threadsafe(coro(), loop)
    
    # 从其他线程调度回调
    loop.call_soon_threadsafe(callback, arg)

7. 混用同步和异步代码导致死锁

在异步代码中使用同步锁可能导致死锁,因为锁会阻塞整个事件循环。

python
import threading

lock = threading.Lock()

async def task1():
    with lock:  # ❌ 线程锁会阻塞整个事件循环!
        await asyncio.sleep(1)

async def task2():
    with lock:  # 如果 task1 持有锁并挂起,task2 会在这里阻塞整个事件循环
        await asyncio.sleep(1)

解决方法:使用 asyncio.Lock 而不是 threading.Lock

python
lock = asyncio.Lock()  # ✅ 协程锁,只挂起协程,不阻塞线程

async def task1():
    async with lock:
        await asyncio.sleep(1)

8. 上下文变量(ContextVar)的使用

异步代码中使用全局变量可能导致问题,因为多个协程在同一线程中交替执行,会互相干扰。

解决方法:使用 contextvars.ContextVar 管理协程上下文

python
from contextvars import ContextVar

user_var = ContextVar("user")

async def process_request(user_id):
    user_var.set(user_id)
    await do_something()
    print(f"Processing user {user_var.get()}")

陷阱总结

陷阱后果解决方法
阻塞调用阻塞事件循环所有协程卡住用异步库或 run_in_executor
忘记 await 协程协程不执行,RuntimeWarning记得 await 或 create_task
异常吞噬错误难以排查await 任务 / add_done_callback
无限制创建任务资源耗尽用 Semaphore 限流
取消不立即生效任务可能继续运行合理设计取消点
跨线程操作数据竞争、崩溃用 call_soon_threadsafe
混用同步锁死锁用 asyncio.Lock
全局变量串扰数据混乱用 ContextVar

追问延伸

  • 如何在异步代码中处理 CPU 密集型任务?
  • 为什么 asyncio 不是线程安全的?哪些操作是线程安全的?
  • 如何优雅地取消一组任务?
  • 你在项目中遇到过哪些异步编程的坑?怎么解决的?
  • 什么是"异步地狱"?如何避免?
  • 如何对异步代码进行单元测试?

Q9: 什么是协程安全?asyncio.Lock 和线程锁的区别? 「🟡 中级」

考察点:考察对协程同步机制的理解,是否清楚协程锁和线程锁的本质区别,筛选以为异步就不需要锁、或把线程锁用到异步代码里的候选人。

参考答案

一、协程也需要锁吗?

很多人以为协程是单线程的,不会有并发安全问题,这是错误的。

协程安全问题示例

python
import asyncio

balance = 100

async def withdraw(amount):
    global balance
    if balance >= amount:
        # 模拟检查后的一些操作(如网络请求、数据库查询等)
        await asyncio.sleep(0.1)  # 这里会切换到其他协程
        balance -= amount
        print(f"Withdrew {amount}, balance: {balance}")
    else:
        print(f"Insufficient funds, balance: {balance}")

async def main():
    # 两个协程同时取钱
    await asyncio.gather(
        withdraw(80),
        withdraw(80)
    )
    print(f"Final balance: {balance}")  # 可能是负数!

asyncio.run(main())

执行结果可能是 balance = -60,因为:

  1. 协程 1 检查余额 100 >= 80,通过
  2. 协程 1 在 await 处挂起
  3. 协程 2 检查余额 100 >= 80,也通过
  4. 协程 2 在 await 处挂起
  5. 两个协程都执行扣款,余额变成负数

结论:只要存在多个协程修改共享资源,且中间有 await 切换点,就可能出现竞态条件,需要锁来保护。

二、asyncio.Lock(协程锁)

asyncio.Lock 是 asyncio 提供的互斥锁,专为协程设计。

python
import asyncio

lock = asyncio.Lock()
balance = 100

async def withdraw(amount):
    global balance
    async with lock:  # 获取锁(挂起等待,不阻塞线程)
        if balance >= amount:
            await asyncio.sleep(0.1)
            balance -= amount
            print(f"Withdrew {amount}, balance: {balance}")
        else:
            print(f"Insufficient funds, balance: {balance}")

async def main():
    await asyncio.gather(withdraw(80), withdraw(80))
    print(f"Final balance: {balance}")  # 20,正确!

asyncio.run(main())

asyncio.Lock 的特点

  • 当锁被占用时,等待的协程会挂起(不是阻塞线程)
  • 事件循环可以继续执行其他协程
  • 锁释放后,等待的协程被唤醒,继续执行
  • 协作式的,依赖 await 切换

三、asyncio.Lock vs threading.Lock

特性asyncio.Lock(协程锁)threading.Lock(线程锁)
等待时的行为挂起协程,线程继续运行阻塞整个线程
切换开销极小(用户态,几纳秒)大(内核态,几微秒)
调度者事件循环(程序调度)操作系统(内核调度)
适用范围同一线程内的协程之间不同线程之间
能否用在异步代码中推荐使用绝对不要用(会死锁)
是否可重入不可重入(还有 RLock)不可重入(还有 RLock)

四、为什么异步代码中不能用线程锁?

python
import asyncio
import threading

lock = threading.Lock()

async def task1():
    with lock:
        print("Task 1 got lock")
        await asyncio.sleep(1)  # 挂起协程,但锁还没释放
        print("Task 1 releasing lock")

async def task2():
    print("Task 2 waiting for lock")
    with lock:  # ❌ 阻塞整个线程!事件循环也卡住了
        print("Task 2 got lock")

async def main():
    await asyncio.gather(task1(), task2())

asyncio.run(main())  # 程序会卡死!

死锁原因

  1. task1 获取线程锁,然后在 await asyncio.sleep(1) 处挂起
  2. task2 尝试获取线程锁,但锁被 task1 持有
  3. 线程锁是阻塞的,task2 会阻塞整个线程
  4. 事件循环被阻塞,task1 永远没有机会恢复执行来释放锁
  5. → 死锁!

五、asyncio 中的其他同步原语

asyncio 也提供了类似 threading 的同步原语,都是协程级别的:

同步原语说明
asyncio.Lock互斥锁
asyncio.RLock可重入锁
asyncio.Condition条件变量(wait/notify)
asyncio.Semaphore信号量(控制并发数)
asyncio.Event事件(set/wait)
asyncio.Barrier屏障(Python 3.11+)
asyncio.Queue异步队列(生产者消费者)

这些同步原语的用法和 threading 模块类似,但都是 async 的,需要用 awaitasync with

六、协程锁的适用场景

  1. 保护共享资源:多个协程修改同一个变量
  2. 限制并发数:用 Semaphore 控制同时进行的 IO 操作数量
  3. 协程间协作:用 Event/Condition 实现协程间的同步

七、什么时候不需要锁?

  1. 单协程操作共享资源:没有并发修改,不需要锁
  2. 原子操作:单个字节码指令是原子的(如 list.append),但尽量不要依赖这个
  3. 消息传递替代共享内存:用 Queue 传递消息而不是共享变量(CSP 模型)
python
# 用 Queue 代替共享变量(推荐的模式)
async def producer(queue):
    for i in range(10):
        await queue.put(i)

async def consumer(queue):
    while True:
        item = await queue.get()
        # 处理 item
        queue.task_done()

追问延伸

  • 协程是单线程的,为什么还需要锁?
  • 在异步代码中使用线程锁会有什么问题?为什么?
  • asyncio.Lock 和 threading.Lock 底层实现有什么区别?
  • 什么是"原子操作"?Python 中哪些操作是原子的?
  • 协程锁和线程锁哪个更快?为什么?
  • 除了锁,还有什么方式可以保证协程安全?

Q10: GIL 在什么情况下会释放? 「🟡 中级」

考察点:考察对 GIL(全局解释器锁)工作机制的深入理解,是否清楚 GIL 的释放时机,筛选对 GIL 只有模糊概念的候选人。

参考答案

GIL(Global Interpreter Lock,全局解释器锁)是 CPython 中的一把互斥锁,确保同一时刻只有一个线程执行 Python 字节码。但 GIL 不是一直占着不放的,它会在特定时机释放。

一、GIL 的释放时机

1. IO 操作时释放

当线程遇到 IO 操作(网络请求、文件读写、sleep 等)时,GIL 会被释放,让其他线程有机会执行。

python
import threading
import time

def io_task():
    # IO 操作会释放 GIL
    time.sleep(1)  # sleep 是 IO 操作,释放 GIL
    # 文件读写也会释放 GIL
    # open("file.txt").read()
    # 网络请求也会释放 GIL
    # requests.get("https://example.com")

原理:IO 操作通常涉及系统调用,线程会进入等待状态。在进入等待之前,Python 会释放 GIL,这样 CPU 不会被浪费。

2. 时间片到期(计算密集型也会释放)

Python 3.2+ 引入了新的 GIL 机制:基于时间的抢占

  • 默认情况下,每 15 毫秒sys.getswitchinterval() 可查),当前线程会检查是否需要释放 GIL
  • 如果有其他线程在等待 GIL,当前线程会释放 GIL,让其他线程有机会运行
  • 这意味着即使是纯计算密集型代码,GIL 也会周期性释放
python
import sys
print(sys.getswitchinterval())  # 默认 0.005 秒(Python 3.11),之前是 0.015 秒

注意:虽然 GIL 会周期性释放,但计算密集型任务用多线程仍然不会比单线程更快,因为:

  • 同一时刻只有一个线程执行 Python 字节码
  • 线程切换有开销
  • GIL 的争抢也有开销

3. C 扩展中手动释放

在 C 扩展中,可以手动释放 GIL,让 Python 线程在执行 C 代码时不持有 GIL。

c
// C 扩展中释放 GIL 的示例
#include "Python.h"

static PyObject* my_function(PyObject* self, PyObject* args) {
    // 释放 GIL
    Py_BEGIN_ALLOW_THREADS
    
    // 这里执行耗时的 C 代码,不持有 GIL
    // 其他 Python 线程可以并行执行
    
    // 重新获取 GIL
    Py_END_ALLOW_THREADS
    
    Py_RETURN_NONE;
}

很多常用库的 C 扩展都会在适当的地方释放 GIL,例如:

  • numpy:在大型数组运算时释放 GIL
  • hashlib:在计算哈希时释放 GIL
  • zlib:在压缩/解压时释放 GIL

这也是为什么有时候 numpy 的多线程计算会有一定加速效果的原因。

4. 调用某些 C 函数

某些 C 标准库函数(如 sleep、文件读写等)在调用时,Python 解释器会自动释放 GIL。

二、GIL 的获取机制

GIL 的获取不是简单的"先到先得",有一套复杂的机制:

  1. 线程请求 GIL:线程想要执行 Python 字节码时,需要先获取 GIL
  2. 如果 GIL 空闲:直接获取
  3. 如果 GIL 被占用:线程进入等待状态
  4. 持有 GIL 的线程释放时:会通知等待的线程
  5. 线程被唤醒后:尝试获取 GIL

在 Python 3.2 之前,GIL 的调度策略是"票机制",导致 IO 密集型线程和 CPU 密集型线程混跑时性能很差。Python 3.2 之后改成了基于时间片的抢占机制,改善了这种情况。

三、验证 GIL 释放的实验

python
import threading
import time

# CPU 密集型任务
def cpu_bound():
    count = 0
    for _ in range(10**7):
        count += 1
    return count

# IO 密集型任务
def io_bound():
    time.sleep(1)

# 实验 1:单线程 CPU 密集型
start = time.time()
cpu_bound()
print(f"单线程 CPU: {time.time() - start:.2f}s")

# 实验 2:多线程 CPU 密集型
start = time.time()
threads = [threading.Thread(target=cpu_bound) for _ in range(4)]
for t in threads:
    t.start()
for t in threads:
    t.join()
print(f"4线程 CPU: {time.time() - start:.2f}s")
# 结果:比单线程慢(因为有 GIL 争抢和线程切换开销)

# 实验 3:多线程 IO 密集型
start = time.time()
threads = [threading.Thread(target=io_bound) for _ in range(4)]
for t in threads:
    t.start()
for t in threads:
    t.join()
print(f"4线程 IO: {time.time() - start:.2f}s")
# 结果:约 1 秒(IO 操作时 GIL 释放,并行等待)

四、GIL 与线程安全的关系

一个常见的误解:有了 GIL,Python 就不需要线程锁了。这是错误的。

GIL 保证的是:同一时刻只有一个线程执行 Python 字节码(解释器层面的原子性)

GIL 不保证的是

  • 高级操作的原子性(如 count += 1 是多条字节码)
  • 多个操作之间的一致性(如先检查后修改)
python
count = 0

def increment():
    global count
    count += 1  # 这不是原子操作!
    # 字节码:
    # LOAD_GLOBAL count
    # LOAD_CONST 1
    # INPLACE_ADD
    # STORE_GLOBAL count
    # GIL 可能在任意两条字节码之间释放

所以,即使有 GIL,在多线程中操作共享变量仍然需要锁。

五、GIL 释放时机总结

释放时机说明示例
IO 操作遇到 IO 时主动释放sleep、文件读写、网络请求
时间片到周期性释放(默认几毫秒)计算密集型代码也会释放
C 扩展手动释放C 代码中手动释放numpy 运算、hashlib 计算
线程结束线程执行完毕自然释放线程正常退出

追问延伸

  • GIL 是一把什么锁?它保护的是什么?
  • 既然有 GIL,为什么还需要线程锁?
  • Python 3.2 前后 GIL 的实现有什么区别?
  • numpy 的多线程为什么能加速?
  • 有没有办法移除 GIL?Gilectomy 项目了解吗?
  • 除了 CPython,其他 Python 实现(如 PyPy)有 GIL 吗?
  • GIL 和 Java 的 synchronized 有什么区别?

Q11: 什么是线程池?为什么要用线程池? 「🟡 中级」

考察点:考察对线程池原理和设计思想的理解,是否清楚线程池的核心参数和使用场景,筛选只会简单使用而不理解原理的候选人。

参考答案

一、什么是线程池

线程池是一种池化技术,预先创建一批线程,放在一个"池子"里,当有任务需要执行时,从池子中取一个空闲线程来执行任务,任务执行完后线程不销毁,而是放回池中等待下一个任务。

┌─────────────────────────────────┐
│           任务队列               │
│  [任务1][任务2][任务3][任务4]...  │
└──────────────┬──────────────────┘

    ┌──────────▼──────────┐
    │     线程池            │
    │  ┌──┐ ┌──┐ ┌──┐ ┌──┐ │
    │  │线│ │线│ │线│ │线│ │
    │  │程│ │程│ │程│ │程│ │
    │  │1 │ │2 │ │3 │ │4 │ │
    │  └──┘ └──┘ └──┘ └──┘ │
    └──────────────────────┘

二、为什么要用线程池

如果每次执行任务都创建一个新线程,会有以下问题:

  1. 创建销毁开销大:线程的创建和销毁需要操作系统介入(分配栈空间、寄存器上下文等),耗时几毫秒到几十毫秒
  2. 资源耗尽:无限制创建线程会消耗大量内存(每个线程栈约 1MB),还会导致频繁的上下文切换
  3. 缺乏管理:线程数量失控后,系统整体性能下降

线程池的好处:

好处说明
降低资源消耗复用线程,减少线程创建和销毁的开销
提高响应速度任务来了直接用空闲线程,不用等待线程创建
控制并发数通过最大线程数限制并发,防止资源耗尽
方便管理可以统一管理、监控、调优
任务队列缓冲任务过多时可以排队等待,不会直接拒绝或崩溃

三、线程池的核心参数

以 Python 的 ThreadPoolExecutor 为例,核心参数有:

python
from concurrent.futures import ThreadPoolExecutor

executor = ThreadPoolExecutor(
    max_workers=10,    # 最大线程数
    thread_name_prefix="worker"  # 线程名前缀(方便调试)
)

Java 的 ThreadPoolExecutor 参数更完整,可以作为参考理解:

参数说明
corePoolSize核心线程数(即使空闲也保留)
maximumPoolSize最大线程数
keepAliveTime空闲线程存活时间
workQueue任务队列
threadFactory线程工厂
rejectedExecutionHandler拒绝策略

Python 的 ThreadPoolExecutor 相对简化:

  • 没有核心线程和最大线程的区分(都是核心线程,且不会超时销毁)
  • 任务队列是无界的(SimpleQueue,Python 3.8+)
  • 没有显式的拒绝策略(队列无界,任务会一直排队)

四、线程池的工作流程

新任务提交


当前线程数 < 核心线程数?
    │        │
    是       否
    │        │
    ▼        ▼
创建新线程  任务加入队列
执行任务       │

           队列满了?
               │    │
               否    是
               │    │
               ▼    ▼
           等待执行  当前线程数 < 最大线程数?
                          │      │
                          是      否
                          │      │
                          ▼      ▼
                    创建非核心线程  执行拒绝策略
                    执行任务

Python 的 ThreadPoolExecutor 简化了这个流程:

  • 线程数达到 max_workers 后,新任务进入队列
  • 队列无界,不会拒绝任务(可能导致内存溢出)
  • 线程一旦创建就不会销毁(直到线程池关闭)

五、线程池大小的设置

线程池大小不是越大越好,需要根据任务类型来设置:

1. CPU 密集型任务

线程数 ≈ CPU 核数(或 CPU 核数 + 1)

  • 线程太多会导致频繁的上下文切换,反而降低性能
  • Python 中因为 GIL 的存在,CPU 密集型应该用进程池而不是线程池

2. IO 密集型任务

线程数 ≈ 2 * CPU 核数,或者更多

  • IO 密集型任务大部分时间在等待,线程数可以多一些
  • 但也不是越多越好,线程太多会有内存和切换开销
  • 一般经验:线程数 = CPU核数 * (1 + 等待时间/计算时间)
python
import os

cpu_count = os.cpu_count()  # CPU 核数

# IO 密集型:可以设大一些
io_pool = ThreadPoolExecutor(max_workers=cpu_count * 5)

# CPU 密集型:应该用进程池
from concurrent.futures import ProcessPoolExecutor
cpu_pool = ProcessPoolExecutor(max_workers=cpu_count)

3. 混合任务

如果既有 CPU 计算又有 IO,可以根据实际情况调整,或者分开使用不同的线程池。

六、线程池的使用注意事项

1. 任务队列不能无限增长

Python 的 ThreadPoolExecutor 使用无界队列,如果任务提交速度远大于处理速度,队列会不断增长,导致内存耗尽。

解决方法:使用有界队列 + 自定义拒绝策略(需要自己实现)

2. 异常处理

线程池中的任务如果抛出异常,默认不会打印堆栈,需要通过 result() 获取:

python
future = executor.submit(task)
try:
    future.result()
except Exception as e:
    print(f"Task failed: {e}")

3. 正确关闭线程池

使用 with 语句可以自动关闭线程池:

python
with ThreadPoolExecutor(max_workers=10) as executor:
    executor.submit(task)
# 退出 with 块时,线程池自动关闭(等待所有任务完成)

4. 不要在线程池中提交阻塞主线程的任务

可能导致死锁(线程池的线程都在等待队列中的任务,但队列已满)。

七、扩展:进程池

concurrent.futures.ProcessPoolExecutor 是进程池,原理和线程池类似,但用进程代替线程:

对比线程池进程池
适用场景IO 密集型CPU 密集型
资源开销较小较大
数据共享共享内存(需锁)独立内存(需 IPC)
GIL 影响受 GIL 限制不受 GIL 限制
任务要求无特殊要求函数和参数需可序列化

追问延伸

  • 线程池的核心参数有哪些?怎么设置?
  • 线程池的工作流程是怎样的?
  • 如何确定线程池的大小?有什么依据?
  • 线程池中的任务抛异常了怎么办?
  • 什么情况下线程池的线程会销毁?
  • 线程池有哪些拒绝策略?
  • 你在项目中用过线程池吗?遇到过什么问题?

Q12: 生产者消费者模式?Python中怎么实现? 「🟡 中级」

考察点:考察对经典并发模式的理解和实际编码能力,是否能根据不同场景选择合适的实现方式,筛选只会写简单并发代码而不会设计模式的候选人。

参考答案

一、什么是生产者消费者模式

生产者消费者模式是并发编程中最经典的设计模式之一:

  • 生产者:负责生产数据(任务),放入队列
  • 消费者:从队列中取出数据(任务)进行处理
  • 队列:作为生产者和消费者之间的缓冲区
┌──────────┐     ┌──────────┐     ┌──────────┐
│ 生产者1  │     │ 生产者2  │     │ 生产者3  │
└─────┬────┘     └────┬─────┘     └────┬─────┘
      │               │                │
      └───────────────┼────────────────┘

              ┌──────────────┐
              │   任务队列    │
              │ [ ][ ][ ][ ] │
              └──────┬───────┘

      ┌──────────────┼──────────────┐
      ▼              ▼              ▼
┌──────────┐   ┌──────────┐   ┌──────────┐
│ 消费者1  │   │ 消费者2  │   │ 消费者3  │
└──────────┘   └──────────┘   └──────────┘

优点

  1. 解耦:生产者和消费者不需要知道对方的存在,只依赖队列
  2. 削峰填谷:生产者速度快时,数据可以在队列中缓存;消费者慢慢处理
  3. 并发:生产者和消费者可以并发执行,提高系统吞吐量
  4. 可扩展:可以灵活增减生产者和消费者的数量

二、多线程版本:queue.Queue

Python 标准库的 queue.Queue 是线程安全的队列,适合多线程的生产者消费者模式。

python
import threading
import queue
import time
import random

# 创建队列,最大容量 10
q = queue.Queue(maxsize=10)

def producer(name):
    for i in range(5):
        item = f"{name}-product-{i}"
        q.put(item)  # 队列满时自动阻塞
        print(f"Producer {name} produced: {item}")
        time.sleep(random.uniform(0.1, 0.5))

def consumer(name):
    while True:
        item = q.get()  # 队列空时自动阻塞
        if item is None:  # 哨兵值,收到 None 表示结束
            q.task_done()
            break
        print(f"Consumer {name} consumed: {item}")
        time.sleep(random.uniform(0.2, 0.6))
        q.task_done()  # 标记任务完成

# 创建生产者和消费者
producers = [threading.Thread(target=producer, args=(f"P{i}",)) for i in range(2)]
consumers = [threading.Thread(target=consumer, args=(f"C{i}",)) for i in range(3)]

# 启动
for p in producers:
    p.start()
for c in consumers:
    c.start()

# 等待生产者生产完
for p in producers:
    p.join()

# 发送结束信号(每个消费者一个 None)
for _ in consumers:
    q.put(None)

# 等待所有任务处理完
q.join()

# 等待消费者结束
for c in consumers:
    c.join()

print("All done!")

Queue 的常用方法

方法说明
put(item, block=True, timeout=None)放入元素,满则阻塞
get(block=True, timeout=None)取出元素,空则阻塞
put_nowait(item)不阻塞放入,满则抛 Full 异常
get_nowait()不阻塞取出,空则抛 Empty 异常
task_done()标记任务完成
join()阻塞,直到所有任务都被标记完成
qsize()当前队列大小(近似值)
empty()是否为空(近似值)
full()是否为满(近似值)

三、多进程版本:multiprocessing.Queue

多进程场景下使用 multiprocessing.Queue,接口和 queue.Queue 类似。

python
from multiprocessing import Process, Queue
import time
import random

def producer(q, name):
    for i in range(5):
        item = f"{name}-product-{i}"
        q.put(item)
        print(f"Producer {name} produced: {item}")
        time.sleep(random.uniform(0.1, 0.5))

def consumer(q, name):
    while True:
        item = q.get()
        if item is None:
            break
        print(f"Consumer {name} consumed: {item}")
        time.sleep(random.uniform(0.2, 0.6))

if __name__ == "__main__":
    q = Queue(maxsize=10)
    
    producers = [Process(target=producer, args=(q, f"P{i}")) for i in range(2)]
    consumers = [Process(target=consumer, args=(q, f"C{i}")) for i in range(3)]
    
    for p in producers:
        p.start()
    for c in consumers:
        c.start()
    
    for p in producers:
        p.join()
    
    # 发送结束信号
    for _ in consumers:
        q.put(None)
    
    for c in consumers:
        c.join()
    
    print("All done!")

注意

  • multiprocessing.Queue 没有 task_done()join() 方法
  • 数据需要经过 pickle 序列化,有性能开销
  • 可以使用 JoinableQueue 来获得 task_done()join() 功能

四、异步版本:asyncio.Queue

异步编程场景下使用 asyncio.Queue

python
import asyncio
import random

async def producer(q, name):
    for i in range(5):
        item = f"{name}-product-{i}"
        await q.put(item)  # 队列满时挂起协程
        print(f"Producer {name} produced: {item}")
        await asyncio.sleep(random.uniform(0.1, 0.5))

async def consumer(q, name):
    while True:
        item = await q.get()  # 队列空时挂起协程
        if item is None:
            q.task_done()
            break
        print(f"Consumer {name} consumed: {item}")
        await asyncio.sleep(random.uniform(0.2, 0.6))
        q.task_done()

async def main():
    q = asyncio.Queue(maxsize=10)
    
    # 创建生产者和消费者任务
    producers = [asyncio.create_task(producer(q, f"P{i}")) for i in range(2)]
    consumers = [asyncio.create_task(consumer(q, f"C{i}")) for i in range(3)]
    
    # 等待生产者完成
    await asyncio.gather(*producers)
    
    # 发送结束信号
    for _ in consumers:
        await q.put(None)
    
    # 等待所有任务处理完
    await q.join()
    
    # 等待消费者结束
    await asyncio.gather(*consumers)
    
    print("All done!")

asyncio.run(main())

五、三种实现对比

对比多线程 queue.Queue多进程 multiprocessing.Queue异步 asyncio.Queue
适用场景IO 密集型CPU 密集型高并发 IO 密集型
线程/进程安全线程安全进程安全协程安全
阻塞方式阻塞线程阻塞进程挂起协程
通信速度快(共享内存)较慢(IPC + 序列化)最快(同一线程)
task_done/join支持JoinableQueue 支持支持
最大并发数几千几十到几百几万到几十万

六、结束信号的处理方式

生产者消费者模式中,如何告诉消费者"没有更多数据了"?

方式一:哨兵值(Sentinel)

用一个特殊值(如 None)表示结束,每个消费者收到一个哨兵值就退出。

python
# 有 N 个消费者,就放 N 个 None
for _ in consumers:
    q.put(None)

优点:简单直观 缺点:如果消费者数量不确定,不适用

方式二:join + task_done

生产者完成后,调用 q.join() 等待所有任务处理完,然后通过其他方式(如事件)通知消费者退出。

方式三:事件信号(Event)

用一个 Event 来通知所有消费者停止:

python
stop_event = threading.Event()

def consumer(q):
    while not stop_event.is_set():
        try:
            item = q.get(timeout=0.1)
            # 处理 item
            q.task_done()
        except queue.Empty:
            continue

七、实际应用场景

  1. Web 服务器:请求队列 + 工作线程/进程
  2. 消息队列:Kafka、RabbitMQ 本质上就是分布式的生产者消费者
  3. 日志系统:业务线程生产日志,专门的线程消费写入文件
  4. 爬虫系统:URL 生产者 + 页面下载消费者 + 数据解析消费者
  5. 任务处理系统:任务提交者 + Worker 池

追问延伸

  • 生产者消费者模式的优点是什么?解决了什么问题?
  • 队列满了怎么办?有哪些处理策略?
  • 如何优雅地停止消费者线程?
  • 有多个消费者时,任务是怎么分配的?能指定消费者吗?
  • 你在项目中用过生产者消费者模式吗?具体是什么场景?
  • 什么是背压(Backpressure)?生产者消费者模式中如何处理?

Q13: 什么是死锁?怎么避免?Python中如何检测? 「🟡 中级」

考察点:考察对死锁概念的理解、预防策略,以及排查死锁的实战经验,筛选缺乏并发编程实战经验的候选人。

参考答案

一、什么是死锁

死锁(Deadlock)是指两个或多个线程/进程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。

二、死锁的四个必要条件

死锁的产生必须同时满足以下四个条件(缺一不可):

条件说明
互斥条件资源在同一时间只能被一个线程占有
请求与保持条件线程已经持有至少一个资源,又请求其他被占有的资源
不剥夺条件已获得的资源不能被其他线程强行剥夺,只能自己释放
循环等待条件多个线程之间形成头尾相接的循环等待资源关系

死锁示例

python
import threading
import time

lock_a = threading.Lock()
lock_b = threading.Lock()

def thread1():
    with lock_a:
        print("Thread 1 got lock A")
        time.sleep(0.1)  # 确保 thread2 拿到 lock B
        print("Thread 1 waiting for lock B")
        with lock_b:  # 等待 lock B
            print("Thread 1 got lock B")

def thread2():
    with lock_b:
        print("Thread 2 got lock B")
        time.sleep(0.1)
        print("Thread 2 waiting for lock A")
        with lock_a:  # 等待 lock A
            print("Thread 2 got lock A")

t1 = threading.Thread(target=thread1)
t2 = threading.Thread(target=thread2)
t1.start()
t2.start()
t1.join()
t2.join()
# 程序会卡死!

分析:

  • 线程1 持有 lock A,等待 lock B
  • 线程2 持有 lock B,等待 lock A
  • 形成循环等待 → 死锁

三、如何避免死锁

只要打破四个必要条件中的任意一个,就能避免死锁。

1. 打破互斥条件 → 不太可行

互斥是资源本身的属性(如锁就是互斥的),一般无法改变。

2. 打破请求与保持条件 → 一次性申请所有资源

线程在开始执行前,一次性申请所有需要的资源,如果有一个申请不到,就都不申请。

python
# 不好的做法:逐步申请
def bad():
    with lock_a:
        # 做一些事
        with lock_b:
            # 做另一些事

# 好的做法:一次性申请(需要设计"同时获取多把锁"的机制)
# 但 Python 标准库没有直接支持,可以自定义

缺点:资源利用率低,可能导致饥饿。

3. 打破不剥夺条件 → 超时放弃

如果尝试获取锁超时,就放弃并释放已经持有的锁。

python
def thread1():
    while True:
        if lock_a.acquire(timeout=1):
            print("Thread 1 got lock A")
            time.sleep(0.1)
            if lock_b.acquire(timeout=1):
                print("Thread 1 got lock B")
                # 业务逻辑
                lock_b.release()
                lock_a.release()
                break
            else:
                print("Thread 1 timeout, releasing lock A")
                lock_a.release()
                time.sleep(0.1)

Python 的 threading.Lock.acquire(timeout=) 支持超时(Python 3.2+)。

缺点:可能活锁(两个线程都不断重试,还是抢不到)。

4. 打破循环等待条件 → 按固定顺序加锁(最常用)

给所有锁编号,规定线程必须按编号从小到大(或从大到小)的顺序获取锁。

python
# 规定:总是先获取编号小的锁,再获取编号大的锁

def thread1():
    # 先 A 后 B(正确顺序)
    with lock_a:
        print("Thread 1 got lock A")
        time.sleep(0.1)
        with lock_b:
            print("Thread 1 got lock B")

def thread2():
    # 也先 A 后 B(和 thread1 一样的顺序)
    with lock_a:  # 先拿 A,而不是 B
        print("Thread 2 got lock A")
        time.sleep(0.1)
        with lock_b:
            print("Thread 2 got lock B")

这样就不会形成循环等待,因为所有线程都按相同顺序获取锁。

更通用的实现

python
def acquire_locks(*locks):
    """按 id 排序获取多把锁,避免死锁"""
    # 按锁的 id 排序,保证所有线程按相同顺序获取
    sorted_locks = sorted(locks, key=lambda x: id(x))
    for lock in sorted_locks:
        lock.acquire()

def release_locks(*locks):
    for lock in locks:
        lock.release()

# 使用
def thread1():
    acquire_locks(lock_a, lock_b)
    try:
        # 业务逻辑
        pass
    finally:
        release_locks(lock_a, lock_b)

5. 其他方法

  • 减少锁的使用:尽量用无锁数据结构或消息传递代替锁
  • 减少锁粒度:用细粒度的锁代替粗粒度的锁
  • 使用 tryLock + 回退:尝试获取所有锁,不成功就释放已获取的,稍后重试

四、死锁检测

死锁有时候难以避免,需要能够检测和排查。

1. 现象判断

  • 程序卡住不动,CPU 占用率低(说明线程在等待,而不是在计算)
  • 某些功能突然不工作了

2. 工具检测

方法一:py-spy(推荐)

py-spy 是一个 Python 性能分析工具,可以采样查看每个线程在做什么。

bash
# 安装
pip install py-spy

# 查看 Python 进程的线程调用栈
py-spy dump --pid <进程ID>

如果发现多个线程都停在 acquire 上,且互相等待,很可能是死锁。

方法二:自定义锁包装类

可以自定义一个 Lock 包装类,记录锁的持有者和等待者,用于检测死锁。

python
import threading
import traceback

class DeadlockDetectingLock:
    def __init__(self):
        self._lock = threading.Lock()
        self._owner = None
        self._waiters = []
        self._owner_stack = None
    
    def acquire(self):
        tid = threading.get_ident()
        if not self._lock.acquire(blocking=False):
            # 记录等待信息
            self._waiters.append((tid, traceback.format_stack()))
            self._lock.acquire()
            self._waiters.remove((tid, traceback.format_stack()))
        
        self._owner = tid
        self._owner_stack = traceback.format_stack()
    
    def release(self):
        self._owner = None
        self._owner_stack = None
        self._lock.release()
    
    def __enter__(self):
        self.acquire()
        return self
    
    def __exit__(self, *args):
        self.release()
方法三:sys.settrace

使用 sys.settrace 跟踪函数调用,检测死锁。但这种方法性能开销大,不适合生产环境。

方法四:pyrasite

pyrasite 可以附加到运行中的 Python 进程,注入代码来检查状态。

五、死锁排查步骤

  1. 确认死锁:程序卡住,CPU 占用低,相关线程不工作
  2. 获取线程栈:用 py-spy 或其他工具 dump 线程调用栈
  3. 分析锁等待关系:看哪些线程在等什么锁,谁持有这些锁
  4. 找到循环等待:确认是否形成了循环等待链
  5. 修复代码:根据原因选择合适的避免策略

六、死锁 vs 活锁 vs 饥饿

概念说明
死锁线程都在等待对方释放资源,谁也无法继续
活锁线程都在运行,但都在不断重试,无法推进(类似两个人在走廊里互相让路但都过不去)
饥饿某个线程一直得不到所需资源,无法执行(如优先级低的线程一直抢不到锁)

追问延伸

  • 死锁的四个必要条件是什么?为什么缺一不可?
  • 你遇到过死锁吗?怎么排查和解决的?
  • 按顺序加锁为什么能避免死锁?有什么缺点?
  • 什么是活锁?和死锁有什么区别?
  • Python 中有什么工具可以检测死锁?
  • 数据库中的死锁和编程语言中的死锁有什么异同?
  • 如何设计一个死锁检测工具?

Q14: 协程和线程的区别?为什么协程更高效? 「🟡 中级」

考察点:考察对协程和线程本质区别的理解,是否清楚协程高效的根本原因,筛选只会用语法而不理解原理的候选人。

参考答案

协程和线程都是并发执行的单元,但在调度方式、切换开销、并发数量等方面有本质区别。

一、核心区别对比

维度线程(Thread)协程(Coroutine)
调度者操作系统内核调度用户态程序(事件循环)调度
切换开销大(内核态切换,几微秒)极小(用户态切换,几纳秒)
切换时机抢占式(操作系统随时可能切换)协作式(只有 await 时才切换)
数量上限几千(受内存和调度开销限制)几万到几十万(非常轻量)
内存占用每个线程栈约 1MB每个协程栈几 KB 到几十 KB
数据共享共享进程内存(需锁)共享线程内存(通常不需要锁)
重量级锁(阻塞线程)轻量级锁(挂起协程)
多核利用可以(但 Python 受 GIL 限制)不可以(单线程内)
调试难度相对简单较难(调用栈不直观)
阻塞影响阻塞一个线程,其他线程不受影响阻塞操作阻塞整个事件循环

二、为什么协程更高效

1. 用户态切换 vs 内核态切换

这是最核心的区别:

线程切换(内核态)

  • 线程由操作系统调度,切换需要陷入内核态
  • 保存和恢复大量寄存器上下文(通用寄存器、程序计数器、栈指针等)
  • 切换页表、刷新 TLB(Translation Lookaside Buffer)
  • 开销:约 1-5 微秒
用户态 → 内核态 → 保存上下文 → 调度 → 恢复上下文 → 用户态

协程切换(用户态)

  • 协程由程序自己调度,不需要内核参与
  • 只需要保存和恢复少量寄存器(如程序计数器、栈指针)
  • 本质上就是函数调用级别的切换
  • 开销:约 几十纳秒(比线程快 100 倍以上)
保存少量寄存器 → 切换栈 → 恢复寄存器

2. 协作式调度 vs 抢占式调度

线程(抢占式)

  • 操作系统可以在任何时候抢占当前线程,切换到另一个线程
  • 优点:不会有某个线程独占 CPU
  • 缺点:切换时机不可控,可能在不合适的时候切换;需要更多同步机制

协程(协作式)

  • 只有在 await 点才会切换,切换时机是可控的
  • 优点:切换时机明确,减少不必要的切换;很多时候不需要锁
  • 缺点:一个协程如果不主动让出 CPU,会一直占着(阻塞操作会拖垮整个事件循环)

3. 内存占用差异

  • 线程:每个线程的栈空间通常是 1MB(Linux 默认),几千个线程就占几个 GB
  • 协程:每个协程的栈非常小,初始可能只有几 KB,可以动态增长
  • 10 万个线程需要约 100GB 内存(不可能),10 万个协程只需要几百 MB

4. 调度开销

  • 线程:线程太多时,操作系统的调度器负担很重,频繁的上下文切换消耗大量 CPU
  • 协程:协程调度是用户态的,切换开销极小,几十万协程也能轻松调度

三、代码层面的对比

python
# 线程版:1000 个线程
import threading
import time

def worker():
    time.sleep(1)

start = time.time()
threads = [threading.Thread(target=worker) for _ in range(1000)]
for t in threads:
    t.start()
for t in threads:
    t.join()
print(f"1000 threads: {time.time() - start:.2f}s")
python
# 协程版:100000 个协程
import asyncio
import time

async def worker():
    await asyncio.sleep(1)

async def main():
    start = time.time()
    tasks = [asyncio.create_task(worker()) for _ in range(100000)]
    await asyncio.gather(*tasks)
    print(f"100000 coroutines: {time.time() - start:.2f}s")

asyncio.run(main())

协程可以轻松支持 10 万级并发,而线程 1000 个就开始吃力了。

四、协程的缺点

协程不是银弹,也有缺点:

  1. 需要异步生态:所有 IO 操作都需要对应的异步库,同步库会阻塞整个事件循环
  2. 不能利用多核:协程在单线程内运行,无法利用多核 CPU(需要配合多进程)
  3. 阻塞代码是灾难:一个协程中的阻塞操作会导致所有协程都卡住
  4. 调试困难:异常堆栈不直观,不像多线程那样容易调试
  5. 心智负担:需要理解事件循环、async/await、Future 等概念

五、适用场景对比

场景推荐方案原因
高并发 IO 密集型(Web 服务、爬虫)协程并发量高,切换开销小
CPU 密集型多进程需要利用多核
混合任务(IO + CPU)多进程 + 协程进程利用多核,协程处理 IO
简单的并发任务多线程编程简单,生态成熟
需要高稳定性多进程进程隔离,互不影响

六、总结

协程之所以高效,根本原因是:

  1. 用户态切换:不需要内核参与,切换开销极小
  2. 协作式调度:切换时机可控,减少不必要的切换
  3. 轻量内存:每个协程占用内存极小,可以创建大量协程

但协程也有局限性,不能完全替代线程和进程,需要根据场景选择合适的并发模型。

追问延伸

  • 协程切换具体比线程切换快在哪里?能量化吗?
  • 为什么协程是单线程的还能处理高并发?
  • 协程和 goroutine(Go)有什么区别?
  • 有了协程,线程还有存在的必要吗?
  • 什么情况下用协程反而不如多线程?
  • 你在项目中是做技术选型的?什么时候用协程,什么时候用线程?