Python进阶系列之-多任务:进程、线程与协程

写在前面:在写代码时,你有没有遇到过“一边下载电影,一边还要听歌,结果程序卡死”的情况?这就是单任务时代的痛点!为了榨干CPU的性能,让程序飞起来,我们必须掌握多任务编程。多任务能充分利用CPU资源,大幅提升程序执行效率,是Python进阶的必学知识点,也是面试的高频考点。

本文从「并发与并行」的核心区别出发,系统讲解多进程、多线程的实现方式、底层特性与常见坑点,深入解析互斥锁如何解决线程安全问题,最后拆解with open背后的上下文管理器原理,带你彻底理清进程、线程的恩怨情仇。

文章目录


一、多任务核心基础

1.1 什么是多任务

同一时间内执行多个任务,就叫多任务。比如电脑同时运行微信、浏览器、音乐播放器,是操作系统级别的多任务;程序里一边下载文件一边处理数据,是代码级别的多任务。

多任务的核心价值:充分利用CPU多核资源,减少程序等待时间,提升执行效率。

1.2 面试必问:并发 vs 并行

这是多任务的基础概念题,二者有本质区别:

  • 并发:多个任务同时请求同一个CPU资源,但同一瞬间CPU只能执行一个任务,于是安排它们交替执行。看起来好像是同时执行的,其实只是CPU切换得太快了。单核CPU只能实现并发。
  • 并行:多个任务真正同时执行。前提是:你的电脑得是多核CPU!

1.3 进程与线程的关系

  • 进程:是CPU分配资源的最小单位。简单理解,一个.exe可执行程序就是一个进程。
  • 线程:是进程的执行路径,执行单元,是CPU调度的最小单位。
  • 多进程:多个.exe同时执行,例如QQ、微信、飞秋一起运行。
  • 多线程:进程内部的多任务执行,例如在微信里,同时和张三、李四聊天。

🚗 大白话比喻:
进程 = 车,线程 = 车道。
如果是单车道,就叫单线程(容易堵车);如果是多车道,就叫多线程(畅通无阻)。

1.4 补充:GIL(全局解释器锁)

在深入多线程之前,必须了解Python的一个特殊机制——GIL(Global Interpreter Lock)
GIL是CPython解释器中的一个互斥锁,它确保同一时刻只有一个线程能执行Python字节码。这意味着,即使在多核CPU上,Python的多线程也无法真正并行执行CPU密集型任务。但在IO密集型任务中,线程会在IO等待时主动释放GIL,因此多线程依然能带来显著的并发收益。

GIL 并不是一直持有不放,它会在两种情况下主动释放:

  1. 遇到 IO 阻塞时:比如网络请求、文件读写、time.sleep 时,线程会主动让出 GIL,其他线程可以趁机执行。这也是 IO 密集型任务用多线程依然能提速的核心原因。
  2. 按时间片轮转:Python3.2 之后,解释器会按固定时间片检查,到期后强制当前线程释放 GIL,让其他线程有机会执行。

正因为如此:
CPU 密集型任务:线程大部分时间在计算,GIL 切换带来额外开销,多线程反而可能比单线程还慢
IO 密集型任务:线程大部分时间在等待,GIL 会频繁让出,多线程能显著提升效率

结论:

  • CPU密集型任务 → 多进程(绕过GIL)
  • IO密集型任务 → 多线程或协程(GIL不是瓶颈)

二、多进程编程

Python的multiprocessing模块提供了完整的多进程支持,可以轻松实现多任务并行执行。
多进程的思路很简单:导包 -> 创建进程对象关联函数 -> 开启进程。

2.1 多进程入门案例

实现“一边敲代码,一边听音乐”的多任务效果:

import multiprocessing
import time

# 任务1:敲代码
def coding():
    for i in range(10):
        print(f'敲代码...{i}')
        time.sleep(0.1)

# 任务2:听音乐
def music():
    for i in range(10):
        print(f'听音乐--------{i}')
        time.sleep(0.1)

if __name__ == '__main__':
    # 创建进程对象,关联目标函数
    p1 = multiprocessing.Process(target=coding)
    p2 = multiprocessing.Process(target=music)
    
    # 开启进程
    p1.start()
    p2.start()

在这里插入图片描述

注意:Windows系统下,多进程代码必须写在if __name__ == '__main__':内部,否则会出现进程递归创建的报错。

为什么 Windows 必须加这行?

答:Windows 系统创建子进程默认采用 spawn 模式:子进程会重新导入并执行当前整个 Python 脚本。如果不做 if name == ‘main’ 判断,子进程会再次执行创建进程的代码,导致无限递归创建进程,最终报错。Linux/macOS 默认采用 fork 模式,直接复制父进程内存空间,没有这个问题,但加上这行属于跨平台兼容的最佳实践。

2.2 带参数的多进程(重点)

给任务函数传参有两种标准方式:

  • args:元组形式传参,参数顺序必须和函数形参完全一致
  • kwargs:字典形式传参,键名必须和函数形参名完全一致
import multiprocessing
import time

def coding(name, num):
    for i in range(num):
        print(f'{name} 正在敲第 {i} 遍代码...')
        time.sleep(0.1)

def music(name, count):
    for i in range(count):
        print(f'{name} 正在听第 {i} 首音乐...')
        time.sleep(0.1)

if __name__ == '__main__':
    # args元组传参
    p1 = multiprocessing.Process(target=coding, args=('乔峰', 10), name='进程1_敲代码')
    print(f'进程p1的名字为: {p1.name}')
    
    # kwargs字典传参
    p2 = multiprocessing.Process(target=music, kwargs={'count': 5, 'name':'虚竹'}, name='进程2_听音乐')
    print(f'进程p2的名字为: {p2.name}')
    
    p1.start()
    p2.start()

