08-27-周三_17-09-29
This commit is contained in:
994
Python/Python并发编程/进程.md
Normal file
994
Python/Python并发编程/进程.md
Normal file
@@ -0,0 +1,994 @@
|
||||
# 进程
|
||||
|
||||
进程(Process)是计算机中**的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。**在早期面向进程设计的计算机结构中,进程是程序的基本执行实体;在当代面向线程设计的计算机结构中,进程是线程的容器。程序是指令、数据及其组织形式的描述,进程是程序的实体。
|
||||
|
||||
狭义定义:**进程是正在运行的程序的实例**(an instance of a computer program that is being executed)。
|
||||
广义定义:进程是一个具有一定独立功能的程序关于某个数据集合的一次运行活动。它是操作系统动态执行的基本单元,在传统的操作系统中,进程既是基本的分配单元,也是基本的执行单元。
|
||||
|
||||
1. 进程是一个实体。每一个进程都有它自己的地址空间,一般情况下,包括文本区域(text region)、数据区域(data region)和堆栈(stack region)。文本区域存储处理器执行的代码;数据区域存储变量和进程执行期间使用的动态分配的内存;堆栈区域存储着活动过程调用的指令和本地变量。
|
||||
2. 进程是一个“执行中的程序”。程序是一个没有生命的实体,只有处理器赋予程序生命时(操作系统执行之),它才能成为一个活动的实体,我们称其为进程。进程是操作系统中最基本、重要的概念。是多道程序系统出现后,为了刻画系统内部出现的动态情况,描述系统内部各道程序的活动规律引进的一个概念,所有多道程序设计操作系统都建立在进程的基础上。
|
||||
|
||||
# 进程调度方式
|
||||
|
||||
**扩展阅读**
|
||||
|
||||
要想多个进程交替运行,操作系统必须对这些进程进行调度,这个调度也不是随即进行的,而是需要遵循一定的法则,由此就有了进程的调度算法。
|
||||
|
||||
- **先来先服务调度(FCFS, First-Come, First-Served)**
|
||||
|
||||
先来先服务(FCFS)调度算法是一种最简单的调度算法,该算法既可用于作业调度,也可用于进程调度。FCFS算法比较有利于长作业(进程),而不利于短作业(进程)。由此可知,本算法适合于CPU繁忙型作业,而不利于I/O繁忙型的作业(进程)。
|
||||
|
||||
- **短作业优先调度(SJF, Shortest Job First)**
|
||||
|
||||
短作业(进程)优先调度算法(SJ/PF)是指对短作业或短进程优先调度的算法,该算法既可用于作业调度,也可用于进程调度。但其对长作业不利;不能保证紧迫性作业(进程)被及时处理;作业的长短只是被估算出来的。
|
||||
|
||||
- **最高优先级调度(Priority Scheduling)**
|
||||
|
||||
每个进程被赋予一个优先级。系统总是选择优先级最高(数值最小或最大)的进程执行。如果两个进程有相同优先级,则可以按FCFS调度。
|
||||
|
||||
- **时间片轮转调度(Round Robin, RR)**
|
||||
|
||||
时间片轮转(Round Robin,RR)法的基本思路是让每个进程在就绪队列中的等待时间与享受服务的时间成比例。在时间片轮转法中,需要将CPU的处理时间分成固定大小的时间片,例如,几十毫秒至几百毫秒。如果一个进程在被调度选中之后用完了系统规定的时间片,但又未完成要求的任务,则它自行释放自己所占有的CPU而排到就绪队列的末尾,等待下一次调度。同时,进程调度程序又去调度当前就绪队列中的第一个进程。
|
||||
显然,轮转法只能用来调度分配一些可以抢占的资源。这些可以抢占的资源可以随时被剥夺,而且可以将它们再分配给别的进程。CPU是可抢占资源的一种。但打印机等资源是不可抢占的。由于作业调度是对除了CPU之外的所有系统硬件资源的分配,其中包含有不可抢占资源,所以作业调度不使用轮转法。
|
||||
|
||||
在轮转法中,时间片长度的选取非常重要。首先,时间片长度的选择会直接影响到系统的开销和响应时间。如果时间片长度过短,则调度程序抢占处理机的次数增多。这将使进程上下文切换次数也大大增加,从而加重系统开销。反过来,如果时间片长度选择过长,例如,一个时间片能保证就绪队列中所需执行时间最长的进程能执行完毕,则轮转法变成了先来先服务法。时间片长度的选择是根据系统对响应时间的要求和就绪队列中所允许最大的进程数来确定的。
|
||||
|
||||
|
||||
- **多级反馈队列调度(Multilevel Feedback Queue, MLFQ)**
|
||||
|
||||
前面介绍的各种用作进程调度的算法都有一定的局限性。如短进程优先的调度算法,仅照顾了短进程而忽略了长进程,而且如果并未指明进程的长度,则短进程优先和基于进程长度的抢占式调度算法都将无法使用。
|
||||
|
||||
而多级反馈队列调度算法则不必事先知道各种进程所需的执行时间,而且还可以满足各种类型进程的需要,因而它是目前被公认的一种较好的进程调度算法。在采用多级反馈队列调度算法的系统中,调度算法的实施过程如下所述。
|
||||
|
||||
1. 应设置多个就绪队列,并为各个队列赋予不同的优先级。第一个队列的优先级最高,第二个队列次之,其余各队列的优先权逐个降低。该算法赋予各个队列中进程执行时间片的大小也各不相同,在优先权愈高的队列中,为每个进程所规定的执行时间片就愈小。例如,第二个队列的时间片要比第一个队列的时间片长一倍,……,第i+1个队列的时间片要比第i个队列的时间片长一倍。
|
||||
|
||||
2. 当一个新进程进入内存后,首先将它放入第一队列的末尾,按FCFS原则排队等待调度。当轮到该进程执行时,如它能在该时间片内完成,便可准备撤离系统;如果它在一个时间片结束时尚未完成,调度程序便将该进程转入第二队列的末尾,再同样地按FCFS原则等待调度执行;如果它在第二队列中运行一个时间片后仍未完成,再依次将它放入第三队列,……,如此下去,当一个长作业(进程)从第一队列依次降到第n队列后,在第n 队列便采取按时间片轮转的方式运行。
|
||||
|
||||
3. 仅当第一队列空闲时,调度程序才调度第二队列中的进程运行;仅当第1~(i-1)队列均空时,才会调度第i队列中的进程运行。如果处理机正在第i队列中为某进程服务时,又有新进程进入优先权较高的队列(第1~(i-1)中的任何一个队列),则此时新进程将抢占正在运行进程的处理机,即由调度程序把正在运行的进程放回到第i队列的末尾,把处理机分配给新到的高优先权进程。
|
||||
|
||||
# 进程的并行与并发
|
||||
|
||||
**并行**:并行是指两者同时执行,比如赛跑,两个人都在不停的往前跑;(资源够用,比如三个线程,四核的CPU )
|
||||
|
||||
**并发**:并发是指资源有限的情况下,两者交替轮流使用资源,比如一段路(单核CPU资源)同时只能过一个人,A走一段后,让给B,B用完继续给A,交替使用,目的是提高效率。
|
||||
|
||||
**并行**:是从微观上,也就是在一个精确的时间片刻,有不同的程序在执行,这就要求必须有多个处理器。
|
||||
|
||||
**并发**:是从宏观上,在一个时间段上可以看出是同时执行的,比如一个服务器同时处理多个session。
|
||||
|
||||
# 同步异步阻塞非阻塞
|
||||
|
||||
## 进程状态介绍
|
||||
|
||||
<img src="进程/进程状态转换图1.png" alt="img-进程状态转换图1" />
|
||||
|
||||
在了解其他概念之前,我们首先要了解进程的几个状态。在程序运行的过程中,由于被操作系统的调度算法控制,程序会进入几个状态:就绪,运行和阻塞。
|
||||
|
||||
- 就绪(Ready)状态:当进程已分配到除CPU以外的所有必要的资源,只要获得处理机便可立即执行,这时的进程状态称为就绪状态。
|
||||
- 执行/运行(Running)状态:当进程已获得处理机,其程序正在处理机上执行,此时的进程状态称为执行状态。
|
||||
- 阻塞(Blocked)状态:正在执行的进程,由于等待某个事件发生而无法执行时,便放弃处理机而处于阻塞状态。引起进程阻塞的事件可有多种,例如,等待I/O完成、申请缓冲区不能满足、等待信件(信号)等。
|
||||
|
||||
<img src="进程/进程状态转换图2.png" alt="img-进程状态转换图2" style="zoom:80%;" />
|
||||
|
||||
## 同步和异步
|
||||
|
||||
所谓同步就是一个任务的完成需要依赖另外一个任务时,只有等待被依赖的任务完成后,依赖的任务才能算完成,这是一种可靠的任务序列。要么成功都成功,失败都失败,两个任务的状态可以保持一致。
|
||||
|
||||
所谓异步是不需要等待被依赖的任务完成,只是通知被依赖的任务要完成什么工作,依赖的任务也立即执行,只要自己完成了整个任务就算完成了。至于被依赖的任务最终是否真正完成,依赖它的任务无法确定,所以它是不可靠的任务序列。
|
||||
|
||||
## 阻塞与非阻塞
|
||||
|
||||
阻塞和非阻塞这两个概念与程序(线程)等待消息通知(无所谓同步或者异步)时的状态有关。也就是说阻塞与非阻塞主要是程序(线程)等待消息通知时的状态角度来说的
|
||||
|
||||
## 同步/异步与阻塞/非阻塞
|
||||
|
||||
- 同步阻塞形式:效率最低。就是你专心排队,什么别的事都不做。
|
||||
|
||||
- 异步阻塞形式:效率较高。如果在银行等待办理业务的人采用的是异步的方式去等待消息被触发(通知),也就是领了一张小纸条,假如在这段时间里他不能离开银行做其它的事情,那么很显然,这个人被阻塞在了这个等待的操作上面;异步操作是可以被阻塞住的,只不过它不是在处理消息时阻塞,而是在等待消息通知时被阻塞。
|
||||
|
||||
- 同步非阻塞形式:效率低下。想象一下你一边打着电话一边还需要抬头看到底队伍排到你了没有,如果把打电话和观察排队的位置看成是程序的两个操作的话,这个程序需要在这两种不同的行为之间来回的切换,效率可想而知是低下的。
|
||||
|
||||
- 异步非阻塞形式:效率更高。因为打电话是你(等待者)的事情,而通知你则是柜台(消息触发机制)的事情,程序没有在两种不同的操作中来回切换。比如说,这个人突然发觉自己烟瘾犯了,需要出去抽根烟,于是他告诉大堂经理说,排到我这个号码的时候麻烦到外面通知我一下,那么他就没有被阻塞在这个等待的操作上面,自然这个就是异步+非阻塞的方式了。
|
||||
|
||||
很多人会把同步和阻塞混淆,是因为很多时候同步操作会以阻塞的形式表现出来,同样的,很多人也会把异步和非阻塞混淆,因为异步操作一般都不会在真正的IO操作处被阻塞。
|
||||
|
||||
# 进程的创建与结束
|
||||
|
||||
## 进程的创建
|
||||
|
||||
但凡是硬件,都需要有操作系统去管理,只要有操作系统,就有进程的概念,就需要有创建进程的方式,一些操作系统只为一个应用程序设计,比如微波炉中的控制器,一旦启动微波炉,所有的进程都已经存在。
|
||||
|
||||
而对于通用系统(跑很多应用程序),需要有系统运行过程中创建或撤销进程的能力,主要分为4中形式创建新的进程:
|
||||
|
||||
1. 系统初始化(查看进程linux中用ps命令,windows中用任务管理器,前台进程负责与用户交互,后台运行的进程与用户无关,运行在后台并且只在需要时才唤醒的进程,称为守护进程,如电子邮件、web页面、新闻、打印)
|
||||
2. 一个进程在运行过程中开启了子进程(如nginx开启多进程,os.fork,subprocess.Popen等)
|
||||
3. 用户的交互式请求,而创建一个新进程(如用户双击暴风影音)
|
||||
4. 一个批处理作业的初始化(只在大型机的批处理系统中应用)
|
||||
|
||||
无论哪一种,新进程的创建都是由一个已经存在的进程执行了一个用于创建进程的系统调用而创建的。
|
||||
|
||||
## 进程的结束
|
||||
|
||||
1. 正常退出(自愿,如用户点击交互式页面的叉号,或程序执行完毕调用发起系统调用正常退出,在linux中用exit,在windows中用ExitProcess)
|
||||
2. 出错退出(自愿,python a.py中a.py不存在)
|
||||
3. 严重错误(非自愿,执行非法指令,如引用不存在的内存,1/0等,可以捕捉异常,try...except...)
|
||||
4. 被其他进程杀死(非自愿,如kill -9)
|
||||
|
||||
# Python 多进程编程
|
||||
|
||||
我们可以使用 python 中的 **multiprocess** 包来实现多进程编程。
|
||||
|
||||
由于 multiprocess 包提供的子模块非常多,为了方便大家归类记忆,将这部分大致分为四个部分:创建进程部分,进程同步部分,进程池部分,进程之间数据共享。
|
||||
|
||||
## 进程创建
|
||||
|
||||
process 模块是一个创建进程的模块,借助这个模块,就可以完成进程的创建。
|
||||
|
||||
**直接创建**:Process 类 + target 函数
|
||||
|
||||
```python
|
||||
from multiprocessing import Process
|
||||
p = Process(target=func, args=(arg1,))
|
||||
p.start()
|
||||
```
|
||||
|
||||
**继承类创建**:自定义 Process 子类,重写 run() 方法
|
||||
|
||||
```python
|
||||
from multiprocessing import Process
|
||||
class MyProcess(Process):
|
||||
def run(self):
|
||||
print('Hello World!')
|
||||
```
|
||||
|
||||
**代码示例**
|
||||
|
||||
```python
|
||||
# 直接创建
|
||||
from multiprocessing import Process
|
||||
|
||||
def func(name):
|
||||
print("hello %s" % name)
|
||||
print("子进程结束")
|
||||
|
||||
if __name__ == '__main__':
|
||||
p = Process(target=func, args=('nls',)) # 实例化对象:子进程p 传递函数名和函数的实参
|
||||
p.start() # 启动子进程
|
||||
print("主进程结束...")
|
||||
|
||||
|
||||
# 继承类创建
|
||||
import os
|
||||
from multiprocessing import Process
|
||||
|
||||
class MyProcess(Process):
|
||||
def __init__(self,name):
|
||||
super().__init__()
|
||||
self.name=name
|
||||
|
||||
def run(self):
|
||||
print(os.getpid())
|
||||
print('%s 正在和女主播聊天' %self.name)
|
||||
|
||||
if __name__ == '__main__':
|
||||
p1 = MyProcess('张三')
|
||||
p1.start()
|
||||
print('主进程结束...')
|
||||
|
||||
```
|
||||
|
||||
**方法介绍:**
|
||||
|
||||
| 方法 | 含义 |
|
||||
| :----------------- | :------------------------------------------------------------ |
|
||||
| `p.start()` | 启动进程,并调用该子进程中的 `p.run() ` |
|
||||
| `p.run()` | 进程启动时运行的方法,正是它去调用target指定的函数,我们自定义类的类中一定要实现该方法 |
|
||||
| `p.terminate()` | 强制终止进程 p,不会进行任何清理操作,如果 p 创建了子进程,该子进程就成了僵尸进程,使用该方法需要特别小心这种情况。如果 p 还保存了一个锁那么也将不会被释放,进而导致死锁 |
|
||||
| `p.is_alive()` | 如果 p 仍然运行,返回 True |
|
||||
| `p.join([timeout])` | 主线程等待p终止(强调:是主线程处于等的状态,而p是处于运行的状态)。timeout是可选的超时时间,需要强调的是,p.join只能join住start开启的进程,而不能join住run开启的进程 |
|
||||
| `p.daemon()` | 默认值为 False,如果设为 True,代表 p 为后台运行的守护进程,当p的父进程终止时,p 也随之终止,并且设定为 True 后,p 不能创建自己的新进程,必须在p.start()之前设置 |
|
||||
| `p.name()` | 进程的名称 |
|
||||
| `p.pid()` | 进程的 pid |
|
||||
| `p.exitcode()` | 进程在运行时为None、如果为–N,表示被信号N结束 |
|
||||
| `p.authkey()` | 进程的身份验证键,默认是由os.urandom()随机生成的32字符的字符串。这个键的用途是为涉及网络连接的底层进程间通信提供安全性,这类连接只有在具有相同的身份验证键时才能成功 |
|
||||
|
||||
注意:在Windows操作系统中由于没有fork(linux操作系统中创建进程的机制),在创建子进程的时候会自动 import 启动它的这个文件,而在 import 的时候又执行了整个文件。因此如果将 process() 直接写在文件中就会无限递归创建子进程报错。所以必须把创建子进程的部分使用 `if __name__ =='__main__'` 判断保护起来,import 的时候,就不会递归运行了。
|
||||
|
||||
### join
|
||||
|
||||
正常情况下,是主进程先执行结束,然后等待子进程执行结束以后,整个程序退出。如果在 start 了以后使用 join,那么将会阻塞(也可以理解为同步)主进程,等子进程结束以后,主进程才能继续执行。
|
||||
|
||||
```python
|
||||
from multiprocessing import Process
|
||||
|
||||
def func(name):
|
||||
print("hello %s" % name)
|
||||
print("子进程")
|
||||
|
||||
if __name__ == '__main__':
|
||||
p = Process(target=func,args=('nls',))
|
||||
p.start()
|
||||
p.join() # 阻塞等待完成
|
||||
print("主程序")
|
||||
|
||||
```
|
||||
|
||||
### 查看进程号
|
||||
|
||||
我们可以通过 os 模块中提供的 getpid 的方法来获取当前进程的进程号
|
||||
|
||||
```python
|
||||
import os
|
||||
from multiprocessing import Process
|
||||
|
||||
def func():
|
||||
print('子进程id:',os.getpid(),'父进程id:',os.getppid())
|
||||
print("子进程结束")
|
||||
|
||||
if __name__ == '__main__':
|
||||
p = Process(target=func,args=())
|
||||
p.start()
|
||||
print("主进程id:",os.getpid())
|
||||
print("主程序结束,等待子进程结束中...")
|
||||
```
|
||||
|
||||
由此我们可以看出,在子进程中查看他的父进程的id号等同于我们在主进程中查看到的id号,可以说明子进程确实是由我们的主进程创建的。
|
||||
|
||||
### 多进程实例
|
||||
|
||||
多个进程同时运行(注意,子进程的执行顺序不是根据启动顺序决定的)
|
||||
|
||||
```python
|
||||
from multiprocessing import Process # 从 multiprocessing 包中导入 Process 模块
|
||||
import time
|
||||
|
||||
def func(name): # 创建一个函数,当作一个任务
|
||||
print("hello %s" % name)
|
||||
time.sleep(1)
|
||||
print("子进程结束")
|
||||
|
||||
if __name__ == '__main__':
|
||||
for i in range(5):
|
||||
p = Process(target=func, args=('nls',))
|
||||
p.start()
|
||||
print("主程序结束,等待子进程....")
|
||||
```
|
||||
|
||||
使用join方法
|
||||
|
||||
```python
|
||||
from multiprocessing import Process # 从multiprocessing包中导入Process模块
|
||||
import time
|
||||
|
||||
def func(name): # 创建一个函数,当作一个任务
|
||||
print("hello %s" % name)
|
||||
time.sleep(1)
|
||||
print("子进程结束")
|
||||
|
||||
if __name__ == '__main__':
|
||||
for i in range(5):
|
||||
p = Process(target=func, args=('nls',))
|
||||
p.start()
|
||||
p.join()
|
||||
print("主程序结束,等待子进程....")
|
||||
|
||||
```
|
||||
|
||||
发现,如果使用了join方法后,子进程变成了顺序执行,每个子进程结束以后,下一个子进程才能开始。同一时刻,只能由一个子进程执行,变成了一种阻塞的方式。
|
||||
|
||||
|
||||
**代码示例**:
|
||||
|
||||
```python
|
||||
import multiprocessing
|
||||
import time
|
||||
|
||||
# 定义子进程执行的函数
|
||||
def worker(num):
|
||||
print(f"进程 {num} 开始工作")
|
||||
time.sleep(2)
|
||||
print(f"进程 {num} 工作结束")
|
||||
|
||||
if __name__ == '__main__':
|
||||
processes = []
|
||||
|
||||
# 创建并启动 3 个进程
|
||||
for i in range(3):
|
||||
p = multiprocessing.Process(target=worker, args=(i,))
|
||||
processes.append(p)
|
||||
p.start()
|
||||
|
||||
# 等待所有进程完成
|
||||
for p in processes:
|
||||
p.join()
|
||||
|
||||
print("所有进程完成,主进程退出...")
|
||||
|
||||
```
|
||||
|
||||
|
||||
### 守护进程
|
||||
|
||||
随着主进程的结束而结束,主进程创建守护进程,进程之间是互相独立的,主进程代码运行结束,守护进程随即终止。
|
||||
|
||||
1. 守护进程会在主进程代码执行结束后就终止
|
||||
2. 守护进程内无法再开启子进程,否则抛出异常
|
||||
|
||||
**示例代码**
|
||||
|
||||
```python
|
||||
from multiprocessing import Process
|
||||
import time
|
||||
|
||||
def foo():
|
||||
print(123)
|
||||
time.sleep(1)
|
||||
print("end123") # 父进程代码执行结束,所以这里不会输出
|
||||
|
||||
def bar():
|
||||
print(456)
|
||||
time.sleep(3)
|
||||
print("end456")
|
||||
|
||||
if __name__ == '__main__':
|
||||
p1=Process(target=foo)
|
||||
p2=Process(target=bar)
|
||||
|
||||
p1.daemon=True # 设置为守护进程
|
||||
p1.start()
|
||||
p2.start()
|
||||
time.sleep(0.1)
|
||||
print("main-------")
|
||||
```
|
||||
|
||||
### socket 聊天并发实例
|
||||
|
||||
**示例代码**
|
||||
|
||||
```python
|
||||
# 服务端
|
||||
import socket
|
||||
import multiprocessing
|
||||
|
||||
def handle_client(conn, addr):
|
||||
"""
|
||||
子进程处理客户端连接的函数
|
||||
[优化点]:添加异常处理防止僵尸进程
|
||||
"""
|
||||
print(f"客户端 {addr} 已连接")
|
||||
try:
|
||||
while True:
|
||||
data = conn.recv(1024)
|
||||
if not data: # 客户端主动断开连接
|
||||
break
|
||||
print(f"接收自 {addr} 的数据: {data.decode()}")
|
||||
conn.sendall(f"服务端响应: {data.decode().upper()}".encode())
|
||||
except ConnectionResetError:
|
||||
print(f"客户端 {addr} 异常断开")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # 端口复用
|
||||
server.bind(("127.0.0.1", 8888))
|
||||
server.listen(5)
|
||||
print("服务器已启动,等待连接...")
|
||||
|
||||
try:
|
||||
while True:
|
||||
conn, addr = server.accept()
|
||||
# 创建子进程处理连接
|
||||
process = multiprocessing.Process(
|
||||
target=handle_client,
|
||||
args=(conn, addr),
|
||||
daemon=True, # 设置守护进程防止僵尸进程
|
||||
)
|
||||
process.start()
|
||||
conn.close() # 主进程关闭连接副本
|
||||
except KeyboardInterrupt:
|
||||
print("\n服务器正在关闭...")
|
||||
finally:
|
||||
server.close()
|
||||
|
||||
# 客户端 - 超简版
|
||||
import socket
|
||||
|
||||
client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
client.connect(("127.0.0.1", 8888))
|
||||
|
||||
while True:
|
||||
msg = input(">>: ").strip()
|
||||
if not msg:
|
||||
continue
|
||||
|
||||
client.send(msg.encode("utf-8"))
|
||||
msg = client.recv(1024)
|
||||
print(msg.decode("utf-8"))
|
||||
|
||||
```
|
||||
|
||||
## 进程间数据共享
|
||||
|
||||
在 Python 中,由于进程间内存空间相互独立,直接共享数据需借助特定机制。
|
||||
|
||||
**示例代码**:
|
||||
|
||||
```python
|
||||
from multiprocessing import Process
|
||||
import os
|
||||
|
||||
# 全局变量
|
||||
count = 0
|
||||
|
||||
def increment():
|
||||
global count
|
||||
count += 1
|
||||
print(f"子进程 {os.getpid()} 修改后的 count: {count}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
# 创建两个子进程
|
||||
p1 = Process(target=increment)
|
||||
p2 = Process(target=increment)
|
||||
|
||||
p1.start()
|
||||
p2.start()
|
||||
p1.join()
|
||||
p2.join()
|
||||
|
||||
print(f"主进程中的 count: {count}")
|
||||
|
||||
```
|
||||
|
||||
### 原生共享内存方案(Value/Array)
|
||||
|
||||
适合共享简单数据类型(如整数、浮点数)或数组。通过底层共享内存实现,无需复制数据,性能较高。
|
||||
|
||||
**代码示例**:
|
||||
|
||||
```python
|
||||
from multiprocessing import Process, Value, Array
|
||||
|
||||
def increment(num):
|
||||
num.value += 1
|
||||
|
||||
if __name__ == '__main__':
|
||||
counter = Value('i', 0) # 'i' 表示整数类型
|
||||
arr = Array('d', [0.0, 1.0, 2.0]) # 'd' 表示双精度浮点数
|
||||
processes = [Process(target=increment, args=(counter,)) for _ in range(5)]
|
||||
for p in processes:
|
||||
p.start()
|
||||
for p in processes:
|
||||
p.join()
|
||||
print(counter.value) # 数据共享,但数据不一致
|
||||
```
|
||||
|
||||
### 进程同步机制
|
||||
|
||||
共享数据时需通过锁(Lock)、信号量(Semaphore)等保证数据一致性。
|
||||
|
||||
**示例代码**:
|
||||
|
||||
```python
|
||||
from multiprocessing import Process, Value, Lock
|
||||
|
||||
# 同步可避免多进程同时修改数据导致的错误
|
||||
def safe_increment(num, lock):
|
||||
with lock: # 自动获取和释放锁: lock.acquire() 和 lock.release()
|
||||
num.value += 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
counter = Value("i", 0)
|
||||
lock = Lock() # 创建对象
|
||||
|
||||
processes = [Process(target=safe_increment, args=(counter, lock)) for _ in range(5)]
|
||||
for p in processes:
|
||||
p.start()
|
||||
for p in processes:
|
||||
p.join()
|
||||
print(counter.value)
|
||||
|
||||
```
|
||||
|
||||
### Manager 代理对象方案
|
||||
|
||||
适合共享复杂数据结构(如字典、列表)。通过代理模式,由 Manager 服务进程管理数据,子进程通过代理访问。
|
||||
|
||||
`Manager` 提供了一种方式来创建可以在多个进程之间共享的对象。`Manager` 允许不同的进程通过代理对象来共享数据结构,包括列表、字典、命名空间等,而无需显式的进程间通信机制(如队列或管道)。`Manager` 实现了进程间的同步机制,确保多个进程可以安全地读写共享数据。
|
||||
|
||||
`multiprocessing.Manager` 提供了一个管理器对象,这个管理器可以生成各种共享对象,如列表、字典、队列、锁等。所有这些对象都可以被不同的进程安全地访问和修改。
|
||||
|
||||
**共享对象类型**:
|
||||
|
||||
- `list`:共享的列表。
|
||||
- `dict`:共享的字典。
|
||||
- `Namespace`:共享的命名空间,允许存储任意属性。
|
||||
- `Queue`:共享的队列,用于进程间通信。
|
||||
- `Lock`:锁,用于进程同步,防止数据竞争。
|
||||
|
||||
**基本使用流程**
|
||||
|
||||
1. 从 `multiprocessing.Manager()` 创建管理器对象。
|
||||
2. 使用管理器对象来创建共享数据结构(如 `list`、`dict` 等)。
|
||||
3. 在多个进程中共享这些数据结构。
|
||||
4. 进程完成后,关闭管理器对象。
|
||||
|
||||
**示例代码**:
|
||||
|
||||
```python
|
||||
from multiprocessing import Manager, Process
|
||||
|
||||
def update_dict(shared_dict, key):
|
||||
shared_dict[key] = key * 2
|
||||
|
||||
if __name__ == '__main__':
|
||||
with Manager() as manager:
|
||||
shared_dict = manager.dict()
|
||||
processes = [Process(target=update_dict, args=(shared_dict, i)) for i in range(3)]
|
||||
for p in processes:
|
||||
p.start()
|
||||
for p in processes:
|
||||
p.join()
|
||||
print(shared_dict)
|
||||
|
||||
```
|
||||
|
||||
### 共享内存高级方案
|
||||
|
||||
|
||||
## 进程间通信
|
||||
|
||||
IPC(Inter-Process Communication)
|
||||
|
||||
在计算机系统中,进程是操作系统分配资源的基本单位,每个进程拥有独立的内存空间和资源。由于进程间的内存隔离,进程间通信成为实现多进程协作的关键技术。
|
||||
|
||||
队列和管道都是将数据存放于内存中,队列又是基于(管道+锁)实现的,可以让我们从复杂的锁问题中解脱出来,我们应该尽量避免使用共享数据,尽可能使用消息传递和队列,避免处理复杂的同步和锁问题,而且在进程数目增多时,往往可以获得更好的可扩展性。
|
||||
|
||||
### 管道
|
||||
|
||||
点对点通信,返回两个连接对象。
|
||||
|
||||
**使用方式**
|
||||
|
||||
```python
|
||||
from multiprocessing import Pipe
|
||||
|
||||
conn1, conn2 = Pipe()
|
||||
conn1.send("Hello")
|
||||
print(conn2.recv())
|
||||
```
|
||||
|
||||
### 队列
|
||||
|
||||
安全传递数据,支持多生产者和消费者。
|
||||
|
||||
**使用方式**
|
||||
|
||||
```python
|
||||
from multiprocessing import Queue
|
||||
|
||||
q = Queue([maxsize]) # 创建共享的进程队列,maxsize 是队列中允许的最大项数,默认为大小限制。
|
||||
|
||||
```
|
||||
|
||||
**常见方法**
|
||||
|
||||
| 方法 | 含义 |
|
||||
| :------- | :--------|
|
||||
| `q.get(block=True, timeout=None)`| 返回q中的一个项目。如果q为空,此方法将阻塞,直到队列中有项目可用为止。block用于控制阻塞行为,默认为True. 如果设置为False,将引发Queue.Empty异常(定义在Queue模块中)。timeout是可选超时时间,用在阻塞模式中。如果在制定的时间间隔内没有项目变为可用,将引发Queue.Empty异常。|
|
||||
| `q.get_nowait()`| 同q.get(False)方法。|
|
||||
| `q.put(obj, block=True, timeout=None)`| 将obj放入队列。如果队列已满,此方法将阻塞至有空间可用为止。block控制阻塞行为,默认为True。如果设置为False,将引发Queue.Empty异常(定义在Queue库模块中)。timeout指定在阻塞模式中等待可用空间的时间长短。超时后将引发Queue.Full异常。|
|
||||
| `q.qsize()` | 返回队列中目前项目的正确数量。此函数的结果并不可靠,因为在返回结果和在稍后程序中使用结果之间,队列中可能添加或删除了项目。在某些系统上,此方法可能引发NotImplementedError异常。|
|
||||
| `q.empty()` | 如果调用此方法时 q为空,返回True。如果其他进程或线程正在往队列中添加项目,结果是不可靠的。也就是说,在返回和使用结果之间,队列中可能已经加入新的项目。 |
|
||||
| `q.full()` | 如果q已满,返回为True. 由于线程的存在,结果也可能是不可靠的(参考q.empty()方法)。|
|
||||
| `q.close()` | 关闭队列,防止队列中加入更多数据。调用此方法时,后台线程将继续写入那些已入队列但尚未写入的数据,但将在此方法完成时马上关闭。如果q被垃圾收集,将自动调用此方法。关闭队列不会在队列使用者中生成任何类型的数据结束信号或异常。例如,如果某个使用者正被阻塞在get()操作上,关闭生产者中的队列不会导致get()方法返回错误。|
|
||||
| `q.cancel_join_thread()` | 不会再进程退出时自动连接后台线程。这可以防止join_thread()方法阻塞。|
|
||||
| `q.join_thread()` | 连接队列的后台线程。此方法用于在调用q.close()方法后,等待所有队列项被消耗。默认情况下,此方法由不是q的原始创建者的所有进程调用。调用q.cancel_join_thread()方法可以禁止这种行为。|
|
||||
|
||||
|
||||
**代码示例**
|
||||
|
||||
```python
|
||||
from multiprocessing import Queue
|
||||
q=Queue(3)
|
||||
|
||||
q.put('1')
|
||||
q.put('2')
|
||||
q.put('3')
|
||||
# q.put(3) # 队列已满,阻塞方式:等待队列中 get('旧数据')
|
||||
|
||||
try:
|
||||
q.put_nowait('4') # 非阻塞方式:但会抛出异常
|
||||
except:
|
||||
print('队列已经满了')
|
||||
|
||||
print(q.full())
|
||||
|
||||
print(q.get())
|
||||
print(q.get())
|
||||
print(q.get())
|
||||
# print(q.get()) # 队列已空,阻塞方式:等待队列中 put('新数据')
|
||||
|
||||
try:
|
||||
q.get_nowait() # 非阻塞方式:但会抛出异常
|
||||
except:
|
||||
print('队列已经空了')
|
||||
|
||||
print(q.empty())
|
||||
```
|
||||
|
||||
我们可以使用队列,是的进程和进程之间的数据能够交换,比如某个进程用于产生数据,某个进程用于拿去数据。这样,进程和进程之间就可以通信了。
|
||||
|
||||
### 案例分析1
|
||||
|
||||
定义了两个进程,一个用于产生数据,一个用于消费数据,使用队列进行数据交换。
|
||||
|
||||
```python
|
||||
from multiprocessing import Process, Queue
|
||||
import time
|
||||
|
||||
|
||||
def func_put(q):
|
||||
for i in range(3):
|
||||
q.put(f"数据{i+1}")
|
||||
|
||||
|
||||
def func_get(q):
|
||||
time.sleep(1)
|
||||
while True:
|
||||
try:
|
||||
print(f"GET到数据:{q.get_nowait()}")
|
||||
except Exception:
|
||||
print("数据已经全部拿走")
|
||||
break
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
q = Queue()
|
||||
p_put = Process(target=func_put, args=(q,))
|
||||
p_get = Process(target=func_get, args=(q,))
|
||||
p_put.start()
|
||||
p_put.join()
|
||||
|
||||
p_get.start()
|
||||
p_get.join()
|
||||
|
||||
```
|
||||
|
||||
### 案例分析2
|
||||
|
||||
多个进程计算并通过队列返回结果
|
||||
|
||||
```python
|
||||
import multiprocessing
|
||||
import time
|
||||
|
||||
|
||||
def calculate_square(num, queue):
|
||||
result = num * num
|
||||
print(
|
||||
f"进程 {multiprocessing.current_process().name} 计算 {num} 的平方,结果是: {result}"
|
||||
)
|
||||
# multiprocessing.current_process().name 获取当前进程的名称,便于调试和输出。
|
||||
time.sleep(1)
|
||||
queue.put(result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
numbers = [1, 2, 3, 4, 5]
|
||||
queue = multiprocessing.Queue()
|
||||
processes = []
|
||||
|
||||
for num in numbers:
|
||||
p = multiprocessing.Process(target=calculate_square, args=(num, queue))
|
||||
processes.append(p)
|
||||
p.start()
|
||||
|
||||
for p in processes:
|
||||
p.join()
|
||||
|
||||
results = []
|
||||
|
||||
while not queue.empty():
|
||||
results.append(queue.get())
|
||||
|
||||
print(f"所有进程计算结果: {results}")
|
||||
print("主进程结束...")
|
||||
|
||||
```
|
||||
|
||||
### 生产者消费者模型
|
||||
|
||||
在并发编程中使用生产者和消费者模式能够解决绝大多数并发问题。该模式通过平衡生产线程和消费线程的工作能力来提高程序的整体处理数据的速度。
|
||||
|
||||
**为什么要使用生产者和消费者模式**
|
||||
在线程世界里,生产者就是生产数据的线程,消费者就是消费数据的线程。在多线程开发当中,如果生产者处理速度很快,而消费者处理速度很慢,那么生产者就必须等待消费者处理完,才能继续生产数据。同样的道理,如果消费者的处理能力大于生产者,那么消费者就必须等待生产者。为了解决这个问题于是引入了生产者和消费者模式。
|
||||
|
||||
**什么是生产者消费者模式**
|
||||
生产者消费者模式是通过一个容器来解决生产者和消费者的强耦合问题。生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取,阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力。
|
||||
|
||||
|
||||
**示例代码**:
|
||||
|
||||
```python
|
||||
from multiprocessing import Process, Queue, current_process
|
||||
import time
|
||||
import random
|
||||
import os
|
||||
|
||||
def consumer(q):
|
||||
while True:
|
||||
res = q.get()
|
||||
if res is None: break # 接收结束信号
|
||||
time.sleep(random.randint(1, 3))
|
||||
print(f"进程 {current_process().name} 吃 {res}")
|
||||
|
||||
def producer(q):
|
||||
for i in range(10):
|
||||
time.sleep(random.randint(1, 3)) # 恢复生产者延时
|
||||
res = f"包子{i}"
|
||||
q.put(res)
|
||||
print(f"进程 {current_process().name} 生产了 {res}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
q = Queue()
|
||||
|
||||
# 生产者进程
|
||||
producers = [Process(target=producer, args=(q,)) for _ in range(1)]
|
||||
# 消费者进程
|
||||
consumers = [Process(target=consumer, args=(q,)) for _ in range(10)]
|
||||
|
||||
# 启动所有进程
|
||||
for p in producers + consumers:
|
||||
p.start()
|
||||
|
||||
# 等待生产者完成
|
||||
for p in producers:
|
||||
p.join()
|
||||
|
||||
# 发送毒丸信号(每个消费者一个)
|
||||
for _ in range(len(consumers)):
|
||||
q.put(None)
|
||||
|
||||
# 等待消费者完成
|
||||
for c in consumers:
|
||||
c.join()
|
||||
|
||||
```
|
||||
|
||||
**JoinableQueue([maxsize])**
|
||||
|
||||
创建可连接的共享进程队列。这就像是一个 Queue 对象,但队列允许项目的使用者通知生产者项目已经被成功处理。通知进程是使用共享的信号和条件变量来实现的。
|
||||
|
||||
| 方法 | 含义 |
|
||||
| :----- | :----- |
|
||||
| `q.task_done()` | 消费者使用此方法发出信号,表示 q.get() 返回的结果已经被处理。如果调用此方法的次数大于从队列中删除的结果数量,将引发 ValueError 异常。|
|
||||
| `q.join() ` | 生产者将使用此方法进行阻塞,直到队列中所有项目均被处理。阻塞将持续到为队列中的每个项目均调用 q.task_done() 方法为止。|
|
||||
|
||||
**示例代码**:
|
||||
|
||||
```python
|
||||
from multiprocessing import Process, JoinableQueue, current_process
|
||||
import random
|
||||
import time
|
||||
|
||||
def consumer(q):
|
||||
while True:
|
||||
res = q.get() # 阻塞
|
||||
time.sleep(random.randint(1, 3))
|
||||
print(f"进程 {current_process().name} 吃 {res}")
|
||||
q.task_done() # 每调用一次,队列内部计数器减
|
||||
|
||||
def producer(q):
|
||||
for i in range(10):
|
||||
time.sleep(random.randint(1, 3))
|
||||
res = f"包子{i}"
|
||||
q.put(res)
|
||||
print(f"进程 {current_process().name} 生产了 {res}")
|
||||
q.join() # 阻塞直到计数器归零,确保所有任务被处理
|
||||
|
||||
if __name__ == "__main__":
|
||||
q = JoinableQueue()
|
||||
|
||||
# 生产者进程
|
||||
producers = [Process(target=producer, args=(q,)) for _ in range(1)]
|
||||
# 消费者进程:主进程结束后,守护进程自动终止,避免无限阻塞
|
||||
consumers = [Process(target=consumer, args=(q,), daemon=True) for _ in range(10)]
|
||||
|
||||
# 启动所有进程
|
||||
for p in producers + consumers:
|
||||
p.start()
|
||||
|
||||
# 等待生产者完成
|
||||
for p in producers:
|
||||
p.join()
|
||||
|
||||
# 等待队列任务处理完毕
|
||||
print("所有任务已完成,程序正常退出")
|
||||
|
||||
```
|
||||
|
||||
|
||||
## 进程池
|
||||
|
||||
进程池(multiprocessing.Pool)是预先创建并管理一组子进程的技术,用于高效处理批量任务。通过复用固定数量的进程,避免频繁创建/销毁进程的开销,提升 CPU 密集型任务的性能。
|
||||
|
||||
- 那么在成千上万个任务需要被执行的时候,我们就需要去创建成千上万个进程么?首先,创建进程需要消耗时间,销毁进程也需要消耗时间。第二即便开启了成千上万的进程,操作系统也不能让他们同时执行,这样反而会影响程序的效率。因此我们不能无限制的根据任务开启或者结束进程。
|
||||
|
||||
- 定义一个池子,在里面放上固定数量的进程,有需求来了,就拿一个池中的进程来处理任务,等到处理完毕,进程并不关闭,而是将进程再放回进程池中继续等待任务。如果有很多任务需要执行,池中的进程数量不够,任务就要等待之前的进程执行任务完毕归来,拿到空闲进程才能继续执行。也就是说,池中进程的数量是固定的,那么同一时间最多有固定数量的进程在运行。这样不会增加操作系统的调度难度,还节省了开闭进程的时间,也一定程度上能够实现并发效果。
|
||||
|
||||
- `multiprocessing.Pool` 是 `multiprocessing` 模块中的一个非常有用的工具,用于管理进程池(Pool of Processes)。它允许你并行地执行函数,并且可以轻松地分配多个任务到多个进程中执行,从而提高程序的执行效率。`Pool` 使得多进程编程的管理变得更加容易,尤其是在需要并行处理大量数据时。
|
||||
|
||||
### 基本概念
|
||||
|
||||
`Pool` 是进程的集合,用于执行并行任务。它提供了一种简化的接口来并行执行多个任务,将任务分配给多个进程并管理它们的执行。
|
||||
|
||||
**进程池的好处**:
|
||||
- 通过限制并发进程的数量,可以有效地管理资源消耗。
|
||||
- 可以自动调度和分配任务到多个进程。
|
||||
- 提供了多种方法(如 `apply`、`map`、`apply_async`、`map_async`)来调度任务并收集结果。
|
||||
|
||||
**创建进程池**:
|
||||
|
||||
```python
|
||||
from multiprocessing import Pool
|
||||
|
||||
# 创建包含4个子进程的进程池(默认值为CPU核心数)
|
||||
pool = Pool(processes=4)
|
||||
```
|
||||
|
||||
**关闭进程池**:
|
||||
|
||||
```python
|
||||
pool.close() # 停止接收新任务
|
||||
pool.join() # 阻塞主进程,等待所有子进程结束
|
||||
```
|
||||
|
||||
### 提交任务
|
||||
|
||||
**代码示例**:同步阻塞方式
|
||||
|
||||
```python
|
||||
from multiprocessing import Pool
|
||||
import time
|
||||
|
||||
|
||||
def task(x):
|
||||
time.sleep(1) # 模拟耗时操作
|
||||
return x * x
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
start = time.time()
|
||||
with Pool(4) as pool:
|
||||
results = []
|
||||
for i in range(4):
|
||||
res = pool.apply(task, (i,)) # 同步提交,逐个执行
|
||||
results.append(res)
|
||||
print(f"结果:{results},耗时:{time.time()-start:.2f}秒")
|
||||
```
|
||||
|
||||
**代码示例**:异步非阻塞方式
|
||||
|
||||
```python
|
||||
from multiprocessing import Pool
|
||||
import time
|
||||
|
||||
def task(x):
|
||||
time.sleep(1)
|
||||
return x * x
|
||||
|
||||
if __name__ == '__main__':
|
||||
start = time.time()
|
||||
with Pool(4) as pool: # 自动调用 pool.close() 和 pool.join()
|
||||
async_results = [pool.apply_async(task, (i,)) for i in range(4)] # 异步提交任务
|
||||
results = [res.get() for res in async_results] # 阻塞直到所有结果返回
|
||||
print(f"结果:{results},耗时:{time.time()-start:.2f}秒")
|
||||
|
||||
```
|
||||
|
||||
**示例代码**:批量处理
|
||||
|
||||
```python
|
||||
from multiprocessing import Pool
|
||||
import time
|
||||
|
||||
|
||||
def task(x):
|
||||
time.sleep(1)
|
||||
return x * x
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
start = time.time()
|
||||
with Pool(4) as pool:
|
||||
results = pool.map(task, range(4)) # 批量提交
|
||||
print(f"结果:{results},耗时:{time.time()-start:.2f}秒")
|
||||
|
||||
```
|
||||
|
||||
### 回调函数
|
||||
|
||||
需要回调函数的场景:进程池中任何一个任务一旦处理完了,就立即告知主进程:我好了,你可以处理我的结果了。主进程则调用一个函数去处理该结果,该函数即回调函数。
|
||||
|
||||
我们可以把耗时间(阻塞)的任务放到进程池中,然后指定回调函数(主进程负责执行),这样主进程在执行回调函数时就省去了I/O的过程,直接拿到的是任务的结果。
|
||||
|
||||
在 Python 的多进程编程中,apply_async 的回调函数(callback)是一种异步处理任务结果的机制,它允许在子进程完成任务后自动触发特定逻辑,而无需阻塞主进程。
|
||||
|
||||
**回调函数的执行机制**:
|
||||
|
||||
- 运行环境:回调函数在**主进程**中执行,而非子进程。这意味着:回调函数内无法直接操作子进程的变量或资源。回调中应避免耗时操作,否则会阻塞主进程。
|
||||
|
||||
- 参数传递:回调函数默认接收**任务的返回值**作为唯一参数。若需传递额外参数,可通过闭包或全局变量实现。
|
||||
|
||||
**示例代码**:
|
||||
|
||||
```python
|
||||
from multiprocessing import Pool
|
||||
|
||||
def square(x):
|
||||
return x * x
|
||||
|
||||
def collect_result(result, result_list):
|
||||
result_list.append(result)
|
||||
|
||||
if __name__ == '__main__':
|
||||
with Pool(4) as pool:
|
||||
results = []
|
||||
# 提交10个任务并绑定回调
|
||||
for i in range(10):
|
||||
pool.apply_async(square, (i,), callback=lambda r: collect_result(r, results))
|
||||
pool.close()
|
||||
pool.join()
|
||||
print("最终结果:", sorted(results))
|
||||
```
|
||||
|
||||
### 案例分析
|
||||
|
||||
实时爬取网页内容并存储至本地文件。
|
||||
|
||||
```python
|
||||
from multiprocessing import Pool
|
||||
import requests
|
||||
import os
|
||||
import time
|
||||
|
||||
|
||||
def get_page(url):
|
||||
print("<进程%s> get %s" % (os.getpid(), url))
|
||||
respone = requests.get(url)
|
||||
if respone.status_code == 200:
|
||||
return {"url": url, "text": respone.text}
|
||||
|
||||
|
||||
def pasrse_page(res):
|
||||
print("<进程%s> parse %s" % (os.getpid(), res["url"]))
|
||||
parse_res = "url:<%s> size:[%s]\n" % (res["url"], len(res["text"]))
|
||||
with open("db.txt", "a") as f:
|
||||
f.write(parse_res)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
start = time.time()
|
||||
urls = [
|
||||
"https://www.baidu.com",
|
||||
"https://www.python.org",
|
||||
"https://www.openstack.org",
|
||||
"http://www.sina.com.cn/",
|
||||
]
|
||||
|
||||
with Pool(4) as pool:
|
||||
# 提交任务并绑定回调
|
||||
async_results = [pool.apply_async(get_page, (i,), callback=pasrse_page) for i in urls]
|
||||
results = [res.get() for res in async_results] # 获取结果
|
||||
print(f"耗时:{time.time()-start:.2f}秒")
|
||||
|
||||
```
|
||||
|
||||
# 课后作业
|
||||
|
||||
- [必须] 动手完成本章节案例
|
||||
- [扩展] 阅读官方文档相关章节
|
||||
|
Reference in New Issue
Block a user