定义:协程,又称微线程,纤程。英文名Coroutine。一句话说明什么是线程:协程是一种用户态的轻量级线程。协程的标准定义:
特点:协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈。因此,协程能保留上一次调用时的状态(即所有局部状态的一个特定组合),每次过程重入时,就相当于进入上一次调用的状态,换种说法:进入上一次离开时所处逻辑流的位置。线程是CPU控制的,而协程是程序自身控制的。
优点:
1、由于自身带有上下文和栈,无需线程上下文切换的开销;
2、无需原子操作的锁定及同步的开销;
3、方便切换控制流,简化编程模型
4、可以轻松实现高并发,且可扩展性高,成本低
缺点:
1、无法利用多核资源:协程的本质是个单线程,它不能同时将 单个CPU 的多个核用上,协程需要和进程配合才能运行在多CPU上.当然我们日常所编写的绝大部分应用都没有这个必要,除非是cpu密集型应用。
2、当进行阻塞(Blocking)操作时,会阻塞整个程序。
greenlet是一个用C实现的协程模块,通过设置.switch()可以实现任意函数之间的切换,但这种切换属于手动切换,当遇到IO操作时,程序会阻塞,而不能自动进行切换。举例如下:
from greenlet import greenlet
import time
def test1():
print(" running test1")
gr2.switch() # 切换到test2
print(" running test1 again ")
time.sleep(2)
gr2.switch()
def test2():
print("\033[31;1m running test2 \033[0m")
gr1.switch()
print("\033[32;1m running test2 again\033[0m")
gr1 = greenlet(test1) # 实例化一个协程
gr2 = greenlet(test2) # 实例化另一个协程
gr1.switch() # 执行gr1,切换到grl1执行test1
运行结果:
running test1
running test2
running test1 again
running test2 again
Gevent 是一个第三方库,可以轻松通过gevent实现并发同步或异步编程,在gevent中用到的主要模式是Greenlet, 它是以C扩展模块形式接入Python的轻量级协程。 Greenlet全部运行在主程序操作系统进程的内部,但它们被协作式地调度。Gevnet遇到IO操作时,会进行自动切话,属于主动式切换。例如:
import gevent,time
def func1():
print(' 主人来电话啦...')
gevent.sleep(3)
print(' 主人那家伙又来电话啦...')
def func2():
print('\033[32;1m 打个电话...\033[0m')
gevent.sleep(2)
print('\033[32;1m 咦,电话给挂了,接着打...\033[0m')
def func3():
print("\033[31;1m 哈哈哈哈 \033[0m")
gevent.sleep(0)
print("\033[31;1m 嘿嘿嘿。。。。\033[0m")
start_time = time.time()
gevent.joinall([
gevent.spawn(func2), # 生成一个协程
gevent.spawn(func1),
gevent.spawn(func3),
])
print("\033[32;1m running time:", (time.time() - start_time))
运行结果:
打个电话...
主人来电话啦...
哈哈哈哈
嘿嘿嘿。。。。
咦,电话给挂了,接着打...
主人那家伙又来电话啦...
running time: 3.006500244140625
# 可以看出协程并发的效果,遇到IO操作时会自动进行切换,当IO操作没有完成时,会不停的进行循环切换,看哪个IO操作完成了。
将所有发生的事件按照先后顺序存放在事件队列中(先进先出,与队列相似), 事件(消息)一般都各自保存各自的处理函数指针,这样每一种事件对应相应的处理函数,再循环事件队列进行处理。
为什么用到事件驱动模型?
通常在写服务器处理模型的程序时,对于接收到请求,有三种常用的处理模型:新建一个进程、新建一个线程或者事件驱动模型。新建一个进程实现比较简单,但当请求较多时,会导致服务器的开销变大,从而降低服务器的性能;新建一个线程,会涉及到线程的同步,可能会面临死锁的问题;事件驱动模型,可以很好地解决前两个问题,但实现逻辑比较复杂。
目前大部分的UI编程都是事件驱动模型,如很多UI平台都会提供onClick()事件,这个事件就代表鼠标按下事件。事件驱动模型大体思路如下:
图中事件1假如是鼠标点击,那么鼠标点击一下就将该事件放入这个事件队列中;假如事件2是按下键盘事件,也将该事件放入事件队列中; 线程会循环的去处理事件队列中的事件。 将事件加入到事件列表,和提取事件处理相互是不影响的,事件的处理速度,并不影响事件的产生速度,这就是典型的生产者消费者模型。比如我每秒点10次鼠标,但是你的处理速度是每秒8次,虽然你处理的慢,但是并不影响我继续点击鼠标。事件驱动模型就是根据事件做出相应的反应,比如点下文档的'X'就关闭文档,点击 '-' 就最小化文档。
中央处理器(CPU,Central Processing Unit)是一块超大规模的集成电路,是一台计算机的运算核心(Core)和控制核心( Control Unit)。它的功能主要是解释计算机指令以及处理计算机软件中的数据。物理结构主要包括:
逻辑部件:运算逻辑部件。主要是进行定点或浮点算数运算操作、移位操作以及逻辑操作,还可以进行地址运算和转换;
寄存器部件:保存指令执行过程中临时存放的寄存器操作数和中间(或最终)的操作结果;
控制部件:主要是负责对指令译码,并且发出为完成每条指令所要执行的各个操作的控制信号;
CPU具有以下4个方面的基本功能:数据通信,资源共享,分布式处理,提供系统可靠性。运作原理可基本分为四个阶段:提取(Fetch)、解码(Decode)、执行(Execute)和写回(Writeback)。
现在操作系统都是采用虚拟存储器,那么对32位操作系统而言,它的寻址空间(虚拟存储空间)为4G(2的32次方)。操作系统的核心是内核(操作系统需要使用部分内存空间来运行,这就是内核空间),独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限(比如访问网卡、音响声卡都是通过内核访问的,而不是用户程序)。为了保证用户进程不能直接操作内核(kernel),保证内核的安全,操心系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。针对linux操作系统而言,将最高的1G字节(从虚拟地址0xC0000000到0xFFFFFFFF),供内核使用,称为内核空间,而将较低的3G字节(从虚拟地址0x00000000到0xBFFFFFFF),供各个进程使用,称为用户空间。
进程切换
进程切换就是上下文的切换
进程阻塞
正在执行的进程,由于期待的某些事件未发生,如请求系统资源失败、等待某种操作的完成、新数据尚未到达或无新工作做等,则由系统自动执行阻塞原语(Block),使自己由运行状态变为阻塞状态(比如socket server等不到client的数据就会阻塞)。可见,进程的阻塞是进程自身的一种主动行为,也因此只有处于运行态的进程(获得CPU),才可能将其转为阻塞状态。当进程进入阻塞状态,是不占用CPU资源的。
文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。
文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。
文件描述符相当于一个索引,通过索引打开真正的内容。
缓存 I/O 又被称作标准 I/O,大多数文件系统的默认 I/O 操作都是缓存 I/O。在 Linux 的缓存 I/O 机制中,操作系统会将 I/O 的数据缓存在文件系统的页缓存( page cache )中,也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。
缓存 I/O 的缺点:
数据在传输过程中需要在应用程序地址空间和内核进行多次数据拷贝操作,这些数据拷贝操作所带来的 CPU 以及内存开销是非常大的。
打开一个文件默认不是在用户的内存空间,而是放入了内核的缓存中,然后在从内核的缓存拷贝到用户的内存空间; 传数据也是一样,先是到内核缓存中,然后才会拷贝到用户的内存空间; 使用内核是很耗CPU的,耗CPU是指拷贝到内存的这个指令,如果有大量数据需要从内核缓存拷贝到用户内存空间,那么就会有大量的指令会消耗CPU资源。访问网卡、声卡等只能通过内核实现,而用户空间是无法直接访问内核空间的,所以需要通过内核缓存的空间将内容拷贝到用户的内存空间,然后用户才可以使用。
刚才说了,对于一次IO访问(以read举例),数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。所以说,当一个read操作发生时,它会经历两个阶段:
正是因为这两个阶段,linux系统产生了下面五种网络模式的方案。
注:由于signal driven IO在实际中并不常用,所以我这只提及剩下的四种IO Model。
在linux中,默认情况下所有的socket都是blocking,一个典型的读操作流程大概是这样:
recve时接收端会阻塞,直到系统接收到数据,系统接收到数据后此时也是阻塞的,会从内核缓存copy到用户内存,然后返回一个OK才是用户真正接收到了数据。
当用户进程调用了recvfrom这个系统调用,kernel就开始了IO的第一个阶段:准备数据(对于网络IO来说,很多时候数据在一开始还没有到达。比如,还没有收到一个完整的UDP包。这个时候kernel就要等待足够的数据到来)。这个过程需要等待,也就是说数据被拷贝到操作系统内核的缓冲区中是需要一个过程的。而在用户进程这边,整个进程会被阻塞(当然,是进程自己选择的阻塞)。当kernel一直等到数据准备好了,它就会将数据从kernel中拷贝到用户内存,然后kernel返回结果,用户进程才解除block的状态,重新运行起来。
所以,blocking IO的特点就是在IO执行的两个阶段都被block了(等数据的阶段和从内核拷贝给用户的阶段)。
linux下,可以通过设置socket使其变为non-blocking。当对一个non-blocking socket执行读操作时,流程是这个样子:
当用户进程发出read操作时,如果kernel中的数据还没有准备好,那么它并不会block用户进程,而是立刻返回一个error。从用户进程角度讲 ,它发起一个read操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个error时,它就知道数据还没有准备好,于是它可以再次发送read操作。一旦kernel中的数据准备好了,并且又再次收到了用户进程的system call(receive的动作),那么它马上就将数据拷贝到了用户内存,然后返回。
所以,nonblocking IO的特点是用户进程需要不断的主动询问kernel数据好了没有。
IO multiplexing就是我们说的select,poll,epoll,有些地方也称这种IO方式为event driven IO。select/epoll的好处就在于单个process就可以同时处理多个网络连接的IO。它的基本原理就是select,poll,epoll这个function会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程。
在单线程且又是阻塞模式下,是没法实现多个IO一起执行的,因为当接收数据时,一直没有接收到的话就会一直卡住。
在单线程下非阻塞模式下,假如此时有10个IO,对这10个IO进行for循环来接收数据,先接收其中2个IO的数据,如果这2个IO没有接收到数据就会返回err,此时就不会在阻塞了,然后继续进行for循环,此时其他IO如果有数据就会将数据接收过来,然后就这样不断的receive,发现err就不阻塞,有数据则接收。使用非阻塞模式就可以处理多个socket,对于用户来说就已经是并发了。但是要注意的是第一阶段不卡了,但是此时第二阶段依然会卡,如果从内核copy到用户内存的数据不大,则很快会copy完成,但是如果数据很大的话,第二阶段就会一直在copy数据,直到数据copy完成,但相应的在第二阶段卡的时间也会很久。
当用户进程调用了select,那么整个进程会被block,假如此时有100个socket的IO,那么kernel会监视所有select负责的socket,当任何一个socket中的数据准备好了(kernel的数据准备好),select就会返回。这个时候用户进程在调用read操作,将数据kernelcopy到用户进程。所以,I/O多路复用的特点是通过一种机制 一个进程能同时等待多个文件描述符,而这些文件描述符(socket连接)其中的任意一个进入读就绪状态,select()函数就可以返回。
多路复用和阻塞模式的区别就是,阻塞模式监视一个socket,有数据则接收;而多路复用就是可以通过select监视N个socket,只要其中任何一个有数据,则进行select返回,然后receive数据(第二阶段数据过大的话,依然会有阻塞)。
假如此时有10000个socket连接,监视到有数据后kernel就会告诉返回给用户进程,但kernel不会告诉用户进程具体是哪个socket连接,所以用户就会循环着10000个socket连接,但是即使其中只有2个socket有数据,用户程序也会去循环着10000个socket连接,这就造成了大量的多余循环操作。
select
select最早于1983年出现在4.2BSD中,它通过一个select()系统调用来监视多个文件描述符的数组,当select()返回后,该数组中就绪的文件描述符便会被内核修改标志位,使得进程可以获得这些文件描述符从而进行后续的读写操作。
select目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点,事实上从现在看来,这也是它所剩不多的优点之一。
select的一个缺点在于单个进程能够监视的文件描述符的数量存在最大限制,在Linux上一般为1024,不过可以通过修改宏定义甚至重新编译内核的方式提升这一限制。
另外,select()所维护的存储大量文件描述符的数据结构,随着文件描述符数量的增大,其复制的开销也线性增长。同时,由于网络响应时间的延迟使得大量TCP连接处于非活跃状态,但调用select()会对所有socket进行一次线性扫描,所以这也浪费了一定的开销。
poll
poll在1986年诞生于System V Release 3,它和select在本质上没有多大差别,但是poll没有最大文件描述符数量的限制。
poll和select同样存在一个缺点就是,包含大量文件描述符的数组被整体复制于用户态和内核的地址空间之间,而不论这些文件描述符是否就绪,它的开销随着文件描述符数量的增加而线性增大。
另外,select()和poll()将就绪的文件描述符告诉进程后,如果进程没有对其进行IO操作,那么下次调用select()和poll()的时候将再次报告这些文件描述符,所以它们一般不会丢失就绪的消息,这种方式称为水平触发(Level Triggered)。
epoll
直到Linux2.6才出现了由内核直接支持的实现方法,那就是epoll,它几乎具备了之前所说的一切优点,被公认为Linux2.6下性能最好的多路I/O就绪通知方法。
epoll可以同时支持水平触发和边缘触发(Edge Triggered,只告诉进程哪些文件描述符刚刚变为就绪状态,它只说一遍,如果我们没有采取行动,那么它将不会再次告知,这种方式称为边缘触发),理论上边缘触发的性能要更高一些,但是代码实现相当复杂。
epoll同样只告知那些就绪的文件描述符,而且当我们调用epoll_wait()获得就绪文件描述符时,返回的不是实际的描述符,而是一个代表就绪描述符数量的值,你只需要去epoll指定的一个数组中依次取得相应数量的文件描述符即可,这里也使用了内存映射(mmap)技术,这样便彻底省掉了这些文件描述符在系统调用时复制的开销。
另一个本质的改进在于epoll采用基于事件的就绪通知方式。在select/poll中,进程只有在调用一定的方法后,内核才对所有监视的文件描述符进行扫描,而epoll事先通过epoll_ctl()来注册一个文件描述符,一旦基于某个文件描述符就绪时,内核会采用类似callback的回调机制,迅速激活这个文件描述符,当进程调用epoll_wait()时便得到通知。
epool会告诉用户进程具体哪个socket连接有数据了,所以用户进程不需要在将所有socket 连接全都循环一次才发现具体哪个有数据。
Windows不支持epool,支持select
inux下的asynchronous IO其实用得很少。先看一下它的流程:
用户进程发起read操作之后,立刻就可以开始去做其它的事(不需要等待kernel拷贝数据到用户)。而另一方面,从kernel的角度,当它受到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何block。然后,kernel会等待数据准备完成,然后kernel主动将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个signal,告诉它read操作完成了(没有任何阻塞)。
异步因为实现比较复杂,所以使用的较少,使用较多的还是epool多路复用。
阻塞模式:
原理:内核阻塞,直到接收到完整的数据后,在将数据copy给用户内存,copy完成后会返回一个OK给当前server的进程,然后进程接触阻塞继续向下执行(执行代码)。
server等待接收数据时,会处于阻塞的状态,当前进程不会再往下执行,除非接收到数据以后才会;假如当前有3个IO,目前第1个IO处于接收数据状态,除非第1个IO内核接收完成,否则第2和第3个内核IO都不会开始数据的接收;内核准备好数据还要讲数据copy给用户内存。
非阻塞模式:
原理:内核不阻塞,用户内存接收数据会阻塞(直到接收完成);多个IO时,内核会同时接收多个IO数据,用户进程会轮询的方式read内核是否准备好数据,如果没有准备好的话内核返回err给用户进程,此时用户进程会问内核其他IO是否准备好数据,没准备好内核返回err给用户进程,准备的话就将数据copy给用户内存。
server等待接收数据,假如当前有3个IO,内核的第1个IO数据没有准备好,用户进程向进程询问第1个IO数据是否准备好,内核返回err给用户进程,用户进程去问内核第2个IO数据是否准备好,准备好了就会将数据copy到用户内存,用户进程以此类推的循环去询问内核; 需要不断的read和返回err,开销较大。
IO多路复用:
select:
有多个socket IO时,通过select来负责所有socket IO,然后内核会监视所有select负责的socket,用户进程会循环所有IO,当任何一个socket IO中的数据准备好了(相当于内核的数据准备好了)且刚好被用户进程循环到,select就会返回datagram ready给用户进程(此时用户内存处于阻塞状态),用户进程将该IO数据copy到用户内存,copy完成后返回OK给用户进程,告知解除用户内存,让其他IO在内核内存准备好的数据可以copy到用户内存(如果当前从内核copy到用户内存的数据较大的话,只能等待数据copy完成,也就是该阶段依然是阻塞,除非数据copy完成后,才会为其他IO的数据进行copy操作)。
使用select不需要内核返回大量的err,但是用户进程依然需要循环所有IO,假如10000个IO,其中只有2个IO数据准备好了,那么用户进程依然需要去循环这10000个IO来发现这两个已经准备好数据的IO。
select还是存在大量循环的操作。
poll:
poll和select基本相似,select支持的文件描述符(相当于每个IO的索引地址,用户进程需要根据具体的地址来进行制定的数据操作)为1024,poll则没有文件描述符的限制。
epoll:
不需要用户进程去循环,当内核数据准备好后会立即告知用户进程具体的哪个socket IO数据准备好了,且只会说一遍,不会再次告知,用户进程不需要循环所有socket IO ,不过epoll的代码实现相当复杂。
异步IO:
用户进程发起read后就立刻开始做其他事情,不需要等内核将数据copy到用户;而内核接到read后会返回信息给用户进程,不会让用户进程产生阻塞(这样第二阶段不会阻塞),然后当内核准备好数据后,内核会主动将数据copy给用户内存(其他模式是用户主动从内核copy数据),当数据copy完成后内核会发送一个signal给用户进程,告诉用户进程read操作完成了(没有任何阻塞)。
异步因为实现比较复杂,所以使用的较少,使用较多的还是epool多路复用。
server端
# 服务器端
import select
import socket
import sys
import queue
server = socket.socket()
server.setblocking(0)
server_addr = ('localhost',10000)
print('starting up on %s port %s' % server_addr)
server.bind(server_addr)
server.listen(5)
inputs = [server, ] #自己也要监测呀,因为server本身也是个fd
outputs = []
message_queues = {}
while True:
print("waiting for next event...")
readable, writeable, exeptional = select.select(inputs,outputs,inputs) #如果没有任何fd就绪,那程序就会一直阻塞在这里
for s in readable: #每个s就是一个socket
if s is server: #别忘记,上面我们server自己也当做一个fd放在了inputs列表里,传给了select,如果这个s是server,代表server这个fd就绪了,
#就是有活动了, 什么情况下它才有活动? 当然 是有新连接进来的时候 呀
#新连接进来了,接受这个连接
conn, client_addr = s.accept()
print("new connection from",client_addr)
conn.setblocking(0)
inputs.append(conn) #为了不阻塞整个程序,我们不会立刻在这里开始接收客户端发来的数据, 把它放到inputs里, 下一次loop时,这个新连接
#就会被交给select去监听,如果这个连接的客户端发来了数据 ,那这个连接的fd在server端就会变成就续的,select就会把这个连接返回,返回到
#readable 列表里,然后你就可以loop readable列表,取出这个连接,开始接收数据了, 下面就是这么干 的
message_queues[conn] = queue.Queue() #接收到客户端的数据后,不立刻返回 ,暂存在队列里,以后发送
else: #s不是server的话,那就只能是一个 与客户端建立的连接的fd了
#客户端的数据过来了,在这接收
data = s.recv(1024)
if data:
print("收到来自[%s]的数据:" % s.getpeername()[0], data)
message_queues[s].put(data) #收到的数据先放到queue里,一会返回给客户端
if s not in outputs:
outputs.append(s) #为了不影响处理与其它客户端的连接 , 这里不立刻返回数据给客户端
else:#如果收不到data代表什么呢? 代表客户端断开了呀
print("客户端断开了",s)
if s in outputs:
outputs.remove(s) #清理已断开的连接
inputs.remove(s) #清理已断开的连接
del message_queues[s] ##清理已断开的连接
for s in writeable:
try :
next_msg = message_queues[s].get_nowait()
except queue.Empty:
print("client [%s]" %s.getpeername()[0], "queue is empty..")
outputs.remove(s)
else:
print("sending msg to [%s]"%s.getpeername()[0], next_msg)
s.send(next_msg.upper())
for s in exeptional:
print("handling exception for ",s.getpeername())
inputs.remove(s)
if s in outputs:
outputs.remove(s)
s.close()
del message_queues[s]
client端
import socket
import sys
messages = [ b'This is the message. ',
b'It will be sent ',
b'in parts.',
]
server_address = ('localhost', 10000)
# 创建一个 TCP/IP socket
socks = [ socket.socket(socket.AF_INET, socket.SOCK_STREAM) for i in range(50)] # 同时开启50个socket
print('connecting to %s port %s' % server_address)
for s in socks:
s.connect(server_address)
for message in messages:
# 发送消息
for s in socks:
print('%s: sending "%s"' % (s.getsockname(), message) )
s.send(message)
# 接收消息
for s in socks:
data = s.recv(1024)
print( '%s: received "%s"' % (s.getsockname(), data) )
if not data:
print(sys.stderr, 'closing socket', s.getsockname() )
该模块允许基于选择模块原语的高级和高效I / O复用。 建议用户使用此模块,除非他们希望精确控制所使用的操作系统级基元。
import selectors
import socket
sel = selectors.DefaultSelector()
def accept(sock, mask):
conn, addr = sock.accept()
print('accepted', conn, 'from', addr)
conn.setblocking(False)
sel.register(conn, selectors.EVENT_READ, read)
def read(conn, mask):
data = conn.recv(10000)
if data:
print('echoing', repr(data), 'to', conn)
conn.send(data) # Hope it won't block
else:
print('closing', conn)
sel.unregister(conn)
conn.close()
sock = socket.socket()
sock.bind(('localhost', 10000))
sock.listen(100)
sock.setblocking(False)
sel.register(sock, selectors.EVENT_READ, accept)
while True:
events = sel.select()
for key, mask in events:
callback = key.data
callback(key.fileobj, mask)