在这里插入图片描述

2.3 获取进程ID

每个进程都有唯一的进程ID(PID),方便我们管理和监控进程:

  • os.getpid():获取当前进程的PID
  • os.getppid():获取当前进程的父进程PID
  • multiprocessing.current_process().pid:也可获取当前进程PID

重点结论: 进程之间的数据是相互隔离的! 子进程相当于把父进程的资源拷贝了一份,互不干扰。

import multiprocessing
import time
import os

def coding(name, num):
    for i in range(num):
        print(f'{name} 正在敲第 {i} 遍代码...')
        time.sleep(0.1)
        print(f'当前进程PID: {os.getpid()}, 父进程PID: {os.getppid()}')

if __name__ == '__main__':
    p1 = multiprocessing.Process(target=coding, args=('乔峰', 10))
    p1.start()
    print(f'main主进程PID: {os.getpid()}')

2.4 进程间数据相互隔离

不同进程拥有独立的内存空间,数据互不共享。一个进程修改全局变量,不会影响其他进程。

import multiprocessing, time, os

my_list = [] # 全局变量

def write_data():
    for i in range(1, 6):
        my_list.append(i)
        print(f'add: {i}')
    print(f'write_data函数: {my_list}')

def read_data():
    time.sleep(3) # 确保写完毕
    print(f'read_data函数: {my_list}') # 发现读不到数据,因为数据隔离!

if __name__ == '__main__':
    p1 = multiprocessing.Process(target=write_data)
    p2 = multiprocessing.Process(target=read_data)
    p1.start()
    p2.start()
    # 获取main进程的id
    print(f'main进程的id为: {os.getpid()}')

运行后会发现,read_data函数读到的列表依然是空的——因为两个进程各有一份独立的变量副本,修改互不影响。
在这里插入图片描述

2.5 进程间通信(IPC)

虽然进程间数据隔离,但有时我们需要在进程间传递数据。Python提供了多种IPC方式,最常用的是Queue(队列):

from multiprocessing import Process, Queue

def producer(q):
    q.put('hello')
    q.put('world')

def consumer(q):
    print(q.get())
    print(q.get())

if __name__ == '__main__':
    q = Queue()
    p1 = Process(target=producer, args=(q,))
    p2 = Process(target=consumer, args=(q,))
    p1.start()
    p2.start()
    p1.join()
    p2.join()

其他IPC方式还包括Pipe(管道)、Manager(共享内存管理器)等。

multiprocessing.Queue 是进程安全的队列,内部自带互斥锁,多个进程同时写入、读取都不会出现数据错乱,非常适合传递简单的消息、小体积数据。

如果需要传递大量数据或者更复杂的共享对象,可以使用 multiprocessing.Manager 或者 shared_memory 共享内存。

2.6 主进程与子进程的生命周期

默认情况下,主进程会等待所有子进程执行完毕后,才会结束整个程序

import multiprocessing
import time

def work():
    for i in range(10):
        print(f'工作中... {i}')
        time.sleep(0.3)

if __name__ == '__main__':
    p1 = multiprocessing.Process(target=work)
    p1.start()
    
    time.sleep(1)
    print('主进程代码执行完毕!')

在这里插入图片描述

运行后可以看到,主进程打印完结束语句后,程序并没有立即退出,会等待子进程循环结束后才终止。

join ():等待子进程结束
如果需要主进程等待子进程全部执行完毕后,再执行后续收尾逻辑,可以使用 join() 方法:

import multiprocessing
import time

def work():
    time.sleep(2)
    print("子任务执行完成")

if __name__ == '__main__':
    p = multiprocessing.Process(target=work)
    p.start()
    p.join()  # 主进程阻塞在这里,等待p执行完毕再往下走
    print("主进程收尾逻辑")

如果有多个子进程,可以逐个调用 join(),实现 “所有子任务完成后再统一处理结果” 的同步逻辑。

2.7 守护进程与终止进程

如果主进程干完了,想让子进程跟着死,怎么办?两种方式:

  1. 设置守护进程(推荐):p1.daemon = True(像守护骑士一样,主公死,骑士亡)。
  2. 手动关闭:p1.terminate()(不推荐,子进程会变成僵尸进程,不立即释放资源)。
import multiprocessing
import time

def work():
    for i in range(10):
        print(f'工作中... {i}')
        time.sleep(0.3)

if __name__ == '__main__':
    p1 = multiprocessing.Process(target=work)
    
    # 方式1:设置为守护进程(推荐)
    # p1.daemon = True
    
    p1.start()
    time.sleep(1)
    
    # 方式2:手动终止进程(不推荐,可能产生僵尸进程)
    p1.terminate()
    
    print('主进程执行结束了!')

⚠️ 注意
terminate()不会立即释放资源,可能产生僵尸进程;守护进程会随主进程正常销毁,更推荐使用。

好的,下面是为您博客补充的 2.8 进程池(Pool) 详细内容。可直接复制到文章中,放在 2.7 节之后。


2.8 进程池(Pool):高效并行计算

为什么需要进程池?

在前面的例子中,我们手动创建一个个进程对象(Process),然后逐个 start()join()。这种方式在小规模任务中没问题,但当任务数量很大(比如上千个)时,手动管理进程会变得繁琐且低效:

  • 创建和销毁进程的开销较大
  • 系统能同时运行的进程数受 CPU 核数和内存限制
  • 手动管理难以控制并发数量,容易耗尽系统资源

进程池(Pool) 正是为了解决这些问题而生。它预先创建一组进程(池),将任务分配给池中的空闲进程执行,任务完成后进程不销毁,而是继续执行下一个任务,从而大幅降低创建/销毁进程的开销,并能有效控制并发数量。

2.8.1 进程池的基本使用

