本节内容
一、协程
1.1、协程概念
1.2、greenlet
1.3、Gevent
1.4、协程之爬虫
1.5、协程之socket
二、论事件驱动与异步IO
三、IO
3.1、概念说明
3.2、IO模式
四、IO多路复用
1、Python Select 解析
协程
1.1、协程概念
协程,又称微线程,纤程。英文名Coroutine。一句话说明什么是线程:协程是一种用户态的轻量级线程。
协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈。因此:
协程能保留上一次调用时的状态(即所有局部状态的一个特定组合),每次过程重入时,就相当于进入上一次调用的状态,换种说法:进入上一次离开时所处逻辑流的位置。
协程的好处:
1、无需线程上下文切换的开销;
2、无需原子操作锁定及同步的开销。
3、"原子操作(atomic operation)是不需要synchronized",所谓原子操作是指不会被线程调度机制打断的操作;这种操作一旦开始,就一直运行到结束,中间不会有任何 context switch (切换到另一个线程)。原子操作可以是一个步骤,也可以是多个操作步骤,但是其顺序是不可以被打乱,或者切割掉只执行部分。视作整体是原子性的核心。
4、方便切换控制流,简化编程模型;
5、高并发+高扩展性+低成本:一个CPU支持上万的协程都不是问题。所以很适合用于高并发处理。
缺点:
1、无法利用多核资源:协程的本质是个单线程,它不能同时将 单个CPU 的多个核用上,协程需要和进程配合才能运行在多CPU上.当然我们日常所编写的绝大部分应用都没有这个必要,除非是cpu密集型应用。
2、进行阻塞(Blocking)操作(如IO时)会阻塞掉整个程序
example:yield实现协程操作
1 import time,queue 2 3 def consumer(name): 4 print("------>starting eatiing baozi......") 5 while True: 6 new_baozi = yield 7 print("[%s] is eating baozi %s" %(name,new_baozi)) 8 time.sleep(1) 9 10 11 def producer(): 12 r1 = con1.__next__() 13 r2 = con2.__next__() 14 n = 0 15 while n < 5: 16 n +=1 17 con1.send(n) 18 con2.send(n) 19 print('\033[32;1m[producer]\033[0m is making baozi %s'% n) 20 21 22 23 24 if __name__=="__main__": 25 con1 = consumer("c1") 26 con2 = consumer('c2') 27 p = producer()
我们先给协程一个标准定义,即符合什么条件就能称之为协程:
1、必须在只有一个单线程里实现并发
2、修改共享数据不需加锁
3、 用户程序里自己保存多个控制流的上下文栈
4、一个协程遇到IO操作自动切换到其它协程
基于上面这4点定义,我们刚才用yield实现的程并不能算是合格的线程,因为它有一点功能没实现,哪一点呢?
思考:
协程是在一个线程里,遇到IO操作就切换。IO操作耗时,那什么时候再切回去?
IO操作一完成,我们就切回去,怎么实现程序自动监测IO操作完成了?
1.2、Greenlet
greenlet是一个用C实现的协程模块,相比与python自带的yield,它可以使你在任意函数之间随意切换,而不需把这个函数先声明为generator
1 from greenlet import greenlet 2 3 def test1(): 4 print(12) 5 gr2.switch() 6 print(34) 7 gr2.switch() 8 9 def test2(): 10 print(56) 11 gr1.switch() 12 print(78) 13 14 15 gr1 = greenlet(test1) 16 gr2 = greenlet(test2) 17 gr1.switch()
确实用着比generator简单,但是没有解决遇到IO操作,就自动切换的问题。
1.3、Gevent
Gevent 是一个第三方库,可以轻松通过gevent实现并发同步或异步编程,在gevent中用到的主要模式是Greenlet, 它是以C扩展模块形式接入Python的轻量级协程。 Greenlet全部运行在主程序操作系统进程的内部,但它们被协作式地调度。
1 #遇到IO就自动切换,并自动监测,IO执行完了,再切回来 2 3 import gevent 4 5 def foo(): 6 print('Running in foo') 7 gevent.sleep(2) 8 print("Explicit context switch to foo again") 9 10 def bar(): 11 print('Explicit精确的 context内容 to bar') 12 gevent.sleep(1) 13 print("Implicit 隐式context switch back to bar") #隐式上下文切换回bar 14 15 16 def func3(): 17 print("running func3") 18 gevent.sleep(0) 19 print("running func3 again") 20 21 gevent.joinall([ 22 gevent.spawn(foo) , #生成 23 gevent.spawn(bar), 24 gevent.spawn(func3), 25 ]) 26 27 28 29 30 """ 31 32 执行顺序: 33 1、Running in foo 34 2、Explicit精确的 context内容 to bar 35 3、running func3,遇到了func3的IO,立马切换到foo 36 4、切换到foo,又遇到IO,自动切换到bar;再bar里又遇到IO,自动切换到func3。func3是gevent.sleep(0),所以执行 37 5、running func3 again 38 6、Implicit 隐式context switch back to bar 39 7、Explicit context switch to foo again 40 41 总共耗时2秒多,不是3秒多 42 """
同步与异步的性能区别
1 import gevent 2 3 def task(pid): 4 """ 5 Some non-deterministic task 6 """ 7 gevent.sleep(0.5) 8 print('Task %s done' % pid) 9 10 def synchronous(): 11 for i in range(1, 10): 12 task(i) 13 14 def asynchronous(): 15 threads = [gevent.spawn(task, i) for i in range(10)] 16 gevent.joinall(threads) 17 18 19 print('Synchronous:') 20 synchronous() 21 22 print('Asynchronous:') 23 asynchronous()
上面程序的重要部分是将task函数封装到Greenlet内部线程的gevent.spawn
。 初始化的greenlet列表存放在数组threads
中,此数组被传给gevent.joinall
函数,后者阻塞当前流程,并执行所有给定的greenlet。执行流程只会在 所有greenlet执行完后才会继续向下走。
遇到IO阻塞时会自动切换任务
1 from gevent import monkey 2 monkey.patch_all() 3 import gevent 4 from urllib.request import urlopen 5 6 7 def f(url): 8 print('GET: %s' % url) 9 resp = urlopen(url) 10 data = resp.read() 11 print('%d bytes received from %s.' % (len(data), url)) 12 13 14 gevent.joinall([ 15 gevent.spawn(f, 'https://www.python.org/'), 16 gevent.spawn(f, 'https://www.yahoo.com/'), 17 gevent.spawn(f, 'https://github.com/'), 18 ])
1.4、协程之爬虫
1 #Gevent 默认监测不到urllib的IO操作,所有串行了,所有要加上monkey 2 3 from urllib import request 4 import gevent,time 5 from gevent import monkey 6 monkey.patch_all() # 把当前程序的所有的IO操作都给我单独的做上标记 7 8 9 def foo(url): 10 print("GET: %s" % url) 11 resp = request.urlopen(url) 12 data = resp.read() 13 print('%d bytes received from %s.' % (len(data), url)) 14 15 urls = ['https://www.python.org/', 16 'https://www.yahoo.com/', 17 'https://github.com/' ] 18 19 20 time_start = time.time() 21 for url in urls: 22 foo(url) 23 print("同步cost",time.time() - time_start) 24 25 async_time_start = time.time() 26 gevent.joinall([ 27 gevent.spawn(foo, 'https://www.python.org/'), 28 gevent.spawn(foo, 'https://www.yahoo.com/'), 29 gevent.spawn(foo, 'https://github.com/'), 30 ]) 31 print("异步cost",time.time() - async_time_start) 32 33 34 35 """ 36 #执行结果 37 "D:\Program Files\Python37\python.exe" D:/ProgramPython181023/learning1023/Python/learn1106/协程之爬虫2.py 38 GET: https://www.python.org/ 39 48842 bytes received from https://www.python.org/. 40 GET: https://www.yahoo.com/ 41 507736 bytes received from https://www.yahoo.com/. 42 GET: https://github.com/ 43 65461 bytes received from https://github.com/. 44 同步cost 24.253386974334717 45 GET: https://www.python.org/ 46 GET: https://www.yahoo.com/ 47 GET: https://github.com/ 48 48842 bytes received from https://www.python.org/. 49 507902 bytes received from https://www.yahoo.com/. 50 65465 bytes received from https://github.com/. 51 异步cost 7.190411567687988 52 53 Process finished with exit code 0 54 55 """
1 from gevent import monkey 2 #monkey.patch_all() 3 import gevent 4 from urllib.request import urlopen 5 import time 6 7 8 def f(url): 9 print("GET:%s"%url) 10 resp = urlopen(url) 11 data = resp.read() 12 print('%d bytes received from %s...'%(len(data),url)) 13 14 15 urls = [ 'https://www.python.org/', 16 'https://www.yahoo.com/', 17 'https://github.com/' 18 ] 19 20 21 time_start = time.time() 22 for url in urls: 23 f(url) 24 print("同步cost",time.time() - time_start) 25 26 27 async_time_start = time.time() 28 gevent.joinall([ 29 gevent.spawn(f,'https://www.python.org/'), 30 gevent.spawn(f, 'https://www.yahoo.com/'), 31 gevent.spawn(f, 'https://github.com/'), 32 ]) 33 print("异步cost",time.time()-async_time_start )
1.5、协程之socket
1 #server side 2 3 4 import sys,socket,time,gevent 5 6 from gevent import socket,monkey 7 monkey.patch_all() 8 9 10 def server(port): 11 s = socket.socket() 12 s.bind(("localhost",port)) 13 s.listen(500) 14 while True: 15 cli,addr = s.accept() 16 gevent.spawn(handle_request,cli) 17 18 19 def handle_request(conn): 20 try: 21 while True: 22 data = conn.recv(1024) 23 print("recv:",data) 24 conn.send(data) 25 if not data: 26 conn.shutdown(socket.SHUT_WR) 27 28 except Exception as ex: 29 print(ex) 30 finally: 31 conn.close() 32 if __name__=="__main__": 33 server(8001)
1 #client side 2 3 4 import socket 5 6 HOST = 'localhost' 7 PORT = 8001 8 9 s = socket.socket(socket.AF_INET,socket.SOCK_STREAM) 10 s.connect((HOST,PORT)) 11 while True: 12 msg = bytes(input(">>:"),encoding="utf-8") 13 s.sendall(msg) 14 data = s.recv(1024) 15 #print(data) 16 17 print("Received",repr(data)) 18 19 s.close()
1 import socket 2 import threading 3 4 def sock_conn(): 5 6 client = socket.socket() 7 8 client.connect(("localhost",8001)) 9 count = 0 10 while True: 11 #msg = input(">>:").strip() 12 #if len(msg) == 0:continue 13 client.send( ("hello %s" %count).encode("utf-8")) 14 15 data = client.recv(1024) 16 17 print("[%s]recv from server:" % threading.get_ident(),data.decode()) #结果 18 count +=1 19 client.close() 20 21 22 for i in range(100): 23 t = threading.Thread(target=sock_conn) 24 t.start()
二、论事件驱动与异步IO
论事件驱动与异步IO
我们写服务器处理模型的程序时,有以下几种模型:
(1)每收到一个请求,创建一个新的进程,来处理该请求;
(2)每收到一个请求,创建一个新的线程,来处理该请求;
(3)每收到一个请求,放入一个事件列表,让主进程通过非阻塞I/O方式来处理请求
上面的几种方式,各有千秋,
第(1)中方法,由于创建新的进程的开销比较大,所以,会导致服务器性能比较差,但实现比较简单。
第(2)种方式,由于要涉及到线程的同步,有可能会面临死锁等问题。
第(3)种方式,在写应用程序代码时,逻辑比前面两种都复杂。
综合考虑各方面因素,一般普遍认为第(3)种方式是大多数网络服务器采用
看图说话讲事件驱动模型
在UI编程中,常常要对鼠标点击进行相应,首先如何获得鼠标点击呢?
方式一:创建一个线程,该线程一直循环检测是否有鼠标点击,那么这个方式有以下几个缺点:
1. CPU资源浪费,可能鼠标点击的频率非常小,但是扫描线程还是会一直循环检测,这会造成很多的CPU资源浪费;如果扫描鼠标点击的接口是阻塞的呢?
2. 如果是堵塞的,又会出现下面这样的问题,如果我们不但要扫描鼠标点击,还要扫描键盘是否按下,由于扫描鼠标时被堵塞了,那么可能永远不会去扫描键盘;
3. 如果一个循环需要扫描的设备非常多,这又会引来响应时间的问题;
所以,该方式是非常不好的。
方式二:就是事件驱动模型
目前大部分的UI编程都是事件驱动模型,如很多UI平台都会提供onClick()事件,这个事件就代表鼠标按下事件。事件驱动模型大体思路如下:
1. 有一个事件(消息)队列;
2. 鼠标按下时,往这个队列中增加一个点击事件(消息);
3. 有个循环,不断从队列取出事件,根据不同的事件,调用不同的函数,如onClick()、onKeyDown()等;
4. 事件(消息)一般都各自保存各自的处理函数指针,这样,每个消息都有独立的处理函数;
事件驱动编程是一种编程范式,这里程序的执行流由外部事件来决定。它的特点是包含一个事件循环,当外部事件发生时使用回调机制来触发相应的处理。另外两种常见的编程范式是(单线程)同步以及多线程编程。
让我们用例子来比较和对比一下单线程、多线程以及事件驱动编程模型。下图展示了随着时间的推移,这三种模式下程序所做的工作。这个程序有3个任务需要完成,每个任务都在等待I/O操作时阻塞自身。阻塞在I/O操作上所花费的时间已经用灰色框标示出来了。
在单线程同步模型中,任务按照顺序执行。如果某个任务因为I/O而阻塞,其他所有的任务都必须等待,直到它完成之后它们才能依次执行。这种明确的执行顺序和串行化处理的行为是很容易推断得出的。如果任务之间并没有互相依赖的关系,但仍然需要互相等待的话这就使得程序不必要的降低了运行速度。
在多线程版本中,这3个任务分别在独立的线程中执行。这些线程由操作系统来管理,在多处理器系统上可以并行处理,或者在单处理器系统上交错执行。这使得当某个线程阻塞在某个资源的同时其他线程得以继续执行。与完成类似功能的同步程序相比,这种方式更有效率,但程序员必须写代码来保护共享资源,防止其被多个线程同时访问。多线程程序更加难以推断,因为这类程序不得不通过线程同步机制如锁、可重入函数、线程局部存储或者其他机制来处理线程安全问题,如果实现不当就会导致出现微妙且令人痛不欲生的bug。
在事件驱动版本的程序中,3个任务交错执行,但仍然在一个单独的线程控制中。当处理I/O或者其他昂贵的操作时,注册一个回调到事件循环中,然后当I/O操作完成时继续执行。回调描述了该如何处理某个事件。事件循环轮询所有的事件,当事件到来时将它们分配给等待处理事件的回调函数。这种方式让程序尽可能的得以执行而不需要用到额外的线程。事件驱动型程序比多线程程序更容易推断出行为,因为程序员不需要关心线程安全问题。
当我们面对如下的环境时,事件驱动模型通常是一个好的选择:
- 程序中有许多任务,而且…
- 任务之间高度独立(因此它们不需要互相通信,或者等待彼此)而且…
- 在等待事件到来时,某些任务会阻塞。
当应用程序需要在任务间共享可变的数据时,这也是一个不错的选择,因为这里不需要采用同步处理。
网络应用程序通常都有上述这些特点,这使得它们能够很好的契合事件驱动编程模型。
此处要提出一个问题,就是,上面的事件驱动模型中,只要一遇到IO就注册一个事件,然后主程序就可以继续干其它的事情了,只到io处理完毕后,继续恢复之前中断的任务,这本质上是怎么实现的呢?哈哈,下面我们就来一起揭开这神秘的面纱。。。。
(前面有一个问题:就是遇到IO就切换, 那什么时候自动切换回来?
定义了一个回调函数,由操作系统读取完成后。就是通过事件驱动的方式,一出现IO操作,我就注册一个事件,就是IO事件。交给操作系统,操作系统里面有一个队列,处理完了之后,把这个结果,通过回调函数通知你,这个过程就完了)
三、IO讲解
同步IO和异步IO,阻塞IO和非阻塞IO分别是什么,到底有什么区别?不同的人在不同的上下文下给出的答案是不同的。所以先限定一下本文的上下文。
本文讨论的背景是Linux环境下的network IO。
3.1 、概念说明
在进行解释之前,首先要说明几个概念:
- 用户空间和内核空间
- 进程切换
- 进程的阻塞
- 文件描述符
- 缓存 I/O
用户空间与内核空间
现在操作系统都是采用虚拟存储器,那么对32位操作系统而言,它的寻址空间(虚拟存储空间)为4G(2的32次方)。操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证用户进程不能直接操作内核(kernel),保证内核的安全,操心系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。针对linux操作系统而言,将最高的1G字节(从虚拟地址0xC0000000到0xFFFFFFFF),供内核使用,称为内核空间,而将较低的3G字节(从虚拟地址0x00000000到0xBFFFFFFF),供各个进程使用,称为用户空间。
进程切换
为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换。因此可以说,任何进程都是在操作系统内核的支持下运行的,是与内核紧密相关的。
从一个进程的运行转到另一个进程上运行,这个过程中经过下面这些变化:
1. 保存处理机上下文,包括程序计数器和其他寄存器。
2. 更新PCB信息。
3. 把进程的PCB移入相应的队列,如就绪、在某事件阻塞等队列。
4. 选择另一个进程执行,并更新其PCB。
5. 更新内存管理的数据结构。
6. 恢复处理机上下文。
总而言之就是很耗资源,具体的可以参考这篇文章:进程切换
注:进程控制块(Processing Control Block),是操作系统核心中一种数据结构,主要表示进程状态。其作用是使一个在多道程序环境下不能独立运行的程序(含数据),成为一个能独立运行的基本单位或与其它进程并发执行的进程。或者说,OS是根据PCB来对并发执行的进程进行控制和管理的。 PCB通常是系统内存占用区中的一个连续存区,它存放着操作系统用于描述进程情况及控制进程运行所需的全部信息
进程的阻塞
正在执行的进程,由于期待的某些事件未发生,如请求系统资源失败、等待某种操作的完成、新数据尚未到达或无新工作做等,则由系统自动执行阻塞原语(Block),使自己由运行状态变为阻塞状态。可见,进程的阻塞是进程自身的一种主动行为,也因此只有处于运行态的进程(获得CPU),才可能将其转为阻塞状态。当进程进入阻塞状态,是不占用CPU资源的
。
文件描述符fd
文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。
文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。
缓存 I/O
缓存 I/O 又被称作标准 I/O,大多数文件系统的默认 I/O 操作都是缓存 I/O。在 Linux 的缓存 I/O 机制中,操作系统会将 I/O 的数据缓存在文件系统的页缓存( page cache )中,也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。
缓存 I/O 的缺点:
数据在传输过程中需要在应用程序地址空间和内核进行多次数据拷贝操作,这些数据拷贝操作所带来的 CPU 以及内存开销是非常大的。
注释:
发数据:
先发到操作系统的IO接口里,然后操作系统 IO的接口,其实是把这份数据读到了内核空间里,然后再把这份数据拷贝到你的用户的内存空间里,是这样的一个过程。
数据要从内核态,拷贝到用户态。内核空间到用户空间,用户是不能直接访问内核的,只能通过调用内核的接口,让内核处理完了,把数据传给你。
3.2、 IO模式
刚才说了,对于一次IO访问(以read举例),数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。所以说,当一个read操作发生时,它会经历两个阶段:
1. 等待数据准备 (Waiting for the data to be ready)
2. 将数据从内核拷贝到进程中 (Copying the data from the kernel to the process)
正式因为这两个阶段,linux系统产生了下面五种网络模式的方案。
- 阻塞 I/O(blocking IO)
- 非阻塞 I/O(nonblocking IO)
- I/O 多路复用( IO multiplexing)
- 信号驱动 I/O( signal driven IO)
- 异步 I/O(asynchronous IO)
注:由于signal driven IO在实际中并不常用,所以我这只提及剩下的四种IO Model。
3.2.1阻塞 I/O(blocking IO)
在linux中,默认情况下所有的socket都是blocking,一个典型的读操作流程大概是这样:
当用户进程调用了recvfrom这个系统调用,kernel就开始了IO的第一个阶段:准备数据(对于网络IO来说,很多时候数据在一开始还没有到达。比如,还没有收到一个完整的UDP包。这个时候kernel就要等待足够的数据到来)。这个过程需要等待,也就是说数据被拷贝到操作系统内核的缓冲区中是需要一个过程的。而在用户进程这边,整个进程会被阻塞(当然,是进程自己选择的阻塞)。当kernel一直等到数据准备好了,它就会将数据从kernel中拷贝到用户内存,然后kernel返回结果,用户进程才解除block的状态,重新运行起来。
所以,blocking IO的特点就是在IO执行的两个阶段(内核等数据和拷贝数据的阶段)都被block了。
3.2.2非阻塞 I/O(nonblocking IO)
linux下,可以通过设置socket使其变为non-blocking。当对一个non-blocking socket执行读操作时,流程是这个样子:
当用户进程发出read操作时,如果kernel中的数据还没有准备好,那么它并不会block用户进程,而是立刻返回一个error。从用户进程角度讲 ,它发起一个read操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个error时,它就知道数据还没有准备好,于是它可以再次发送read操作。一旦kernel中的数据准备好了,并且又再次收到了用户进程的system call,那么它马上就将数据拷贝到了用户内存,然后返回。
所以,nonblocking IO的特点是用户进程需要不断的主动询问kernel数据好了没有。(还是会在卡在等数据和拷贝数据)
3.2.3 I/O 多路复用( IO multiplexing)
IO multiplexing就是我们说的select,poll,epoll,有些地方也称这种IO方式为event driven IO。select/epoll的好处就在于单个process就可以同时处理多个网络连接的IO。它的基本原理就是select,poll,epoll这个function会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程。
当用户进程调用了select,那么整个进程会被block
,而同时,kernel会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。
所以,I/O 多路复用的特点是通过一种机制一个进程能同时等待多个文件描述符,而这些文件描述符(套接字描述符)其中的任意一个进入读就绪状态,select()函数就可以返回。
这个图和blocking IO的图其实并没有太大的不同,事实上,还更差一些。因为这里需要使用两个system call (select 和 recvfrom),而blocking IO只调用了一个system call (recvfrom)。但是,用select的优势在于它可以同时处理多个connection。
所以,如果处理的连接数不是很高的话,使用select/epoll的web server不一定比使用multi-threading + blocking IO的web server性能更好,可能延迟还更大。select/epoll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。)
在IO multiplexing Model中,实际中,对于每一个socket,一般都设置成为non-blocking,但是,如上图所示,整个用户的process其实是一直被block的。只不过process是被select这个函数block,而不是被socket IO给block。
还是会卡在内核拷贝数据给用户内存阶段
3.2.4 异步 I/O(asynchronous IO)
inux下的asynchronous IO其实用得很少。先看一下它的流程:
用户进程发起read操作之后,立刻就可以开始去做其它的事。而另一方面,从kernel的角度,当它受到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何block。然后,kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个signal,告诉它read操作完成了。
(最流弊的方式,都不会卡住,在等数据阶段和拷贝数据阶段,都由内核完成,完成后,之间拷贝给用户内存后,在发消息通知用户,告诉他已经完成)
总结
blocking和non-blocking的区别
调用blocking IO会一直block住对应的进程直到操作完成,而non-blocking IO在kernel还准备数据的情况下会立刻返回。
synchronous IO和asynchronous IO的区别
(同步IO: 阻塞I/O、非阻塞I/O、多路复用I/O)
在说明synchronous IO和asynchronous IO的区别之前,需要先给出两者的定义。POSIX的定义是这样子的:
- A synchronous I/O operation causes the requesting process to be blocked until that I/O operation completes;
- An asynchronous I/O operation does not cause the requesting process to be blocked;
两者的区别就在于synchronous IO做”IO operation”的时候会将process阻塞。按照这个定义,之前所述的blocking IO,non-blocking IO,IO multiplexing都属于synchronous IO。
有人会说,non-blocking IO并没有被block啊。这里有个非常“狡猾”的地方,定义中所指的”IO operation”是指真实的IO操作,就是例子中的recvfrom这个system call。non-blocking IO在执行recvfrom这个system call的时候,如果kernel的数据没有准备好,这时候不会block进程。但是,当kernel中数据准备好的时候,recvfrom会将数据从kernel拷贝到用户内存中,这个时候进程是被block了,在这段时间内,进程是被block的。
而asynchronous IO则不一样,当进程发起IO 操作之后,就直接返回再也不理睬了,直到kernel发送一个信号,告诉进程说IO完成。在这整个过程中,进程完全没有被block。
各个IO Model的比较如图所示:
通过上面的图片,可以发现non-blocking IO和asynchronous IO的区别还是很明显的。在non-blocking IO中,虽然进程大部分时间都不会被block,但是它仍然要求进程去主动的check,并且当数据准备完成以后,也需要进程主动的再次调用recvfrom来将数据拷贝到用户内存。而asynchronous IO则完全不同。它就像是用户进程将整个IO操作交给了他人(kernel)完成,然后他人做完后发信号通知。在此期间,用户进程不需要去检查IO操作的状态,也不需要主动的去拷贝数据。
备注:signal-driver I/O Alex也不会,没讲
四、I/O 多路复用
4.1、Python Select 解析
首先列一下,sellect、poll、epoll三者的区别
select
select最早于1983年出现在4.2BSD中,它通过一个select()系统调用来监视多个文件描述符的数组,当select()返回后,该数组中就绪的文件描述符便会被内核修改标志位,使得进程可以获得这些文件描述符从而进行后续的读写操作。
select目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点,事实上从现在看来,这也是它所剩不多的优点之一。
select的一个缺点在于单个进程能够监视的文件描述符的数量存在最大限制,在Linux上一般为1024,不过可以通过修改宏定义甚至重新编译内核的方式提升这一限制。
另外,select()所维护的存储大量文件描述符的数据结构,随着文件描述符数量的增大,其复制的开销也线性增长。同时,由于网络响应时间的延迟使得大量TCP连接处于非活跃状态,但调用select()会对所有socket进行一次线性扫描,所以这也浪费了一定的开销。
(注释:windows 不支持epoll,只支持select。
select() 的缺点 :1、限制最大的连接数,1024个;2、就是内核监测到有一个socket有数据了,就把结果返回,但不告诉是那个socket有数据了,这样,用户端只能自己select,如果有65535个socket,只有两个有数据,那循环的65533次就浪费了)
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()时便得到通知。
(注解:epoll还是IO多路复用,是同步I/O,不是异步I/O
epoll 就是100个里活跃了2个,就只循环这2个
水平触发:就是内存获取数据后,通知用户拷贝,用户没拷贝,那下次循环,还是会同样的通知用户。只要数据没取,就会一直保存在内存里
边缘触发:就是用户没取数据,内核下次就不通知你了,但数据还是在内存里)