1、初识并发编程
1.1、串行,并行,并发
串行(serial):一个cpu上按顺序完成多个任务;
并行(parallelism):任务数小于或等于cup核数,多个任务是同时执行的;
并发(concurrency):一个CPU采用时间片管理方式,交替的处理多个任务。一般是任务数多余cpu核数,通过操作系统的各种任务调度算法,实现用多个任务一起执行(实际上总有一些任务不在执行,因为切换任务的速度相当快,看上去一起执行而已)。
1.2、进程,线程,协程
通过案例了解概念:
进程:我的工厂有一条生产线,这条生产线就是一个进程。
线程:在我工厂的一条生产线上,我安排了五名工人在生产线上工作,五名工人就是五个线程。
一条生产线上有多名工人就是单进程多线程。
多个生产线上有多名工人就是多进程多线程。
协程:作为一个资本家,我看到工人在生产任务较轻的时候休息是不能接受的,于是我规定当生产线没有任务时,就去帮工厂打扫卫生,即如果一个线程等待某些条件,可以充分利用这个时间去做其它事情,这就是协程。
进程,线程和协程的总结:
①、线程是程序执行的最小单位,而进程是操作系统分配资源的最小单位;
②、一个进程由一个或多个线程组成,线程是一个进程中代码的不同执行路线;
③、进程之间相互独立,但同一进程下的各个线程之间共享程序的内存空间(包括代码段、数据集、堆等)及一些进程级的资源(如打开文件和信号),某进程内的线程在其它进程不可见;
④、调度和切换:线程上下文切换比进程上下文切换要快得多;
⑤、进程(Process):拥有自己独立的堆和栈,既不共享堆,也不共享栈,进程由操作系统调度;进程切换需要的资源很最大,效率低(是一个具有一定独立功能的程序关于某个数据集合的一次运行活动);
⑥、线程(Thread):拥有自己独立的栈和共享的堆,共享堆,不共享栈,标准线程由操作系统调度;线程切换需要的资源一般,效率一般(线程是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位);
⑦、协程coroutine):拥有自己独立的栈和共享的堆,共享堆,不共享栈,协程由程序员在协程的代码里显示调度;协程切换任务资源很小,效率高(协程是一种在线程中,比线程更加轻量级的存在,由程序员自己写程序来管理。)。
1.3、同步,异步
概述:同步和异步强调的是消息通信机制 (synchronous communication/ asynchronous communication)。
同步:A调用B,等待B返回结果后,A继续执行。
异步:A调用B,A继续执行,不等待B返回结果;B有结果了,通知A,A再做处理。
2、线程(Thread)
特点:
①、是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位;
②、线程是程序执行的最小单位,而进程是操作系统分配资源的最小单位;
③、一个进程由一个或多个线程组成,线程是一个进程中代码的不同执行路线;
④、拥有自己独立的栈和共享的堆,共享堆,不共享栈,标准线程由操作系统调度;
⑤、调度和切换:线程上下文切换比进程上下文切换要快得多。
概述:Python的标准库提供了两个模块:_thread和threading,_thread是低级模块,threading是高级模块(threading是对_thread的封装),大多数情况下,我们只需要使用threading这个高级模块。
线程的创建方式:
①、方法包装
②、类包装
提示:线程的执行统一通过start()方法。
2.1、通过方法包装创建线程
概述:
①、通过方法包装创建线程时,线程通过Thread()方法进行创建,Thread()方法有两个参数,参数1为target,表示线程开启后执行哪个方法(方法不要带小括号,带小括号表示运行(或者说叫调用)某个函数,不带小括号表示存储了某个函数的地址),参数2为args,表示执行target指定的函数时传入的参数(args的类型是元组)。
②、线程通过start()方法开启,只创建线程而不开启线程,线程是不会运行的。
实操:通过方法包装的方式创建两个线程,线程开启后都打印信息(为了让程序慢速运行,引入sleep()函数),通过实例观察多个线程间是如何运行的。
from threading import Thread
from time import sleep
# 线程触发后运行的函数
def fun1(name):
print(f"线程{name},start")
for i in range(5):
print(f"线程{name}正在打印")
# 降低程序运行的速度
sleep(1)
print(f"线程{name},end")
# 程序的入口
if __name__ == '__main__':
print("主线程,start")
# 创建线程,target表示线程开启后执行哪个方法,args表示执行方法传入的参数(args的类型是元组)
t1 = Thread(target=fun1,args=("t1",))
t2 = Thread(target=fun1,args=("t2",))
# 启动线程
t1.start()
t2.start()
print("主线程,end")
运行结果如下:
案例总结:①、主线程,线程t1,线程t2这三个线程是相互独立的,即使主线程结束其他线程仍然能继续运行。
②、由于只有一个cpu来处理,所以不同的线程要抢夺同一个打印流进行打印,导致打印顺序较乱。
2.2、类包装创建线程
概述:类包装创建线程指创建一个实例对象,实例对象对应的类继承了Thread,线程运行后执行指定类中的run函数(这里是对父类run函数的重写),指定类中的构造方法还要调用Thread的构造方法,调用指定函数进行线程创建时,参数的个数取决于指定类中__init__除self外参数的个数。
from threading import Thread
from time import sleep
# 传入的参数一定是Thread
class myThread(Thread):
def __init__(self,name):
# 调用Thread的构造方法
Thread.__init__(self)
self.name = name
# 函数名不能修改,这是对原有实例方法进行重写
def run(self):
print(f"线程{self.name},start")
for i in range(5):
print(f"线程{self.name}正在打印")
# 降低程序运行的速度
sleep(1)
print(f"线程{self.name},end")
if __name__ == '__main__':
print("主线程,start")
# 创建线程
t1 = myThread("t1")
t2 = myThread("t2")
# 启动线程
t1.start()
t2.start()
print("主线程,end")
运行结果如下:
2.3、join()和守护线程
2.3.1、join()
概述:在前面的实操中可以发现,主线程,t1线程和t2线程都作为独立线程,主线程提前结束也不会影响其他线程的运行,但是如果现在有需求让主线程等待其他线程结束后再结束主线程,此时就可以用到join()方法。
实操:创建两个线程,分别为t3和t4,指定主线程要等待t3进程结束再结束主线程。
from threading import Thread
from time import sleep
def fun2(name):
for i in range(3):
print(f"{name}线程start")
sleep(1)
print(f"{name}线程end")
if __name__ == '__main__':
print("主线程开启")
# 创建线程(创建线程时不要省略参数的指定,不如会报错)
t3 = Thread(target=fun2,args=("t3",))
t4 = Thread(target=fun2,args=("t4",))
# 开启线程
t3.start()
t4.start()
# 指定主线程在特定线程结束后结束
t3.join()
print("主线程结束")
运行结果如下(为了突出运行结果,这里我选了一个极端的结果,由于t3和t4运行的是同样的程序,运行速度完成看两个线程谁抢打印流抢得快,t4有时会在t3结束前结束)
2.3.2、守护线程
概述:有一种线程叫守护线程,主要的特征是它的生命周期随着主线程的死亡而死亡(由于这种特征导致守护线程一般都是为主线程服务)。在python中,线程通过setDaemon(True|False)方法或daemon属性赋值为True来设置是否为守护线程。
实操:通过类包装创建两个名为t5,t6的线程,当主线程结束时,t5也会自动结束(将t5设置为守护线程)。
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(5):
print(f"{self.name}线程start")
print(f"{self.name}线程end")
if __name__ == '__main__':
print("主线程开启")
# 通过类包装创建线程
t5 = myThread("t5")
t6 = myThread("t6")
# 指定t5为守护线程
t5.daemon = True
# t5.setDaemon(True)
# 运行线程
t5.start()
t6.start()
print("主线程结束")
运行结果如下:
注意:
①、需要在线程开启前设置守护线程;
②、线程正在执行某些耗时的操作或者等待某些资源释放时,导致线程不能立即终止(可以在run函数中的循环中加入如下语句sleep(1),就会惊奇的发现守护进程不会被回收)。
2.4、全局解释器锁GIL问题
概述:在python中,无论你有多少核,在Cpython解释器中永远都是假象,同一时间只能执行一个进程,这是python设计的一个缺陷,所以说python中的线程是"含有水分的线程"。
GIL(Global Interpreter Lock):
概述:Python代码的执行由Python 虚拟机(也叫解释器主循环,CPython版本)来控制,Python 在设计之初就考虑到要在解释器的主循环中,同时只有一个线程在执行,即在任意时刻,只有一个线程在解释器中运行。对Python 虚拟机的访问由全局解释器锁(GIL)来控制,正是这个锁能保证同一时刻只有一个线程在运行。
注意:GIL并不是Python的特性,它是在实现Python解析器(CPython)时所引入的一个概念,同样一段代码可以通过CPython,PyPy,Psyco等不同的Python执行环境来执行,就没有GIL的问题。然而因为CPython是大部分环境下默认的Python执行环境。所以在很多人的概念里CPython就是Python,也就想当然的把GIL归结为Python语言的缺陷。
2.5、线程同步和互斥锁
2.5.1、线程同步
概述:处理多线程问题时,多个线程访问同一个对象,并且某些线程还想修改这个对象。 这时候,我们就需要用到“线程同步”。 线程同步其实就是一种等待机制,多个需要同时访问此对象的线程进入这个对象的等待池形成队列,等待前面的线程使用完毕后,下一个线程再使用。
实操:模拟一个取款案例,创建两个类,第一个类为账户类Account,第二个类为取款类Withdraw,模拟两个人同时对一个账户进行取款的操作,打印取款金额和账户余额。
# 模拟取款案例
from threading import Thread
from time import sleep
# 定义账户类
class Account:
def __init__(self,name,money):
self.name = name
self.money = money
# 定义取款类
class Withdraw(Thread):
def __init__(self,account,drawNum:int):
Thread.__init__(self)
self.account = account
self.drawNum = drawNum
# 定义一个属性用于统计取出的总数
self.totalNum = 0
def run(self):
# 判断取款金额是否大于账户余额
if self.drawNum>self.account.money:
return "账户余额不足"
# 这里暂停一秒是为了让线程阻塞,产生线程冲突问题
sleep(1)
self.account.money = self.account.money-self.drawNum
self.totalNum += self.drawNum
print(f"本次{self.account.name}的取款金额为{self.totalNum}")
print(f"{self.account.name}的余额为{self.account.money}")
if __name__ == '__main__':
# 创建一个账户
muxikeqi = Account("muxikeqi",200)
# 创建两个线程模拟两个人取同一个账户的钱
boy1 = Withdraw(muxikeqi,200)
boy2 = Withdraw(muxikeqi,200)
# 开启线程
boy1.start()
boy2.start()
运行结果如下:
结果分析:导致账户余额为负数的主要原有就是sleep(1)语句,两个线程都判断账户余额是否大于取款金额,满足就睡眠1秒,由于线程之间是单线程运行,所以会存在先后顺序,导致两个取款人都成功进入后面的取款操作,进而导致取款两次。要解决这个问题,可以通过"锁机制"来实现线程同步问题。
2.5.2、互斥锁
概述:互斥锁是对共享数据进行锁定,保证同一时刻只能有一个线程去操作。
要点:
①、必须使用同一个锁对象。
②、互斥锁的作用就是保证同一时刻只能有一个线程去操作共享数据,保证共享数据不会出现错误问题。
③、使用互斥锁的好处确保某段关键代码只能由一个线程从头到尾完整地去执行。
④、使用互斥锁会影响代码的执行效率。
⑤、同时持有多把锁,容易出现死锁的情况。
注意: 互斥锁是多个线程一起去抢,抢到锁的线程先执行,没有抢到锁的线程需要等待,等互斥锁使用完释放后,其它等待的线程再去抢这个锁。
知识点补充:
①、threading模块中定义了Lock变量,这个变量本质上是一个函数,通过调用这个函数可以获取一把互斥锁。
②、acquire和release方法之间的代码同一时刻只能有一个线程去操作。
③、如果在调用acquire方法的时候 其他线程已经使用了这个互斥锁,那么此时acquire方法会堵塞,直到这个互斥锁释放后才能再次上锁。
实操:为2.5.1小节中的取款案例添加互斥锁,实现线程同步。
# 为取款案例添加互斥锁实现线程同步
from threading import Thread, Lock
from time import sleep
class Account:
def __init__(self,username,money):
self.username = username
self.money = money
class Withdraw(Thread):
def __init__(self,account,drawNum):
Thread.__init__(self)
self.account = account
self.drawNum = drawNum
self.totalMoney = 0
def run(self):
# 这里表示锁开始的地方,不同的线程从这里开始排队
myLock.acquire()
# 判断取款金额是否大于账户余额
if self.drawNum > self.account.money:
print("余额不足")
return "余额不足"
sleep(2)
self.account.money -= self.drawNum
self.totalMoney += self.drawNum
print(f"本次{self.account.username}账户的取款金额为{self.totalMoney}")
print(f"{self.account.username}的余额为{self.account.money}")
# 这里表示锁结束的地方,到这里锁就释放并重新生成让其他排队的线程去抢
myLock.release()
if __name__ == '__main__':
# 创建一个账户
muxikeqi = Account("muxikeqi",200)
# 创建两个线程模拟两个人同时取一个账户
boy1 = Withdraw(muxikeqi,100)
boy2 = Withdraw(muxikeqi,200)
# 创建锁
myLock = Lock()
# 开启线程
boy1.start()
boy2.start()
运行结果如下:
2.6、死锁
概述:在多线程程序中,死锁问题很大一部分是由于一个线程同时获取多个锁造成的。
解决办法:同一个代码块,不要同时持有两个对象锁。
实操(了解,看懂代码就行):假设在进行一场大逃杀类的游戏,你和敌人走进一个房间发现了一把手枪和一发子弹,用两把锁模拟你们抢武器的场景。
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()
运行结果如下(程序会一直运行,进入死锁状态):
2.7、信号量(Semaphore)
概述:互斥锁使用后,一个资源同时只有一个线程访问。但如果我们想同时让N个线程访问某个资源时,就可以使用信号量。信号量用于控制同时访问资源的数量。信号量和锁相似,锁同一时间只允许一个对象(进程)通过,信号量同一时间允许多个对象(进程)通过。
应用场景:
①、读写文件的时候,一般只能只有一个线程在进行写操作,但读操作可以有多个线程同时进行,如果需要限制同时读文件的线程个数,这时候就可以用到信号量(如果用互斥锁,就是限制同一时刻只能有一个线程读取文件)。
②、爬虫抓取数据时。
原理:信号量底层就是一个内置的计数器。每当资源获取时(调用acquire)计数器-1,当计数器数值为零时其他线程就无法进入,资源释放时(调用release)计数器+1。
实操:现有一个1VS1的游戏对局房间,每次只能有两名玩家进入,利用线程中的信号量实现该游戏房间的玩家数量控制。
from threading import Thread,Semaphore
from time import sleep
def game(name , se):
se.acquire()
print(f"{name}进入对局")
sleep(2)
print(f"{name}离开对局")
se.release()
if __name__ == '__main__':
# 创建信号量对象
se = Semaphore(2)
# 通过循环创建多个线程并启动
for i in range(5):
t =Thread(target=game,args=(f"测试人机P{i}",se))
t.start()
运行结果如下:
易错点提醒:
①、不要导错包,信号量使用的是threading模块中的semaphore。
②、注意看演示代码,不要拼写错误。
2.8、事件(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 |
实操:现有三名运动员准备100米比赛,三名运动员到达起跑地点后要等待裁判法令后才能开始起跑,等到裁判一声令下所有运动员都起跑,利用线程中的事件实现这一场景。
import threading
import time
from threading import Event,Thread
from time import sleep
def run(name, event):
print(f"{name}进入起跑点")
sleep(1)
event.wait()
print(f"{name}起跑了")
if __name__ == '__main__':
# 创建事件对象
event = threading.Event()
# 创建三个线程模拟三个运动员
for i in range(3):
t = Thread(target=run,args=(f"运动员{i+1}",event))
t.start()
time.sleep(4)
print("预备,跑!")
event. Set()
运行结果如下:
2.9、生产者消费者模式
生产者:指的是负责生产数据的模块(这里模块可能是:方法、对象、线程、进程)。
消费者:指的是负责处理数据的模块(这里模块可能是:方法、对象、线程、进程)
缓冲区:消费者不能直接使用生产者的数据,它们之间有个"缓冲区"。生产者将生产好的数据放入"缓冲区",消费者从"缓冲区"拿要处理的数据。
设置缓冲区的优点 (缓冲区是实现并发的核心):
①、实现线程的并发协作:有了缓冲区以后,生产者线程只需要往缓冲区里面放置数据,而不需要管消费者消费的情况;同样,消费者只需要从缓冲区拿数据处理即可,也不需要管生产者生产的情况。 这样,就从逻辑上实现了“生产者线程”和“消费者线程”的分离。
②、解耦了生产者和消费者:生产者不需要和消费者直接打交道。
③、解决忙闲不均,提高效率:生产者生产数据慢时,缓冲区仍有数据,不影响消费者消费;消费者处理数据慢时,生产者仍然可以继续往缓冲区里面放置数据
缓冲区和queue对象:从一个线程向另一个线程发送数据最安全的方式可能就是使用 queue 库中的队列了。创建一个被多个线程共享的 Queue 对象,这些线程通过使用 put() 和 get() 操作向队列中添加或者删除元素。Queue 对象已经包含了必要的锁,所以可以通过它在多个线程间安全地共享数据。
实操:现有一家初创公司经营主机组装工作,当库存少于10台时就组织组装新的主机,但由于市场疲软,因此买家购买速度会低于公司的生产速度。
from queue import Queue
from threading import Thread
from time import sleep
def producer():
# 用num变量来计数
num = 1
while True:
# qsize()方法用于查询缓冲区中有多少条数据
if queue.qsize() < 5:
print(f"生产编号{num}的主机")
queue.put(f"编号{num}主机")
num+=1
else:
print("货源充足,无需补货")
sleep(1)
def coustomer():
while True:
print(f"购买编号{queue.get()}的主机")
sleep(3)
if __name__ == '__main__':
# 由于生产者和消费者都要使用到缓冲区,因此将其放在测试代码中
# 创建队列对象
queue = Queue()
# 创建两个线程分别代表生产者和消费者
t_producter = Thread(target=producer)
t_customer = Thread(target=coustomer)
# 启动线程
t_producter.start()
t_customer.start()
运行结果如下:
3、进程(Process)
概述:
①、进程拥有自己独立的堆和栈,既不共享堆,也不共享栈,进程由操作系统调度;进程切换需要的资源很最大,效率低。
②、对于操作系统来说,一个任务就是一个进程(Process),比如打开一个浏览器就是启动一个浏览器进程。
优点:
①、可以使用计算机多核,进行任务的并行执行,提高执行效率。
②、运行不受其他进程影响,创建方便。
③、空间独立,数据安全。
缺点:进程的创建和删除消耗的系统资源较多。
3.1、创建进程
概述:Python的标准库提供了模块multiprocessing进行进程的创建。
创建方式:
①、方法包装
②、类包装
提示:和线程一样,所有进程都需要通过start方法启动。
3.1.1、方法包装创建进程
实操:创建两个进程,进程触发后就调用fun1函数,打印主进程id和两个子进程的id及子进程所属父进程的id。
import os
from multiprocessing import Process
from time import sleep
def fun1(name):
print(f"进程{name}start")
sleep(1)
print(f"进程{name}的id:{os.getpid()}")
print(f"进程{name}的父进程ID:{os.getppid()}")
print(f"进程{name}end")
# 创建进程时,如果不加__main__的限制,就会无限制的创建子进程导致报错(这是windows中的bug)
if __name__ == '__main__':
print(f"当前进程id:{os.getpid()}")
# 创建进程
p1 = Process(target=fun1,args=("p1",))
p2 = Process(target=fun1,args=("p2",))
# 启动进程
p1.start()
p2.start()
运行结果如下:
3.1.2、类包装创建进程
概述:通过类包装创建进程就是使用 Process 类创建实例化对象,其本质是调用该类的构造方法创建新进程。
实操:通过类包装的方式创建两个进程,并打印两个进程的生命周期。
from multiprocessing import Process
from time import sleep
class ProcessTest(Process):
def __init__(self,name):
Process.__init__(self)
self.name = name
def run(self):
print(f"{self.name},start")
sleep(2)
print(f"{self.name},end")
if __name__ == '__main__':
# 创建进程的实例对象
p1 = ProcessTest("p1")
p2 = ProcessTest("p2")
# 启动进程
p1.start()
p2.start()
运行结果如下:
3.2、queue队列实现进程通信
概述:在2.9小节的练习中使用queue模块中的 Queue 类实现线程间通信,但要实现进程间通信,需要使用 multiprocessing 模块中的 Queue 类。
原理:queue 实现进程间通信的方式,就是使用操作系统给开辟的一个队列空间,各个进程可以把数据放到该队列中,也可以从队列中把自己需要的信息取走。
实操:通过queue队列实现进程通信,主线程中为队列添加多条数据,子线程开启后拿到数据并打印获取的数据,子线程获取完数据后再为队列添加任意一条数据,再在主线程中获取子线程为队列添加的数据,并且要保证子线程运行结束后再结束主线程。
from multiprocessing import Process, Queue
from time import sleep
class myProcess(Process):
def __init__(self,name,mq):
Process.__init__(self)
self.name = name
self.mq = mq
def run(self):
print(f"进程{self.name},start")
# 子进程获取队列中的数据
print(f"{self.name}获取数据{self.mq.get()}")
sleep(1)
# 为队列添加新的数据(由于有三个进程,所以这里会为队列添加三条数据)
self.mq.put("i'm new data")
print(f"进程{self.name},end")
if __name__ == '__main__':
print("主线程start!")
# 创建队列对象(队列的特性是先进先出)
mq = Queue()
# 通过变量num控制创建多少个进程和为队列添加多少条数据
num = 3
# 为队列添加数据
for i in range(num):
mq.put(i+1)
# 创建一个空列表保存创建的进程
p_list = []
# 通过循环创建多个进程
for i in range(num):
# 创建进程时要传入队列对象,否则无法获取到队列中的内容
p = myProcess(f"p{i+1}",mq)
p_list.append(p)
# 启动进程
p.start()
# 主线程等待子线程结束(join方法只能加入已启动的方法)
p.join()
print(f"获取队列中新加入的数据:{mq.get()}")
print("主线程end!")
运行结果如下:
3.3、管道(Pipe)实现进程通信
概述:Pipe方法返回(conn1, conn2)代表一个管道的两个端。Pipe方法有duplex参数,如果duplex参数为True(默认值),那么这个参数是全双工模式,也就是说conn1和conn2均可收发。若duplex为False,conn1只负责接收消息,conn2只负责发送消息。send和recv方法分别是发送和接受消息的方法。例如,在全双工模式下,可以调用conn1.send发送消息,conn1.recv接收消息。如果没有消息可接收,recv方法会一直阻塞。如果管道已经被关闭,那么recv方法会抛出EOFError。
实操:通过该案例了解如何通过管道实现进程的通信。
import multiprocessing
from multiprocessing import Process
from time import sleep
def fun1(name,conn1):
# 准备要发送的数据
info = "消息1"
print(f"进程{name}发送数据:{info}")
# 发送数据
conn1.send(info)
sleep(1)
print(f"{name}接收到管道另一端的消息:{conn1.recv()}")
def fun2(name,conn2):
# 准备要发送的数据
info = "消息2"
print(f"进程{name}发送数据:{info}")
# 发送数据
conn2.send(info)
sleep(1)
print(f"{name}接收到管道另一端的消息:{conn2.recv()}")
if __name__ == '__main__':
# 创建管道
conn1,conn2 = multiprocessing.Pipe()
# 创建子线程
p1 = Process(target=fun1,args=("p1",conn1))
p2 = Process(target=fun2,args=("p2",conn2))
# 启动子线程
p1.start()
p2.start()
运行结果如下:
3.4、Manager管理器实现进程通信
概述:管理器提供了一种创建共享数据的方法,从而可以在不同进程中共享。
注意:Manager管理器使用完后记得关闭,和操作文件的open()函数有点类似。
实操:在主线程中打开资源管理器,并为资源管理器创建一个列表和一个字典,然后为列表中添加任意数据,再创建一个子线程,要求主线程在子线程结束后结束,在子线程中获取主线程为资源管理器中的列表添加的数据并为资源管理器中的字典和列表添加任意数据,最后在主线程中获取子线程为资源管理器添加的数据。
from multiprocessing import Manager, Process
def func(name,m_list,m_dict):
# 在子进程中获取资源管理器中的列表数据
print(f"子进程获取到的资源管理器中列表数据如下:{m_list}")
# 在子进程中为资源管理器中的字典和列表添加数据
m_dict["name"] = "muxikeqi"
m_list.append("新加入的内容")
if __name__ == '__main__':
with Manager() as m:
# 资源管理器中的列表
m_list = m.list()
# 资源管理器中的字典
m_dict = m.dict()
# 在主进程中为资源管理器中的列表添加数据
m_list.append("info1")
# 将主进程中资源管理器的数据传入子进程,让子进程使用
p1 = Process(target=func,args=("myUsername",m_list,m_dict))
# 启动子进程
p1.start()
# 主进程等待子进程p1结束后结束
p1.join()
# 主进程获取子进程中为资源管理器添加的内容
print("主进程获取的m_list:",m_list)
print("主进程获取的m_dict:",m_dict)
运行结果如下:
3.5、进程池(Pool)
概述:Python提供了更好的管理多个进程的方式,就是使用进程池。进程池可以提供指定数量的进程给用户使用,即当有新的请求提交到进程池中时,如果池未满,则会创建一个新的进程用来执行该请求;反之,如果池中的进程数已经达到规定最大值,那么该请求就会等待,只要池中有进程空闲下来,该请求就能得到执行。
优点:①、提高效率,节省开辟进程和开辟内存空间的时间及销毁进程的时间。
②、节省内存空间。
类/方法 | 功能 | 参数 |
---|---|---|
Pool(processes) | 创建进程池对象 | processes表示进程池中有多少进程 |
pool.apply_async(func,args,kwds) | 异步执行 ;将事件放入到进程池队列 | func 事件函数 args 以元组形式给func传参kwds 以字典形式给func传参 返回值:返回一个代表进程池事件的对象,通过返回值的get方法可以得到事件函数的返回值 |
pool.apply(func,args,kwds) | 同步执行;将事件放入到进程池队列 | func 事件函数 args 以元组形式给func传参 kwds 以字典形式给func传参 |
pool.close() | 关闭进程池 | |
pool.join() | 回收进程池 | |
pool.map(func,iter) | 类似于python的map函数,将要做的事件放入进程池 | func 要执行的函数 iter 迭代对象 |
实操:创建一个线程池,线程池中包含3个线程用于执行指定任务,再给出任意个数的任务让线程池去执行,任务要包括多个函数的任务和单个函数的任务方便参考,并且主线程在子线程全部结束后再结束。
import os # import os
from multiprocessing import Pool
from time import sleep
def fun1(name):
print(f"进程{name}id:{os.getpid()}正在执行fun1")
sleep(2)
return name
def fun2(name):
print(f"进程{name}id:{os.getpid()}正在执行fun2")
sleep(2)
if __name__ == '__main__':
print("主进程start!")
# 创建线程池对象
pool = Pool(3)
# 某一个进程先执行fun1,执行完fun1函数后立即去执行fun2函数
pool.apply_async(func=fun1,args=("p1",),callback=fun2)
pool.apply_async(func=fun1,args=("p2",),callback=fun2)
pool.apply_async(func=fun1,args=("p3",), callback=fun2)
# 某一个进程执行fun1函数,执行完就看是否有其他任务需要执行
pool.apply_async(func=fun1,args=("p4",))
pool.apply_async(func=fun1,args=("p5",))
pool.apply_async(func=fun2,args=("p6",))
# 关闭线程池
pool.close()
# 主线程等待线程池结束后再结束
pool.join()
print("主线程end!")
运行结果如下:
实操2:利用函数式编程实现实操1中类似的效果。
import os
from multiprocessing import Pool
from time import sleep
def fun1(name):
print(f"进程{name}id:{os.getpid()}正在执行fun1")
sleep(2)
return name
if __name__ == '__main__':
# 使用with进行管理,不用管关闭线程池的问题
with Pool(3) as pool:
args = pool.map(fun1,("p1","p2","p3","p4","p5","p6"))
print("执行的任务包括:")
for arg in args:
print(arg,end="\t")
运行结果如下:
4、协程(Coroutines)
4.1、初识协程
概述:①、协程也叫作纤程(Fiber),全称是“协同程序”,用来实现任务协作。是一种在线程中,比线程更加轻量级的存在,由程序员自己写程序来管理(进程和线程都有操作系统来管理)。
②、当出现IO阻塞时,CPU一直等待IO返回,处于空转状态。这时候用协程,可以执行其他任务。当IO返回结果后,再回来处理数据。充分利用了IO等待的时间,提高了效率。
协程的核心(控制流的让出和恢复)
①、每个协程有自己的执行栈,可以保存自己的执行现场;
②、可以由用户程序按需创建协程(比如:遇到io操作);
③、协程“主动让出(yield)”执行权时候,会保存执行现场(保存中断时的寄存器上下文和栈),然后切换到其他协程;
④、协程恢复执行(resume)时,根据之前保存的执行现场恢复到中断前的状态,继续执行,这样就通过协程实现了轻量的由用户态调度的多任务模型。
协程和多线程的比较:(将设要完成三个任务)
①、在单线程同步模型中,任务按照顺序执行。如果某个任务因为I/O而阻塞,其他所有的任务都必须等待,直到它完成之后它们才能依次执行。
②、多线程版本中,3个任务分别在独立的线程中执行。这些线程由操作系统来管理,在多处理器系统上可以并行处理,或者在单处理器系统上交错执行。这使得当某个线程阻塞在某个资源的同时其他线程得以继续执行。
③、中协程版本的程序,3个任务交错执行,但仍然在一个单独的线程控制中。当处理I/O或者其他操作时,注册一个回调到事件循环中,然后当I/O操作完成时继续执行。回调描述了该如何处理某个事件。事件循环轮询所有的事件,当事件到来时将它们分配给等待处理事件的回调函数。
协程的优点:
①、由于自身带有上下文和栈,无需线程上下文切换的开销,属于程序级别的切换,操作系统完全感知不到,因而更加轻量级;
②、无需原子操作的锁定及同步的开销;
③、方便切换控制流,简化编程模型;
④、单线程内就可以实现并发的效果,最大限度地利用cpu,且可扩展性高,成本低(注:一个CPU支持上万的协程都不是问题。所以很适合用于高并发处理)
注意:asyncio协程是写爬虫比较好的方式。比多线程和多进程都好,因为开辟新的线程和进程是非常耗时的。
协程的缺点:
①、无法利用多核资源,因为协程的本质是个单线程,它不能同时将单个CPU 的多个核用上,协程需要和进程配合才能运行在多CPU上。
②、日常所编写的绝大部分应用都没有这个必要,除非是cpu密集型应用。
4.2、yield方式实现协程(了解,已淘汰)
python中引入协程的历史:
①、最初的生成器变形 yield/send ;
②、引入 @asyncio.coroutine 和 yield from ;
③、Python3.5版本后,引入 async / await 关键字;
实操:通过下面的例子了解如何通过yield的方式实现协程。
import time
def func1():
for i in range(3):
print(f'func1:第{i}次打印啦')
yield # 只要方法包含了yield,就变成一个生成器
time. Sleep(1)
def func2():
g = func1() #func1是一个生成器,func1()就不会直接调用,需要通过next()函数或for循环调用
print(type(g))
for k in range(3):
print(f'func2:第{k}次打印了' )
next(g) #继续执行func1的代码
time.sleep(1)
if __name__ == '__main__':
#有了yield,我们实现了两个任务的切换+保存状态
start_time = time.time()
func2()
end_time = time.time()
print(f"耗时{end_time-start_time}")
运行结果如下:
4.3、syncio异步IO实现协程(重点)
概述:
①、正常的函数执行时是不会中断的,此时需要写一个能够中断的函数,就需要加 async;
②、async 用来声明一个函数为异步函数,异步函数的特点是能在函数执行过程中挂起,去执行其他异步函数,等到挂起条件(假设挂起条件是sleep(2))消失后,也就是2秒到了再回来执行
③、await 用来用来声明程序挂起,比如异步程序执行到某一步时需要等待的时间很长,就将此挂起,去执行其他的异步程序。
④、asyncio 是python3.5之后的协程模块,是python实现并发重要的包,这个包的底层是使用事件循环驱动实现并发。
实操:通过案例了解synicio异步IO如何实现协程。
import asyncio
import time
# 用async标记这是一个异步函数
async def fun1():
for i in range(3):
print(f"fun1打印第{i+1}次")
await asyncio.sleep(2) # 异步睡眠
return ("fun1执行完毕")
async def fun2():
for y in range(3):
print(f"fun2打印第{y+1}次")
await asyncio.sleep(2) # 异步睡眠
return "fun2执行完毕"
async def main():
# 用main函数调用fun1和fun2函数,也要声明为异步函数
res = await asyncio.gather(fun1(),fun2())
print(res)
if __name__ == '__main__':
star_time = time.time()
# 启动调用fun1和fun2的异步函数main()
asyncio.run(main())
end_time = time.time()
# 打印统计的耗时时间
print(f"耗时:{end_time-star_time}")
运行结果如下: