W
e
l
c
o
m
e
: )

13.并发编程

本篇导航

并发编程解决的是「让程序同时干多件事」的问题。本篇按「概念 → 线程 → 进程 → 协程」四条线展开:

§1 概念(串行 / 并行 / 并发、进程 / 线程 / 协程、同步 / 异步)→ §2 线程 threading(创建、传参、join、守护线程、GIL、互斥锁、死锁、信号量、事件、生产者消费者)→ §3 进程 multiprocessing(创建、进程号、传参、注意点、Queue / Pipe / Manager、进程池)→ §4 协程(yield → async / await + asyncio)。

阅读建议:三条主线要分清——线程共享内存但不安全(受 GIL 限制,适合 IO 密集);进程不共享内存但安全(可绕开 GIL,适合 CPU 密集,但有创建开销、需 IPC);协程单线程内协作式调度(最轻量,适合高并发 IO)。抓住这一条,后面所有 API 都只是它的展开。

环境说明:本篇所有实测结论基于 Python 3.13.14(Windows / 16 核)。凡涉及「Python 具体行为」处均已实跑验证并标注输出。

原文勘误(全篇文字性笔误与过时表述汇总,已直接修正)

原文由 PDF/截图 OCR 转写而来,散落若干笔误与已过时说法。此类不改动原意的错字已直接改正,在此汇总以便对照:

位置 原文 已改为
§2.10 「Lock 变量本质上是一个函数」 类(官方文档:「现在 Lock 是一个类…在更早的 Python 版本中是工厂函数」)
§2.12 from multiprocessing import Semaphore(线程场景) 应为 from threading import Semaphore(实测用 multiprocessing 版本配线程时信号量完全失效)
§2.7 「setDaemon(True\|False) 在 3.10 后被废除」 是「弃用」(DeprecationWarning),尚未移除;官方:「自 3.10 版本弃用」
§2.9 「无论你有多少核…同一时间执行的线程只有一个…设计缺陷」 补:IO 操作时总会释放 GIL;3.13 起可用自由线程构建(--disable-gil)禁用 GIL
§2.3 / §2.7 / §2.8 多处 注释吞掉代码(如 # t2.setDaemon(True) t1.daemon = True) 拆成两行
全篇 中英文之间缺空格、句末半角 . 混用 统一为中文标点 + 中英间空格

1 并发编程介绍

1.1 串行、并行与并发的区别

(图片未上传成功:Pasted-image-20250419224058)

  • 串行(serial):一个 CPU 上,按顺序完成多个任务。
  • 并行(parallelism):多核 CPU 处理多任务时,操作系统会给 CPU 的每个内核安排一个执行的任务,多个内核真正地一起执行任务。注意:多核 CPU 是并行地执行多任务,始终有多个任务同时执行;当任务数不超过 CPU 核数时,任务就是真正一起执行的。
  • 并发(concurrency):一个 CPU 采用时间片管理方式,交替处理多个任务。通常是任务数多于 CPU 核数,靠操作系统的任务调度算法让多个任务「一起」执行——实际上总有一些任务并未执行,只是切换速度极快,看上去像在同时执行而已。
单核如何「同时」跑多个程序

对于单核 CPU 处理多任务,操作系统轮流让各个软件交替执行。假如:软件 1 执行 0.01 秒,切换到软件 2,软件 2 执行 0.01 秒,再切换到软件 3,执行 0.01 秒……这样反复执行下去。表面上看,每个软件都是交替执行的,但是由于 CPU 的执行速度实在是太快了,我们感觉就像这些软件都在同时执行一样。这里需要注意单核 CPU 是并发的执行多任务的。

一句话区分「并发 / 并行」

并发是「同一时间段内交替推进多个任务」(宏观同时、微观交替),单核也能做到;并行是「同一时刻真正同时执行多个任务」,必须多核。日常说的「多任务」多数是并发。

1.2 进程、线程、协程的区别

1.2.1 一个故事说明进程、线程、协程的关系

(图片未上传成功:Pasted-image-20250419225343)

工厂与工人

乔布斯想开工厂生产手机,费劲力气,制作一条生产线,这个生产线上有很多的器件以及材料。一条生产线就是一个进程。

只有生产线是不够的,所以找五个工人来进行生产,这个工人能够利用这些材料最终一步步的将手机做出来,这五个工人就是五个线程。

为了提高生产率,想到 3 种办法:

  1. 一条生产线上多招些工人,一起来做手机,这样效率是成倍增长,即单进程多线程方式
  2. 多条生产线,每个生产线上多个工人,即多进程多线程
  3. 乔布斯深入一线发现工人不是那么忙,有很多等待时间。于是规定:如果某个员工在等待生产线某个零件生产时,不要闲着,干点其他工作。也就是说:如果一个线程等待某些条件,可以充分利用这个时间去做其它事情,这就是:协程方式。

(图片未上传成功:Pasted-image-20250419230432)

  1. 线程是程序执行的最小单位,而进程是操作系统分配资源的最小单位;
  2. 一个进程由一个或多个线程组成,线程是一个进程中代码的不同执行路线;
  3. 进程之间相互独立,但同一进程下的各个线程之间共享程序的内存空间(包括代码段、数据集、堆等)及一些进程级的资源(如打开文件和信号),某进程内的线程在其它进程不可见;
  4. 调度和切换:线程上下文切换比进程上下文切换要快得多。
总结
  • 进程(Process):拥有自己独立的堆和栈,既不共享堆,也不共享栈,进程由操作系统调度;进程切换需要的资源最大,效率低。
  • 线程(Thread):拥有自己独立的栈和共享的堆,共享堆,不共享栈,标准线程由操作系统调度;线程切换需要的资源一般,效率一般(当然了在不考虑 GIL 的情况下)。
  • 协程(Coroutine):拥有自己独立的栈和共享的堆,共享堆,不共享栈,协程由程序员在协程的代码里显式调度;协程切换任务资源很小,效率高。

1.2.2 进程(Process)

1.2.2.1 进程的介绍

在 Python 程序中,想要实现多任务可以使用进程来完成,进程是实现多任务的一种方式。

1.2.2.2 进程的概念

进程(Process):一个正在运行的程序或者软件就是一个进程,它是操作系统进行资源分配的基本单位,也就是说每启动一个进程,操作系统都会给其分配一定的运行资源(内存资源)保证进程的运行。

比如:现实生活中的公司可以理解成是一个进程,公司提供办公资源(电脑、办公桌椅等),真正干活的是员工,员工可以理解成线程。

注意

一个程序运行后至少有一个进程,一个进程默认有一个线程,进程里面可以创建多个线程,线程是依附在进程里面的,没有进程就没有线程。

什么是「多任务」

现代操作系统比如 Mac OS、Linux、Windows 等,都是支持「多任务」的操作系统。什么叫「多任务」呢?简单地说,就是操作系统可以同时运行多个任务。打个比方,你一边在逛淘宝,一边在听音乐,一边在用微信聊天,这就是多任务,至少同时有 3 个任务正在运行。还有很多任务悄悄地在后台同时运行着,只是桌面上没有显示而已。

多任务效果图:

(图片未上传成功:Pasted-image-20250419232507)

对于操作系统来说,一个任务就是一个进程(Process),比如打开一个浏览器就是启动一个浏览器进程,打开一个记事本就启动了一个记事本进程,打开两个记事本就启动了两个记事本进程,打开一个 Word 就启动了一个 Word 进程。

(图片未上传成功:Pasted-image-20250419232825)

1.2.2.3 进程的作用

单进程效果图:

(图片未上传成功:Pasted-image-20250419234424)

多进程效果图:

(图片未上传成功:Pasted-image-20250419234432)

说明:

多进程可以完成多任务,每个进程就好比一家独立的公司,每个公司都各自在运营,每个进程也各自在运行,执行各自的任务。

1.2.2.4 小结
  • 进程是操作系统进行资源分配的基本单位。
  • 进程是 Python 程序中实现多任务的一种方式。

1.2.3 线程(Thread)

1.2.3.1 线程的介绍

在 Python 中,想要实现多任务除了使用进程,还可以使用线程来完成,线程是实现多任务的另外一种方式。

1.2.3.2 线程的概念

线程(Thread):是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。

线程是进程中执行代码的一个分支,每个执行分支(线程)要想执行代码需要 CPU 进行调度,也就是说线程是 CPU 调度的基本单位。每个进程至少都有一个线程,而这个线程就是我们通常说的主线程。

微信的例子

有些进程不止同时干一件事,比如微信,它可以同时进行打字聊天、视频聊天、朋友圈等事情。在一个进程内部,要同时干多件事,就需要同时运行多个「子任务」,我们把进程内的这些「子任务」称为线程(Thread)。

1.2.3.3 线程的作用

多线程可以完成多任务。

多线程效果图:

(图片未上传成功:Pasted-image-20250419234700)

1.2.3.4 小结
  • 线程是 Python 程序中实现多任务的另外一种方式,线程的执行需要 CPU 调度来完成。

1.2.4 并发编程解决方案

多任务的实现有 3 种方式:

  1. 多进程模式
  2. 多线程模式
  3. 多进程 + 多线程模式
注意
  • 启动多个进程,每个进程虽然只有一个线程,但多个进程可以一块执行多个任务。
  • 启动一个进程,在一个进程内启动多个线程,这样,多个线程也可以一块执行多个任务。
  • 启动多个进程,每个进程再启动多个线程,这样同时执行的任务就更多了,当然这种模型更复杂,实际很少采用。

1.2.5 协程(Coroutines)

协程(Coroutines),也叫作纤程(Fiber),是一种在线程中,比线程更加轻量级的存在,由程序员自己写程序来管理。

当出现 IO 阻塞时,CPU 一直等待 IO 返回,处于空转状态。这时候用协程,可以执行其他任务。当 IO 返回结果后,再回来处理数据。充分利用了 IO 等待的时间,提高了效率。

1.3 同步和异步介绍

同步和异步强调的是消息通信机制(synchronous communication / asynchronous communication)。

  • 同步(synchronous):A 调用 B,等待 B 返回结果后,A 继续执行。
  • 异步(asynchronous):A 调用 B,A 继续执行,不等待 B 返回结果;B 有结果了,通知 A,A 再做处理。

(图片未上传成功:Pasted-image-20250420013920)

同步方式通信:

  1. 小龙女买一本书《Python 实战笔记》。
  2. 书店老板说:等一等,我帮你查查。
  3. 小龙女等一小时。
  4. 老板说,找到书了,发给你。

异步方式通信:

  1. 小龙女买一本书《Python 实战笔记》。
  2. 书店老板说:我查一下,有结果了告诉你。
  3. 小龙女刷抖音一小时。
  4. 老板说,找到书了,发给你。

2 线程 Thread

2.1 什么是线程

(图片未上传成功:Pasted-image-20250419230432_1)

线程(Thread)特点:

  1. 线程(Thread)是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位;
  2. 线程是程序执行的最小单位,而进程是操作系统分配资源的最小单位;
  3. 一个进程由一个或多个线程组成,线程是一个进程中代码的不同执行路线;
  4. 拥有自己独立的栈和共享的堆,共享堆,不共享栈。标准线程由操作系统调度;
  5. 调度和切换:线程上下文切换比进程上下文切换要快得多。

2.2 线程的创建方式

Python 的标准库提供了两个模块:_thread 和 threading,_thread 是低级模块,threading 是高级模块,对 _thread 进行了封装。绝大多数情况下,我们只需要使用 threading 这个高级模块。

线程的创建可以分为两种方式:

  1. 方法包装
  2. 类包装

线程的执行统一通过 start() 方法。

2.3 线程的创建方式(方法包装)

2.3.1 导入线程模块

# 导入线程模块
from threading import Thread

2.3.2 线程类 Thread 参数说明

Thread([group [, target [, name [, args [, kwargs]]]]])
  • group:线程组,目前只能使用 None
  • target:执行的目标任务名
  • args:以元组的方式给执行任务传参
  • kwargs:以字典方式给执行任务传参
  • name:线程名,一般不用设置
获取当前线程与线程名
threading.current_thread()
threading.current_thread().name
补充:Thread 的完整签名

实测 3.13.14 的构造签名为
Thread(group=None, target=None, name=None, args=(), kwargs=None, *, daemon=None),
其中 daemon 是 Python 3.3 起新增的仅限关键字参数,可直接在创建时指定是否守护线程。

2.3.3 启动线程

启动线程使用 start 方法。

2.3.4 多线程完成多任务的代码(方法包装)

# 方法方式创建线程
from threading import Thread
from time import sleep

def func1(name):
    for i in range(3):
        print(f"thread:{name} :{i}")
        sleep(1)

if __name__ == '__main__':
    print("主线程,start")
    # 创建线程
    t1 = Thread(target=func1, args=("t1",))
    t2 = Thread(target=func1, args=("t2",))
    # 启动线程
    t1.start()
    t2.start()
    print("主线程,end")
多线程打印的换行会「串行」

运行结果可能会出现换行问题,是因为多个线程抢夺控制台输出的 IO 流。在上一个 print 还没有打印换行时,就失去了控制台输出的 IO 流,导致上一个 print 的换行输出在下一个 print 打印完成之后。

比如下面这个输出,换行就没有按照预想的显示(thread:t2 :1 和 thread:t1 :1 挤在了同一行):

主线程,start
thread:t1 :0
thread:t2 :0
主线程,end
thread:t2 :1thread:t1 :1

thread:t2 :2
thread:t1 :2

(图片未上传成功:Pasted-image-20250420015709)

import threading
import time

# 唱歌任务
def sing():
    # 扩展: 获取当前线程
    # print("sing当前执行的线程为:", threading.current_thread())
    for i in range(3):
        print("正在唱歌...%d" % i)
        time.sleep(1)

# 跳舞任务
def dance():
    # 扩展: 获取当前线程
    # print("dance当前执行的线程为:", threading.current_thread())
    for i in range(3):
        print("正在跳舞...%d" % i)
        time.sleep(1)

if __name__ == '__main__':
    # 扩展: 获取当前线程
    # print("当前执行的线程为:", threading.current_thread())
    # 创建唱歌的线程
    # target: 线程执行的函数名
    sing_thread = threading.Thread(target=sing)

    # 创建跳舞的线程
    dance_thread = threading.Thread(target=dance)

    # 开启线程
    sing_thread.start()
    dance_thread.start()

(图片未上传成功:Pasted-image-20250420152218)

2.3.5 小结

  1. 导入线程模块
    • import threading
  2. 创建子线程并指定执行的任务
    • sub_thread = threading.Thread(target=任务名)
  3. 启动线程执行任务
    • sub_thread.start()

2.4 线程的创建方式(类包装)

2.4.1 多线程完成多任务的代码(类包装)

# 类的方式创建线程
from threading import Thread
from time import sleep

class MyThread(Thread):
    def __init__(self, name):
        Thread.__init__(self)
        self.name = name

    def run(self):
        for i in range(3):
            print(f"thread:{self.name} : {i}")
            sleep(1)

if __name__ == '__main__':
    print("主线程,start")
    # 创建线程(类的方式)
    t1 = MyThread('t1')
    t2 = MyThread('t2')
    # 启动线程
    t1.start()
    t2.start()
    print("主线程,end")

(图片未上传成功:Pasted-image-20250420020805)

类包装 vs 方法包装
  • 方法包装:Thread(target=函数, args=(...)) —— 把普通函数当任务,适合逻辑简单的场景。
  • 类包装:继承 Thread 并重写 run(),把任务写进 run 里。注意是 run 而不是 start:start() 由父类负责「启动新线程并回调 run()」,直接调用 run() 只会在当前线程里同步执行。

2.5 线程执行带有参数的任务

2.5.1 线程执行带有参数的任务的介绍

如果线程执行的任务带有参数,该如何给函数传参呢?

Thread 类执行任务并给任务传参数有两种方式:

  • args 表示以元组的方式给执行任务传参
  • kwargs 表示以字典方式给执行任务传参

其实前面的例子中已经用过 args 参数了。

2.5.2 args 参数的使用

示例代码:

import threading
import time

# 带有参数的任务
def task(count):
    for i in range(count):
        print(f"任务执行{i}")
        time.sleep(0.2)
    else:
        print("任务执行完成")

if __name__ == '__main__':
    # 创建子线程
    # args: 以元组的方式给任务传入参数
    sub_thread = threading.Thread(target=task, args=(5,))
    sub_thread.start()

执行结果:

任务执行0
任务执行1
任务执行2
任务执行3
任务执行4
任务执行完成

(图片未上传成功:Pasted-image-20250420150403)

args=(5,) 那个逗号不能省

args 接收的是元组。单元素元组必须写成 (5,);若写成 (5) 就退化成整数 5,for 循环会抛 TypeError: 'int' object is not iterable。

2.5.3 kwargs 参数的使用

示例代码:

import threading
import time

# 带有参数的任务
def task(count):
    for i in range(count):
        print(f"任务执行{i}")
        time.sleep(0.2)
    else:
        print("任务执行完成")

if __name__ == '__main__':
    # 创建子线程
    # kwargs: 表示以字典方式传入参数
    sub_thread = threading.Thread(target=task, kwargs={"count": 3})
    sub_thread.start()

执行结果:

任务执行0
任务执行1
任务执行2
任务执行完成

(图片未上传成功:Pasted-image-20250420150556)

2.5.4 小结

  • 线程执行任务并传参有两种方式:
    • 元组方式传参(args):元组方式传参一定要和参数的顺序保持一致。
    • 字典方式传参(kwargs):字典方式传参字典中的 key 一定要和参数名保持一致。

2.6 join()

之前的代码,主线程不会阻塞等待子线程结束。

如果需要阻塞等待子线程结束后,再继续执行主线程,可使用 join() 方法。

from threading import Thread
from time import sleep

def fun1(name):
    print(f'{name} start')
    for i in range(3):
        print(f'{name}:{i}')
        sleep(1)
    print(f'{name} end')

def fun2(name):
    print(f'{name} start')
    for i in range(3):
        print(f'{name}:{i}')
        sleep(3)
    print(f'{name} end')

if __name__ == '__main__':
    print('主线程start')
    t1 = Thread(target=fun1, args=('t1',))
    t2 = Thread(target=fun2, args=('t2',))
    # 启动线程
    t1.start()
    t2.start()
    # 主线程会等待 t1 结束后再向下执行
    print('主线程开始阻塞等待 t1 执行完毕')
    t1.join()
    # 主线程会等待 t2 结束后再向下执行
    print('主线程开始阻塞等待 t2 执行完毕')
    t2.join()
    print('主线程end')

(图片未上传成功:Pasted-image-20250420030401)

join() 是「阻塞当前线程,等目标线程结束」

t.join() 会让调用它的线程(这里是主线程)停下来,直到 t 执行完毕。实测:对一个 sleep(0.5) 的线程调用 join(),主线程确实被阻塞约 0.50 秒。

  • 无参:一直等到目标线程结束。
  • join(timeout):最多等 timeout 秒,超时就返回(不报错)。
  • 注意 join() 是串行化手段:多个线程都 join,就等于「排队」,会削弱并发收益(见 §2.10 的对比)。

2.7 守护线程

还有一种叫守护线程,主要特征是它的生命周期:主线程死亡,它也随之死亡。在 Python 中,线程通过 setDaemon(True|False) 设置是否为守护线程。

原文勘误:setDaemon 是「弃用」而非「废弃」

setDaemon(True|False) 自 Python 3.10 起弃用(DeprecationWarning),但尚未移除,本机 3.13.14 仍可调用(实测调用后会抛
DeprecationWarning: setDaemon() is deprecated, set the daemon attribute instead)。

推荐改用属性赋值:t1.daemon = True,或在创建时传 Thread(daemon=True)。

守护线程的作用:

  • 守护线程作用是为其他线程提供便利服务,守护线程最典型的应用就是 GC(垃圾收集器)。
from threading import Thread
from time import sleep

class MyThread(Thread):
    def __init__(self, name):
        Thread.__init__(self)
        self.name = name

    def run(self):
        print(f'线程 {self.name} start')
        for i in range(3):
            print(f'{self.name}:{i}')
            sleep(1)
        print(f'线程 {self.name} end')

if __name__ == '__main__':
    print('主线程 start')
    # 创建线程
    t1 = MyThread('t1')
    t2 = MyThread('t2')
    # 设置守护线程
    # setDaemon 方法在 3.10 以后已经被弃用
    # 使用 daemon 属性设置
    # t1.setDaemon(True)
    # t2.setDaemon(True)
    t1.daemon = True
    t2.daemon = True
    # 启动线程
    t1.start()
    t2.start()
    print('主线程 end')

(图片未上传成功:Pasted-image-20250420164036)

勘误说明

原文此处为 # t2.setDaemon(True) t1.daemon = True,注释把下一行代码吞掉了(t1.daemon = True 实际未执行)。已拆成两行:注释归注释、代码归代码。

守护线程的两条准则

官方文档对守护线程的备注值得记住:守护线程在程序关闭时会突然关闭,它的资源(已打开的文件、数据库事务等)可能没有被正确释放。如果想要线程正常停止,应把它设为非守护并用合适的信号机制(如 Event)通知它退出。

2.8 线程的注意点

2.8.1 线程的注意点介绍

  1. 线程之间执行是无序的
  2. 主线程会等待所有的子线程执行结束再结束
  3. 线程之间共享全局变量
  4. 线程之间共享全局变量数据出现错误问题

2.8.2 线程之间执行是无序的

import threading
import time

def task():
    time.sleep(3)
    print("当前线程:", threading.current_thread().name)

if __name__ == '__main__':
    for _ in range(5):
        sub_thread = threading.Thread(target=task)
        sub_thread.start()

执行结果(每次运行顺序都可能不同):

当前线程: Thread-2 (task)
当前线程: Thread-3 (task)
当前线程:当前线程:  Thread-5 (task)Thread-4 (task)

(图片未上传成功:Pasted-image-20250420151709)

说明:

  • 线程之间执行是无序的,它是由 CPU 调度决定的,CPU 调度哪个线程,哪个线程就先执行,没有调度的线程不能执行。
  • 进程之间执行也是无序的,它是由操作系统调度决定的,操作系统调度哪个进程,哪个进程就先执行,没有调度的进程不能执行。

2.8.3 主线程会等待所有的子线程执行结束再结束

假如我们现在创建一个子线程,这个子线程执行完大概需要 2.5 秒钟,现在让主线程执行 1 秒钟就退出程序,查看一下执行结果,示例代码如下:

import threading
import time

# 测试主线程是否会等待子线程执行完成以后程序再退出
def show_info():
    for i in range(5):
        print("test:", i)
        time.sleep(0.5)

if __name__ == '__main__':
    sub_thread = threading.Thread(target=show_info)
    sub_thread.start()

    # 主线程延时1秒
    time.sleep(1)
    print("over")

执行结果:

test: 0
test: 1
over
test: 2
test: 3
test: 4

(图片未上传成功:Pasted-image-20250420164723)

说明:

通过上面代码的执行结果,我们可以得知:主线程会等待所有的子线程执行结束再结束。

那如果就想让主线程执行 1 秒钟、子线程随之销毁不再执行,该怎么办呢?

  • 我们可以设置守护主线程。

守护主线程:

  • 守护主线程就是主线程退出子线程销毁不再执行。

设置守护主线程有两种方式:

  1. threading.Thread(target=show_info, daemon=True)
  2. 线程对象.setDaemon(True)
    • 3.10 之后已经弃用,改为 线程对象.daemon = True

设置守护主线程的示例代码:

import threading
import time

# 测试主线程是否会等待子线程执行完成以后程序再退出
def show_info():
    for i in range(5):
        print("test:", i)
        time.sleep(0.5)

if __name__ == '__main__':
    # 创建子线程守护主线程
    # daemon=True 守护主线程
    # 守护主线程方式1
    sub_thread = threading.Thread(target=show_info, daemon=True)
    # 设置成为守护主线程,主线程退出后子线程直接销毁不再执行子线程的代码
    # 守护主线程方式2
    # sub_thread.setDaemon(True)
    sub_thread.start()

    # 主线程延时1秒
    time.sleep(1)
    print("over")

执行结果:

test: 0
test: 1
over

(图片未上传成功:Pasted-image-20250420165022)

勘误说明

原文此处为 # sub_thread.setDaemon(True) sub_thread.start(),同样是注释吞掉代码,已拆成两行。

对照读法

同一个 show_info(5 次 × 0.5s = 2.5s):

  • 不设守护:主线程 1 秒后打印 over,但仍等子线程跑完 test: 2/3/4 才退出。
  • 设 daemon=True:主线程打印 over 后立即退出,子线程被强制结束,test: 2/3/4 不再出现。

这就是「守护线程随主线程一起死」的直观体现。

2.8.4 线程之间共享全局变量

需求:

  1. 定义一个列表类型的全局变量。
  2. 创建两个子线程分别执行向全局变量添加数据的任务和向全局变量读取数据的任务。
  3. 查看线程之间是否共享全局变量数据。
import threading
import time

# 定义全局变量
my_list = list()

# 写入数据任务
def write_data():
    for i in range(5):
        my_list.append(i)
        time.sleep(0.1)
    print("write_data:", my_list)

# 读取数据任务
def read_data():
    print("read_data:", my_list)

if __name__ == '__main__':
    # 创建写入数据的线程
    write_thread = threading.Thread(target=write_data)
    # 创建读取数据的线程
    read_thread = threading.Thread(target=read_data)

    write_thread.start()
    # 延时
    # time.sleep(1)
    # 主线程等待写入线程执行完成以后代码再继续往下执行
    write_thread.join()
    print("开始读取数据啦")
    read_thread.start()

执行结果:

write_data: [0, 1, 2, 3, 4]
开始读取数据啦
read_data: [0, 1, 2, 3, 4]

(图片未上传成功:Pasted-image-20250420165554)

2.8.5 线程之间共享全局变量数据出现错误问题

需求:

  1. 定义两个函数,实现循环 100 万次,每循环一次给全局变量加 1。
  2. 创建两个子线程执行对应的两个函数,查看计算后的结果。
import threading

# 定义全局变量
g_num = 0

# 循环一次给全局变量加1
def sum_num1():
    for i in range(1000000):
        global g_num
        g_num += 1

    print("sum1:", g_num)

# 循环一次给全局变量加1
def sum_num2():
    for i in range(1000000):
        global g_num
        g_num += 1
    print("sum2:", g_num)

if __name__ == '__main__':
    # 创建两个线程
    first_thread = threading.Thread(target=sum_num1)
    second_thread = threading.Thread(target=sum_num2)

    # 启动线程
    first_thread.start()
    # 启动线程
    second_thread.start()

执行结果(结果每次不同,且通常小于期望的 200 万):

sum1: 1210949
sum2: 1496035

注意点:

多线程同时对全局变量操作数据发生了错误。

错误分析:

(图片未上传成功:Pasted-image-20250420172402)

两个线程 first_thread 和 second_thread 都要对全局变量 g_num(默认是 0)进行加 1 运算,但是由于是多线程同时操作,有可能出现下面情况:

  1. 在 g_num = 0 时,first_thread 取得 g_num = 0。此时系统把 first_thread 调度为 sleeping 状态,把 second_thread 转换为 running 状态,second_thread 也获得 g_num = 0。
  2. 然后 second_thread 对得到的值进行加 1 并赋给 g_num,使得 g_num = 1。
  3. 然后系统又把 second_thread 调度为 sleeping,把 first_thread 转为 running。线程 first_thread 又把它之前得到的 0 加 1 后赋值给 g_num。
  4. 这样导致虽然 first_thread 和 second_thread 都对 g_num 加 1,但结果仍然是 g_num = 1。

全局变量数据错误的解决办法:

线程同步:保证同一时刻只能有一个线程去操作全局变量。

同步:就是协同步调,按预定的先后次序进行运行。如:你说完,我再说,好比现实生活中的对讲机。

线程同步的方式:

  1. 线程等待(join)
  2. 互斥锁

线程等待的示例代码:

import threading

# 定义全局变量
g_num = 0

# 循环1000000次每次给全局变量加1
def sum_num1():
    for i in range(1000000):
        global g_num
        g_num += 1

    print("sum1:", g_num)

# 循环1000000次每次给全局变量加1
def sum_num2():
    for i in range(1000000):
        global g_num
        g_num += 1
    print("sum2:", g_num)

if __name__ == '__main__':
    # 创建两个线程
    first_thread = threading.Thread(target=sum_num1)
    second_thread = threading.Thread(target=sum_num2)

    # 启动线程
    first_thread.start()
    # 主线程等待第一个线程执行完成以后代码再继续执行,让其执行第二个线程
    # 线程同步: 一个任务执行完成以后另外一个任务才能执行,同一个时刻只有一个任务在执行
    first_thread.join()
    # 启动线程
    second_thread.start()

执行结果:

sum1: 1000000
sum2: 2000000
为什么 join 能解决

g_num += 1 在字节码层面是「读 → 加 → 写」三步,不是原子操作,多个线程交替执行会互相覆盖(丢失更新)。
用 first_thread.join() 让两个线程串行执行,就消除了竞争——结果是精确的 1000000 / 2000000。
代价是失去并发(等价于串行),所以更常用的是下一节的互斥锁(只锁关键代码,粒度更细)。

2.8.6 小结

  • 线程执行是无序的。
  • 主线程默认会等待所有子线程执行结束再结束,设置守护主线程的目的是主线程退出子线程销毁。
  • 线程之间共享全局变量,好处是可以对全局变量的数据进行共享。
  • 线程之间共享全局变量可能会导致数据出现错误问题,可以使用线程同步方式来解决这个问题。
    • 线程等待(join)

2.9 全局锁 GIL 问题

(图片未上传成功:Pasted-image-20250420175441)

在 Python 中,无论有多少核,在 CPython 解释器中执行 Python 字节码时,同一时间执行的线程只有一个。这是 Python 设计之初(为保证解释器内部对象模型的安全)引入的机制,所以 Python 中的线程被戏称为「含有水分的线程」。

2.9.1 Python GIL(Global Interpreter Lock)

Python 代码的执行由 Python 虚拟机(也称解释器主循环,CPython 版本)控制。Python 在设计之初就规定:主循环中同时只有一个线程在执行,即任意时刻只有一个线程在解释器中运行。对 Python 虚拟机的访问由全局解释器锁(GIL)控制,正是这个锁保证了同一时刻只有一个线程在运行。

(图片未上传成功:Pasted-image-20250420185036)

GIL 不是「Python 语言」的特性

GIL 是在实现 Python 解析器(CPython)时所引入的一个概念。同样一段代码可以通过 CPython、PyPy、Jython 等不同的执行环境来执行,在这些环境中就不一定有 GIL 问题。然而因为 CPython 是大部分环境下默认的 Python 执行环境,所以在很多人的概念里 CPython 就是 Python,也就想当然地把 GIL 归结为 Python 语言的缺陷。

原文勘误与重要补充:GIL 的边界

原文「无论多少核都只有一个线程执行、这是设计缺陷」的说法只对 CPython + CPU 密集 + 默认构建成立。官方文档补充了两点,务必记住:

  1. IO 操作时会释放 GIL:官方原文——「某些标准库或第三方库的扩展模块被设计为在执行计算密集型任务如压缩或哈希时释放 GIL。此外,在执行 I/O 操作时也总是会释放 GIL」。这正是多线程适合 IO 密集任务的根本原因。
  2. GIL 可以关闭:官方原文——「在 Python 3.13 中,GIL 可以使用 --disable-gil 构建配置选项来禁用」,即所谓自由线程(free-threaded) 构建版,此时多个 Python 线程可以在不同核心上真正并行执行 Python 代码。

本机实测(3.13.14,标准构建):sys._is_gil_enabled() → True。
想充分发挥多核,官方推荐 multiprocessing 或 concurrent.futures.ProcessPoolExecutor(见 §3)。

实测:GIL 对两类任务的影响截然不同

在本机(16 核)实测,结论与「选线程还是选进程」直接相关:

场景 串行 4 线程 4 进程
CPU 密集(4 次循环累加) 1.79s 约 1.9s(无加速,受 GIL) 1.01s(约 1.8× 加速)
IO 密集(4 次 sleep(0.5)) 2.00s 0.50s (同线程量级)

一句话选型:CPU 密集用多进程,IO 密集用多线程或协程。

2.10 线程同步和互斥锁

2.10.1 同一个资源,多人想用?排队啊

排队的生活类比

现实生活中,我们会遇到「同一个资源,多个人都想使用」的问题。比如:教室里,只有一台电脑,多个人都想使用。天然的解决办法就是,在电脑旁边,大家排队。前一人使用完后,后一人再使用。再比如,上厕所排队。

(图片未上传成功:Pasted-image-20250420180058)

2.10.2 线程同步的概念

处理多线程问题时,多个线程会访问同一个对象,其中某些线程还想修改它——这时就需要「线程同步」。线程同步本质是一种等待机制:多个想同时访问该对象的线程进入等待池排成队列,等前一个线程使用完毕,下一个线程再使用。

(图片未上传成功:Pasted-image-20250420180649)

示例:多线程操作同一个对象(未使用线程同步)

from threading import Thread
from time import sleep

class Account:
    def __init__(self, money, name):
        self.money = money
        self.name = name

# 模拟提款操作
class Drawing(Thread):
    def __init__(self, drawingNum, account):
        Thread.__init__(self)
        self.drawingNum = drawingNum
        self.account = account
        self.expenseTotal = 0

    def run(self):
        if self.account.money - self.drawingNum < 0:
            return
        sleep(1)  # 判断完后阻塞。其他线程开始运行。
        self.account.money -= self.drawingNum
        self.expenseTotal += self.drawingNum
        print(f"账户:{self.account.name},余额是:{self.account.money}")
        print(f"账户:{self.account.name},总共取了:{self.expenseTotal}")

if __name__ == '__main__':
    a1 = Account(100, "小龙女")
    draw1 = Drawing(80, a1)  # 定义取钱线程对象;
    draw2 = Drawing(80, a1)  # 定义取钱线程对象;
    draw1.start()  # 你取钱
    draw2.start()  # 你老婆取钱

(图片未上传成功:Pasted-image-20250420181403)

竞态的具体后果(实测复现)

没有线程同步机制,两个线程同时操作同一个账户对象,竟然从只有 100 元的账户,轻松取出 80*2=160 元,账户余额竟然变成了 -60。

本机实测(sleep 改为 0.3s 以加快复现)确认:最终余额 = -60。这么大的问题,显然银行不会答应的。

2.10.3 线程同步的方式

  1. 线程等待(join)
    线程等待对主线程来说实际上是变回了串行执行,主线程只有等待 join 的子线程执行完毕才能继续向下执行。效率不高。
  2. 互斥锁
    1. 必须使用同一个锁对象
    2. 互斥锁的作用就是保证同一时刻只能有一个线程去操作共享数据,保证共享数据不会出现错误问题
    3. 使用互斥锁的好处确保某段关键代码只能由一个线程从头到尾完整地去执行
    4. 使用互斥锁会影响代码的执行效率,多任务改成了单任务执行
    5. 同时持有多把锁,容易出现死锁的情况

线程等待方式解决:

from threading import Thread
from time import sleep

class Account:
    def __init__(self, money, name):
        self.money = money
        self.name = name

    # 模拟提款操作

class Drawing(Thread):
    def __init__(self, drawingNum, account):
        Thread.__init__(self)
        self.drawingNum = drawingNum
        self.account = account
        self.expenseTotal = 0

    def run(self):
        if self.account.money - self.drawingNum < 0:
            print(f"余额不足——账户:{self.account.name},余额是:{self.account.money}")
            return
        sleep(1)  # 判断完后阻塞。其他线程开始运行。
        self.account.money -= self.drawingNum
        self.expenseTotal += self.drawingNum
        print(f"账户:{self.account.name},余额是:{self.account.money}")
        print(f"账户:{self.account.name},总共取了:{self.expenseTotal}")

if __name__ == '__main__':
    a1 = Account(100, "小龙女")
    draw1 = Drawing(80, a1)  # 定义取钱线程对象;
    draw2 = Drawing(80, a1)  # 定义取钱线程对象;
    draw1.start()  # 你取钱
    # 阻塞等待你取完钱才能继续向下执行
    draw1.join()
    draw2.start()  # 你老婆取钱

(图片未上传成功:Pasted-image-20250420182027)

2.10.4 互斥锁

2.10.4.1 互斥锁的概念

互斥锁:对共享数据进行锁定,保证同一时刻只能有一个线程去操作。

注意

互斥锁是多个线程一起去抢,抢到锁的线程先执行,没有抢到锁的线程需要等待,等互斥锁使用完释放后,其它等待的线程再去抢这个锁。

2.10.4.2 互斥锁的使用

threading 模块中定义了 Lock,通过调用它可以获取一把互斥锁。

互斥锁使用步骤:

# 创建锁
mutex = threading.Lock()

# 上锁
mutex.acquire()

...
这里编写代码能保证同一时刻只能有一个线程去操作, 对共享数据进行锁定
...

# 释放锁
mutex.release()
原文勘误:Lock 是「类」而不是「函数」

原文称「Lock 变量本质上是一个函数」——这是旧版本的说法。官方文档明确:「现在 Lock 是一个类。在更早的 Python 版本中,Lock 是一个返回下层私有锁类型的实例的工厂函数」。

本机实测:type(threading.Lock) → <class 'type'>(是类);threading.Lock() 的实例类型为 <class '_thread.lock'>。

注意
  • acquire 和 release 方法之间的代码同一时刻只能有一个线程去操作。
  • 如果在调用 acquire 方法的时候,其他线程已经使用了这个互斥锁,那么此时 acquire 方法会堵塞,直到这个互斥锁释放后才能再次上锁。
补充:acquire 的完整签名

官方签名 Lock.acquire(blocking=True, timeout=-1):

  • blocking=False → 非阻塞,立即返回 True/False(抢不到不等待)。
  • timeout → 最多等待指定秒数(不能与 blocking=False 同用)。
    另外官方建议用 with lock:(上下文管理协议)代替手动 acquire/release,可避免忘记释放锁。
2.10.4.3 使用互斥锁示例

示例:多线程操作同一个对象(增加互斥锁,使用线程同步)

from threading import Thread, Lock
from time import sleep

class Account:
    def __init__(self, money, name):
        self.money = money
        self.name = name

class Drawing(Thread):
    # 获取互斥锁
    lock = Lock()

    # 模拟提款操作
    def __init__(self, drawingNum, account):
        Thread.__init__(self)
        self.drawingNum = drawingNum
        self.account = account
        self.expenseTotal = 0

    def run(self):
        Drawing.lock.acquire()
        if self.account.money - self.drawingNum < 0:
            print(f"余额不足——账户:{self.account.name},余额是:{self.account.money}")
            return
        sleep(1)  # 判断完后阻塞。其他线程开始运行。
        self.account.money -= self.drawingNum
        self.expenseTotal += self.drawingNum
        Drawing.lock.release()
        print(f"账户:{self.account.name},余额是:{self.account.money}")
        print(f"账户:{self.account.name},总共取了:{self.expenseTotal}")

if __name__ == '__main__':
    a1 = Account(100, "小龙女")
    draw1 = Drawing(80, a1)  # 定义取钱线程对象;
    draw2 = Drawing(80, a1)  # 定义取钱线程对象;
    draw1.start()  # 你取钱
    draw2.start()  # 你老婆取钱

(图片未上传成功:Pasted-image-20250420183857)

这段示例代码本身有隐患

注意 run() 里 if ... : return 分支在 acquire() 之后直接返回,没有 release() —— 一旦余额不足,锁永远不释放,后面的线程会全部卡死(这正是 §2.11 要讲的死锁雏形)。
规范写法应把释放放进 try/finally,或改用 with Drawing.lock:。

2.11 死锁

2.11.1 死锁的概念

死锁:一直等待对方释放锁的情景就是死锁。

为了更直观地理解死锁,先看一张现实生活的类比图:

(图片未上传成功:Pasted-image-20250420184756)

说明:

现实社会中,男女双方一直等待对方先道歉的这种行为就好比是死锁。

死锁的结果:

  • 会造成应用程序的停止响应,不能再处理其它任务了。

2.11.2 死锁示例

在多线程程序中,死锁问题很大一部分是由于一个线程同时获取多个锁造成的。

示例:有两个人都要做饭,都需要「锅(锁 A)」和「菜刀(锁 B)」才能炒菜。

(图片未上传成功:Pasted-image-20250420184913)

from threading import Thread, Lock
from time import sleep

def fun1():
    lock1.acquire()
    print('fun1拿到菜刀')
    sleep(2)
    lock2.acquire()
    print('fun1拿到锅')
    lock2.release()
    print('fun1释放锅')
    lock1.release()
    print('fun1释放菜刀')

def fun2():
    lock2.acquire()
    print('fun2拿到锅')
    lock1.acquire()
    print('fun2拿到菜刀')
    lock1.release()
    print('fun2释放菜刀')
    lock2.release()
    print('fun2释放锅')

if __name__ == '__main__':
    lock1 = Lock()
    lock2 = Lock()
    t1 = Thread(target=fun1)
    t2 = Thread(target=fun2)
    t1.start()
    t2.start()

(图片未上传成功:Pasted-image-20250420192219)

死锁实测复现

本机以子进程 + 3 秒超时运行上述代码:3 秒内未结束,且卡在
fun1拿到菜刀 / fun2拿到锅 之后就再无输出 → 死锁确认。
原因:fun1 持 lock1 等 lock2,fun2 持 lock2 等 lock1,互相等待、谁也不放手。

示例:锁的释放时机不对导致死锁

根据下标在列表中取值,保证同一时刻只能有一个线程去取值。

import threading
import time

# 全局资源
my_list = [3, 6, 8, 1]
# 创建互斥锁
lock = threading.Lock()

# 根据下标去取值,保证同一时刻只能有一个线程去取值
def get_value(index):
    # 上锁
    lock.acquire()
    print(threading.current_thread())
    # 判断下标是否越界
    if index >= len(my_list):
        print("下标越界:", index)
        return
    value = my_list[index]
    print(value)
    time.sleep(0.2)
    # 释放锁
    lock.release()

if __name__ == '__main__':
    # 模拟大量线程去执行取值操作
    for i in range(30):
        sub_thread = threading.Thread(target=get_value, args=(i,))
        sub_thread.start()

(图片未上传成功:Pasted-image-20250420193259)

这里才是「锁释放时机不对」的典型

index >= len(my_list) 分支提前 return 但没释放锁 → 该线程持锁不还,
之后所有线程都卡在 lock.acquire(),程序「停止响应」。这就是死锁的另一种成因:异常/提前返回路径漏释放锁。

示例:在合适的地方释放锁

import threading
import time

# 创建互斥锁
lock = threading.Lock()

# 根据下标去取值, 保证同一时刻只能有一个线程去取值
def get_value(index):
    # 上锁
    lock.acquire()
    print(threading.current_thread())
    my_list = [3, 6, 8, 1]
    if index >= len(my_list):
        print("下标越界:", index)
        # 当下标越界需要释放锁,让后面的线程还可以取值
        lock.release()
        return
    value = my_list[index]
    print(value)
    time.sleep(0.2)
    # 释放锁
    lock.release()

if __name__ == '__main__':
    # 模拟大量线程去执行取值操作
    for i in range(10):
        sub_thread = threading.Thread(target=get_value, args=(i,))
        sub_thread.start()

(图片未上传成功:Pasted-image-20250420193519)

2.11.3 避免死锁

  • 用互斥锁的时候需要注意死锁的问题,要在合适的地方注意释放锁。
  • 死锁是由于「同步块需要同时持有多个锁造成」的,要解决这个问题,思路很简单,就是:同一个代码块,不要同时持有两个对象锁;或者一个线程在等待一段时间后没有取得所有锁,就释放已有锁,等等。
三条通用防死锁守则
  1. 统一加锁顺序:所有线程都按同一顺序(如先 lock1 再 lock2)申请多把锁。
  2. 用 with lock: 或 try/finally:保证任何退出路径(含 return / 异常)都能释放锁。
  3. 加超时:lock.acquire(timeout=...),超时后主动放弃并回退。

2.12 信号量(Semaphore)

(图片未上传成功:Pasted-image-20250420193626)

互斥锁限制一个资源同时只能被一个线程访问。若希望某个资源能同时被 N 个(指定数值)线程访问,就可以使用信号量。

信号量控制同时访问资源的数量。信号量和锁相似,锁同一时间只允许一个对象(进程)通过,信号量同一时间允许多个对象(进程)通过。

2.12.1 应用场景

  • 在读写文件的时候,一般只能只有一个线程在写,而读可以有多个线程同时进行,如果需要限制同时读文件的线程个数,这时候就可以用到信号量了(如果用互斥锁,就是限制同一时刻只能有一个线程读取文件)。
  • 在做爬虫抓取数据时。

2.12.2 底层原理

信号量底层就是一个内置的计数器。每当资源获取时(调用 acquire)计数器 -1,资源释放时(调用 release)计数器 +1。

from threading import Thread, Semaphore
from time import sleep

"""
一个房间一次只允许两个人通过
若不使用信号量,会造成所有人都进入这个房子
若只允许一人通过可以用锁-Lock()
"""

def home(name, se):
    se.acquire()  # 拿到一把钥匙
    print(f'{name}进入了房间')
    sleep(3)
    print(f'******************{name}走出来房间')
    se.release()  # 还回一把钥匙

if __name__ == '__main__':
    se = Semaphore(2)  # 创建信号量的对象,有两把钥匙
    for i in range(7):
        p = Thread(target=home, args=(f'tom{i}', se))
        p.start()

(图片未上传成功:Pasted-image-20250420194031)

原文勘误:线程场景应导入 threading.Semaphore

原文写的是 from multiprocessing import Semaphore(进程模块的信号量),却用来配合 threading.Thread 使用。

实测后果:用 multiprocessing.Semaphore(2) 配线程时,4 个线程全部同时进入房间(信号量完全失效),因为进程级信号量无法约束同进程内线程;
改用 threading.Semaphore(2) 后,实测「最大同时进入数 = 2」,符合预期。

规律:线程用 threading 的同步原语,进程用 multiprocessing 的,不可混用。

2.13 事件(Event)

事件 Event 主要用于唤醒正在阻塞等待状态的线程。

(图片未上传成功:Pasted-image-20250420194259)

原理

Event 对象包含一个可由线程设置的信号标志,它允许线程等待某些事件的发生。在初始情况下,event 对象中的信号标志被设置为假。如果有线程等待一个 event 对象,而这个 event 对象的标志为假,那么这个线程将会被一直阻塞直至该标志为真。一个线程如果将一个 event 对象的信号标志设置为真,它将唤醒所有等待这个 event 对象的线程。如果一个线程等待一个已经被设置为真的 event 对象,那么它将忽略这个事件,继续执行。

Event() 可以创建一个事件管理标志,该标志(event)默认为 False,event 对象主要有四种方法可以调用:

方法名 说明
event.wait(timeout=None) 调用该方法的线程会被阻塞,如果设置了 timeout 参数,超时后,线程会停止阻塞继续执行;
event.set() 将 event 的标志设置为 True,调用 wait 方法的所有线程将被唤醒
event.clear() 将 event 的标志设置为 False,调用 wait 方法的所有线程将被阻塞
event.is_set() 判断 event 的标志是否为 True

示例:Event 事件对象经典用法

import threading
import time

# 创建事件对象
event = threading.Event()

def chihuoguo(name):
    # 等待事件,进入等待阻塞状态
    print(f'{name}已经启动')
    print(f'小伙伴{name}已经进入就餐状态!')
    time.sleep(1)
    event.wait()
    # 收到事件后进入运行状态
    print(f'{name}收到通知了.')
    print(f'小伙伴{name}开始吃咯!')

if __name__ == '__main__':
    # 创建新线程
    thread1 = threading.Thread(target=chihuoguo, args=("tom",))
    thread2 = threading.Thread(target=chihuoguo, args=("cherry",))
    # 开启线程
    thread1.start()
    thread2.start()
    time.sleep(10)
    # 发送事件通知
    print('---->>>主线程通知小伙伴开吃咯!')
    event.set()

(图片未上传成功:Pasted-image-20250420195146)

实测:Event 是「一次广播唤醒全部」

本机实测:3 个线程都在 event.wait() 阻塞,set() 之前其结果列表为空;调用 event.set() 后 3 个线程全部被唤醒(结果为 ['w0', 'w1', 'w2'])。
注意 set() 后标志保持 True,若需再次阻塞需先 clear()。

2.14 生产者和消费者模式

(图片未上传成功:Pasted-image-20250420195419)

多线程环境下经常需要多个线程并发协作,这时就要了解一个重要模型——「生产者/消费者模式」。

(图片未上传成功:Pasted-image-20250420195445)

2.14.1 什么是生产者?

生产者指的是负责生产数据的模块(这里模块可能是:方法、对象、线程、进程)。

2.14.2 什么是消费者?

消费者指的是负责处理数据的模块(这里模块可能是:方法、对象、线程、进程)。

2.14.3 什么是缓冲区?

消费者不能直接使用生产者的数据,它们之间有个「缓冲区」。生产者将生产好的数据放入「缓冲区」,消费者从「缓冲区」拿要处理的数据。

缓冲区是实现并发的核心,缓冲区的设置有 3 个好处:

  1. 实现线程的并发协作
    有了缓冲区以后,生产者线程只需要往缓冲区里面放置数据,而不需要管消费者消费的情况;同样,消费者只需要从缓冲区拿数据处理即可,也不需要管生产者生产的情况。这样,就从逻辑上实现了「生产者线程」和「消费者线程」的分离。
  2. 解耦了生产者和消费者
    生产者不需要和消费者直接打交道。
  3. 解决忙闲不均,提高效率
    生产者生产数据慢时,缓冲区仍有数据,不影响消费者消费;消费者处理数据慢时,生产者仍然可以继续往缓冲区里面放置数据。

2.14.4 缓冲区和 queue 对象

从一个线程向另一个线程发送数据最安全的方式可能就是使用 queue 库中的队列了。创建一个被多个线程共享的 Queue 对象,这些线程通过使用 put() 和 get() 操作来向队列中添加或者删除元素。Queue 对象已经包含了必要的锁,所以你可以通过它在多个线程间安全地共享数据。

示例:生产者消费者模式典型代码

from queue import Queue
from threading import Thread
from time import sleep

queue = Queue()

def producer():
    num = 1
    while True:
        if queue.qsize() < 5:
            print(f'生产:{num}号,大馒头')
            queue.put(f'大馒头:{num}号')
            num += 1
        else:
            print('馒头框满了,等待来人消费啊!')
        sleep(1)

def consumer():
    while True:
        print(f'获取馒头:{queue.get()}')
        sleep(1)

if __name__ == '__main__':
    t = Thread(target=producer)
    t.start()
    c = Thread(target=consumer)
    c.start()
    c2 = Thread(target=consumer)
    c2.start()

(图片未上传成功:Pasted-image-20250420200150)

为什么用 queue.Queue 而不是自己写列表

queue.Queue 内部自带锁(put/get 都是线程安全的),还提供 qsize()、empty() 等接口,以及阻塞式的 get()(队列空时等待)。所以生产者消费者模式的标准写法就是「共享一个 Queue」,不用再手动加锁。
注意:qsize() 只是「近似值」,多线程下不保证精确。


3 进程 Process

3.1 什么是进程

(图片未上传成功:Pasted-image-20250419230432_2)

进程(Process):拥有自己独立的堆和栈,既不共享堆,也不共享栈,进程由操作系统调度;进程切换需要的资源最大,效率低。

什么是「多任务」

现代操作系统比如 Mac OS、Linux、Windows 等,都是支持「多任务」的操作系统。什么叫「多任务」呢?简单地说,就是操作系统可以同时运行多个任务。打个比方,你一边在逛淘宝,一边在听音乐,一边在用微信聊天,这就是多任务,至少同时有 3 个任务正在运行。还有很多任务悄悄地在后台同时运行着,只是桌面上没有显示而已。

多任务效果图:

(图片未上传成功:Pasted-image-20250419232507_1)

对于操作系统来说,一个任务就是一个进程(Process),比如打开一个浏览器就是启动一个浏览器进程,打开一个记事本就启动了一个记事本进程,打开两个记事本就启动了两个记事本进程,打开一个 Word 就启动了一个 Word 进程。

(图片未上传成功:Pasted-image-20250419232825_1)

3.2 进程的优缺点

进程的优点:

  1. 可以使用计算机多核,进行任务的并行执行,提高执行效率
  2. 运行不受其他进程影响,创建方便
  3. 空间独立,数据安全

进程的缺点:

  1. 进程的创建和删除消耗的系统资源较多
为什么 CPU 密集要选进程

进程的「空间独立」既是优点也是缺点:它让数据安全(不受其它进程干扰),也让它可绕开 GIL——因为每个进程有独立的解释器和独立的 GIL,所以多个进程能在多核上真正并行执行 Python 字节码。
本机实测(16 核):CPU 密集任务 4 进程相对串行约 1.8× 加速,而 4 线程几乎无加速(对比见 §2.9)。

3.3 进程的创建方式(方法模式)

Python 的标准库提供了个模块:multiprocessing。

进程的创建可以分为两种方式:

  1. 方法包装
  2. 类包装

创建进程后,使用 start() 启动进程。

3.3.1 导入进程包

# 导入进程包
import multiprocessing

3.3.2 Process 进程类的说明

Process([group[, target[, name[, args[, kwargs]]]]])
  • group:指定进程组,目前只能使用 None
  • target:执行的目标任务名
  • name:进程名字
  • args:以元组方式给执行任务传参
  • kwargs:以字典方式给执行任务传参

Process 创建的实例对象的常用方法:

  • start():启动子进程实例(创建子进程)
  • join():等待子进程执行结束
  • terminate():不管任务是否完成,立即终止子进程

Process 创建的实例对象的常用属性:

name:当前进程的别名,默认为 Process-N,N 为从 1 开始递增的整数

3.3.3 多进程完成多任务的代码(方法模式)

示例:方法模式创建进程

# 方法包装-多进程实现
from multiprocessing import Process
import os
from time import sleep

def func1(name):
    print("当前进程ID:", os.getpid())
    print("父进程ID:", os.getppid())
    print(f"Process:{name} start")
    sleep(3)
    print(f"Process:{name} end")

'''
这是一个关于windows上多进程实现的bug。
在windows上,子进程会自动import启动它的这个文件,而在import的时候是会自动执行这些语句的。
如果不加__main__限制的话,就会无限递归创建子进程,进而报错。
于是import的时候使用 __name__ == "__main__" 保护起来就可以了。
'''
if __name__ == "__main__":
    print("当前进程ID:", os.getpid())
    # 创建进程
    p1 = Process(target=func1, args=('p1',))
    p2 = Process(target=func1, args=('p2',))
    p1.start()
    p2.start()

(图片未上传成功:Pasted-image-20250420232218)

Windows 必须写 if __name__ == "__main__"

上面的注释点出了关键:Windows 用 spawn 方式启动子进程,子进程会重新 import 主模块。若不把创建进程的代码放进 if __name__ == "__main__" 保护块,就会无限递归创建子进程直至报错。

补充一条实测坑:spawn 模式下不能传嵌套(局部)函数作为 target/任务函数。本机实测传局部函数会抛
AttributeError: Can't get local object 'main.<locals>.add'(子进程无法 pickle 局部对象)。任务函数必须定义在模块顶层。

from multiprocessing import Process
from time import sleep

# 跳舞任务
def dance():
    for i in range(5):
        print("跳舞中...")
        sleep(0.2)

# 唱歌任务
def sing():
    for i in range(5):
        print("唱歌中...")
        sleep(0.2)

if __name__ == '__main__':
    # 创建跳舞的子进程
    # group: 表示进程组,目前只能使用None
    # target: 表示执行的目标任务名(函数名、方法名)
    # name: 进程名称, 默认是Process-1, .....
    dance_process = Process(target=dance, name="dance_process")
    sing_process = Process(target=sing)

    # 启动子进程执行对应的任务
    dance_process.start()
    sing_process.start()

(图片未上传成功:Pasted-image-20250420231420)

3.3.4 进程的创建方式(继承 Process 类)

和使用 Thread 类创建子线程的方式非常类似,使用 Process 类创建实例化对象,其本质是调用该类的构造方法创建新进程。Process 类的构造方法格式如下:

def __init__(self, group=None, target=None, name=None, args=(), kwargs={})

其中,各个参数的含义为:

  • group:指定进程组,目前只能使用 None
  • target:执行的目标任务名
  • name:进程名字
  • args:以元组方式给执行任务传参
  • kwargs:以字典方式给执行任务传参

示例:类的方式创建进程

# 类的方式-多进程实现
from multiprocessing import Process
from time import sleep

class MyProcess(Process):
    def __init__(self, name):
        Process.__init__(self)
        self.name = name

    def run(self):
        print(f"Process:{self.name} start")
        sleep(3)
        print(f"Process:{self.name} end")

if __name__ == "__main__":  # 创建进程
    p1 = MyProcess("p1")
    p2 = MyProcess("p2")
    p1.start()
    p2.start()

(图片未上传成功:Pasted-image-20250420232936)

官方签名对照

官方 Process 构造签名为
Process(group=None, target=None, name=None, args=(), kwargs={}, *, daemon=None),
与上面写法一致;daemon 同样是仅限关键字参数(可让子进程随主进程退出而终止,见 §3.6)。

3.3.5 小结

  1. 导入进程包
    • import multiprocessing
  2. 创建子进程并指定执行的任务
    • sub_process = multiprocessing.Process(target=任务名)
  3. 启动进程执行任务
    • sub_process.start()

3.4 获取进程编号

3.4.1 获取进程编号的目的

获取进程编号的目的是验证主进程和子进程的关系,可以得知子进程是由哪个主进程创建出来的。

获取进程编号的两种操作:

  • 获取当前进程编号
  • 获取当前父进程编号

3.4.2 获取当前进程编号

os.getpid() 表示获取当前进程编号。

示例代码:

import multiprocessing
import time
import os

# 跳舞任务
def dance():
    # 获取当前进程的编号
    print("dance:", os.getpid())
    # 获取当前进程
    print("dance:", multiprocessing.current_process())
    for i in range(5):
        print("跳舞中...")
        time.sleep(0.2)
        # 扩展:根据进程编号杀死指定进程
        os.kill(os.getpid(), 9)

# 唱歌任务
def sing():
    # 获取当前进程的编号
    print("sing:", os.getpid())
    # 获取当前进程
    print("sing:", multiprocessing.current_process())
    for i in range(5):
        print("唱歌中...")
        time.sleep(0.2)

if __name__ == '__main__':

    # 获取当前进程的编号
    print("main:", os.getpid())
    # 获取当前进程
    print("main:", multiprocessing.current_process())
    # 创建跳舞的子进程
    # group: 表示进程组,目前只能使用None
    # target: 表示执行的目标任务名(函数名、方法名)
    # name: 进程名称, 默认是Process-1, .....
    dance_process = multiprocessing.Process(target=dance, name="myprocess1")
    sing_process = multiprocessing.Process(target=sing)

    # 启动子进程执行对应的任务
    dance_process.start()
    sing_process.start()

执行结果:

(图片未上传成功:Pasted-image-20250420233434)

os.kill(os.getpid(), 9) 会「当场自杀」

上面注释里的「扩展:根据进程编号杀死指定进程」在循环中执行 os.kill(os.getpid(), 9),等于立即终止自己。
实测(子进程验证):os.kill(os.getpid(), 9) 之后进程退出码为 9,其后的 print 一律不执行。
这是演示用法,实际使用需谨慎——它会直接杀掉当前进程,不给清理机会。

3.4.3 获取当前父进程编号

os.getppid() 表示获取当前父进程编号。

示例代码:

import multiprocessing
import time
import os

# 跳舞任务
def dance():
    # 获取当前进程的编号
    print("dance:", os.getpid())
    # 获取当前进程
    print("dance:", multiprocessing.current_process())
    # 获取父进程的编号
    print("dance的父进程编号:", os.getppid())
    for i in range(5):
        print("跳舞中...")
        time.sleep(0.2)
        # 扩展:根据进程编号杀死指定进程
        os.kill(os.getpid(), 9)

# 唱歌任务
def sing():
    # 获取当前进程的编号
    print("sing:", os.getpid())
    # 获取当前进程
    print("sing:", multiprocessing.current_process())
    # 获取父进程的编号
    print("sing的父进程编号:", os.getppid())
    for i in range(5):
        print("唱歌中...")
        time.sleep(0.2)

if __name__ == '__main__':
    # 获取当前进程的编号
    print("main:", os.getpid())
    # 获取当前进程
    print("main:", multiprocessing.current_process())
    # 创建跳舞的子进程
    # group: 表示进程组,目前只能使用None
    # target: 表示执行的目标任务名(函数名、方法名)
    # name: 进程名称, 默认是Process-1, .....
    dance_process = multiprocessing.Process(target=dance, name="myprocess1")
    sing_process = multiprocessing.Process(target=sing)

    # 启动子进程执行对应的任务
    dance_process.start()
    sing_process.start()

(图片未上传成功:Pasted-image-20250421153404)

3.4.4 小结

  • 获取当前进程编号
    • os.getpid()
  • 获取当前父进程编号
    • os.getppid()
  • 获取进程编号可以查看父子进程的关系
实测:子进程的 getppid() 就是主进程的 getpid()

本机实测主进程 os.getpid() = 11604、os.getppid() = 20520;子进程里的 os.getppid() 会等于启动它的主进程的 pid,据此可验证父子关系。

3.5 进程执行带有参数的任务

3.5.1 进程执行带有参数的任务的介绍

前面进程执行的任务都没有参数;如果任务带有参数,该如何传参呢?

Process 类执行任务并给任务传参数有两种方式:

  • args 表示以元组的方式给执行任务传参
  • kwargs 表示以字典方式给执行任务传参

3.5.2 args 参数的使用

示例代码:

import multiprocessing
import time

# 带有参数的任务
def task(count):
    for i in range(count):
        print("任务执行中..")
        time.sleep(0.2)
    else:
        print("任务执行完成")

if __name__ == '__main__':
    # 创建子进程
    # args: 以元组的方式给任务传入参数
    sub_process = multiprocessing.Process(target=task, args=(5,))
    sub_process.start()

执行结果:

(图片未上传成功:Pasted-image-20250420234028)

3.5.3 kwargs 参数的使用

示例代码:

import multiprocessing
import time

# 带有参数的任务
def task(count):
    for i in range(count):
        print("任务执行中..")
        time.sleep(0.2)
    else:
        print("任务执行完成")

if __name__ == '__main__':
    # 创建子进程
    # kwargs: 表示以字典方式传入参数
    sub_process = multiprocessing.Process(target=task, kwargs={"count": 3})
    sub_process.start()

执行结果:

(图片未上传成功:Pasted-image-20250420234125)

3.5.4 小结

  • 进程执行任务并传参有两种方式:
    • 元组方式传参(args):元组方式传参一定要和参数的顺序保持一致。
    • 字典方式传参(kwargs):字典方式传参字典中的 key 一定要和参数名保持一致。

3.6 进程的注意点

3.6.1 进程的注意点介绍

  1. 进程之间不共享全局变量
  2. 主进程会等待所有的子进程执行结束再结束

3.6.2 进程之间不共享全局变量

import multiprocessing
import time

# 定义全局变量
g_list = list()

# 添加数据的任务
def add_data():
    for i in range(5):
        g_list.append(i)
        print("add:", i)
        time.sleep(0.2)

    # 代码执行到此,说明数据添加完成
    print("add_data:", g_list)

def read_data():
    print("read_data", g_list)

if __name__ == '__main__':
    # 创建添加数据的子进程
    add_data_process = multiprocessing.Process(target=add_data)
    # 创建读取数据的子进程
    read_data_process = multiprocessing.Process(target=read_data)

    # 启动子进程执行对应的任务
    add_data_process.start()
    # 主进程等待添加数据的子进程执行完成以后程序再继续往下执行,读取数据
    add_data_process.join()
    read_data_process.start()

    print("main:", g_list)

    # 总结: 多进程之间不共享全局变量

执行结果:

(图片未上传成功:Pasted-image-20250420234935)

进程之间不共享全局变量的解释效果图:

(图片未上传成功:Pasted-image-20250420235026)

实测:子进程改全局变量,主进程看不到

本机实测:子进程 add() 往全局列表 g_list 追加了 3 个元素,但主进程里 g_list 仍然是 [](空)——
进程之间不共享全局变量。这与 §2.8 的线程行为恰好相反(线程共享全局变量)。

3.6.3 进程之间不共享全局变量的小结

  • 创建子进程会拷贝主进程的资源,相当于主进程的一个副本(好比一对双胞胎)。进程之间之所以不共享全局变量,是因为它们操作的根本不是同一个进程里的全局变量,只不过这些全局变量在不同进程中恰好同名而已。

3.6.4 主进程会等待所有的子进程执行结束再结束

现在创建一个子进程(执行完约需 2 秒钟),让主进程执行 0.5 秒钟后就退出程序,看看会发生什么:

from time import sleep
from multiprocessing import Process

# 定义进程所需要执行的任务
def task():
    print('task start')
    for i in range(10):
        print(f"任务{i}执行中...")
        sleep(0.2)
    print('task end')

if __name__ == '__main__':
    print('主进程 start')
    # 创建子进程
    sub_process = Process(target=task)
    # 启动子进程
    sub_process.start()
    sleep(1)
    print('主进程 end')
    exit()
    # 总结: 主进程会等待所有的子进程执行完成以后程序再退出

执行结果:

(图片未上传成功:Pasted-image-20250421004503)

说明:

通过上面代码的执行结果,我们可以得知:主进程会等待所有的子进程执行结束再结束。

那如果就想让主进程执行 0.5 秒钟、子进程随之销毁不再执行,该怎么办呢?

  • 我们可以设置守护主进程,或者在主进程退出之前让子进程销毁。

守护主进程:

  • 守护主进程就是主进程退出子进程销毁不再执行。

子进程销毁:

  • 子进程执行结束。

示例:保证主进程正常退出的示例代码(守护主进程):

from time import sleep
from multiprocessing import Process

# 定义进程所需要执行的任务
def task():
    print('task start')
    for i in range(10):
        print(f"任务{i}执行中...")
        sleep(0.2)
    print('task end')

if __name__ == '__main__':
    print('主进程 start')
    # 创建子进程
    sub_process = Process(target=task)
    # 设置守护主进程,主进程退出子进程直接销毁,子进程的生命周期依赖于主进程
    sub_process.daemon = True
    # 启动子进程
    sub_process.start()
    sleep(1)
    print('主进程 end')

    # 总结: 主进程会等待所有的子进程执行完成以后程序再退出
    # 如果想要主进程退出子进程销毁,可以设置守护主进程或者在主进程退出之前让子进程销毁

执行结果:

(图片未上传成功:Pasted-image-20250421001823)

示例:保证主进程正常退出的示例代码(子进程销毁):

from time import sleep
from multiprocessing import Process

# 定义进程所需要执行的任务
def task():
    print('task start')
    for i in range(10):
        print(f"任务{i}执行中...")
        sleep(0.2)
    print('task end')

if __name__ == '__main__':
    print('主进程 start')
    # 创建子进程
    sub_process = Process(target=task)
    # 启动子进程
    sub_process.start()
    sleep(1)
    # 让子进程销毁
    sub_process.terminate()
    print('主进程 end')

    # 总结: 主进程会等待所有的子进程执行完成以后程序再退出
    # 如果想要主进程退出子进程销毁,可以设置守护主进程或者在主进程退出之前让子进程销毁

执行结果:

(图片未上传成功:Pasted-image-20250421002442)

3.6.5 主进程会等待所有的子进程执行结束再结束的小结

  • 为了保证子进程能够正常的运行,主进程会等所有的子进程执行完成以后再销毁。设置守护主进程的目的是主进程退出子进程销毁,不让主进程再等待子进程去执行。
  • 设置守护主进程方式:子进程对象.daemon = True
  • 销毁子进程方式:子进程对象.terminate()

3.7 Queue 实现进程间通信

(图片未上传成功:Pasted-image-20250421004656)

前面讲解了使用 queue 模块中的 Queue 类实现线程间通信,但要实现进程间通信,需要使用 multiprocessing 模块中的 Queue 类。

简单的理解 Queue 实现进程间通信的方式,就是使用了操作系统给开辟的一个队列空间,各个进程可以把数据放到该队列中,当然也可以从队列中把自己需要的信息取走。

示例:使用 Queue 实现进程间通信的经典代码

from multiprocessing import Process, Queue

class MyProcess(Process):
    def __init__(self, name, mq):
        Process.__init__(self)
        self.name = name
        self.mq = mq

    def run(self):
        print(f"Process:{self.name} start")
        print(f'从队列中取出数据:{self.mq.get()}')
        self.mq.put(self.name)
        print(f"Process:{self.name} end")

if __name__ == '__main__':
    # 创建进程列表
    t_list = []
    # 创建队列
    mq = Queue()
    # 向队列中放入数据
    mq.put('1')
    mq.put('2')
    mq.put('3')
    # 循环创建进程
    for i in range(3):
        t = MyProcess(f'myProcess{i}', mq)
        t.start()
        t_list.append(t)
    # 分别阻塞等待每个子进程结束
    for t in t_list:
        t.join()
    # 从队列中取出数据
    print(mq.get())
    print(mq.get())
    print(mq.get())

(图片未上传成功:Pasted-image-20250421010326)

queue.Queue 与 multiprocessing.Queue 是两回事
  • 线程间通信:用 queue.Queue(§2.14)。
  • 进程间通信:用 multiprocessing.Queue(本节)。

本机实测 multiprocessing.Queue:子进程 put 的 3 个值,主进程能依次 get 出来([0, 1, 2]),验证了跨进程的数据传递。它的底层是「操作系统开辟的队列空间 + 序列化」,与线程队列的共享内存机制不同。

3.8 Pipe 实现进程间通信

(图片未上传成功:Pasted-image-20250421010607)

Pipe 直译过来的意思是「管」或「管道」,和实际生活中的管(管道)是非常类似的。

Pipe 方法返回 (conn1, conn2) 代表一个管道的两个端。

注意

Pipe 方法有 duplex 参数,如果 duplex 参数为 True(默认值),那么这个参数是全双工模式,也就是说 conn1 和 conn2 均可收发。若 duplex 为 False,conn1 只负责接收消息,conn2 只负责发送消息。send 和 recv 方法分别是发送和接受消息的方法。例如,在全双工模式下,可以调用 conn1.send 发送消息,conn1.recv 接收消息。如果没有消息可接收,recv 方法会一直阻塞。如果管道已经被关闭,那么 recv 方法会抛出 EOFError。

示例:使用 Pipe 管道实现进程间通信

import multiprocessing
from time import sleep

def func1(conn1):
    sub_info = "Hello!"
    print(f"进程1--{multiprocessing.current_process().pid}发送数据:{sub_info}")
    sleep(1)
    conn1.send(sub_info)
    print(f"来自进程2:{conn1.recv()}")
    sleep(1)

def func2(conn2):
    sub_info = "你好!"
    print(f"进程2--{multiprocessing.current_process().pid}发送数据:{sub_info}")
    sleep(1)
    conn2.send(sub_info)
    print(f"来自进程1:{conn2.recv()}")
    sleep(1)

if __name__ == '__main__':
    # 创建管道
    conn1, conn2 = multiprocessing.Pipe()
    # 创建子进程
    process1 = multiprocessing.Process(target=func1, args=(conn1,))
    process2 = multiprocessing.Process(target=func2, args=(conn2,))
    # 启动子进程
    process1.start()
    process2.start()

(图片未上传成功:Pasted-image-20250421020927)

Pipe 是「点对点」的,别多进程抢一端

Pipe 只有两个端点,官方明确指出:如果两个进程同时尝试从管道的同一端读取或写入,数据可能损坏。所以它适合「恰好两个进程通信」,多方通信请用 Queue。

3.9 Manager 管理器

管理器提供了一种创建共享数据的方法,从而可以在不同进程中共享。

示例:管理器 Manager 实现进程通信

from multiprocessing import Process, current_process, Manager

def func(name, m_list, m_dict):
    m_dict['name'] = '小龙女'
    m_list.append('你好')

if __name__ == "__main__":
    with Manager() as mgr:
        m_list = mgr.list()
        m_dict = mgr.dict()
        m_list.append('Hello!!')
        # 两个进程不能直接互相使用对象,需要互相传递
        p1 = Process(target=func, args=('p1', m_list, m_dict))
        p1.start()
        p1.join()  # 等p1进程结束,主进程继续执行
        print(m_list)
        print(m_dict)

(图片未上传成功:Pasted-image-20250421021349)

Manager 是「代管 + 代理」的共享

与 §3.6「进程不共享全局变量」并不矛盾:Manager 起了一个管理器进程,mgr.list() / mgr.dict() 返回的是代理对象(proxy),
真正的数据存在管理器进程里。任何进程通过代理读写,都是跨进程通信,所以能「共享」。
开销比普通变量大(每次读写都要通信),适用于需要共享可变结构的场景。

3.10 进程池(Pool)

Python 提供了更好的管理多个进程的方式,就是使用进程池。

(图片未上传成功:Pasted-image-20250421021728)

进程池可以预先提供指定数量的进程供复用:当有新请求提交到进程池时,若池未满,就创建一个新的进程来执行该请求;若池中进程数已达上限,该请求就会等待,一旦池中有进程空闲下来,它便能得到执行。

使用进程池的优点:

  1. 提高效率,节省开辟进程和开辟内存空间的时间及销毁进程的时间
  2. 节省内存空间
类/方法 功能 参数
Pool(processes) 创建进程池对象 processes 表示进程池中有多少进程
pool.apply_async(func, args, kwds, callback, error_callback) 异步执行;将事件放入到进程池队列 func 事件函数
args 以元组形式给 func 传参
kwds 以字典形式给 func 传参
callback 事件函数执行完毕后自动执行的操作
返回值:返回一个代表进程池事件的对象,通过返回值的 get 方法可以得到事件函数的返回值,get 方法会阻塞等待事件函数的返回值。
pool.apply(func, args, kwds) 同步执行;将事件放入到进程池队列 func 事件函数
args 以元组形式给 func 传参
kwds 以字典形式给 func 传参
pool.close() 关闭进程池
pool.join() 回收进程池
pool.map(func, iter) 类似于 Python 的 map 函数,将要做的事件放入进程池 func 要执行的函数
iter 迭代对象
返回值:一个列表,所有返回值

示例:进程池使用案例

from multiprocessing import Pool
import os
from time import sleep

def func1(name):
    print(f'===子进程——{name} start===')
    print(f"当前进程的ID:{os.getpid()},{name}")
    sleep(2)
    print(f'===子进程——{name} end===')
    return name

def func2(args):
    print(args)

if __name__ == "__main__":
    print('---主进程 start---')
    pool = Pool(5)
    res = pool.apply_async(func=func1, args=('test_process',))
    print(type(res))
    # 主进程会阻塞等待,直到取到返回值
    print(res.get())
    print('=' * 20)
    # callback 事件函数执行完毕后自动执行的操作,不会阻塞主进程
    pool.apply_async(func=func1, args=('my_process1',), callback=func2)
    pool.apply_async(func=func1, args=('my_process2',), callback=func2)
    pool.apply_async(func=func1, args=('my_process3',), callback=func2)
    pool.apply_async(func=func1, args=('my_process4',))
    pool.apply_async(func=func1, args=('my_process5',))
    pool.apply_async(func=func1, args=('my_process6',))
    pool.apply_async(func=func1, args=('my_process7',))
    pool.apply_async(func=func1, args=('my_process8',))
    # 关闭进程池
    pool.close()
    # 回收进程池
    pool.join()
    print('---主进程 end---')

(图片未上传成功:Pasted-image-20250421161310)

示例:使用 with 管理进程池

from multiprocessing import Pool
import os
from time import sleep

def func1(name):
    print(f"当前进程的ID:{os.getpid()},{name}")
    sleep(2)
    return name

if __name__ == "__main__":
    args = ('my_process1', 'my_process2', 'my_process3',
            'my_process4', 'my_process5', 'my_process6',
            'my_process7', 'my_process8', 'my_process9')
    # with 会自动管理进程池
    with Pool(5) as pool:
        res = pool.map(func1, args)
        print(type(res))
        for i, x in enumerate(res, 1):
            print(x, end="\t" if not i % 3 == 0 else "\n")

(图片未上传成功:Pasted-image-20250421161957)

apply / apply_async / map 怎么选
  • apply:同步,会阻塞直到任务完成,等于「排队执行」(基本等价于串行)。
  • apply_async:异步,立即返回一个 AsyncResult;要拿结果用 .get()(会阻塞),要收尾必须 close() + join()。
  • map:把可迭代对象切成多份分发,按顺序返回结果列表,最省心。
    注意:官方推荐新代码优先用 concurrent.futures.ProcessPoolExecutor(更现代的接口),multiprocessing.Pool 仍可用。
Pool 的正确收尾顺序

apply_async 后必须 pool.close()(不再接受新任务)再 pool.join()(等所有任务结束);
反过来(先 join 后 close)会抛 ValueError: Pool is still running。用 with Pool(...) as pool: 则自动处理。


4 协程 Coroutines

(图片未上传成功:Pasted-image-20250421024200)

4.1 协程是什么

(图片未上传成功:Pasted-image-20250421024231)

协程(Coroutines),也叫纤程(Fiber),全称「协同程序」,用来实现任务协作。它存在于线程之中,比线程更加轻量级,由程序员自己写程序来管理。

当出现 IO 阻塞时,CPU 一直等待 IO 返回,处于空转状态。这时候用协程,可以执行其他任务。当 IO 返回结果后,再回来处理数据。充分利用了 IO 等待的时间,提高了效率。

4.2 一个故事说明进程、线程、协程的关系

(图片未上传成功:Pasted-image-20250419225343_1)

工厂与工人

乔布斯想开工厂生产手机,费劲力气,制作一条生产线,这个生产线上有很多的器件以及材料。一条生产线就是一个进程。

只有生产线是不够的,所以找五个工人来进行生产,这个工人能够利用这些材料最终一步步的将手机做出来,这五个工人就是五个线程。

为了提高生产率,想到 3 种办法:

  1. 一条生产线上多招些工人,一起来做手机,这样效率是成倍增长,即单进程多线程方式
  2. 多条生产线,每个生产线上多个工人,即多进程多线程
  3. 乔布斯深入一线发现工人不是那么忙,有很多等待时间。于是规定:如果某个员工在等待生产线某个零件生产时,不要闲着,干点其他工作。也就是说:如果一个线程等待某些条件,可以充分利用这个时间去做其它事情,这就是:协程方式。

4.3 协程的核心(控制流的让出和恢复)

  1. 每个协程有自己的执行栈,可以保存自己的执行现场。
  2. 可以由用户程序按需创建协程(比如:遇到 io 操作)。
  3. 协程「主动让出(yield)」执行权时候,会保存执行现场(保存中断时的寄存器上下文和栈),然后切换到其他协程。
  4. 协程恢复执行(resume)时,根据之前保存的执行现场恢复到中断前的状态,继续执行,这样就通过协程实现了轻量的由用户态调度的多任务模型。

4.4 协程和多线程比较

比如,有 3 个任务需要完成,每个任务都在等待 I/O 操作时阻塞自身。阻塞在 I/O 操作上所花费的时间已经用灰色框标示出来了。

(图片未上传成功:Pasted-image-20250421024751)

  1. 在单线程同步模型中,任务按照顺序执行。如果某个任务因为 I/O 而阻塞,其他所有的任务都必须等待,直到它完成之后它们才能依次执行。
  2. 多线程版本中,这 3 个任务分别在独立的线程中执行。这些线程由操作系统来管理,在多处理器系统上可以并行处理,或者在单处理器系统上交错执行。这使得当某个线程阻塞在某个资源的同时其他线程得以继续执行。
  3. 协程版本的程序中,3 个任务交错执行,但仍然在一个单独的线程控制中。当处理 I/O 或者其他昂贵的操作时,注册一个回调到事件循环中,然后当 I/O 操作完成时继续执行。回调描述了该如何处理某个事件。事件循环轮询所有的事件,当事件到来时将它们分配给等待处理事件的回调函数。

4.5 协程的优点

  1. 由于自身带有上下文和栈,无需线程上下文切换的开销,属于程序级别的切换,操作系统完全感知不到,因而更加轻量级;
  2. 无需原子操作的锁定及同步的开销;
  3. 方便切换控制流,简化编程模型;
  4. 单线程内就可以实现并发的效果,最大限度地利用 CPU,且可扩展性高,成本低(注:一个 CPU 支持上万的协程都不是问题。所以很适合用于高并发处理)。

写爬虫时,asyncio 协程常常是比多线程、多进程更好的选择——开辟新线程和新进程的开销相当大。

4.6 协程的缺点

  1. 无法利用多核资源:协程的本质是个单线程,它不能同时将单个 CPU 的多个核用上,协程需要和进程配合才能运行在多 CPU 上。
  2. 当然,日常绝大部分应用都没有这个必要,除非是 CPU 密集型应用。

4.7 使用 yield 实现协程(已淘汰,了解)

Python 中的协程经历了很长的一段发展历程。其大概经历了如下三个阶段:

  1. 最初的生成器变形 yield / send
  2. 引入 @asyncio.coroutine 和 yield from
  3. Python 3.5 版本后,引入 async / await 关键字

示例:不使用协程执行多个任务

import time

def func1():
    for i in range(3):
        print(f'北京:第{i}次打印啦')
        time.sleep(1)
    return "func1执行完毕"

def func2():
    for k in range(3):
        print(f'上海:第{k}次打印了')
        time.sleep(1)
    return "func2执行完毕"

def main():
    func1()
    func2()

if __name__ == '__main__':
    start_time = time.time()
    main()
    end_time = time.time()
    print(f"耗时{end_time - start_time}")  # 不使用协程,耗时6秒

(图片未上传成功:Pasted-image-20250421025330)

示例:使用 yield 协程,实现任务切换

import time

def func1():
    for i in range(3):
        print(f'北京:第{i}次打印啦')
        yield  # 只要方法包含了yield,就变成一个生成器
        time.sleep(1)

def func2():
    # func1是一个生成器,func1() 就不会直接调用,需要通过next()或for循环调用
    g = func1()
    print(type(g))
    for k in range(3):
        print(f'上海:第{k}次打印了')
        next(g)  # 继续执行func1的代码
        time.sleep(1)

# 有了yield,我们实现了两个任务的切换+保存状态
start_time = time.time()
func2()
end_time = time.time()
print(f"耗时{end_time - start_time}")  # 耗时5.0秒,效率差别不大

(图片未上传成功:Pasted-image-20250421025629)

基于 yield 并发执行,多任务之间来回切换,这就是个简单的协程的体现,但是它能够节省 I/O 时间吗?不能。

为什么 yield 版省不了时间

因为 func1 里的阻塞是 time.sleep(1)(同步阻塞),next(g) 切换过去之后,遇到 time.sleep 依然会把整个线程停住,其它任务照样动弹不得。
真正的省时要靠「可让出的事件循环 + 非阻塞 IO」,即 await asyncio.sleep(1) 而非 time.sleep(1)。这就是下一节 asyncio 的意义。

4.8 asyncio 实现协程(重点)

  1. 正常的函数执行时是不会中断的,所以你要写一个能够中断的函数,就需要加 async。
  2. async 用来声明一个函数为异步函数,异步函数的特点是能在函数执行过程中挂起,去执行其他异步函数,等到挂起条件(假设挂起条件是 sleep(5))消失后,也就是 5 秒到了再回来执行。
  3. await 用来声明程序挂起,比如异步程序执行到某一步时需要等待的时间很长,就将此挂起,去执行其他的异步程序。
  4. asyncio 是 Python 3.5 之后的协程模块,是 Python 实现并发重要的包,这个包使用事件循环驱动实现并发。

示例:不使用 asyncio 的任务切换

import time

def func1():
    for i in range(3):
        print(f'北京:第{i}次打印啦')
        time.sleep(1)
    return "func1执行完毕"

def func2():
    for k in range(3):
        print(f'上海:第{k}次打印了')
        time.sleep(1)
    return "func2执行完毕"

def main():
    func1()
    func2()

if __name__ == '__main__':
    start_time = time.time()
    main()
    end_time = time.time()
    print(f"耗时{end_time - start_time}")  # 不使用协程,耗时6秒

(图片未上传成功:Pasted-image-20250421030043)

使用 asyncio,整体执行完,耗时 3 秒,效率极大提高。

示例:asyncio 异步 IO 的典型使用方式

import asyncio
import time

async def func1():  # async表示方法是异步的
    for i in range(3):
        print(f'北京:第{i}次打印啦')
        await asyncio.sleep(1)
    return "func1执行完毕"

async def func2():
    for k in range(3):
        print(f'上海:第{k}次打印了')
        await asyncio.sleep(1)
    return "func2执行完毕"

async def main():
    res = await asyncio.gather(func1(), func2())
    # await异步执行func1方法
    # 返回值为函数的返回值列表,本例为["func1执行完毕", "func2执行完毕"]
    print(res)

if __name__ == '__main__':
    start_time = time.time()
    asyncio.run(main())
    end_time = time.time()
    print(f"耗时{end_time - start_time}")  # 耗时3秒,效率极大提高

(图片未上传成功:Pasted-image-20250421030413)

实测:asyncio 3 秒 vs 串行 6 秒

本机实测(3.13.14):

  • 串行执行两个各 3 × sleep(1) 的函数 → 6.00 秒;
  • 用 asyncio.run + asyncio.gather 并发执行 → 3.03 秒,返回值 ['func1执行完毕', 'func2执行完毕']。

时间从 6s 降到 3s,正是「两个任务的重叠等待被并行化」的效果。关键前提是用了 await asyncio.sleep(1) 这个非阻塞等待——它会主动把控制权交还事件循环。

三个最容易踩的坑
  1. 在协程里用 time.sleep:会阻塞整个事件循环,协程并发直接退化成串行。必须用 await asyncio.sleep(...)。
  2. 直接调用协程函数不会执行:func1() 只返回一个协程对象,必须 await 它、或交给 asyncio.run / gather / create_task。
  3. asyncio.run 不能嵌套:它内部会新建并关闭事件循环,在一个已运行的事件循环里再调 asyncio.run 会报错(应改用 await)。

5 术语表(重要英文单词)

5.1 并发模型相关

术语 全拼 / 说明 释义
serial 串行 一个 CPU 上按顺序逐个完成任务,前一个不结束、后一个不开始。
parallelism 并行 多核上同一时刻真正同时执行多个任务,必须有多核支撑。
concurrency 并发 同一时间段内交替推进多个任务,宏观同时、微观切换,单核即可实现。
synchronous 同步 A 调用 B 后等待 B 返回结果,A 才继续执行。
asynchronous 异步 A 调用 B 后不等待,B 有结果了再通知 A 处理。
blocking 阻塞 调用方被挂起、无法继续推进,直到条件满足。
non-blocking 非阻塞 调用立即返回,不等结果;需配合轮询 / 回调 / 事件循环。
race condition 竞态条件 多线程/进程对共享数据交叉读写,导致结果不确定或错误。
producer / consumer 生产者 / 消费者模式 通过「缓冲区」解耦生产与消费双方的并发协作模型。
buffer 缓冲区 生产者与消费者之间的中转存储,实现解耦、削峰、并发协作。

5.2 线程 / 进程 / 协程相关

术语 全拼 / 说明 释义
Process 进程 操作系统分配资源的基本单位,拥有独立的内存空间(堆 + 栈均不共享)。
Thread 线程 CPU 调度的基本单位,进程内的执行分支,共享堆、不共享栈。
Coroutine 协程(协同程序) 比线程更轻量的用户态任务,由程序员调度,共享堆、不共享栈。
Fiber 纤程 协程的别称。
GIL Global Interpreter Lock,全局解释器锁 CPython 的机制,保证同一时刻只有一个线程执行 Python 字节码。
daemon 守护线程 / 守护进程 主线程/主进程退出时随之销毁的后台任务;线程用 daemon 属性设置。
Mutex / Lock Mutual Exclusion,互斥锁 保证同一时刻只有一个线程访问共享数据的同步原语。
Semaphore 信号量 允许多个(N 个)线程同时访问资源的同步原语,底层是计数器。
Event 事件 带布尔标志的同步原语,用于唤醒正在阻塞等待的线程。
deadlock 死锁 多个线程互相等待对方释放锁,导致程序永久卡死。
join 汇合 / 阻塞等待 阻塞当前线程(或进程),直到目标线程(或进程)执行结束。
keyword / positional argument 关键字 / 位置参数 传参方式;args 用元组按位置、kwargs 用字典按关键字。

5.3 进程通信与 asyncio 相关

术语 全拼 / 说明 释义
IPC Inter-Process Communication,进程间通信 进程之间交换数据的机制,如 Queue / Pipe / Manager。
Queue 队列 线程安全的先进先出结构;multiprocessing.Queue 用于跨进程通信。
Pipe 管道 点对点双向通信通道,返回 (conn1, conn2) 两个端点。
Manager 管理器 multiprocessing 提供的共享数据方案,返回代理对象供多进程访问。
Pool 进程池 预先创建固定数量的进程反复复用,避免频繁创建/销毁开销。
callback 回调 任务完成后自动调用的函数(如 apply_async(callback=...))。
async / await 异步 / 等待 Python 3.5+ 的协程关键字;async 声明异步函数,await 挂起等待。
asyncio Asynchronous I/O,异步 IO Python 标准库的异步框架,基于事件循环驱动协程并发。
event loop 事件循环 asyncio 的核心调度器,轮询事件并分配给对应回调/协程。
gather 聚集 / 汇合 asyncio.gather(*coros) 并发运行多个协程并按顺序收集结果。
generator 生成器 含 yield 的函数;协程的早期实现形态(yield / send)。
spawn / fork 进程启动方式 Windows 默认 spawn(重新 import 主模块,须 __main__ 保护);Unix 默认 fork。

6 系列导航

本篇的三条主线一句话回顾
  1. 线程:共享内存、切换快、受 GIL 限制 → 适合 IO 密集(或需共享内存)。
  2. 进程:空间独立、可绕 GIL、创建开销大 → 适合 CPU 密集,跨进程数据用 Queue / Pipe / Manager。
  3. 协程:单线程内协作调度、最轻量 → 适合 高并发 IO,关键是 async / await + 非阻塞等待。

7 参考链接

分类:Python

posted @ 2026-04-11 02:02  挖掘鱼  阅读(25)  评论(0)    收藏  举报