multiprocessing.Pool 提供了便捷的进程池接口,常用方法如下:

方法 说明 是否阻塞
apply(func, args) 同步执行,一个任务执行完后才能提交下一个 ✅ 阻塞
apply_async(func, args, callback) 异步执行,非阻塞,可同时提交多个任务 ❌ 非阻塞
map(func, iterable) 同步版 map,将可迭代对象中的每个元素交给进程池处理 ✅ 阻塞
map_async(func, iterable, callback) 异步版 map ❌ 非阻塞
close() 关闭进程池,不再接受新任务 -
join() 等待所有任务执行完毕(必须先 close) ✅ 阻塞

基础示例:计算平方

import multiprocessing
import time

def square(x):
    time.sleep(0.5)
    return x * x

if __name__ == '__main__':
    # 创建进程池,默认使用 CPU 核心数
    pool = multiprocessing.Pool(processes=4)
    
    # 同步方式:apply
    result = pool.apply(square, (10,))
    print(f'同步结果: {result}')
    
    # 异步方式:apply_async,返回 AsyncResult 对象
    async_result = pool.apply_async(square, (5,))
    print(f'异步结果: {async_result.get()}')  # get() 会阻塞等待结果
    
    # 批量任务:map
    numbers = [1, 2, 3, 4, 5]
    results = pool.map(square, numbers)
    print(f'map 结果: {results}')
    
    pool.close()
    pool.join()

输出示例(每次运行可能有微小差异,但结果一致):

在这里插入图片描述

注意pool.close()pool.join() 必须成对出现,且 join() 必须在 close() 之后调用。

2.8.2 异步提交与回调函数

apply_asyncmap_async 是非阻塞的,可以同时提交多个任务,并通过 callback 参数指定任务完成后的回调函数。

import multiprocessing
import time

def worker(num):
    time.sleep(1)
    return num * 2

def callback(result):
    print(f'任务完成,结果: {result}')

if __name__ == '__main__':
    pool = multiprocessing.Pool(processes=3)
    
    # 异步提交多个任务,每个任务完成后自动调用 callback
    for i in range(5):
        pool.apply_async(worker, args=(i,), callback=callback)
    
    pool.close()
    pool.join()
    print('所有任务执行完毕')

输出示例(顺序可能不同,因为任务完成时间有先后):

在这里插入图片描述

回调函数在主进程中执行,适合用于更新 UI、记录日志等操作。

2.8.3 进程池的上下文管理器

Python 3.3+ 支持使用 with 语句管理进程池,自动调用 close()join(),代码更简洁:

import multiprocessing

def cube(x):
    return x ** 3

if __name__ == '__main__':
    with multiprocessing.Pool(processes=4) as pool:
        results = pool.map(cube, range(10))
        print(results)  # [0, 1, 8, 27, 64, 125, 216, 343, 512, 729]
2.8.4 进程池 vs 手动创建进程
对比项 手动创建 Process 进程池 Pool
创建开销 每次任务都创建/销毁进程,开销大 预创建进程,复用,开销小
并发控制 需手动管理数量,容易失控 通过 processes 参数精确控制
任务提交 需手动 start/join 提供 apply/map 等便捷方法
结果获取 需通过队列或共享内存 直接返回结果,支持回调
适用场景 少量、长期运行的后台进程 大量、短小的并行任务
2.8.5 实战:并行计算 π 值

蒙特卡洛方法估算 π 是一个经典的并行计算案例,非常适合用进程池加速。

import multiprocessing
import random
import math

def estimate_pi_part(n):
    """计算 n 个随机点落在圆内的数量"""
    count = 0
    for _ in range(n):
        x = random.random()
        y = random.random()
        if x*x + y*y <= 1:
            count += 1
    return count

def estimate_pi(total_points, processes=4):
    """使用进程池并行估算 π"""
    points_per_process = total_points // processes
    with multiprocessing.Pool(processes=processes) as pool:
        counts = pool.map(estimate_pi_part, [points_per_process] * processes)
    total_in_circle = sum(counts)
    pi_estimate = 4 * total_in_circle / total_points
    return pi_estimate

if __name__ == '__main__':
    total = 10_000_000  # 1千万个点
    pi = estimate_pi(total, processes=8)
    print(f'估算 π ≈ {pi:.6f}')
    print(f'误差: {abs(math.pi - pi):.6f}')

运行后输出类似:
在这里插入图片描述

性能提升:使用 8 进程比单进程快约 6~7 倍(受 CPU 核心数和任务调度影响)。

2.8.6 注意事项
  1. 进程池中的异常处理:如果 worker 函数抛出异常,get()map() 会重新抛出该异常,需要在主进程中捕获。
  2. 不要在进程池中使用全局变量:每个子进程都有独立的内存空间,修改全局变量不会影响其他进程。
  3. 进程池不适合 IO 密集型任务:IO 密集型更适合线程池或协程,因为进程切换开销大于线程。
  4. Windows 上的限制:Windows 下进程池必须在 if __name__ == '__main__': 中创建,否则会递归报错。

三、多线程编程

线程的操作和进程非常类似,把 multiprocessing 换成 threading 即可。线程是进程内部的执行单元,threading模块提供了多线程支持。多线程开销比多进程小,尤其适合IO密集型任务。

3.1 多线程入门案例

同样实现“边敲代码边听音乐”,多线程代码和多进程结构非常相似:

import threading
import time

def coding():
    for i in range(10):
        print(f'正在敲第 {i} 遍代码! -------')
        time.sleep(0.1)

def music():
    for i in range(10):
        print(f'正在听第 {i} 首音乐!')
        time.sleep(0.1)

if __name__ == '__main__':
    t1 = threading.Thread(target=coding)
    t2 = threading.Thread(target=music)
    t1.start()
    t2.start()

