13.并发编程
1 并发编程介绍
1.1 串行、并行与并发的区别
(图片未上传成功:Pasted-image-20250419224058)
- 串行(serial):一个 CPU 上,按顺序完成多个任务。
- 并行(parallelism):多核 CPU 处理多任务时,操作系统会给 CPU 的每个内核安排一个执行的任务,多个内核真正地一起执行任务。注意:多核 CPU 是并行地执行多任务,始终有多个任务同时执行;当任务数不超过 CPU 核数时,任务就是真正一起执行的。
- 并发(concurrency):一个 CPU 采用时间片管理方式,交替处理多个任务。通常是任务数多于 CPU 核数,靠操作系统的任务调度算法让多个任务「一起」执行——实际上总有一些任务并未执行,只是切换速度极快,看上去像在同时执行而已。
1.2 进程、线程、协程的区别
1.2.1 一个故事说明进程、线程、协程的关系
(图片未上传成功:Pasted-image-20250419225343)
(图片未上传成功:Pasted-image-20250419230432)
- 线程是程序执行的最小单位,而进程是操作系统分配资源的最小单位;
- 一个进程由一个或多个线程组成,线程是一个进程中代码的不同执行路线;
- 进程之间相互独立,但同一进程下的各个线程之间共享程序的内存空间(包括代码段、数据集、堆等)及一些进程级的资源(如打开文件和信号),某进程内的线程在其它进程不可见;
- 调度和切换:线程上下文切换比进程上下文切换要快得多。
1.2.2 进程(Process)
1.2.2.1 进程的介绍
在 Python 程序中,想要实现多任务可以使用进程来完成,进程是实现多任务的一种方式。
1.2.2.2 进程的概念
进程(Process):一个正在运行的程序或者软件就是一个进程,它是操作系统进行资源分配的基本单位,也就是说每启动一个进程,操作系统都会给其分配一定的运行资源(内存资源)保证进程的运行。
比如:现实生活中的公司可以理解成是一个进程,公司提供办公资源(电脑、办公桌椅等),真正干活的是员工,员工可以理解成线程。
对于操作系统来说,一个任务就是一个进程(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 调度的基本单位。每个进程至少都有一个线程,而这个线程就是我们通常说的主线程。
1.2.3.3 线程的作用
多线程可以完成多任务。
多线程效果图:
(图片未上传成功:Pasted-image-20250419234700)
1.2.3.4 小结
- 线程是 Python 程序中实现多任务的另外一种方式,线程的执行需要 CPU 调度来完成。
1.2.4 并发编程解决方案
多任务的实现有 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)
同步方式通信:
- 小龙女买一本书《Python 实战笔记》。
- 书店老板说:等一等,我帮你查查。
- 小龙女等一小时。
- 老板说,找到书了,发给你。
异步方式通信:
- 小龙女买一本书《Python 实战笔记》。
- 书店老板说:我查一下,有结果了告诉你。
- 小龙女刷抖音一小时。
- 老板说,找到书了,发给你。
2 线程 Thread
2.1 什么是线程
(图片未上传成功:Pasted-image-20250419230432_1)
线程(Thread)特点:
- 线程(Thread)是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位;
- 线程是程序执行的最小单位,而进程是操作系统分配资源的最小单位;
- 一个进程由一个或多个线程组成,线程是一个进程中代码的不同执行路线;
- 拥有自己独立的栈和共享的堆,共享堆,不共享栈。标准线程由操作系统调度;
- 调度和切换:线程上下文切换比进程上下文切换要快得多。
2.2 线程的创建方式
Python 的标准库提供了两个模块:_thread 和 threading,_thread 是低级模块,threading 是高级模块,对 _thread 进行了封装。绝大多数情况下,我们只需要使用 threading 这个高级模块。
线程的创建可以分为两种方式:
- 方法包装
- 类包装
线程的执行统一通过 start() 方法。
2.3 线程的创建方式(方法包装)
2.3.1 导入线程模块
# 导入线程模块
from threading import Thread
2.3.2 线程类 Thread 参数说明
Thread([group [, target [, name [, args [, kwargs]]]]])
group:线程组,目前只能使用Nonetarget:执行的目标任务名args:以元组的方式给执行任务传参kwargs:以字典方式给执行任务传参name:线程名,一般不用设置
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")
主线程,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 小结
- 导入线程模块
import threading
- 创建子线程并指定执行的任务
sub_thread = threading.Thread(target=任务名)
- 启动线程执行任务
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)
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)
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)
2.7 守护线程
还有一种叫守护线程,主要特征是它的生命周期:主线程死亡,它也随之死亡。在 Python 中,线程通过 setDaemon(True|False) 设置是否为守护线程。
守护线程的作用:
- 守护线程作用是为其他线程提供便利服务,守护线程最典型的应用就是 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)
2.8 线程的注意点
2.8.1 线程的注意点介绍
- 线程之间执行是无序的
- 主线程会等待所有的子线程执行结束再结束
- 线程之间共享全局变量
- 线程之间共享全局变量数据出现错误问题
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 秒钟、子线程随之销毁不再执行,该怎么办呢?
- 我们可以设置守护主线程。
守护主线程:
- 守护主线程就是主线程退出子线程销毁不再执行。
设置守护主线程有两种方式:
threading.Thread(target=show_info, daemon=True)线程对象.setDaemon(True)- 3.10 之后已经弃用,改为
线程对象.daemon = True
- 3.10 之后已经弃用,改为
设置守护主线程的示例代码:
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)
2.8.4 线程之间共享全局变量
需求:
- 定义一个列表类型的全局变量。
- 创建两个子线程分别执行向全局变量添加数据的任务和向全局变量读取数据的任务。
- 查看线程之间是否共享全局变量数据。
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 线程之间共享全局变量数据出现错误问题
需求:
- 定义两个函数,实现循环 100 万次,每循环一次给全局变量加 1。
- 创建两个子线程执行对应的两个函数,查看计算后的结果。
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 运算,但是由于是多线程同时操作,有可能出现下面情况:
- 在
g_num = 0时,first_thread取得g_num = 0。此时系统把first_thread调度为 sleeping 状态,把second_thread转换为 running 状态,second_thread也获得g_num = 0。 - 然后
second_thread对得到的值进行加 1 并赋给g_num,使得g_num = 1。 - 然后系统又把
second_thread调度为 sleeping,把first_thread转为 running。线程first_thread又把它之前得到的 0 加 1 后赋值给g_num。 - 这样导致虽然
first_thread和second_thread都对g_num加 1,但结果仍然是g_num = 1。
全局变量数据错误的解决办法:
线程同步:保证同一时刻只能有一个线程去操作全局变量。
同步:就是协同步调,按预定的先后次序进行运行。如:你说完,我再说,好比现实生活中的对讲机。
线程同步的方式:
- 线程等待(join)
- 互斥锁
线程等待的示例代码:
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
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)
2.10 线程同步和互斥锁
2.10.1 同一个资源,多人想用?排队啊
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)
2.10.3 线程同步的方式
- 线程等待(join)
线程等待对主线程来说实际上是变回了串行执行,主线程只有等待 join 的子线程执行完毕才能继续向下执行。效率不高。 - 互斥锁
- 必须使用同一个锁对象
- 互斥锁的作用就是保证同一时刻只能有一个线程去操作共享数据,保证共享数据不会出现错误问题
- 使用互斥锁的好处确保某段关键代码只能由一个线程从头到尾完整地去执行
- 使用互斥锁会影响代码的执行效率,多任务改成了单任务执行
- 同时持有多把锁,容易出现死锁的情况
线程等待方式解决:
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()
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)
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)
示例:锁的释放时机不对导致死锁
根据下标在列表中取值,保证同一时刻只能有一个线程去取值。
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)
示例:在合适的地方释放锁
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 避免死锁
- 用互斥锁的时候需要注意死锁的问题,要在合适的地方注意释放锁。
- 死锁是由于「同步块需要同时持有多个锁造成」的,要解决这个问题,思路很简单,就是:同一个代码块,不要同时持有两个对象锁;或者一个线程在等待一段时间后没有取得所有锁,就释放已有锁,等等。
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)
2.13 事件(Event)
事件 Event 主要用于唤醒正在阻塞等待状态的线程。
(图片未上传成功:Pasted-image-20250420194259)
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)
2.14 生产者和消费者模式
(图片未上传成功:Pasted-image-20250420195419)
多线程环境下经常需要多个线程并发协作,这时就要了解一个重要模型——「生产者/消费者模式」。
(图片未上传成功:Pasted-image-20250420195445)
2.14.1 什么是生产者?
生产者指的是负责生产数据的模块(这里模块可能是:方法、对象、线程、进程)。
2.14.2 什么是消费者?
消费者指的是负责处理数据的模块(这里模块可能是:方法、对象、线程、进程)。
2.14.3 什么是缓冲区?
消费者不能直接使用生产者的数据,它们之间有个「缓冲区」。生产者将生产好的数据放入「缓冲区」,消费者从「缓冲区」拿要处理的数据。
缓冲区是实现并发的核心,缓冲区的设置有 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)
3 进程 Process
3.1 什么是进程
(图片未上传成功:Pasted-image-20250419230432_2)
进程(Process):拥有自己独立的堆和栈,既不共享堆,也不共享栈,进程由操作系统调度;进程切换需要的资源最大,效率低。
对于操作系统来说,一个任务就是一个进程(Process),比如打开一个浏览器就是启动一个浏览器进程,打开一个记事本就启动了一个记事本进程,打开两个记事本就启动了两个记事本进程,打开一个 Word 就启动了一个 Word 进程。
(图片未上传成功:Pasted-image-20250419232825_1)
3.2 进程的优缺点
进程的优点:
- 可以使用计算机多核,进行任务的并行执行,提高执行效率
- 运行不受其他进程影响,创建方便
- 空间独立,数据安全
进程的缺点:
- 进程的创建和删除消耗的系统资源较多
3.3 进程的创建方式(方法模式)
Python 的标准库提供了个模块:multiprocessing。
进程的创建可以分为两种方式:
- 方法包装
- 类包装
创建进程后,使用 start() 启动进程。
3.3.1 导入进程包
# 导入进程包
import multiprocessing
3.3.2 Process 进程类的说明
Process([group[, target[, name[, args[, kwargs]]]]])
group:指定进程组,目前只能使用Nonetarget:执行的目标任务名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)
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:指定进程组,目前只能使用Nonetarget:执行的目标任务名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)
3.3.5 小结
- 导入进程包
import multiprocessing
- 创建子进程并指定执行的任务
sub_process = multiprocessing.Process(target=任务名)
- 启动进程执行任务
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)
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()
- 获取进程编号可以查看父子进程的关系
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 进程的注意点介绍
- 进程之间不共享全局变量
- 主进程会等待所有的子进程执行结束再结束
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)
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)
3.8 Pipe 实现进程间通信
(图片未上传成功:Pasted-image-20250421010607)
Pipe 直译过来的意思是「管」或「管道」,和实际生活中的管(管道)是非常类似的。
Pipe 方法返回 (conn1, conn2) 代表一个管道的两个端。
示例:使用 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)
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)
3.10 进程池(Pool)
Python 提供了更好的管理多个进程的方式,就是使用进程池。
(图片未上传成功:Pasted-image-20250421021728)
进程池可以预先提供指定数量的进程供复用:当有新请求提交到进程池时,若池未满,就创建一个新的进程来执行该请求;若池中进程数已达上限,该请求就会等待,一旦池中有进程空闲下来,它便能得到执行。
使用进程池的优点:
- 提高效率,节省开辟进程和开辟内存空间的时间及销毁进程的时间
- 节省内存空间
| 类/方法 | 功能 | 参数 |
|---|---|---|
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)
4 协程 Coroutines
(图片未上传成功:Pasted-image-20250421024200)
4.1 协程是什么
(图片未上传成功:Pasted-image-20250421024231)
协程(Coroutines),也叫纤程(Fiber),全称「协同程序」,用来实现任务协作。它存在于线程之中,比线程更加轻量级,由程序员自己写程序来管理。
当出现 IO 阻塞时,CPU 一直等待 IO 返回,处于空转状态。这时候用协程,可以执行其他任务。当 IO 返回结果后,再回来处理数据。充分利用了 IO 等待的时间,提高了效率。
4.2 一个故事说明进程、线程、协程的关系
(图片未上传成功:Pasted-image-20250419225343_1)
4.3 协程的核心(控制流的让出和恢复)
- 每个协程有自己的执行栈,可以保存自己的执行现场。
- 可以由用户程序按需创建协程(比如:遇到 io 操作)。
- 协程「主动让出(yield)」执行权时候,会保存执行现场(保存中断时的寄存器上下文和栈),然后切换到其他协程。
- 协程恢复执行(resume)时,根据之前保存的执行现场恢复到中断前的状态,继续执行,这样就通过协程实现了轻量的由用户态调度的多任务模型。
4.4 协程和多线程比较
比如,有 3 个任务需要完成,每个任务都在等待 I/O 操作时阻塞自身。阻塞在 I/O 操作上所花费的时间已经用灰色框标示出来了。
(图片未上传成功:Pasted-image-20250421024751)
- 在单线程同步模型中,任务按照顺序执行。如果某个任务因为 I/O 而阻塞,其他所有的任务都必须等待,直到它完成之后它们才能依次执行。
- 多线程版本中,这 3 个任务分别在独立的线程中执行。这些线程由操作系统来管理,在多处理器系统上可以并行处理,或者在单处理器系统上交错执行。这使得当某个线程阻塞在某个资源的同时其他线程得以继续执行。
- 协程版本的程序中,3 个任务交错执行,但仍然在一个单独的线程控制中。当处理 I/O 或者其他昂贵的操作时,注册一个回调到事件循环中,然后当 I/O 操作完成时继续执行。回调描述了该如何处理某个事件。事件循环轮询所有的事件,当事件到来时将它们分配给等待处理事件的回调函数。
4.5 协程的优点
- 由于自身带有上下文和栈,无需线程上下文切换的开销,属于程序级别的切换,操作系统完全感知不到,因而更加轻量级;
- 无需原子操作的锁定及同步的开销;
- 方便切换控制流,简化编程模型;
- 单线程内就可以实现并发的效果,最大限度地利用 CPU,且可扩展性高,成本低(注:一个 CPU 支持上万的协程都不是问题。所以很适合用于高并发处理)。
写爬虫时,
asyncio协程常常是比多线程、多进程更好的选择——开辟新线程和新进程的开销相当大。
4.6 协程的缺点
- 无法利用多核资源:协程的本质是个单线程,它不能同时将单个 CPU 的多个核用上,协程需要和进程配合才能运行在多 CPU 上。
- 当然,日常绝大部分应用都没有这个必要,除非是 CPU 密集型应用。
4.7 使用 yield 实现协程(已淘汰,了解)
Python 中的协程经历了很长的一段发展历程。其大概经历了如下三个阶段:
- 最初的生成器变形
yield/send - 引入
@asyncio.coroutine和yield from - 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 时间吗?不能。
4.8 asyncio 实现协程(重点)
- 正常的函数执行时是不会中断的,所以你要写一个能够中断的函数,就需要加
async。 async用来声明一个函数为异步函数,异步函数的特点是能在函数执行过程中挂起,去执行其他异步函数,等到挂起条件(假设挂起条件是sleep(5))消失后,也就是 5 秒到了再回来执行。await用来声明程序挂起,比如异步程序执行到某一步时需要等待的时间很长,就将此挂起,去执行其他的异步程序。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)
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 系列导航
- 上一篇:12.正则表达式 ★★★
- 下一篇:14.网络编程
- 相关专题:
- 02_Python高级编程(含并发进阶)
- 03_Python网络和并发编程(本笔记的上游超集 / 交叉篇目)
7 参考链接
- Python 官方文档 ·
threading(基于线程的并行,含 GIL 与性能的考量):https://docs.python.org/zh-cn/3/library/threading.html - Python 官方文档 ·
multiprocessing(基于进程的并行,含 spawn / fork 启动方式):https://docs.python.org/zh-cn/3/library/multiprocessing.html - Python 官方文档 ·
asyncio(异步 I/O):https://docs.python.org/zh-cn/3/library/asyncio.html - Python 官方文档 ·
queue(队列,线程安全):https://docs.python.org/zh-cn/3/library/queue.html - Python 官方文档 · 术语表(global interpreter lock / free-threaded 等定义):https://docs.python.org/zh-cn/3/glossary.html
- Python 3.13 新特性(
--disable-gil自由线程构建):https://docs.python.org/zh-cn/3/whatsnew/3.13.html
分类:Python

浙公网安备 33010602011771号