13.并发编程

1 并发编程介绍

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

  • 串行 (serial):一个 CPU 上,按顺序完成多个任务。

  • 并行 (parallelism):对于多核 CPU 处理多任务,操作系统会给 CPU 的每个内核安排一个执行的任务,多个内核是真正的一起执行任务。这里需要注意多核 CPU 是并行的执行多任务,始终有多个任务一起执行。任务数小于等于 CPU 核数,即任务真的是一起执行的。

  • 并发 (concurrency):一个 CPU 采用时间片管理方式,交替的处理多个任务。一般是是任务数多于 CPU 核数,通过操作系统的各种任务调度算法,实现用多个任务“一起”执行(实际上总有一些任务不在执行,因为切换任务的速度相当快,看上去一起执行而已)。

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

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

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

> 乔布斯想开工厂生产手机,费劲力气,制作一条生产线,这个生产线上有很多的器件以及材料。**一条生产线就是一个进程**。
> 只有生产线是不够的,所以找五个工人来进行生产,这个工人能够利用这些材料最终一步步的将手机做出来,**这五个工人就是五个线程**。 > > 为了提高生产率,想到 3 种办法: > > 1. 一条生产线上多招些工人,一起来做手机,这样效率是成倍増长,即单进程多线程方式 > 2. 多条生产线,每个生产线上多个工人,即多进程多线程 > 3. 乔布斯深入一线发现工人不是那么忙,有很多等待时间。于是规定:如果某个员工在等待生产线某个零件生产时 ,不要闲着,干点其他工作。也就是说:如果一个线程等待某些条件,可以充分利用这个时间去做其它事情,**这就是:协程方式**。

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

进程(Process)

进程的介绍

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

进程的概念

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

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

注意
**一个程序运行后至少有一个进程,一个进程默认有一个线程**,进程里面可以创建多个线程,**线程是依附在进程里面的,没有进程就没有线程**。
> 现代操作系统比如 Mac OS,Linux,Windows 等,都是支持“多任务”的操作系统。什么叫“多任务”呢?简单地说,就是操作系统可以同时运行多个任务。打个比方,你一边在用逛淘宝,一边在听音乐,一边在用微信聊天,这就是多任务,至少同时有 3 个任务正在运行。还有很多任务悄悄地在后台同时运行着,只是桌面上没有显示而已。
**多任务效果图:**
> ![](images/13.并发编程/Pasted-image-20250419232507.png)

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

进程的作用

单进程效果图:

多进程效果图:

说明:

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

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

线程(Thread)

线程的介绍

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

线程的概念

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

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

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

多线程可以完成多任务

多线程效果图:

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

并发编程解决方案

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

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