3.2 多线程内存执行模型

多线程共享同一个进程的资源,多个线程抢占CPU执行,内存结构如下图所示:

在这里插入图片描述

每个线程有自己独立的栈空间,但共享进程的堆内存和全局资源,因此线程间通信比进程简单得多,但也带来了数据安全问题。

3.3 带参数的多线程

和多进程一样,线程也支持args元组传参和kwargs字典传参:

import threading
import time

def coding(name, num):
    for i in range(num):
        print(f'{name} 正在敲第 {i} 遍代码! -------')
        time.sleep(0.1)

def music(name, count):
    for i in range(count):
        print(f'{name} 正在听第 {i} 首音乐!')
        time.sleep(0.1)

if __name__ == '__main__':
    t1 = threading.Thread(target=coding, args=('乔峰', 9))
    t2 = threading.Thread(target=music, kwargs={'name': '虚竹', 'count': 10})
    t1.start()
    t2.start()

在这里插入图片描述

3.4 线程执行顺序:抢占式调度

多线程的执行顺序是随机的,因为Python采用抢占式调度:哪个线程抢到CPU资源,哪个线程就执行。

import threading
import time

def get_info():
    time.sleep(0.1)
    ct = threading.current_thread()
    print(f'当前的线程对象是: {ct}')

if __name__ == '__main__':
    for i in range(10):
        t = threading.Thread(target=get_info)
        t.start()

多次运行会发现,线程的打印顺序每次都不一样。
在这里插入图片描述

3.5 主线程与守护线程

和进程的生命周期规则一致:

  • 默认主线程会等待所有子线程执行完毕再结束
  • 设置守护线程后,主线程结束则守护线程立即终止
import threading
import time

def work():
    for i in range(10):
        print(f'工作中... {i}')
        time.sleep(0.3)

if __name__ == '__main__':
    t1 = threading.Thread(target=work)
    # 设置守护线程
    t1.setDaemon(True)
    t1.start()
    
    time.sleep(1)
    print('主线程执行结束了!')

和进程对应,线程也支持 join() 方法,用于让主线程等待指定子线程执行完毕后再继续:

import threading
import time

def work():
    time.sleep(2)
    print("子线程任务完成")

t = threading.Thread(target=work)
t.start()
t.join()  # 主线程阻塞等待
print("主线程继续执行")

3.6 线程间资源共享

结论:同一进程的多个线程,可以共享该进程的全局变量资源!

import threading, time

my_list = []

def write_data():
    for i in range(1, 6):
        my_list.append(i)
        print(f'add: {i}')
    print(f'write_data函数: {my_list}')

def read_data():
    time.sleep(2)
    print(f'read_data函数: {my_list}') # 能读到数据!

if __name__ == '__main__':
    t1 = threading.Thread(target=write_data)
    t2 = threading.Thread(target=read_data)
    t1.start()
    t2.start()

在这里插入图片描述

同样,主线程默认也会等待子线程结束。可以通过t1.setDaemon(True) t1.daemon = True 设置守护线程。

3.7 线程安全问题与互斥锁

问题产生
多个线程共享全局变量虽然方便,但引来了一个大Boss:多个线程同时修改同一个变量时,资源竞争导致数据不安全。比如两个线程各累加100万次,最终结果却不到200万。因为在 g_num += 1 这一步(读取、计算、写回)时,容易被其他线程打断。

根本原因:累加操作不是原子性的,线程在读取变量后、写回内存前,可能被其他线程抢占,导致数据覆盖。+= 不是原子操作。原子操作指的是不可被 CPU 调度打断、要么全部执行要么完全不执行的操作。g_num += 1 看似一行代码,实际分为三步:

  1. 读取全局变量 g_num 的值到寄存器
  2. 在寄存器中执行 +1 计算
  3. 将计算结果写回全局变量内存

这三步中间随时可能被其他线程抢占,导致两个线程读到同一个旧值,分别加 1 后写回,最终相当于只加了 1 次,出现数据丢失。

小知识:Python 中单个字节码指令是原子的,比如简单的赋值、单个方法调用;但复合运算、多行逻辑一定不是原子操作。

import threading

g_num = 0

def get_sum1():
    for i in range(1000000):
        global g_num
        g_num += 1
    print(f'get_sum1计算结果: {g_num}')

def get_sum2():
    for i in range(1000000):
        global g_num
        g_num += 1
    print(f'get_sum2计算结果: {g_num}')

if __name__ == '__main__':
    t1 = threading.Thread(target=get_sum1)
    t2 = threading.Thread(target=get_sum2)
    t1.start()
    t2.start()

多次运行会发现,最终结果几乎永远小于2000000。
在这里插入图片描述

解决方案:互斥锁
互斥锁(Lock)可以保证同一时间只有一个线程能操作共享数据,把非原子操作变成原子操作,彻底解决线程安全问题。

使用步骤:

  1. 创建锁对象:mutex = threading.Lock()
  2. 操作共享数据前加锁:mutex.acquire()
  3. 操作完毕释放锁:mutex.release()
import threading

g_num = 0
# 创建互斥锁
mutex = threading.Lock()

def get_sum1():
    mutex.acquire()  # 加锁
    for i in range(1000000):
        global g_num
        g_num += 1
    mutex.release()  # 释放锁
    print(f'get_sum1计算结果: {g_num}')

def get_sum2():
    mutex.acquire()
    for i in range(1000000):
        global g_num
        g_num += 1
    mutex.release()
    print(f'get_sum2计算结果: {g_num}')

if __name__ == '__main__':
    t1 = threading.Thread(target=get_sum1)
    t2 = threading.Thread(target=get_sum2)
    t1.start()
    t2.start()

加锁后,无论运行多少次,最终结果都会是准确的2000000。
在这里插入图片描述

💡 注意
加锁会牺牲部分性能,换来数据安全;
避坑:必须使用同一把锁,且加锁范围要合理,千万别忘了 release()及时释放锁,否则会导致死锁!

好的,以下是为您博客新增的 3.8 死锁:成因、避免与调试 详细内容,可直接插入到 3.7 节之后。


3.8 死锁:成因、避免与调试

3.8.1 什么是死锁

死锁(Deadlock) 是指两个或多个线程(或进程)在执行过程中,因争夺资源而造成的一种互相等待的状态,导致所有线程都无法继续执行,程序永久阻塞。

死锁是并发编程中最棘手的 Bug 之一,它不像普通异常那样可以被捕获,一旦发生,程序就“僵死”在那里,既不报错也不退出。是多线程面试的高频考点。

3.8.2 死锁产生的四个必要条件

死锁必须同时满足以下四个条件才会发生(只要破坏任意一个,死锁就不会产生):

  1. 互斥条件:资源在同一时刻只能被一个线程占用(如互斥锁)。
  2. 持有并等待条件:线程已经持有了至少一个资源,同时在等待其他线程持有的资源。
  3. 不可剥夺条件:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只能由持有者主动释放。
  4. 循环等待条件:存在一个线程等待链,每个线程都在等待下一个线程持有的资源,形成一个闭环。
3.8.3 经典死锁示例
import threading
import time

# 创建两把锁
lock1 = threading.Lock()
lock2 = threading.Lock()

def thread_a():
    print("线程A: 尝试获取锁1...")
    lock1.acquire()
    print("线程A: 成功获取锁1")
    
    time.sleep(0.1)  # 模拟一些操作,确保死锁更容易发生
    
    print("线程A: 尝试获取锁2...")
    lock2.acquire()
    print("线程A: 成功获取锁2")
    
    lock2.release()
    lock1.release()
    print("线程A: 释放所有锁")

def thread_b():
    print("线程B: 尝试获取锁2...")
    lock2.acquire()
    print("线程B: 成功获取锁2")
    
    time.sleep(0.1)
    
    print("线程B: 尝试获取锁1...")
    lock1.acquire()
    print("线程B: 成功获取锁1")
    
    lock1.release()
    lock2.release()
    print("线程B: 释放所有锁")

if __name__ == '__main__':
    t1 = threading.Thread(target=thread_a)
    t2 = threading.Thread(target=thread_b)
    t1.start()
    t2.start()
    t1.join()
    t2.join()
    print("程序结束")

运行后,程序大概率会永远卡住,不会打印“程序结束”。这是因为:

  • 线程A拿到了锁1,等待锁2
  • 线程B拿到了锁2,等待锁1
  • 双方互不相让,形成循环等待
3.8.4 如何避免死锁

方法一:固定加锁顺序(最常用、最可靠)

所有线程按照相同的顺序获取锁,破坏循环等待条件。

def thread_a():
    lock1.acquire()
    lock2.acquire()
    # ... 临界区代码 ...
    lock2.release()
    lock1.release()

def thread_b():
    # 与线程A保持相同的加锁顺序:先lock1,再lock2
    lock1.acquire()
    lock2.acquire()
    # ... 临界区代码 ...
    lock2.release()
    lock1.release()

