什么是并发编程
并发编程就是通过代码编程让计算机在一定时间内同时跑多个程序所进行的编程操作,实现让CPU执行多任务,并发编程的目标是充分地利用CPU,以达到最高的处理性能。
多任务的实现有3种方式:
- 进程:是操作系统资源分配和独立运行的最小单位。
- 线程:是进程内的一个任务执行独立单元,是任务调度和系统执行的最小单位。
- 协程:是用户态的轻量级线程,协程的调度完全由用户控制,主要为了单线程下模拟多线程。
什么进程
狭义定义:进程是正在运行的程序的实例(an instance of a computer program that is being executed)。
广义定义:进程是一个具有一定独立功能的程序关于某个数据集合的一次运行活动。它是操作系统动态执行的基本单元,在传统的操作系统中,进程既是基本的分配单元,也是基本的执行单元。
进程(Process)是被CPU运行起来的程序以及相关的资源的统称,是系统进行资源分配和调度的最小单位。
进程与程序的区别
程序是指令、数据及其组织形式的描述(可执行的代码文件),进程是程序的实体。程序本身是没有生命周期的,它只是存在磁盘上的一些指令代码,程序一旦运行就是进程。
进程的组成
进程一般由程序段、数据集、程序控制块三部分组成:
程序段也叫指令集,进程执行过程中需要运行的代码段,是存储在内存中对应进程的程序段中。
数据集也叫数据段,进程执行过程中向操作系统申请分配的所需要使用的资源。程序运行时,使用与产生的运算数据。如全局变量、局部变量等就存放在对应进程的数据集内。
控制块也叫程序控制块(Program Control Block,简称PCB)),用于记录进程的外部特征,描述进程的执行变化过程,操作系统可以利用它来控制和管理进程,是操作系统感知进程存在的唯一标志。创建进程,实质上是创建进程中的进程控制块,而销毁进程,实质上是回收进程中的进程控制块
进程与进程之间分配的计算机资源是相互独立,相互隔离的。
进程的标记
操作系统里每打开一个程序都会创建一个进程ID,即PID(Process Identification),是进程运行时系统分配的,是操作系统用于区分进程的唯一标识符,在进程运行过程中固定不变的,当进程执行任务结束,操作系统会回收进程相关的一切,也包括了PID。同一个程序在运行起来由操作系统创建进程时,每次得到的PID也是可能不一样的。
进程的调度
要想多个进程交替运行,操作系统必须对这些进程进行调度,这个调度也不是随机进行的,而是需要遵循一定的法则,由此就有了进程的调度算法。
FCFS调度算法
是一种最简单的调度算法,该算法既可用于作业调度,也可用于进程调度。FCFS算法比较有利于长作业(进程),而不利于短作业(进程)。由此可知,本算法适合于CPU繁忙型作业,而不利于I/O繁忙型的作业(进程)。
SJF/SPF调度算法
短作业(短进程)优先调度算法(SJF,SPF)是指对短作业或短进程优先调度的算法,该算法既可用于作业调度,也可用于进程调度。但其对长作业不利;不能保证紧迫性作业(进程)被及时处理;作业的长短只是被估算出来的,无法准确预测需要的CPU运行时间(原因有很多,例如:程序中太多 if-else 语句,分支很多,而且出现input,recv等类似的输入操作,因此无法预估所需时间),而且会导致“饥饿”现象(长作业一直得不到调度)。
RR调度算法
时间片轮转调度算法(Round Robin,RR)的基本思路是让每个进程在就绪队列中的等待时间与享受服务的时间成比例。在时间片轮转法中,需要将CPU的处理时间分成固定大小的时间片,例如,几十毫秒至几百毫秒。如果一个进程在被调度选中之后用完了系统规定的时间片,但又未完成要求的任务,则它自行释放自己所占有的CPU而排到就绪队列的末尾,等待下一次调度。同时,进程调度程序又去调度当前就绪队列中的第一个进程。
在轮转法中,时间片长度的选取非常重要。首先,时间片长度的选择会直接影响到系统的开销和响应时间。如果时间片长度过短,则调度程序抢占处理机的次数增多。这将使进程上下文切换次数也大大增加,从而加重系统开销。反过来,如果时间片长度选择过长,例如,一个时间片能保证就绪队列中所需执行时间最长的进程能执行完毕,则轮转法变成了先来先服务法。时间片长度的选择是根据系统对响应时间的要求和就绪队列中所允许最大的进程数来确定的。
在轮转法中,加入到就绪队列的进程有3种情况:
- 分给它的时间片用完,但进程还未完成,回到就绪队列的末尾等待下次调度去继续执行。
- 分给该进程的时间片并未用完,只是因为请求I/O或由于进程的互斥与同步关系而被阻塞。当阻塞解除之后再回到就绪队列。
- 新创建进程进入就绪队列。
MFQ调度算法
多级反馈队列调度算法(Multi-level Feedback Queue,MFQ)属于上面几种算法的折中算法,是目前被公认的最优,最公平的进程/作业调度算法,它不必事先知道各种进程的执行时间,也满足各种类型进程的调度需要
并行、并发与串行
并行(Parallel),是指多个任务作业在同一时间内分别在各个CPU下执行。在多核CPU中才会有并行。
并发 (Concurrent),是指资源有限的情况(单核CPU)下,系统调度只能在同一时间执行一个任务,CPU的控制权在多个任务作业之间来回快速切换,因为CPU切换速度非常的快,所以会造成看起来就像是同时执行了多个任务作业的幻觉。并发在单核CPU或多核CPU都可以存在。并发看起来像是并行,实际是串行。
串行(Serial):是指多个任务作业在同一时间内CPU只能执行一个任务作业,当第一个任务作业完成以后,才轮到第二个任务作业,以此类推。
进程的状态
在实际开发中,往往任务作业的数量要远高于CPU核数,所以在程序运行的过程中,由于被操作系统的调度算法控制,程序会进入以下几个状态:就绪(Ready),运行(Running)和阻塞(Blocking)。
- 就绪状态(Ready),当进程已分配到除CPU以外的所有必要的资源,只要获得CPU资源便可立即执行,这时的进程状态称为就绪状态。
- 执行/运行状态(Running):当进程已获得CPU资源,其程序正在CPU上执行,此时的进程状态称为执行状态。
- 阻塞状态(Blocked):正在执行的进程,由于等待某个IO事件(网络请求,文件读写)发生而无法执行时,便放弃对CPU资源的占用而处于阻塞状态。引起进程阻塞的事件可有多种,例如,等待I/O完成、申请缓冲区不能满足、等待信件(信号)等。
上面提到因为多个进程调度的原因,所以呈现出三种状态,这三种状态的出现也带来了同步、异步、阻塞与非阻塞的概念。
同步与异步、阻塞与非阻塞
首先要清楚,同步和异步是个多个任务处理过程的方式或手段,而阻塞和非阻塞是多个任务处理过程的某个任务的等待状态(往往是因为IO操作带来的阻塞,如网络IO或文件IO)。
同步异步与阻塞非阻塞是两种不同的概念也并不冲突。
同步(Synchronous)
执行A任务时,当A任务产生结果后,B任务可以执行操作,意思是一个任务接着一个任务的执行下来。
异步(Asynchronous)
执行A任务时,当A任务进入等待(挂起)状态后就可以去执行B任务,然后A任务等待(挂起)状态消失后再回来执行A任务的后续操作,意思是多个任务可以交替执行,如果B任务需要A任务的结果,也无需等待A任务执行结束。
阻塞(Blocking)
执行A任务时进入等待(挂起)状态,在这个等待(挂起)状态下CPU不能执行其他的任务操作。也就是CPU不工作了。
非阻塞(Nonblocking)
执行A任务时进入等待(挂起)状态,在这个等待(挂起)状态下,CPU可以执行其他的B任务操作,也就是CPU工作中。
同步与异步和阻塞与非阻塞还可以产生不同的组合:同步阻塞、同步非阻塞、异步阻塞、异步非阻塞。
同步阻塞
就是执行多个任务时,一个接着一个地执行,如果A任务处于等待(挂起)状态时,CPU也老老实实等待着,不能进行其他操作。是四种组合里面效率最低的一种。
异步阻塞形式
就是执行多个任务时,多个任务可以交替执行,但是任意一个任务处于等待(挂起)状态时,CPU也老老实实等待着,不能切换到其他任务操作。当然,如果没有一个任务处于等待(挂起)状态时,CPU就会交替的执行。因为有阻塞的情况出现,所以这个组合根本无法发挥异步的效果,看起来与同步阻塞几乎没什么区别。
同步非阻塞形式
就是执行多个任务时,一个接着一个地执行,如果A任务处于等待(挂起)状态时,CPU则会切换到其他任务B操作,而B任务操作过程中不会进入阻塞状态,当B任务操作结束以后,CPU切换回A任务接着执行,直到A任务结束,CPU接着执行其他任务操作。
异步非阻塞形式
就是执行多个任务时,多个任务可以交替执行,当任意一个任务A处于等待(挂起)状态时,CPU会切换到其他任务B操作中,当A任务等待(挂起)状态消失以后,CPU接着交替执行多个任务,异步非阻塞是我们所追求的最完美形式
python实现
multiprocessing 模块
常用方法
假设p为multiprocessing.Process(target=任务函数/函数方法)的返回值,子进程操作对象。
方法名 | 描述 |
---|---|
p.start() | 启动进程p,并调用该子进程对象p中的run()方法 |
p.run() | 进程p启动时运行的方法,去调用start方法的参数target指定的函数。如果要自定义进程类时一定要实现或重写该方法。 |
p.terminate() | 强制终止进程p,不会进行任何清理操作,如果进程p创建了子进程,该子进程就成了僵尸进程,使用该方法需要特别小心这种情况。如果p还保存了一个锁那么也将不会被释放,进而导致死锁 |
p.is_alive() | 如果进程p仍然运行中,返回True |
p.join([timeout]) | 主进程等待进程p终止(强调:是主进程处于等待的状态,而进程p是处于运行的状态)。timeout是可选的超时时间,需要强调的是,p.join只能join住start开启的进程,而不能join住run开启的进程 |
常用属性
属性名 | 描述 |
---|---|
daemon | 默认值为False,如果设为True,代表进程p为后台运行的守护进程,当进程p的父进程终止时,进程p也随之终止,并且设定为True后,进程p不能创建自己的新进程,daemon属性的值必须在p.start()之前设置 |
name | 进程的名称 |
pid | 进程的唯一标识符 |
创建进程
import os, time
import multiprocessing
def watch():
# print(id(process), process)
print("watch-id=", id(watch))
for i in range(3):
print("看电视....", os.getpid())
time.sleep(1)
if __name__ == '__main__':
# 进程操作对象 = multiprocessing.Process(target=任务)
# 任务可以是一个函数,也可以是一个方法
print(os.getpid())
print("watch-id=", id(watch))
process = multiprocessing.Process(target=watch)
print(id(process), process)
process.start() # 创建进程
"""
13169
看电视.... 13171
看电视.... 13171
看电视.... 13171
"""
windows系统注意事项
import multiprocessing
import os
import time
def watch():
for i in range(3):
print("看电视....", os.getpid())
time.sleep(1)
process = multiprocessing.Process(target=watch)
process.start()
导致上面代码报错的原因:是因为windows中python创建子进程是通过Import导入父进程代码到子进程中实现的子进程创建方式,所以import在导入以后会自动执行被导入模块的代码,因此报错,而linux/macOS下python创建子进程是通过fork系统调用实现的,因为是复制父进程的原因,所以linux下上面的代码没有问题。
解决方案:把创建进程的代码写在 if name == ‘main’:判断语句下面。
import os, time
import multiprocessing
def watch():
# print(id(process), process)
print("watch-id=", id(watch))
print(__name__) # __mp_main__
for i in range(3):
print("看电视....", os.getpid())
time.sleep(1)
if __name__ == '__main__': # __main__
# 进程操作对象 = multiprocessing.Process(target=任务)
# 任务可以是一个函数,也可以是一个方法
print(__name__)
print(os.getpid())
print("watch-id=", id(watch))
process = multiprocessing.Process(target=watch)
print(id(process), process)
process.start() # 创建进程
"""
13169
看电视.... 13171
看电视.... 13171
看电视.... 13171
"""
创建多进程
代码:
import multiprocessing
import os
import time
def watch():
for i in range(3):
print("看电视....", os.getpid())
time.sleep(1)
def drink(food):
for i in range(3):
print(f"喝{food}....",os.getpid())
time.sleep(1)
def eat(food):
for i in range(3):
print(f"吃{food}....", os.getpid())
time.sleep(1)
if __name__ == '__main__':
print("主进程", os.getpid())
watch_process = multiprocessing.Process(target=watch)
drink_process = multiprocessing.Process(target=drink, kwargs={"food": "羊汤"})
eat_process = multiprocessing.Process(target=eat, args=("米饭", ))
watch_process.start()
drink_process.start()
eat_process.start()
target参数也支持传递对象的方法到子进程中,代码:
import multiprocessing
import os
import time
class Humen(object):
def watch(self):
for i in range(3):
print("看电视....", os.getpid())
time.sleep(1)
def drink(self, food):
for i in range(3):
print(f"喝{food}....",os.getpid())
time.sleep(1)
def eat(self, food):
for i in range(3):
print(f"吃{food}....", os.getpid())
time.sleep(1)
if __name__ == '__main__':
xiaoming = Humen()
print("主进程", os.getpid())
watch_process = multiprocessing.Process(target=xiaoming.watch)
drink_process = multiprocessing.Process(target=xiaoming.drink, kwargs={"food": "羊汤"})
eat_process = multiprocessing.Process(target=xiaoming.eat, args=("米饭", ))
watch_process.start()
drink_process.start()
eat_process.start()
继承Process进程类创建进程
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(f'{self.name}子进程运行了')
if __name__ == '__main__':
p1 = MyProcess('1号')
p2 = MyProcess('2号')
p3 = MyProcess('3号')
p1.start() # start会自动调用run
p2.start()
# p2.run()
p3.start()
print('主线程')
进程的结束
- 正常退出(自愿,如用户点击交互式页面的叉号,或程序执行完毕调用发起系统调用正常退出,在linux中用exit,在windows中用ExitProcess)
- 出错退出(自愿,python a.py中a.py不存在)
- 严重错误(非自愿,执行非法指令,如引用不存在的内存,1/0等,可以捕捉异常,try…except…)
- 被其他进程杀死(非自愿,如kill -9 pid)
进程有很多优点,它提供了多道编程技术,让我们感觉每个人都拥有自己的CPU等计算机独立资源,可以提高计算机的利用率。但是,进程也存在着很多不足之处:
- 进程只能在一个时间干一件事,如果想同时干两件事或多件事,进程就无能为力了。
- 进程在执行的过程中如果阻塞,例如等待输入,整个进程就会挂起,即使进程中有些工作不依赖于输入的数据,也将无法执行。
- 操作系统在每个进程的创建、管理、切换、回收等操作上,需要耗费一部分的计算机资源,而如果在多任务场景下,多个进程替换调度比较频繁的话,那么CPU就会浪费大量的资源在切换调度进程与进程的创建和回收等操作上面了。
基于上面的不足,所以又出现了线程的概念。