协程(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 再做处理。

同步方式通信:

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

异步方式通信:

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

2 线程 Thread

2.1 什么是线程

线程 (Thread) 特点:

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

2.2 线程的创建方式

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

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

  1. 方法包装
  2. 类包装

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

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

导入线程模块

#导入线程模块
from threading import Thread

线程类 Thread 参数说明

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

启动线程

启动线程使用 start 方法

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

# 方法方式创建线程  
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 打印完成之后。
比如,如下的输出换行就没有按照预想的显示:
主线程,start
thread:t1 :0
thread:t2 :0
主线程,end
thread:t2 :1thread:t1 :1

thread:t2 :2
thread:t1 :2

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()

小结

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

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

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

# 类的方式创建线程  
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")

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

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

假如我们使用线程执行的任务带有参数,如何给函数传参呢?

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

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

其实前面我们使用线程执行的过程中已经使用过了 args 参数。

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
任务执行完成

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
任务执行完成

小结

  • 线程执行任务并传参有两种方式:
    • 元组方式传参 (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')

2.7 守护线程

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

注意
`setDaemon(True|False)` 在 3.10 后被废弃。
> > 使用属性赋值来实现:`t1.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')

2.8 线程的注意点

线程的注意点介绍

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

线程之间执行是无序的

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)

说明:

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

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

假如我们现在创建一个子线程,这个子线程执行完大概需要 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

说明:

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

假如我们就让主线程执行 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

线程之间共享全局变量

需求:

  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]

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

需求:

  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()

执行结果:

sum1: 1210949
sum2: 1496035

注意点:

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

错误分析:

两个线程 first_threadsecond_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_threadfirst_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)

2.9 全局锁 GIL 问题

在 python 中,无论你有多少核,在 CPython 解释器中永远都是假
象。无论你是 4 核,8 核,还是 16 核……。不好意思,同一时间执行的线程只有一个线程,它就是这个样子的。这个是 python 的一个开发时候,设计的一个缺陷,所以说 python 中的线程是“含有水分的线程”。

Python GIL(Global Interpreter Lock)

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

> GIL 并不是 Python 的特性,它是在实现 Python 解析器 (CPython) 时所引入的一个概念,同样一段代码可以通过 CPython,PyPy,Psyco 等不同的 Python 执行环境来执行。在 PyPy,Psyco 等不同的 Python 执行环境中执行就没有 GIL 的问题。然而因为 CPython 是大部分环境下默认的 Python 执行环境。所以在很多人的概念里 CPython 就是 Python,也就想当然的把 GIL 归结为 Python 语言的缺陷
### 2.10 线程同步和互斥锁

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

> 现实生活中,我们会遇到“同一个资源,多个人都想使用”的问题。比如:教室里,只有一台电脑,多个人都想使用。天然的解决办法就是,在电脑旁边,大家排队。前一人使用完后,后一人再使用。再比如,上厕所排队。
![](images/13.并发编程/Pasted-image-20250420180058.png)

线程同步的概念

处理多线程问题时,多个线程访问同一个对象,并且某些线程还想修改这个对象。这时候,我们就需要用到“线程同步”。线程同步其实就是一种等待机制,多个需要同时访问此对象的线程进入这个对象的等待池形成队列,等待前面的线程使用完毕后,下一个线程再使用。

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

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()  # 你老婆取钱

没有线程同步机制,两个线程同时操作同一个账户对象,竟然从只有 100 元的账户,轻松取出 80*2=160 元,账户余额竟然成为了 -60。这么大的问题,显然银行不会答应的。

线程同步的方式

  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()  # 你老婆取钱

互斥锁

互斥锁的概念

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

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

threading 模块中定义了 Lock 变量,这个变量本质上是一个函数,通过调用这个函数可以获取一把互斥锁。

互斥锁使用步骤:

# 创建锁
mutex = threading.Lock()

# 上锁
mutex.acquire()

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

# 释放锁
mutex.release()
注意
- `acquire` 和 `release` 方法之间的代码同一时刻只能有一个线程去操作
> - 如果在调用 `acquire` 方法的时候 其他线程已经使用了这个互斥锁,那么此时 `acquire` 方法会堵塞,直到这个互斥锁释放后才能再次上锁。
使用互斥锁示例

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

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()  # 你老婆取钱

2.11 死锁

死锁的概念

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

为了更好的理解死锁,来看一个现实生活的效果图:

说明:

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

死锁的结果

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

死锁示例

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

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

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()

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

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

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()

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

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()

避免死锁

  • 用互斥锁的时候需要注意死锁的问题,要在合适的地方注意释放锁。
  • 死锁是由于“同步块需要同时持有多个锁造成”的,要解决这个问题,思路很简单,就是:同一个代码块,不要同时持有两个对象锁;或者一个线程在等待一段时间后没有取得所有锁,就释放已有锁。等等

2.12 信号量 (Semaphore)

互斥锁使用后,一个资源同时只有一个线程访问。如果某个资源,我们同时想让 N 个 (指定数值) 线程访问?这时候,可以使用信号量。

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

应用场景

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

底层原理

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

from threading import Thread, Lock  
from time import sleep  
from multiprocessing import Semaphore  
  
"""  
一个房间一次只允许两个人通过  
若不使用信号量,会造成所有人都进入这个房子  
若只允许一人通过可以用锁-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()

2.13 事件 (Event)

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

原理
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()

2.14 生产者和消费者模式

多线程环境下,我们经常需要多个线程的并发和协作。这个时候,就需要了解一个重要的多线程并发协作模型“生产者/消费者模式”。

什么是生产者?

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

什么是消费者?

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

什么是缓冲区?

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

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

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

缓冲区和 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()

3 进程 Process

3.1 什么是进程

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

> 现代操作系统比如 Mac OS,Linux,Windows 等,都是支持“多任务”的操作系统。什么叫“多任务”呢?简单地说,就是操作系统可以同时运行多个任务。打个比方,你一边在用逛淘宝,一边在听音乐,一边在用微信聊天,这就是多任务,至少同时有 3 个任务正在运行。还有很多任务悄悄地在后台同时运行着,只是桌面上没有显示而已。
**多任务效果图:**
> ![](images/13.并发编程/Pasted-image-20250419232507.png)

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

3.2 进程的优缺点

进程的优点:

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

进程的缺点:

  1. 进程的创建和删除消耗的系统资源较多

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

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

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

  1. 方法包装
  2. 类包装

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

导入进程包

#导入进程包
import multiprocessing

Process 进程类的说明

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

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

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

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

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

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

示例:方法模式创建进程

# 方法包装-多进程实现  
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()

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()

进程的创建方式 (继承 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()

小结

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

3.4 获取进程编号

获取进程编号的目的

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

获取进程编号的两种操作

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

获取当前进程编号

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()

执行结果:

获取当前父进程编号

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()

小结

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

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

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

前面我们使用进程执行的任务是没有参数的,假如我们使用进程执行的任务带有参数,如何给函数传参呢?

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

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

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()

执行结果:

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()

执行结果:

小结

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

3.6 进程的注意点

进程的注意点介绍

  1. 进程之间不共享全局变量
  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)  
  
    # 总结: 多进程之间不共享全局变量

执行结果:

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

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

  • 创建子进程会对主进程资源进行拷贝,也就是说子进程是主进程的一个副本,好比是一对双胞胎,之所以进程之间不共享全局变量,是因为操作的不是同一个进程里面的全局变量,只不过不同进程里面的全局变量名字相同而已。

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

假如我们现在创建一个子进程,这个子进程执行完大概需要 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()  
    # 总结: 主进程会等待所有的子进程执行完成以后程序再退出

执行结果:

说明:

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

假如我们就让主进程执行 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')
  
    # 总结: 主进程会等待所有的子进程执行完成以后程序再退出  
    # 如果想要主进程退出子进程销毁,可以设置守护主进程或者在主进程退出之前让子进程销毁

执行结果:

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

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

执行结果:

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

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

3.7 Queue 实现进程间通信

前面讲解了使用 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())

3.8 Pipe 实现进程间通信

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()

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)

3.10 进程池(Pool)

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

进程池可以提供指定数量的进程给用户使用,即当有新的请求提交到进程池中时,如果池未满,则会创建一个新的进程用来执行该请求;反之,如果池中的进程数已经达到规定最大值,那么该请求就会等待,只要池中有进程空闲下来,该请求就能得到执行。

使用进程池的优点:

  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---')

示例:使用 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")

4 协程 Coroutines

4.1 协程是什么

协程,Coroutines,也叫作纤程 (Fiber)

协程,全称是“协同程序”,用来实现任务协作。是一种在线程中,比线程更加轻量级的存在,由程序员自己写程序来管理。

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

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

> 乔布斯想开工厂生产手机,费劲力气,制作一条生产线,这个生产线上有很多的器件以及材料。**一条生产线就是一个进程**。
> 只有生产线是不够的,所以找五个工人来进行生产,这个工人能够利用这些材料最终一步步的将手机做出来,**这五个工人就是五个线程**。 > > 为了提高生产率,想到 3 种办法: > > 1. 一条生产线上多招些工人,一起来做手机,这样效率是成倍増长,即单进程多线程方式 > 2. 多条生产线,每个生产线上多个工人,即多进程多线程 > 3. 乔布斯深入一线发现工人不是那么忙,有很多等待时间。于是规定:如果某个员工在等待生产线某个零件生产时 ,不要闲着,干点其他工作。也就是说:如果一个线程等待某些条件,可以充分利用这个时间去做其它事情,**这就是:协程方式**。

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

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

4.4 协程和多线程比较

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

  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.coroutineyield from
  3. Python3.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秒

示例:使用 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秒,效率差别不大

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

4.8 asyncio 实现协程 (重点)

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

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

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秒

使用 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秒,效率极大提高

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