Python自动化运维 - day9 - 进程与线程
概述
我们都知道windows是支持多任务的操作系统。
什么叫“多任务”呢?简单地说,就是操作系统可以同时运行多个任务。打个比方,你一边在用浏览器上网,一边在听MP3,一边在用Word赶作业,这就是多任务,至少同时有3个任务正在运行。还有很多任务悄悄地在后台同时运行着,只是桌面上没有显示而已。
现在,多核CPU已经非常普及了,但是,即使过去的单核CPU,也可以执行多任务。由于CPU执行代码都是顺序执行的,那么,单核CPU是怎么执行多任务的呢?
答案就是操作系统轮流让各个任务交替执行,任务1执行0.01秒,切换到任务2,任务2执行0.01秒,再切换到任务3,执行0.01秒……这样反复执行下去。表面上看,每个任务都是交替执行的,但是,由于CPU的执行速度实在是太快了,我们感觉就像所有任务都在同时执行一样。
真正的并行执行多任务只能在多核CPU上实现,但是,由于任务数量远远多于CPU的核心数量,所以,操作系统也会自动把很多任务轮流调度到每个核心上执行。
对于操作系统来说,一个任务就是一个进程(Process),比如打开一个浏览器就是启动一个浏览器进程,打开一个记事本就启动了一个记事本进程,打开两个记事本就启动了两个记事本进程,打开一个Word就启动了一个Word进程。
有些进程还不止同时干一件事,比如Word,它可以同时进行打字、拼写检查、打印等事情。在一个进程内部,要同时干多件事,就需要同时运行多个“子任务”,我们把进程内的这些“子任务”称为线程(Thread)。
由于每个进程至少要干一件事,所以,一个进程至少有一个线程。当然,像Word这种复杂的进程可以有多个线程,多个线程可以同时执行,多线程的执行方式和多进程是一样的,也是由操作系统在多个线程之间快速切换,让每个线程都短暂地交替运行,看起来就像同时执行一样。当然,真正地同时执行多线程需要多核CPU才可能实现。
我们前面编写的所有的Python程序,都是执行单任务的进程,也就是只有一个线程。如果我们要同时执行多个任务怎么办?有两种解决方案:
- 一种是启动多个进程,每个进程虽然只有一个线程,但多个进程可以一块执行多个任务。
- 一种方法是启动一个进程,在一个进程内启动多个线程,这样,多个线程也可以一块执行多个任务。
当然还有第三种方法,就是启动多个进程,每个进程再启动多个线程,这样同时执行的任务就更多了,当然这种模型更复杂,实际很少采用。
总结一下就是,多任务的实现有3种方式:
-
-
- 多进程模式;
- 多线程模式;
- 多进程+多线程模式
-
同时执行多个任务通常各个任务之间并不是没有关联的,而是需要相互通信和协调,有时,任务1必须暂停等待任务2完成后才能继续执行,有时,任务3和任务4又不能同时执行,所以,多进程和多线程的程序的复杂度要远远高于我们前面写的单进程单线程的程序。
Python既支持多进程,又支持多线程。
进程
正在进行的一个过程或者说一个任务。而负责执行任务的则是CPU
由于现在计算计算机都是多任务同时进行的,比如:打开了QQ,然后听着音乐,后面下载者片儿,那么这些都是怎么完成的呢?答案是通过多进程。操作系统会对CPU的时间进行规划,每个进程执行一个任务(功能),CPU会快速的在这些进行之间进行切换已达到同时进行的目的(单核CPU的情况)
进程与程序
程序:一堆代码的集合体。 进程:指的是程序运行的过程。 注意的是:一个程序执行两次,那么会产生两个互相隔离的进程。
并发与并行
并行:同时运行,只有具备多个CPU才能实现并行 并发:是伪并行,即看起来是同时运行。单个CPU+多道技术就可以实现并发。(并行也属于并发)
同步与异步
同步指一个进程在执行某个请求的时候,若该请求需要一段时间才能返回信息,那么这个进程将会一直等待下去,直到返回西南喜才继续执行下去。 异步是指进程不需要一直等下去,而是继续执行下面的操作,不管其他进程的状态。当有消息返回时系统会通知进程处理,这样可以提高执行的效率。 例子:打电话就是同步,发短信就是异步
进程的创建
主要分为4种: 1、系统初始化:(查看进程Linux中用ps命令,windows中用任务管理器,前台进程负责与用户交互,后台运行的进程与用户无关,运行在后台并且只有在需要时才唤醒的进程,成为守护进程,如电子邮件,web页面,新闻,打印等) 2、一个进程在运行过程中开启了子进程(如nginx开启多线程,操作系统os.fork(),subprocess.Popen等) 3、用户的交互请求,而创建一个新的进程(如用户双击QQ) 4、一个批处理作业的开始(只在大型批处理系统中应用)
以上四种其实都是由一个已经存在了的进程执行了一个用于创建进程的系统调用而创建的。
- 在unix/Linux系统中该调用是:fork,它非常特殊。普通的函数调用,调用一次,返回一次,但是
fork()
调用一次,返回两次,因为操作系统自动把当前进程(称为父进程)复制了一份(称为子进程),然后,分别在父进程和子进程内返回。子进程返回0,父进程返回子进程的PID。 - 在winodws中调用的是createProcess,CreateProcess既处理进程的创建,也负责把正确的程序装入新进程。
注意:
- 进程创建后父进程和子进程有各自不同的地址空间(多道技术要求物理层面实现进程之间内存的隔离),任何一个进程的在其地址空间中的修改都不会影响到另外的进程。
- 在Unix/linux,子进程的初始地址空间是父进程的一个副本,子进程和父进程是可以有只读的共享内存区的。但是对于Winodws系统来说,从一开始父进程与子进程的地址空间就是不同的。
进程之间共享终端,共享一个文件系统
进程的状态
进程的状态主要分为三种:进行、阻塞、就绪
线程
在传统的操作系统中,每个进程有一个地址空间,而且默认就有一个控制线程,多线程(及多个控制线程)的概念是,在一个进程中存在多个控制线程,多个控制线程共享该进程的地址空间,进程只是用来把资源集中到一起(进程只是一个资源单位,或者说资源集合),而线程才是CPU的执行单位。
为何要用多线程
多线程指的是,在一个进程中开启多个线程,简单来说:如果多个任务公用一块地址空间,那么必须在一个进程内开启多个线程。 1、多线程共享一个进程的地址空间 2、线程比进程更轻量级,线程比进程更容易创建和撤销,在许多操作系统中,创建一个线程比创建一个进程要快10-100倍 3、对于CPU密集型的应用,多线程并不能提升性能,但对于I/O密集型,使用多线程会明显的提升速度(I/O密集型,根本用不上多核优势) 4、在多CPU系统中,为了最大限度的利用多核,可以开启多个线程(比开进程开销要小的多) --> 针对其他语言 注意: Python中的线程比较特殊,其他语言,1个进程内4个线程,如果有4个CPU的时候,是可以同时运行的,而Python在同一时间1个进程内,只有一个线程可以工作。(就算你有再多的CPU,对Python来说用不上)
线程与进程的区别
1、线程共享创建它的进程的地址空间,进程拥有自己的地址空间 2、线程可以直接访问进程的数据,进程拥有它父进程内存空间的拷贝 3、线程可以和同一进程内其他的线程直接通信,进程必须interprocess communicateion(IPC机制)进行通信 4、线程可以被很容易的创建,而进程依赖于父进程内存空间的拷贝 5、线程可以直接控制同一进程内的其他线程,进程只能控制自己的子进程 6、改变主线程(控制)可能会影响其他线程,改变主进程不会影响它的子进程
multiprocessing模块
python中的多线程无法利用多核优势,如果想要充分地使用多核CPU的资源(os.cpu_count()查看),在python中大部分情况需要使用多进程。Python提供了multiprocessing,该模块用来开启子进程,并在子进程中执行我们定制的任务(比如函数),该模块与多线程模块threading的编程接口类似。
multiprocessing模块的功能众多:支持子进程、通信和共享数据、执行不同形式的同步,提供了Process、Queue、Pipe、Lock等组件。
需要再次强调的一点是:与线程不同,进程没有任何共享状态,进程修改的数据,改动仅限于该进程内。
Process类和使用
注意:在windows中Process()必须放到# if __name__ == '__main__':下
利用Process创建进程的类:
Process([group [, target [, name [, args [, kwargs]]]]]),由该类实例化得到的对象,表示一个子进程中的任务(尚未启动) 强调: 1. 需要使用关键字的方式来指定参数 2. args指定的为传给target函数的位置参数,是一个元组形式,必须有逗号
参数:
- group参数未使用,值始终为None
- target表示调用对象,即子进程要执行的任务
- args表示调用对象的位置参数元组,args=(1,2,'egon',)
- kwargs表示调用对象的字典,kwargs={'name':'egon','age':18}
- name为子进程的名称
Process类的方法
p.start(): # 启动进程,并调用该子进程中的p.run() --> 和直接调用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开启的进程
Process的其他属性
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字符的字符串。这个键的用途是为涉及网络连接的底层进程间通信提供安全性,这类连接只有在具有相同的身份验证键时才能成功
特别强调:设置 p.daemon=True 是会随着主进程执行完毕而被回收,不管子进程是否完成任务。
基本使用
使用Process创建进程的类有两种方法:
1、通过实例化Process类完成进程的创建
2、继承Process类,定制自己需要的功能后实例化创建进程类
# --------------------------- 方法1 --------------------------- import random import time from multiprocessing import Process def hello(name): print('Welcome to my Home') time.sleep(random.randint(1,3)) print('Bye Bye') p = Process(target=hello,args=('daxin',)) # 创建子进程p p.start() # 启动子进程 print('主进程结束') # --------------------------- 方法2 --------------------------- import random import time from multiprocessing import Process class MyProcess(Process): def __init__(self,name): super(MyProcess, self).__init__() # 必须继承父类的构造函数 self.name = name def run(self): # 必须叫run方法,因为start,就是执行的run方法。 print('Welcome to {0} Home'.format(self.name)) time.sleep(random.randint(1,3)) print('Bye Bye') p = MyProcess('daxin') p.start() print('主进程结束')
利用多进程完成修改socket server
上一节我们利用socket完成了socket server的编写,这里我们使用multiprocessing对server端进行改写,完成并发接受请求的功能。
1 import socket 2 import multiprocessing 3 4 server = socket.socket(socket.AF_INET,socket.SOCK_STREAM) 5 server.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1) # 重用监听的IP和端口 6 server.bind(('127.0.0.1',8080)) 7 server.listen(5) 8 9 10 def talk(conn): 11 12 while True: 13 14 try: 15 msg = conn.recv(1024) 16 if not msg:break 17 conn.send(msg.upper()) 18 except Exception as e: 19 break 20 21 22 while True: 23 conn,addr = server.accept() 24 p = multiprocessing.Process(target=talk,args=(conn,)) 25 p.start()
1 import socket 2 3 4 client = socket.socket(socket.AF_INET,socket.SOCK_STREAM) 5 client.connect(('127.0.0.1',8080)) 6 7 while True: 8 9 msg = input('Please input words:').strip() 10 if not msg:continue 11 client.send(msg.encode('utf-8')) 12 13 word = client.recv(1024) 14 print(word.decode('utf-8'))
如果服务端接受上万个请求,那么岂不是要创建1万个进程去分别对应?这样是不行的,那么我们可以使用进程池的概念来解决这个问题,进程池的问题,在后续小节中详细说明
进程同步锁
进程之间数据不共享,但是共享同一套文件系统,所以访问同一个文件,或同一个打印终端,是没有问题的,竞争带来的结果就是错乱,如何控制,就是加锁处理。
1 import multiprocessing 2 3 def fileinput(filename,str): 4 5 with open(filename,'a',encoding='UTF-8') as f: 6 f.write(str) 7 8 if __name__ == '__main__': 9 for i in range(10): 10 p = multiprocessing.Process(target=fileinput,args=('a.txt','进程 %s\n' % i)) 11 p.start() 12 13 # 打印的顺序:是谁抢到谁写,那么顺序可能不是1,2,3...9
锁的目的就是:当程序1在使用的时候,申请锁,并且锁住共享资源,待使用完毕后,释放锁资源,其他程序获取锁后,重复这个过程。
Multiprocessing模块提供了Lock对象用来完成进程同步锁的功能
from multiprocessing import Lock lock = Lock() # 对象没有参数 # 通过使用lock对象的acquire/release方法来进行 锁/释放 的需求。
利用进程同步锁模拟抢票软件的需求:
- 创建票文件,内容为json,设置余票数量
- 并发100个进程抢票
- 利用random + time 模块模拟网络延迟
import random import time import json from multiprocessing import Process,Lock def gettickles(filename,str,lock): lock.acquire() # 对要修改的部分加锁 with open(filename,encoding='utf-8') as f: dic = json.loads(f.read()) if dic['count'] > 0 : dic['count'] -= 1 time.sleep(random.random()) with open(filename,'w',encoding='utf-8') as f: f.write(json.dumps(dic)) print('\033[33m{0}抢票成功\033[0m'.format(str)) else: print('\033[35m{0}抢票失败\033[0m'.format(str)) lock.release() # 修改完毕后解锁 if __name__ == '__main__': lock = Lock() # 创建一个锁文件 p_l = [] for i in range(1000): p = Process(target=gettickles,args=('a.txt','用户%s' % i,lock)) p_l.append(p) p.start()
加锁可以保证多个进程修改同一块数据时,同一时间只能有一个任务可以进行修改,即串行的修改,没错,速度是慢了,但牺牲了速度却保证了数据安全。
进程池
在利用Python进行系统管理的时候,特别是同时操作多个文件目录,或者远程控制多台主机,并行操作可以节约大量的时间。多进程是实现并发的手段之一,需要注意的问题是:
- 很明显需要并发执行的任务通常要远大于核数
- 一个操作系统不可能无限开启进程,通常有几个核就开几个进程
- 进程开启过多,效率反而会下降(开启进程是需要占用系统资源的,而且开启多余核数目的进程也无法做到并行)
例如当被操作对象数目不大时,可以直接利用multiprocessing中的Process动态成生多个进程,十几个还好,但如果是上百个,上千个。。。手动的去限制进程数量却又太过繁琐,此时可以发挥进程池的功效。
我们就可以通过维护一个进程池来控制进程数目,比如httpd的进程模式,规定最小进程数和最大进程数...
ps:对于远程过程调用的高级应用程序而言,应该使用进程池,Pool可以提供指定数量的进程,供用户调用,当有新的请求提交到pool中时,如果池还没有满,那么就会创建一个新的进程用来执行该请求;但如果池中的进程数已经达到规定最大值,那么该请求就会等待,直到池中有进程结束,就重用进程池中的进程。
创建进程池的类:如果指定numprocess为3,则进程池会从无到有创建三个进程,然后自始至终使用这三个进程去执行所有任务,不会开启其他进程
from multiprocessing import Pool pool = Pool(processes=None, initializer=None, initargs=())
参数:
- processes:进程池的最大进程数量
- initiallizer:初始化完毕后要执行的函数
- initargs:要传递给函数的参数
常用方法
p.apply(func [, args [, kwargs]]) # 调用进程池中的一个进程执行函数func,args/kwargs为传递的参数,注意apply是阻塞式的,既串行执行。 p.apply_async(func [, args [, kwargs]]) # 功能同apply,区别是非阻塞的,既异步执行。 ———> 常用 p.close() # 关闭进程池,防止进一步操作。如果所有操作持续挂起,它们将在工作进程终止前完成 P.join() # 等待所有工作进程退出。此方法只能在close()或teminate()之后调用
注意:
apply_async 会返回AsyncResul对象,这个AsyncResul对象有有一下方法:
1 obj.get() 2 # 返回结果,如果有必要则等待结果到达。timeout是可选的。如果在指定时间内还没有到达,将引发一场。如果远程操作中引发了异常,它将在调用此方法时再次被引发。 3 4 obj.ready() 5 # 如果调用完成,返回True 6 7 obj.successful() 8 # 如果调用完成且没有引发异常,返回True,如果在结果就绪之前调用此方法,引发异常 9 10 obj.wait([timeout]) 11 # 等待结果变为可用。 12 13 obj.terminate() 14 # 立即终止所有工作进程,同时不执行任何清理或结束任何挂起工作。如果p被垃圾回收,将自动调用此函数
利用进程池改写socket server:
import os import socket import multiprocessing server = socket.socket(socket.AF_INET,socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1) server.bind(('127.0.0.1',8100)) server.listen(5) def talk(conn): print('我的进程号是: %s' % os.getpid() ) while True: msg = conn.recv(1024) if not msg:break data = msg.decode('utf-8') msg = data.upper() conn.send(msg.encode('utf-8')) if __name__ == '__main__': pool = multiprocessing.Pool(1) while True: conn,addr = server.accept() print(addr) pool.apply_async(talk,args=(conn,)) pool.close() pool.join()
这里指定了进程池的数量为1,那么并发两个连接的话,第二个会hold住,只有第一个断开后,才会连接,注意:进程的Pid号,还是相同的。
回调函数
需要回调函数的场景:进程池中任何一个任务一旦处理完了,就立即告知主进程:我好了额,你可以处理我的结果了。主进程则调用一个函数去处理该结果,该函数即回调函数。我们可以把耗时间(阻塞)的任务放到进程池中,然后指定回调函数(主进程负责执行),这样主进程在执行回调函数时就省去了I/O的过程,直接拿到的是任务的结果。
apply_async(self, func, args=(), kwds={}, callback=None) # func的结果会交给指定的callback函数处理
一个爬虫的小例子:
from multiprocessing import Pool import requests import os def geturl(url): print('我的进程号为: %s' % os.getpid()) print('我处理的url为: %s ' % url ) response = requests.get(url) # 请求网页 return response.text # 返回网页源码 def urlparser(htmlcode): print('我的进程号是: %s ' % os.getpid()) datalength = len(htmlcode) # 计算源码的长度 print('解析到的html大小为: %s' % datalength) if __name__ == '__main__': pool = Pool() url = [ 'http://www.baidu.com', 'http://www.sina.com', 'http://www.qq.com', 'http://www.163.com' ] res_l = [] for i in url: res = pool.apply_async(geturl,args=(i,),callback=urlparser) # res 是 geturl执行的结果,因为已经交给urlparser处理了,所以这里不用拿 res_l.append(res) pool.close() pool.join() for res in res_l: print(res.get()) # 这里拿到的就是网页的源码
进程间通讯
进程彼此之间互相隔离,要实现进程间通信(IPC),multiprocessing模块提供的两种形式:队列和管道,这两种方式都是使用消息传递的。但是还有一种基于共享数据的方式,现在已经不推荐使用,建议使用队列的方式进行进程间通讯。
展望未来,基于消息传递的并发编程是大势所趋,即便是使用线程,推荐做法也是将程序设计为大量独立的线程集合,通过消息队列交换数据。这样极大地减少了对使用锁定和其他同步手段的需求,还可以扩展到分布式系统中。
队列
底层就是以管道和锁定的方式实现。
创建队列的类:
Queue([maxsize]):创建共享的进程队列,Queue是多进程安全的队列,可以使用Queue实现多进程之间的数据传递。 # 参数 maxsize: 队列能承载的最大数量,省略的话则不限制队列大小
基本使用:
from multiprocessing import Queue q = Queue(3) q.put('a') # 数据存入Queue print(q.get()) # 从Queue中取出数据
注意:队列(Queue)是FIFO模式,既先进先出。
队列的方法
q.put() 用于插入数据到队列中。
q.put(obj, block=True, timeout=None) # 参数: # blocked,timeout:如果blocked为True(默认值),并且timeout为正值,该方法会阻塞timeout指定的时间,直到该队列有剩余的空间。如果超时,会抛出Queue.Full异常。如果blocked为False,但该Queue已满,会立即抛出Queue.Full异常。
PS:q.put_nowait() 等同于 q.put(block=False)
q.get() 用于从队列中获取数据。
q.get(block=True,timeout=None) # 参数: # blocked和timeout。如果blocked为True(默认值),并且timeout为正值,那么在等待时间内没有取到任何元素,会抛出Queue.Empty异常。如果blocked为False,有两种情况存在,如果Queue有一个值可用,则立即返回该值,否则,如果队列为空,则立即抛出Queue.Empty异常.
PS:q.get_nowait() 等同于 q.get(block=False)
1 q.empty() # 调用此方法时q为空则返回True,该结果不可靠,比如在返回True的过程中,如果队列中又加入了项目。 2 3 q.full() # 调用此方法时q已满则返回True,该结果不可靠,比如在返回True的过程中,如果队列中的项目被取走。 4 5 q.qsize() # 返回队列中目前项目的正确数量,结果也不可靠,理由同q.empty()和q.full()一样
生产者消费者模型
在并发编程中使用生产者和消费者模式能够解决绝大多数并发问题。该模式通过平衡生产线程和消费线程的工作能力来提高程序的整体处理数据的速度。
为什么要使用生产者和消费者模式
在线程世界里,生产者就是生产数据的线程,消费者就是消费数据的线程。在多线程开发当中,如果生产者处理速度很快,而消费者处理速度很慢,那么生产者就必须等待消费者处理完,才能继续生产数据。同样的道理,如果消费者的处理能力大于生产者,那么消费者就必须等待生产者。为了解决这个问题于是引入了生产者和消费者模式。
什么是生产者消费者模式
生产者消费者模式是通过一个容器来解决生产者和消费者的强耦合问题。生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取,阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力。
基于队列实现生产者消费者模型:
- 生产者只负责生产蛋糕,生产完毕的蛋糕放在队列中
- 消费者只负责消费蛋糕,每次从队列中拿取蛋糕
1 import time 2 import random 3 from multiprocessing import Process,Queue 4 5 def customer(q,name): 6 while True: 7 food = q.get() # 从队列中获取蛋糕 8 time.sleep(random.randint(1,3)) # 模拟吃蛋糕的时间 9 print('\033[32m{0} 吃完了 {1}\033[0m'.format(name,food)) 10 11 def producer(seq,q,name): 12 for i in seq: 13 time.sleep(random.randint(1,3)) # 模拟做蛋糕的时间 14 q.put(i) # 把做好的蛋糕放在队列中 15 print('\033[35m{0} 制作了 {1}\033[0m'.format(name,i)) 16 17 if __name__ == '__main__': 18 19 q = Queue() 20 seq = [ '蛋糕{0}'.format(i) for i in range(11)] 21 p = Process(target=producer,args=(seq,q,'大欣')) 22 p.start() 23 24 c = Process(target=customer,args=(q,'顾客')) 25 c.start() 26 27 print('主进程')
上面的例子很完美,但是生产者生产完毕,消费者也消费完毕了,那么我们的主程序就应该退出了,可是并没有,因为消费者还在等待从队列中获取(q.get),这里我们考虑可以发送一个做完/吃完的信号,抓取到信号后退出即可。
- 在队列中放固定的值来做信号
- 利用JoinableQueue对象 + daemon属性 来对消费者进程进行回收
1 import time 2 import random 3 from multiprocessing import Process,Queue 4 5 def customer(q,name): 6 while True: 7 food = q.get() 8 time.sleep(random.randint(1,3)) 9 if not food:break # 获取信号,退出小循环 10 print('\033[32m{0} 吃完了 {1}\033[0m'.format(name,food)) 11 12 def producer(seq,q,name): 13 for i in seq: 14 time.sleep(random.randint(1,3)) 15 q.put(i) 16 print('\033[35m{0} 制作了 {1}\033[0m'.format(name,i)) 17 q.put(None) # 制作完毕,发送信号 18 19 if __name__ == '__main__': 20 21 q = Queue() 22 seq = [ '蛋糕{0}'.format(i) for i in range(11)] 23 p = Process(target=producer,args=(seq,q,'大欣')) 24 p.start() 25 26 c = Process(target=customer,args=(q,'顾客')) 27 c.start() 28 29 print('主进程')
1 import time 2 import random 3 from multiprocessing import Process,JoinableQueue 4 5 def customer(q,name): 6 while True: 7 food = q.get() 8 time.sleep(random.randint(1,3)) 9 q.task_done() # 每吃一个,就汇报一下 10 print('\033[32m{0} 吃完了 {1}\033[0m'.format(name,food)) 11 12 def producer(seq,q,name): 13 for i in seq: 14 time.sleep(random.randint(1,3)) 15 q.put(i) 16 print('\033[35m{0} 制作了 {1}\033[0m'.format(name,i)) 17 q.join() # 等待消费者进行汇报 18 print('--->') 19 20 if __name__ == '__main__': 21 22 q = JoinableQueue() # 创建JoinableQueue对象 23 seq = [ '蛋糕{0}'.format(i) for i in range(11)] 24 p = Process(target=producer,args=(seq,q,'大欣')) 25 p.start() 26 27 c = Process(target=customer,args=(q,'顾客')) 28 c.start() 29 30 print('主进程')
1 import time 2 import random 3 from multiprocessing import Process,JoinableQueue 4 5 def customer(q,name): 6 while True: 7 food = q.get() 8 time.sleep(random.randint(1,3)) 9 q.task_done() # 每吃一个,就汇报一下 10 print('\033[32m{0} 吃完了 {1}\033[0m'.format(name,food)) 11 12 def producer(seq,q,name): 13 for i in seq: 14 time.sleep(random.randint(1,3)) 15 q.put(i) 16 print('\033[35m{0} 制作了 {1}\033[0m'.format(name,i)) 17 q.join() # 等待消费者进行汇报 18 print('--->') 19 20 if __name__ == '__main__': 21 22 q = JoinableQueue() # 创建JoinableQueue对象 23 seq = [ '蛋糕{0}'.format(i) for i in range(11)] 24 p = Process(target=producer,args=(seq,q,'大欣')) 25 p.start() 26 27 c = Process(target=customer,args=(q,'顾客')) 28 c.daemon = True 29 c.start() 30 31 32 p.join() 33 print('主进程')
其中:
- 利用JoinableQueue对象的join,task_done方法,完成确认/通知的目的。
- 如果生产者生产完毕,消费者必然也会给生产者确认消费完毕,那么只要等待生产者执行完毕后进行就可以退出主进程了。
- 主进程退出但是消费者进程还未回收,那么就可以设置消费者daemon属性为true,跟随主进程被回收即可。
共享数据
进程间数据是独立的,可以借助于队列或管道实现通信,二者都是基于消息传递的,虽然进程间数据独立,但也可以通过Manager实现数据共享,事实上Manager的功能远不止于此。
Manager() # 没有参数 # 使用Manager对象创建共享数据类型
利用Manager创建数据,完成进程共享
import os from multiprocessing import Manager,Process def worker(d,l): d[os.getpid()]=os.getpid() # 对共享数据进行修改 l.append(os.getpid()) if __name__ == '__main__': m = Manager() d = m.dict() # 创建共享字典 l = m.list() # 创建共享列表 p_l = [] for i in range(10): p= Process(target=worker,args=(d,l)) p_l.append(p) p.start() for p in p_l: p.join() print(d) print(l)
GIL
GIL:Global Interpreter Lock 全局解释器锁,GIL并不是Python的特性,它是在实现Python解析器(CPython)时所引入的一个概念。就好比C++是一套语言(语法)标准,但是可以用不同的编译器来编译成可执行代码。有名的编译器例如GCC,INTEL C++,Visual C++等。Python也一样,同样一段代码可以通过CPython,PyPy,Psyco等不同的Python执行环境来执行。像其中的JPython就没有GIL。然而因为CPython是大部分环境下默认的Python执行环境。所以在很多人的概念里CPython就是Python,也就想当然的把GIL归结为Python语言的缺陷。所以这里要先明确一点:GIL并不是Python的特性,Python完全可以不依赖于GIL。
官方是这样解释GIL的:
In CPython, the global interpreter lock, or GIL, is a mutex that prevents multiple native threads from executing Python bytecodes at once. This lock is necessary mainly because CPython’s memory management is not thread-safe. (However, since the GIL exists, other features have grown to depend on the guarantees that it enforces.) # 在CPython中,全局解释器锁或者简称GIL, # 是一个用来阻止多线程程序在一次运行时多次执行Python字节码程序的锁, # 在CPython中这个锁是必须的,因为CPython的内存管理不是线程安全的 # (然而,自从GIL存在,其他特性已经发展到依赖于GIL的强制执行)
PS:为了防止多线程并发执行机器码。
为什么会有GIL
由于物理上得限制,各CPU厂商在核心频率上的比赛已经被多核所取代。为了更有效的利用多核处理器的性能,就出现了多线程的编程方式,而随之带来的就是线程间数据一致性和状态同步的困难。即使在CPU内部的Cache也不例外,为了有效解决多份缓存之间的数据同步时各厂商花费了不少心思,也不可避免的带来了一定的性能损失。
GIL本质就是一把互斥锁,既然是互斥锁,所有互斥锁的本质都一样,都是将并发运行变成串行,以此来控制同一时间内共享数据只能被一个任务所修改,进而保证数据安全。
可以肯定的一点是:保护不同的数据的安全,就应该加不同的锁。
GIL与LOCK
GIL保护的是解释器级的数据,保护用户自己的数据则需要自己加锁处理,如下图举例:
首先:在一个进程内的所有线程数据是共享的,由于GIL的存在,统一时刻只能一个线程在运行。
- 线程1拿到GIL锁,加载count数据,准备修改的时候,被CPU进行调度
- 线程2拿到GIL锁,加载count数据(此时没有被修改),然后修改,保存,这时count的数据已经被修改了
- 线程1重新获取GIL锁,继续修改count的值,这时由于count的值已经变了,所以,就造成两个线程同时修改共享数据,并没有产生正确得结果。
PS:由于GIL是解释器级别的锁,用户数据,那么需要用户自行加锁处理。
个人小结
首先多个线程先访问到解释器的代码,即拿到执行权限,然后将target的代码交给解释器的代码去执行
在一个python的进程内,不仅有应用的主线程或者由该主线程开启的其他线程,还有解释器开启的垃圾回收等解释器级别的线程,总之,所有线程都运行在这一个进程内,毫无疑问解释器的代码是所有线程共享的,所以垃圾回收线程也可能访问到解释器的代码而去执行,这就导致了一个问题:Python的垃圾回收线程在执行时,会扫描当前进程所在的内存空间中,引用计数为0的变量等信息,然后进行回收。如果没有GIL那么在执行清理的动作,其他线程又对该变量进行赋值,那么当垃圾回收线程获取CPU执行权限后,会继续进行清理,那么就可能造成数据的混乱,所以当GIL锁存在时,当垃圾回收线程检测到引用计数为0的数据后,对数据进行加锁处理,这样即便是其他线程再次访问也不会造成数据的混乱。
由于GIL的存在,同一个进程下的线程,无法进行并发,并行也不行。 但是由于GIL是基于进程的,所以可以有多核多个进程并发,而每个进程下同时只能有一个线程运行。
Threading模块
Python 标准库提供了 thread 和 threading 两个模块来对多线程进行支持。其中, thread 模块以低级、原始的方式来处理和控制线程,而 threading 模块通过对 thread 进行二次封装,提供了更方便的 api 来处理线程。
PS:multiprocessing完全模仿了threading模块的接口,二者在使用层面,有很大的相似性,所以很多用法都是相同的,所以可能看起来会比较眼熟。
Thread类和使用
Thread 是threading模块中最重要的类之一,可以使用它来创建线程。
有两种方式来创建线程:
- 通过继承Thread类,重写它的run方法;
- 创建一个threading.Thread对象,在它的初始化函数(__init__)中将可调用对象作为参数传入;
# -----------------------实例化对象-------------------------- import threading def work(name): print('hello,{0}'.format(name)) if __name__ == '__main__': t = threading.Thread(target=work,args=('daxin',)) t.start() print('主进程') # -----------------------自己创建类-------------------------- import threading class Work(threading.Thread): def __init__(self,name): super(Work, self).__init__() self.name = name def run(self): print('hello,{0}'.format(self.name)) if __name__ == '__main__': t = Work(name='daxin') t.start() print('主进程')
PS:执行的时候,我们可以看到会先打印"hello,daxin",然后才会打印"主进程",所以这也同时说明了,创建线程比创建进程消耗资源少的多,线程会被很快的创建出来并执行。如果我们在target执行的函数和主函数中,同时打印os.getpid,你会发现,进程号是相同的,这也说明了这里开启的是子线程。
1 # 开多个线程完成不同的功能 2 # 模拟编辑器的功能 3 4 import threading 5 import datetime 6 7 data_list = [] # 存储用户输入的数据 8 form_list = [] # 存储格式化的数据 9 10 def userinput(): # 采集用户输入线程 11 while True: 12 data = input('>>: ').strip() 13 if not data:continue 14 data_list.append(data) 15 16 17 def format(): # 格式化数据线程 18 while True: 19 if data_list: 20 data = data_list.pop() 21 format_data = '{0} {1}'.format(datetime.datetime.now(),data.upper()) 22 form_list.append(format_data) 23 24 25 def save(): # 持久化存储线程 26 while True: 27 if form_list: 28 write_data = form_list.pop() 29 with open('db.txt','a',encoding='utf-8') as f: 30 f.write('{}\n'.format(write_data)) 31 32 if __name__ == '__main__': 33 t1 = threading.Thread(target=userinput) 34 t2 = threading.Thread(target=format) 35 t3 = threading.Thread(target=save) 36 t1.start() 37 t2.start() 38 t3.start()
Thread对象的常用方法
t.join() #和 multiprocessing 的join方法相同,使主线程hold住,等待子线程执行完毕后继续执行 t.setDaemon() # 和 multiprocessing 的 daemon方法相同,使子线程编程守护线程,主线程退出后,子线程一同退出(和multiprocessing一样,也要放在开启线程之前设置)
threading模块提供的方法
threading.current_thread() # 查看当前线程(current_thread().getNmae()获取当前线程的名称) threading.enumerate(): # 查看当前活跃的线程,是一个列表形式 threading.active_count(): # 当前活跃线程的数量
互斥锁
由于线程间的数据是共享的,GIL锁住的是解释器级别的数据,而用户的数据,如果我们不加锁,有可能造成混论,如下例子:
import threading import time n = 10 def work(): global n temp = n time.sleep(1) n = temp - 1 if __name__ == '__main__': t_l = [] for i in range(10): t = threading.Thread(target=work) t_l.append(t) t.start() print(t_l) for t in t_l: t.join() print(n)
我们认为结果应该是0,但是结果可能不如人意,因为进程间共享数据的问题,多个进程同时修改共享数据时,由于GIL的存在同一时刻只有1个线程在运行。所以线程读取到的数据可能都是100,而不是其他线程修改后的数据,所以如果要保持数据的正确性,那么就需要牺牲性能,既使用互斥锁,串型修改。
import threading mutex = threading.Lock() # 创建一个互斥锁 # 由于进程内的线程共享进程数据,那么不需要传递,就可以直接调用 # 调用方式一 mutex.acquire() # 加锁 '''code''' mutex.release() # 解锁 # 调用方式二 with mutex: '''code'''
1 #!/usr/bin/env python 2 # _*_coding:utf-8_*_ 3 # __time__: 2017/12/16 17:05 4 # __author__: daxin 5 6 import threading 7 8 import time 9 10 n = 100 11 12 def work(): 13 with mutex: # 修改动作进行加锁处理 14 global n 15 temp = n 16 time.sleep(1) 17 n = temp - 1 18 19 if __name__ == '__main__': 20 21 t_l = [] 22 mutex = threading.Lock() # 创建一个互斥锁 23 for i in range(10): 24 t = threading.Thread(target=work) 25 t_l.append(t) 26 t.start() 27 28 print(t_l) 29 for t in t_l: 30 t.join() 31 32 print(n)
死锁和递归锁
当多个锁在线程内来回调用,就容易造成死锁。即你等待其他线程释放锁A,而其他线程等待你释放锁B。
递归锁
# 创建递归锁 mutexA = mutexB = threading.RLock() # mutexA 和 mutexB 其实就是两个不同的名字,引用的是统一把锁 # 使用方法 和Lock()使用方法相同,但原理不同。 1、当创建递归锁后,Python解释器对该锁维护一个另外的参数,即引用计数。 2、加一次锁,那么引用计数+1,解一次锁,那么引用计数-1 3、只有引用计数为0时,才能提供给其他线程使用。
下面来看一个死锁的例子
#!/usr/bin/env python # _*_coding:utf-8_*_ # __time__: 2017/12/16 19:10 # __author__: daxin import time import threading class MyThread(threading.Thread): def run(self): self.f1() self.f2() def f1(self): mutexA.acquire() print('\033[31m %s 获得了锁A\033[0m' % threading.current_thread().getName()) mutexB.acquire() print('\033[32m %s 获得了锁B\033[0m' % threading.current_thread().getName()) mutexB.release() mutexA.release() def f2(self): mutexB.acquire() print('\033[31m %s 获得了锁A\033[0m' % threading.current_thread().getName()) time.sleep(0.5) mutexA.acquire() print('\033[32m %s 获得了锁B\033[0m' % threading.current_thread().getName()) mutexA.release() mutexB.release() if __name__ == '__main__': mutexA = threading.Lock() mutexB = threading.Lock() t_l = [] for i in range(10): t = MyThread() t_l.append(t) t.start() for t in t_l: t.join()
PS:上面的程序是为了模拟死锁的场景,正常情况下不会这样使用。
1 #!/usr/bin/env python 2 # _*_coding:utf-8_*_ 3 # __time__: 2017/12/16 19:10 4 # __author__: daxin 5 # __file__: 死锁和地柜锁.py 6 7 import time 8 import threading 9 10 class MyThread(threading.Thread): 11 def run(self): 12 self.f1() 13 self.f2() 14 15 def f1(self): 16 mutexA.acquire() 17 print('\033[31m %s 获得了锁A\033[0m' % threading.current_thread().getName() ) 18 mutexB.acquire() 19 print('\033[32m %s 获得了锁B\033[0m' % threading.current_thread().getName() ) 20 mutexB.release() 21 mutexA.release() 22 23 def f2(self): 24 mutexB.acquire() 25 print('\033[31m %s 获得了锁A\033[0m' % threading.current_thread().getName()) 26 time.sleep(0.5) 27 mutexA.acquire() 28 print('\033[32m %s 获得了锁B\033[0m' % threading.current_thread().getName()) 29 mutexA.release() 30 mutexB.release() 31 32 if __name__ == '__main__': 33 # mutexA = threading.Lock() 34 # mutexB = threading.Lock() 35 36 mutexA = mutexB = threading.RLock() # 这里只需要改为递归锁即可。 37 38 t_l = [] 39 for i in range(10): 40 t = MyThread() 41 t_l.append(t) 42 t.start() 43 44 for t in t_l: 45 t.join()
ThreadLocal
在多线程环境下,每个线程都有自己的数据。一个线程使用自己的局部变量比使用全局变量好,因为局部变量只有线程自己能看见,不会影响其他线程,而全局变量的修改必须加锁。但是局部变量也有问题,就是在函数调用的时候,传递起来很麻烦。
PS:当我们在某一个线程的内部创建了一个局部变量,而这个线程内部调用的多个函数都需要该变量,那么在函数调用时每次都需要传递,听起来就很麻烦。此时ThreadLocal应运而生。
在主进程中创建ThreadLocal
对象,每个Thread
对它都可以读写属性,但互不影响。你可以把ThreadLocal看成全局变量,但每个属性都是线程的局部变量,可以任意读写而互不干扰,也不用管理锁的问题,ThreadLocal
内部会处理。即:一个ThreadLocal
变量虽然是全局变量,但每个线程都只能读写自己线程的独立副本,互不干扰。ThreadLocal
解决了参数在一个线程中各个函数之间互相传递的问题。
import threading local_balance = threading.local() # 创建ThreadLocal对象 def change_it(n): local_balance.balance = local_balance.balance + n # 对对象进行操作不会影响其他线程 local_balance.balance = local_balance.balance - n print(local_balance.balance) def get_it(n): local_balance.balance = 1 for i in range(1000000): change_it(n) if __name__ == '__main__': t1 = threading.Thread(target=get_it,args=(5,)) t2 = threading.Thread(target=get_it,args=(8,)) t1.start() t2.start() t1.join() t2.join()
PS:这里的ThreadLocal只能在线程中读写,所以local_balance.balance只能放在线程中进行初始化,在主进程中是不行的。
信号量Semaphore
信号量,本质上也是一个锁,主要用于控制同一时间几个线程同时访问共享资源。
import threading sem = threading.Semaphore(num) # num表示同时允许几个线程访问共享资源 # 调用方法:在需要访问共享资源的部分,使用如下格式。 with sem: '''code'''
1 #!/usr/bin/env python 2 # _*_coding:utf-8_*_ 3 # __time__: 2017/12/17 17:36 4 # __author__: daxin 5 # __file__: 信号量.py 6 7 8 import threading 9 import time 10 11 12 n = 100 13 14 def work(): 15 with sem: # 访问共享数据 16 global n 17 time.sleep(1) 18 tmp = n 19 print('I am in the %s' % threading.current_thread().getName()) 20 n = tmp-1 21 print(n) 22 23 24 if __name__ == '__main__': 25 26 sem = threading.Semaphore(5) # 创建信号量并设定初始值为5. 27 t_l=[] 28 for i in range(20): 29 t = threading.Thread(target=work) 30 t_l.append(t) 31 t.start() 32 33 for t in t_l: 34 t.join() 35 36 print(n) 37 38 # 由于同时有5个线程可以访问共享数据,那么结果也可能不尽如人意。
信号量主要的使用场景,eg:限制线程连接数据库的数量等。
疑问:很多人会觉得信号量和进程池差不多,其实他们是有本质的区别的,进程池规定可以开启的进程数,而信号量并不规定可以开启的线程数,规定的是可以共同访问共享数据的线程数,你可以开几十上百个线程,但是同一时刻只能有信号量规定数目的线程可以访问。
事件Event
事件用于线程间的通讯,比如主线程控制其他线程的执行等,事件主要提供了三个方法wait、clear、set。
- clear:将“Flag”设置为False。
- set:将“Flag”设置为True。
- wait:等待“Flag“为True后,继续执行。
那什么是Flag?
Event对象在全局定义了一个“Flag”,如果“Flag”值为 False,那么当程序执行 Event对象的wait方法时就会阻塞,如果“Flag”值为True,那么执行Event对象的wait方法时便不再阻塞。
Event原理
使用 threading.Event 实现线程间通信,使用threading.Event可以使一个线程等待其他线程的通知,我们把这个Event传递到线程对象中,Event默认内置了一个标志,初始值为False。一旦该线程通过wait()方法进入等待状态,直到另一个线程调用该Event的set()方法将内置标志设置为True时,该Event会通知所有等待状态的线程恢复运行。
#!/usr/bin/env python # _*_coding:utf-8_*_ # __time__: 2017/12/17 20:52 # __author__: daxin # __file__: Event事件.py import time import threading import random def connect_mysql(): # 模拟数据库连接线程 print('\033[32m%s Connect to mysql...\033[0m' % threading.current_thread().getName()) e.wait() time.sleep(random.randint(1,3)) print('\033[32m%s Connecting mysql Successful.\033[0m' % threading.current_thread().getName() ) def check_mysql(): # 模拟数据库检查线程 print('\033[35m%s check to mysql status.\033[0m' % threading.current_thread().getName()) time.sleep(5) print('\033[35m%s mysql status is Ok.\033[0m' % threading.current_thread().getName() ) e.set() if __name__ == '__main__': e = threading.Event() # 创建事件对象 for i in range(10): t = threading.Thread(target=connect_mysql) t.start() # 先开启数据库连接线程 t = threading.Thread(target=check_mysql) t.start() # 再开启数据库检查线程 # 由于数据库连接线程中设置了wait,所以不会显示连接成功 # 只有检查线程检查数据库状态正常,发送set信号,那么连接线程才会继续执行。
定时器Timer
顾名思义就是在某一时刻执行。
import threading t = threading.Timer(时间,函数,参数) t.start() # 在多久之后执行函数,并传递参数。
1 import threading 2 3 def talk(name): 4 print('hello world {}'.format(name)) 5 6 if __name__ == '__main__': 7 t = threading.Timer(3,talk,args=('daxin',)) 8 t.start()
Queue模块
Python中,队列是线程间最常用的交换数据的形式。Queue模块是提供队列操作的模块。前面我们说multiprocessing模块是仿照threading模块来写的,所以把Queue模块的数据添加到了multiprocessing里,即通过multiprocessing.Queue来创建队列,而threading中则没有,它使用了Queue模块来完成队列的操作。
基本使用方法和Multiprocessing模块中的Queue基本相同
import queue q = queue.Queue(5) # 实例化一个序列 q.put('a') # 放入数据 print(q.get()) # 获取数据
Queue模块还包含的三种队列:
- Queue:先进先出队列
- LifoQueue: 先进后出队列,即 栈
- PriorityQueue:优先级队列
import queue q1 = queue.Queue(5) q2 = queue.LifoQueue(5) q3 = queue.PriorityQueue(5) # FIFO q1.put('a') q1.put('b') print(q1.get()) # LIFO q2.put('a') q2.put('b') print(q2.get()) # Pri q3.put((6,'c')) q3.put((10,'a')) q3.put((8,'b')) print(q3.get())
协程
协程指的是单线程下的并发,又称微线程,纤程。英文名Coroutine。想要明白它到底是什么的话,那么就需要先从并发开始
引子
什么是并发?即伪并行,通过快速的在多个任务之间进行切换而达到的并行的效果。需要注意的是,想要并发,那么CPU在切换的时候就需要记录当前任务执行的位置,所以并发的本质就是:切换+保存状态。
CPU在运行一个任务时,会在两种情况下进行切换,一种情况是该任务发生了阻塞(I/O),另外一种情况是该任务计算的时间过长.
针对第二种情况,我们无能为力,因为操作系统总想做到让CPU能光顾所有任务。
根据并发的本质,想一想,我们前面说的一个知识点是可以做到的。是的, 就是yield。
# 串型执行 import time def eating(name,something): '''接受数据,处理数据''' print('{} is eating {}'.format(name,something)) def producer(): '''产生数据''' for i in range(1000000): food = '包子' + str(i) eating('daxin',food) start=time.time() producer() end=time.time() print('Totol time is :{}'.format(end-start)) # 时间大概为3.1秒 # 基于 yield import time def eating(name): '''接受数据,处理数据''' while True: something = yield # yield的表达式形式 print('{} is eating {}'.format(name,something)) def producer(): '''产生数据''' g = eating('daxin') next(g) # 初始化 yield 函数 for i in range(1000000): food = '包子' + str(i) g.send(food) start=time.time() producer() end=time.time() print('Totol time is :{}'.format(end-start)) # 时间大概为3.2秒
根据上面的例子可以看出,虽然使用yield进行了切换,但是对于纯计算的任务,来回切换,并没有提升效率。所以只有在程序出现I/O阻塞时,切换,才能真正提高效率。这里使用yield并不能在遇I/O阻塞时进行切换。
对于单线程下,我们不可避免程序中出现io操作,但如果我们能在自己的程序中(即用户程序级别,而非操作系统级别)控制单线程下的多个任务能在一个任务遇到io阻塞时就切换到另外一个任务去计算,这样就保证了该线程能够最大限度地处于就绪态,即随时都可以被cpu执行的状态,相当于我们在用户程序级别将自己的io操作最大限度地隐藏起来,从而可以迷惑操作系统,让其看到:该线程好像是一直在计算,io比较少,从而更多的将cpu的执行权限分配给我们的线程。
介绍
现在的操作系统最大的特点就是支持异步IO(向上面说的遇到IO就切换就算是异步IO),所以也可以说单线程的异步编程模型就是协程。
如果充分利用操作系统提供的异步IO支持,就可以用单进程单线程模型来执行多任务,这种全新的模型称为事件驱动模型,Nginx就是支持异步IO的Web服务器,它在单核CPU上采用单进程模型就可以高效地支持多任务。在多核CPU上,可以运行多个进程(数量与CPU核心数相同),充分利用多核CPU。由于系统总的进程数量十分有限,因此操作系统调度非常高效。用异步IO编程模型来实现多任务是一个主要的趋势。有了协程的支持,就可以基于事件驱动编写高效的多任务程序。
协程是一种用户态的轻量级线程,即协程是由用户程序自己控制调度的。
注意:
- python的线程属于内核级别的,即由操作系统控制调度(如单线程遇到io或执行时间过长就会被迫交出cpu执行权限,切换其他线程运行)
- 单线程内开启协程,一旦遇到io,就会从应用程序级别(而非操作系统)控制切换,以此来提升效率(!!!非io操作的切换与效率无关)
对比操作系统控制线程的切换,用户在单线程内控制协程的切换
优点如下:
1. 协程的切换开销更小,属于程序级别的切换,操作系统完全感知不到,因而更加轻量级 2. 单线程内就可以实现并发的效果,最大限度地利用cpu
缺点如下:
1. 协程的本质是单线程下,无法利用多核,可以是一个程序开启多个进程,每个进程内开启多个线程,每个线程内开启协程 2. 协程指的是单个线程,因而一旦协程出现阻塞,将会阻塞整个线程
协程特点:
- 必须在只有一个单线程里实现并发
- 修改共享数据不需加锁
- 用户程序里自己保存多个控制流的上下文栈
- 附加:一个协程遇到IO操作自动切换到其它协程(如何实现检测IO,yield、greenlet都无法实现,就用到了gevent模块(select机制))
Greenlet模块
如果我们在单个线程内有20个任务,要想实现在多个任务之间切换,使用yield生成器的方式过于麻烦(需要先得到初始化一次的生成器,然后再调用send。。。非常麻烦),而使用greenlet模块可以非常简单地实现这20个任务直接的切换。
from greenlet import greenlet # 导入greenlet模块 def task1(): print('task1 the first') g2.switch() print('task1 the second') g2.switch() def task2(): print('task2 the first') g1.switch() print('task2 the second') g1 = greenlet(task1) # 实例化greenlet对象,传递任务函数 g2 = greenlet(task2) g1.switch() # switch标示切换
但是看样子,greenlet只是yield的升级版,也并不能遇到I/O时,就主动切换。如果在任务中进行阻塞,那么还是会等待阻塞完毕,才会去切换。
Gevent模块
Gevent 是一个第三方库,可以轻松通过gevent实现并发同步或异步编程,在gevent中用到的主要模式是Greenlet, 它是以C扩展模块形式接入Python的轻量级协程。 Greenlet全部运行在主程序操作系统进程的内部,但它们被协作式地调度。
Gevent遇到IO阻塞时会自动的在所有Gevent.spawn对象中切换。
Gevent的基本用法
g1 = gevent.spawn(func,1,,2,3,x=4,y=5)
创建一个协程对象,spawn括号内第一个参数是函数名,如task1,后面可以有多个参数,可以是位置实参或关键字实参,都是传给函数eat的。
g1.join()
等待g1结束
gevent.joinall([g1,g2,...])
等待所有列表中的gevent.spawn对象结束
g1.value
拿到g1对象中func的返回值
import gevent def task1(): print('task1 the first') gevent.sleep(5) print('task1 the second') def task2(): print('task2 the first') gevent.sleep(3) print('task2 the second') g1 = gevent.spawn(task1) # 创建gevent对象 g2 = gevent.spawn(task2) g1.join() # 等待gevent对象执行完毕,否则会直接退出 g2.join() print('master process') # 执行task1遇到阻塞时会自动切换到task2,当继续遇到阻塞时会继续切换到task1
PS:之所以会用gevent.sleep(),而不用time.sleep()是因为,想要制造I/O阻塞,那么必须要gevent识别,如果我们单纯的使用time.sleep(),gevent是无法识别的,但是gevent提供了一个名叫补丁的插件,如果想要gevent 监测当前程序中所有的阻塞,那么就需要使用如下命令:
from gevent import monkey;monkey.patch_all()
1 from gevent import monkey;monkey.patch_all() 2 import time 3 import gevent 4 5 def task1(): 6 print('task1 the first') 7 time.sleep(5) 8 print('task1 the second') 9 10 def task2(): 11 print('task2 the first') 12 time.sleep(3) 13 print('task2 the second') 14 15 g1 = gevent.spawn(task1) # 创建gevent对象 16 g2 = gevent.spawn(task2) 17 18 g1.join() # 等待gevent对象执行完毕,否则会直接退出 19 g2.join() 20 21 print('master process')
注意:必须放到被打补丁者的前面,所以一般会放在程序开头的位置。
利用Gevent实现协程
1、简单的爬虫小任务,遇到网络阻塞时,那么就进行切换。
# -------------------- 串型执行 -------------------- import requests import time def get_url(url): print('Connect: %s' % url) response = requests.get(url) print(response.text) if __name__ == '__main__': url_list = ['http://www.python.org', 'http://www.yahoo.com', 'http://www.github.com'] start = time.time() for url in url_list: get_url(url) end = time.time() print('Total: %s' % (end-start)) # 6秒 # -------------------- gevent协程 -------------------- from gevent import monkey;monkey.patch_all() import requests import gevent import time def get_url(url): print('Connecting : %s' % url) response = requests.get(url) print(response.text) if __name__ == '__main__': url_list = ['http://www.python.org','http://www.yahoo.com','http://www.github.com'] g_list = [] start = time.time() for url in url_list: g = gevent.spawn(get_url,url) g_list.append(g) for g in g_list: g.join() end = time.time() print('Total: %s' % (end - start)) # 3秒 # 可以看到在单线程的情况下,利用协程完成的效率是串型的一倍
2、利用协程完成,socket server实现单线程并发。
from gevent import monkey;monkey.patch_all() import gevent import socket class Socketserver(object): def __init__(self,ip,port): self.ip = ip self.port = port def _initserver(self): server = socket.socket(socket.AF_INET,socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind((self.ip,self.port)) server.listen(5) return server def talk(self,conn,addr): while True: data = conn.recv(1024).decode('utf-8') if not data:break print(addr,' :',data) conn.send(data.upper().encode('utf-8')) def start(self): print('启动服务器,监听: %s 端口' % ( self.port )) server = self._initserver() while True: conn,addr = server.accept() gevent.spawn(self.talk,conn,addr) # 有链接,交给gevent处理 if __name__ == '__main__': server = Socketserver('127.0.0.1',8080) server.start()
随便写了个客户端测试(多开,连接并发送数据测试)
import socket client = socket.socket(socket.AF_INET,socket.SOCK_STREAM) client.connect(('127.0.0.1',8080)) while True: msg = input('Please input words:').strip() if not msg:continue client.send(msg.encode('utf-8')) word = client.recv(1024) print(word.decode('utf-8'))
1 from threading import Thread 2 from socket import * 3 import threading 4 5 def client(server_ip,port): 6 c=socket(AF_INET,SOCK_STREAM) #套接字对象一定要加到函数内,即局部名称空间内,放在函数外则被所有线程共享,则大家公用一个套接字对象,那么客户端端口永远一样了 7 c.connect((server_ip,port)) 8 9 count=0 10 while True: 11 c.send(('%s say hello %s' %(threading.current_thread().getName(),count)).encode('utf-8')) 12 msg=c.recv(1024) 13 print(msg.decode('utf-8')) 14 count+=1 15 if __name__ == '__main__': 16 for i in range(500): 17 t=Thread(target=client,args=('127.0.0.1',8080)) 18 t.start() 19 20 多线程并发多个客户端