方法二:使用超时机制(acquire(timeout)

acquire 设置超时时间,如果指定时间内无法获取锁,就放弃并释放已持有的锁,避免死锁。

def thread_a():
    if lock1.acquire(timeout=2):
        print("线程A: 获取锁1成功")
        time.sleep(0.1)
        if lock2.acquire(timeout=2):
            print("线程A: 获取锁2成功")
            lock2.release()
        else:
            print("线程A: 获取锁2超时,释放锁1")
        lock1.release()
    else:
        print("线程A: 获取锁1超时")

方法三:使用可重入锁 RLock

RLock(可重入锁)允许同一线程多次获取锁而不会死锁,适合递归调用或同一线程需要多次加锁的场景。但它不能解决不同线程间的死锁问题。

lock = threading.RLock()

def recursive_func(n):
    if n <= 0:
        return
    lock.acquire()
    print(f"递归第{n}层")
    recursive_func(n - 1)
    lock.release()

recursive_func(3)  # 正常运行,不会死锁

方法四:使用 with 语句自动释放锁

with 语句可以确保锁在使用完毕后自动释放,即使发生异常也不会遗漏 release(),减少因忘记释放锁导致的潜在问题。

def safe_lock():
    with lock1:
        with lock2:
            # 临界区代码
            pass
3.8.5 如何调试死锁

死锁发生后,程序不会崩溃,而是“卡死”。可以通过以下手段排查:

  1. 使用 Ctrl+C 中断程序,查看堆栈信息

当程序卡住时,按下 Ctrl+C,Python 会打印当前所有线程的堆栈,帮助你定位哪些线程在等待什么锁。

^C
Traceback (most recent call last):
  File "deadlock.py", line 35, in thread_a
    lock2.acquire()
KeyboardInterrupt
  1. 使用 threading.enumerate() 查看活跃线程
import threading
import time

def check_threads():
    while True:
        threads = threading.enumerate()
        print(f"当前活跃线程数: {len(threads)}")
        for t in threads:
            print(f"  线程: {t.name}, 是否存活: {t.is_alive()}")
        time.sleep(1)

# 在死锁程序中启动监控线程
monitor = threading.Thread(target=check_threads, daemon=True)
monitor.start()
  1. 使用 logging 模块记录锁的获取与释放

在生产环境中,可以通过日志记录每次加锁和解锁的操作,帮助事后分析死锁原因。

import logging
import threading

logging.basicConfig(level=logging.DEBUG)

lock1 = threading.Lock()
lock2 = threading.Lock()

def thread_a():
    logging.debug("尝试获取锁1")
    lock1.acquire()
    logging.debug("成功获取锁1")
    # ...
    lock1.release()
    logging.debug("释放锁1")
3.8.6 死锁检测工具
  • faulthandler 模块:Python 3.3+ 内置,可以在程序卡住时通过信号触发打印所有线程的堆栈。
  • py-spy:第三方工具,可以实时查看运行中 Python 程序的线程状态和堆栈。
  • gdb + python-dbg:高级调试工具,适用于复杂的死锁场景。

死锁是并发编程中必须警惕的问题。养成良好加锁习惯(如固定顺序、使用 with),可以大大降低死锁风险。如果不幸遇到死锁,善用调试工具能帮你快速定位问题。


四、协程详解

4.1 什么是协程

协程(Coroutine),也叫“微线程”,是一种用户态下的轻量级线程。它允许在单线程内实现多任务的并发执行,任务的切换由程序自身控制,而不是由操作系统内核调度。

核心特点:

  • 轻量级:一个线程中可以包含成千上万个协程,每个协程只占用极小的内存(约几 KB)。
  • 协作式调度:协程主动让出执行权(通过 await),而不是被操作系统抢占。
  • 无锁竞争:由于是单线程执行,不存在共享资源的锁冲突,避免了死锁和竞态条件。

总结: 协程最适合 IO 密集型​ 应用(如网络爬虫、Web 服务器、数据库访问),因为它能在等待 IO 时自动让出 CPU,转而执行其他协程,极大提高吞吐量。而对于 CPU 密集型计算,协程并不能带来加速(因为单线程),此时应使用多进程或多线程。

4.2 协程 vs 线程

  • 线程:由操作系统调度,切换开销大,存在锁的开销
  • 协程:由用户程序调度,切换开销极小,单线程内实现多任务,没有锁的开销

类比理解:

  • 线程就像多个工人(线程)同时干活,但工人们需要争抢工具(锁),切换成本高。
  • 协程就像一个工人(单线程)在不同任务之间快速切换,每次切换前主动保存现场,切换后恢复,几乎没有额外开销。

4.3 简单示例

Python中可以通过asyncio模块实现协程:

import asyncio

async def task1():
    for i in range(3):
        print(f'任务1执行中...{i}')
        await asyncio.sleep(0.1)

async def task2():
    for i in range(3):
        print(f'任务2执行中...{i}')
        await asyncio.sleep(0.1)

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

asyncio.run(main())

在这里插入图片描述

4.4 协程的底层原理:事件循环

Python 的协程基于 事件循环(Event Loop) 实现。事件循环是一个无限循环,负责监听和分发事件(如 IO 就绪、定时器到期),并调度协程的执行。

工作流程

  1. 事件循环启动后,将协程对象注册为任务(Task)。
  2. 当协程执行到 await 时,它会让出控制权,事件循环挂起该协程,并切换到下一个就绪的协程。
  3. await 的条件满足(如 IO 完成、定时器到达),事件循环会将协程唤醒,继续执行后续代码。
  4. 所有协程执行完毕,事件循环结束。
import asyncio

async def hello():
    print("Hello")
    await asyncio.sleep(1)  # 模拟 IO 等待
    print("World")

# 创建事件循环并运行
asyncio.run(hello())

注意asyncio.run() 是 Python 3.7+ 引入的顶级入口,它会自动创建事件循环、运行协程,并在完成后关闭循环。

4.5 关键概念:协程、任务与 Future

  • 协程函数:用 async def 定义的函数,调用后返回一个协程对象。
  • 协程对象:协程函数的返回值,需要通过 await 或在事件循环中执行。
  • 任务(Task):对协程的进一步封装,用于并发调度。通过 asyncio.create_task() 创建。
  • Future:表示一个未来的结果,类似于 JavaScript 中的 Promise。await 的本质就是等待 Future 完成。
import asyncio

async def my_coro():
    await asyncio.sleep(1)
    return 42

async def main():
    # 创建任务(立即开始调度)
    task = asyncio.create_task(my_coro())
    # 等待任务完成
    result = await task
    print(result)  # 42

asyncio.run(main())

4.6 并发执行多个协程

asyncio.gather() 可以同时运行多个协程,并等待它们全部完成,返回结果列表。

import asyncio

async def fetch_data(url):
    print(f"开始请求: {url}")
    await asyncio.sleep(1)  # 模拟网络延迟
    print(f"完成请求: {url}")
    return f"数据来自 {url}"

async def main():
    urls = ["http://a.com", "http://b.com", "http://c.com"]
    # 并发执行三个请求
    results = await asyncio.gather(
        fetch_data(urls[0]),
        fetch_data(urls[1]),
        fetch_data(urls[2])
    )
    print(results)

asyncio.run(main())

输出(三个请求几乎同时开始,总耗时约 1 秒):
在这里插入图片描述
如果使用同步方式顺序执行,则需要 3 秒。协程的并发优势一目了然。

4.7 阻塞调用与线程池:正确混用同步与异步

1. 为什么需要阻塞调用与线程池?

asyncio的事件循环是单线程的,任何同步阻塞调用(如time.sleep(), requests.get(), 同步文件操作)都会卡死整个循环,导致所有协程无法执行。

解决方案: 使用loop.run_in_executor(executor, func, *args)将阻塞函数放到线程池(或进程池)中执行,让事件循环继续运行。

2. 正确做法

  1. 使用aiohttp、aiofiles等异步库替代同步库。
  2. 对于无法避免的同步代码,用run_in_executor
  3. 注意线程池的生命周期管理,推荐使用with ThreadPoolExecutor()

3. 代码示例

# 示例:在协程中执行同步阻塞函数
import asyncio
import time
from concurrent.futures import ThreadPoolExecutor

def blocking_io():
    """模拟同步阻塞IO操作"""
    print("开始阻塞IO...")
    time.sleep(2)  # 同步阻塞
    print("阻塞IO完成")
    return "结果"

async def main():
    print("主协程开始")
    loop = asyncio.get_running_loop()
    
    # 错误方式:直接调用会阻塞事件循环
    # result = blocking_io()
    
    # 正确方式:放到线程池中执行
    with ThreadPoolExecutor() as pool:
        result = await loop.run_in_executor(pool, blocking_io)
    
    print(f"获取到结果: {result}")
    print("主协程结束")

asyncio.run(main())

4.8 常见误区与注意事项

  1. 协程不是并行:协程是单线程内的并发,无法利用多核 CPU。要实现真正的并行,需结合多进程(multiprocessing)。
  2. 不要在协程中使用 time.sleep():它会阻塞整个线程,导致所有协程停滞。应使用 await asyncio.sleep()
  3. 协程中不要执行 CPU 密集型任务:长时间的计算会阻塞事件循环,导致其他协程无法执行。可将 CPU 密集任务放到线程池或进程池中。
  4. 避免在协程中直接调用同步 IO 库:如 requestsopen() 等,它们会阻塞线程。应使用异步库(如 aiohttpaiofiles)。
  5. asyncio.run() 只能调用一次:在一个脚本中通常只调用一次,作为入口。

4.9 本节核心总结

  • 协程:用户态轻量级并发单元,切换开销极小,适合 IO 密集型场景。
  • 事件循环:协程的调度引擎,负责管理任务的挂起与唤醒。
  • async/await:定义协程和挂起点的关键字。
  • asyncio.gather():并发运行多个协程,等待全部完成。
  • 注意事项:避免阻塞调用、CPU 密集型任务、使用专用异步库。

协程是现代 Python 高并发编程的核心,掌握它将使你能够编写高效的网络服务、爬虫和实时应用。下一章将介绍 Python 的垃圾回收机制,深入了解内存管理。


五、上下文管理器

5.1 从with open说起

我们操作文件时,通常会用with open语法,它的好处是:代码块执行完毕后,会自动关闭文件,无需手动调用close(),还能自动处理异常场景。

with open('./1.txt', 'r', encoding='utf-8') as f:
    print(f.read())
# 离开with代码块后,文件自动关闭

为什么with open能自动释放资源?底层的核心就是上下文管理器

5.2 自定义上下文管理器

一个类只要实现了__enter__()__exit__()两个魔法方法,它就是上下文管理器:

  • __enter__():进入with代码块前自动执行,返回值会赋值给as后的变量
  • __exit__():离开with代码块后自动执行,负责释放资源、处理异常

我们可以自定义一个文件类,模拟open的效果:

class MyFile(object):
    def __init__(self, file_name, file_model):
        self.file_name = file_name
        self.file_model = file_model
        self.fp = None

    def __enter__(self):
        # 进入with前执行:打开文件
        self.fp = open(self.file_name, self.file_model, encoding='utf-8')
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        # 离开with后执行:关闭文件
        self.fp.close()
        print('释放资源成功!')

if __name__ == '__main__':
    with MyFile('./1.txt', 'r') as mf:
        print(mf.fp.read())

5.3 使用装饰器实现上下文管理器

除了通过类实现 __enter____exit__ 方法,Python 还提供了一种更简洁的方式——使用 @contextmanager 装饰器。它可以将一个普通的生成器函数快速转变为上下文管理器。

5.3.1 装饰器方式的基本用法
from contextlib import contextmanager

@contextmanager
def my_open(file_name, mode):
    fp = open(file_name, mode, encoding='utf-8')
    try:
        yield fp          # yield 之前的代码相当于 __enter__
    finally:
        fp.close()        # finally 中的代码相当于 __exit__

工作原理

  • yield 之前的代码在进入 with 块时执行(相当于 __enter__)。
  • yield 后面的值会赋给 as 后的变量。
  • yield 之后的代码在离开 with 块时执行(相当于 __exit__),无论是否发生异常都会执行 finally 块。

使用示例

with my_open('test.txt', 'r') as f:
    print(f.read())
# 离开 with 块后,文件自动关闭
5.3.2 与类方式的对比
对比项 类方式 装饰器方式
代码量 较多(需定义类、__enter____exit__ 较少(一个函数 + yield)
可读性 结构清晰,适合复杂逻辑 简洁直观,适合简单场景
异常处理 通过 __exit__ 参数手动处理 依赖 try/finally 自动处理
状态管理 可通过 self 维护状态 通过局部变量维护状态

选择建议

  • 如果上下文逻辑简单(如打开/关闭资源),优先使用装饰器方式。
  • 如果需要精细控制异常处理(如抑制异常、记录日志),或者需要维护多个状态变量,建议使用类方式。
5.3.3 __exit__ 的三个参数(装饰器方式的底层机制)

虽然装饰器方式不需要我们手动编写 __exit__,但 @contextmanager 内部实际上还是利用了 __exit__ 机制。当 with 块中发生异常时,yield 语句会抛出异常,finally 块中的代码仍然会执行。如果希望抑制异常(不让它传播出去),可以在 yield 周围使用 try/except 捕获。

__exit__ 的三个参数在装饰器方式中同样存在,只不过由 contextmanager 内部自动处理:

  • exc_type:异常类型,如果 with 块内无异常则为 None
  • exc_val:异常对象,包含异常的具体信息
  • exc_tb:异常追踪栈,记录异常发生的代码位置

如果 exit 返回 True,会抑制 with 块内的异常,不再向外抛出;返回 False 或无返回值,则异常会正常向上传递。利用这个特性,可以在上下文管理器中统一捕获和处理异常。

示例:在装饰器方式中捕获异常

from contextlib import contextmanager

@contextmanager
def safe_open(file_name, mode):
    fp = None
    try:
        fp = open(file_name, mode, encoding='utf-8')
        yield fp
    except FileNotFoundError as e:
        print(f"文件不存在: {e}")
        # 不重新抛出异常,相当于抑制了异常
    finally:
        if fp:
            fp.close()

with safe_open('不存在的文件.txt', 'r') as f:
    if f:
        print(f.read())
5.3.4 使用 contextmanager 的注意事项
  1. yield 只能有一个:生成器函数中只能有一个 yield 语句,用于将资源返回给 with 块。
  2. 必须使用 try/finally:确保资源在发生异常时也能被正确释放。
  3. 不要忘记 yield 后的代码:如果 yield 之后没有清理代码,资源将不会被释放。
  4. @contextmanager 只能用于简单的上下文管理器:如果需要同时管理多个资源或实现嵌套上下文,建议使用类方式或 ExitStack
5.3.5 简化写法:contextlib 装饰器

对于简单的上下文管理器,不需要写类,使用 contextlib.contextmanager 装饰器 + 生成器可以更简洁地实现,也是日常开发的主流写法:

from contextlib import contextmanager

@contextmanager
def my_open(file_name, mode):
    # __enter__ 部分的逻辑
    fp = open(file_name, mode, encoding='utf-8')
    yield fp  # yield 之前是进入逻辑,yield之后是退出逻辑
    # __exit__ 部分的逻辑
    fp.close()
    print("释放资源成功!")

# 使用方式和原生with完全一致
with my_open('./1.txt', 'r') as f:
    print(f.read())

yield 将代码分割为两部分:yield 之前的代码在进入 with 块时执行,对应 __enter__yield 之后的代码在离开 with 块时执行,对应 __exit__

5.3.6 实战:计时器上下文管理器
import time
from contextlib import contextmanager

@contextmanager
def timer(label="耗时"):
    start = time.time()
    try:
        yield
    finally:
        end = time.time()
        print(f"{label}: {end - start:.4f} 秒")

# 使用
with timer("数据处理"):
    sum(range(10_000_000))

5.4 执行原理解析

上下文管理器的完整执行流程可以通过下图直观理解:

  1. 执行MyFile('./1.txt', 'r'),调用__init__初始化对象
  2. 进入with代码块前,自动调用__enter__方法,打开文件并返回对象给mf
  3. 执行with内部的代码,读取文件内容
  4. 离开with代码块时,自动调用__exit__方法,关闭文件释放资源

5.5 如何选择多任务方案

面对不同的业务场景,选择合适的并发模型至关重要:

  • CPU 密集型任务(如视频解码、科学计算)→ 多进程(绕过 GIL,利用多核)
  • IO 密集型 + 高并发(如 Web 服务器、爬虫)→ 协程(单线程高效,切换开销最小)
  • IO 密集型 + 中等规模(如文件处理、数据库操作)→ 多线程(简单易用,开发成本低)
  • 需要分布式部署多进程 + 消息队列(如 Celery)

在这里插入图片描述

口诀:CPU 密集用进程,IO 密集用协程,简单场景用线程。
算得多(CPU 密集)→ 选多进程,绕过 GIL 用满多核
等得多(IO 密集)→ 选多线程,简单好上手
并发高(海量 IO)→ 选协程,开销最小吞吐量最高


六、全文总结

1. 进程、线程、协程对比

特性 进程 线程 协程
调度者 操作系统 操作系统 用户程序
切换开销 最大(上下文切换、页表切换) 较大(内核态切换) 极小(用户态切换)
内存占用 独立地址空间,占用大 共享地址空间,占用中等 共享地址空间,占用极小
数据共享 进程间通信(IPC)复杂 共享变量(需加锁) 直接共享,无需锁
GIL 影响 无影响(每个进程独立GIL) 有影响(CPU密集受限) 无影响(单线程)
适用场景 CPU 密集型、多核并行 IO 密集型、中等并发 高并发 IO 密集型
创建数量 几十到几百 几千 几十万到百万
典型库 multiprocessing threading asyncio

2. 核心知识点

  • 并发是交替执行,并行是真正同时执行;多核CPU才能实现并行
  • 进程是资源分配单位,线程是调度执行单位;进程间数据隔离,线程间数据共享
  • 多线程共享资源会引发线程安全问题,通过互斥锁可以解决
  • 上下文管理器通过__enter____exit__实现资源自动释放,是with语法的底层支撑

多任务编程的核心是平衡效率与安全,根据业务场景选择合适的多任务方案,才能写出高效且稳定的代码。

写在最后

到这里,我们已经完整走过了 Python 多任务编程的三大支柱:进程、线程与协程。从并发并行的概念辨析,到 GIL 的底层约束;从手动创建进程/线程,到进程池/线程池的优雅管理;从互斥锁解决线程安全,到死锁的预防与调试;再到协程的事件循环与异步编程,最后落地到上下文管理器的封装思想——这一路下来,相信你对 Python 的并发模型已经有了系统而深入的理解。

多任务编程的核心不在于掌握多少 API,而在于理解每种方案的适用边界。CPU 密集型用多进程,IO 密集型用协程,中等并发用线程——没有银弹,只有权衡。而无论选择哪种方案,线程安全、资源管理、死锁预防这三个问题始终如影随形,需要你在实践中不断积累经验。

最后送你一句话:“并发编程的难点不在于让代码跑起来,而在于让它一直正确地跑下去。”​ 希望这篇博客能成为你多任务编程路上的一个坚实起点。如果觉得有帮助,欢迎点赞、收藏、转发,你的支持是我持续输出的最大动力。有任何疑问或想法,欢迎在评论区留言交流。

更多推荐