事件驱动模型 IO多路复用 阻塞IO与非阻塞IO select epool

时间:2023-03-08 22:21:47
事件驱动模型 IO多路复用 阻塞IO与非阻塞IO select epool

一.事件驱动

1.要理解事件驱动和程序,就需要与非事件驱动的程序进行比较。实际上,现代的程序大多是事件驱动的,比如多线程的程序,肯定是事件驱动的。早期则存在许多非事件驱动的程序,这样的程序,在需要等待某个条件触发时,会不断地检查这个条件,直到条件满足,这是很浪费cpu时间的。而事件驱动的程序,则有机会释放cpu从而进入睡眠态(注意是有机会,当然程序也可自行决定不释放cpu),当事件触发时被操作系统唤醒,这样就能更加有效地使用cpu.
2.再说什么是事件驱动的程序。一个典型的事件驱动的程序,就是一个死循环,并以一个线程的形式存在,这个死循环包括两个部分,第一个部分是按照一定的条件接收并选择一个要处理的事件,第二个部分就是事件的处理过程。程序的执行过程就是选择事件和处理事件,而当没有任何事件触发时,程序会因查询事件队列失败而进入睡眠状态,从而释放cpu。
3.事件驱动的程序,必定会直接或者间接拥有一个事件队列,用于存储未能及时处理的事件。
4.事件驱动的程序的行为,完全受外部输入的事件控制,所以,事件驱动的系统中,存在大量这种程序,并以事件作为主要的通信方式。
5.事件驱动的程序,还有一个最大的好处,就是可以按照一定的顺序处理队列中的事件,而这个顺序则是由事件的触发顺序决定的,这一特性往往被用于保证某些过程的原子化。
6.目前windows,linux,nucleus,vxworks都是事件驱动的,只有一些单片机可能是非事件驱动的。

注意,事件驱动的监听事件是由操作系统调用的cpu来完成的

事件驱动编程是一种编程范式,这里程序的执行流由外部事件来决定。它的特点是包含一个事件循环,当外部事件发生时使用回调机制来触发相应的处理。另外两种常见的编程范式是(单线程)同步以及多线程编程。

协程实现的IO阻塞自动切换,那么协程又是怎么实现的,在原理是是怎么实现的。如何去实现事件驱动的情况下IO的自动阻塞的切换,这个学名就叫=> IO多路复用 
比如socketserver,多个客户端连接,单线程下实现并发效果,就叫多路复用。
同步IO和异步IO,阻塞IO和非阻塞IO分别是什么,到底有什么区别?不同的人在不同的上下文下给出的答案是不同的。所以先限定一下本文的上下文。

二.IO模型前戏准备

在进行解释之前,首先要说明几个概念:

1.用户空间和内核空间

操心系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。 
针对linux操作系统而言,将最高的1G字节(从虚拟地址0xC0000000到0xFFFFFFFF),供内核使用,称为内核空间,而将较低的3G字节(从虚拟地址0x00000000到0xBFFFFFFF),供各个进程使用,称为用户空间。

2.进程切换

注:总而言之就是很耗资源的

3.进程阻塞

当进程进入阻塞状态,是不占用CPU资源的。

4.文件描述符fd

文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念

文件描述符其实就是咱们平时说的句柄,只不过文件描述符是linux中的概念。注意,我们的accept或recv调用时即向系统发出recvfrom请求

(1)  如果内核缓冲区没有数据--->等待--->数据到了内核缓冲区,转到用户进程缓冲区;

(2) 如果先用select监听到某个文件描述符对应的内核缓冲区有了数据,当我们再调用accept或recv时,直接将数据转到用户缓冲区。

5.缓存 I/O

缓存 I/O 又被称作标准 I/O,大多数文件系统的默认 I/O 操作都是缓存 I/O。在 Linux 的缓存 I/O 机制中,操作系统会将 I/O 的数据缓存在文件系统的页缓存( page cache )中,也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。用户空间没法直接访问内核空间的,内核态到用户态的数据拷贝

思考:为什么数据一定要先到内核区,直接到用户内存不是更直接吗?
缓存 I/O 的缺点:

数据在传输过程中需要在应用程序地址空间和内核进行多次数据拷贝操作,这些数据拷贝操作所带来的 CPU 以及内存开销是非常大的。

同步(synchronous) IO和异步(asynchronous) IO,阻塞(blocking)
IO和非阻塞(non-blocking)IO分别是什么,到底有什么区别?这个问题其实不同的人给出的答案都可能不同,比如wiki,就认为asynchronous
IO和non-blocking
IO是一个东西。这其实是因为不同的人的知识背景不同,并且在讨论这个问题的时候上下文(context)也不相同。所以,为了更好的回答这个问题,我先限定一下本文的上下文。
本文讨论的背景是Linux环境下的network IO。

Stevens在文章中一共比较了五种IO Model:

      • blocking IO
      • nonblocking IO
      • IO multiplexing
      • signal driven IO
      • asynchronous IO

由于signal driven IO在实际中并不常用,所以我这只提及剩下的四种IO Model。
再说一下IO发生时涉及的对象和步骤。
      对于一个network IO (这里我们以read举例),它会涉及到两个系统对象,一个是调用这个IO的process (or thread),另一个就是系统内核(kernel)。当一个read操作发生时,它会经历两个阶段:
 1 等待数据准备 (Waiting for the data to be ready)
 2 将数据从内核拷贝到进程中 (Copying the data from the kernel to the process)
记住这两点很重要,因为这些IO Model的区别就是在两个阶段上各有不同的情况。

三.IO multiplexing(IO多路复用)

IO multiplexing这个词可能有点陌生,但是如果我说select,epoll,大概就都能明白了。有些地方也称这种IO方式为event driven IO。我们都知道,select/epoll的好处就在于单个process就可以同时处理多个网络连接的IO。它的基本原理就是select/epoll这个function会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程。它的流程如图:

事件驱动模型 IO多路复用 阻塞IO与非阻塞IO select epool

当用户进程调用了select,那么整个进程会被block,而同时,kernel会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。
这个图和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。

注意1:select函数返回结果中如果有文件可读了,那么进程就可以通过调用accept()或recv()来让kernel将位于内核中准备到的数据copy到用户区。

注意2: select的优势在于可以处理多个连接,不适用于单个连接

四.select poll epoll IO多路复用介绍

首先列一下,sellect、poll、epoll三者的区别

  • select 
    select最早于1983年出现在4.2BSD中,它通过一个select()系统调用来监视多个文件描述符的数组,当select()返回后,该数组中就绪的文件描述符便会被内核修改标志位,使得进程可以获得这些文件描述符从而进行后续的读写操作。 
    select目前几乎在所有的平台上支持 
      
    select的一个缺点在于单个进程能够监视的文件描述符的数量存在最大限制,在Linux上一般为1024,不过可以通过修改宏定义甚至重新编译内核的方式提升这一限制。 
      
    另外,select()所维护的存储大量文件描述符的数据结构,随着文件描述符数量的增大,其复制的开销也线性增长。同时,由于网络响应时间的延迟使得大量TCP连接处于非活跃状态,但调用select()会对所有socket进行一次线性扫描,所以这也浪费了一定的开销。
    • poll 
      它和select在本质上没有多大差别,但是poll没有最大文件描述符数量的限制。 
      一般也不用它,相当于过渡阶段

    • epoll 
      直到Linux2.6才出现了由内核直接支持的实现方法,那就是epoll。被公认为Linux2.6下性能最好的多路I/O就绪通知方法。windows不支持

      没有最大文件描述符数量的限制。 
      比如100个连接,有两个活跃了,epoll会告诉用户这两个两个活跃了,直接取就ok了,而select是循环一遍。

      (了解)epoll可以同时支持水平触发和边缘触发(Edge

      Triggered,只告诉进程哪些文件描述符刚刚变为就绪状态,它只说一遍,如果我们没有采取行动,那么它将不会再次告知,这种方式称为边缘触发),理论上边缘触发的性能要更高一些,但是代码实现相当复杂。 
      另一个本质的改进在于epoll采用基于事件的就绪通知方式。在select/poll中,进程只有在调用一定的方法后,内核才对所有监视的文件描述符进行扫描,而epoll事先通过epoll_ctl()来注册一个文件描述符,一旦基于某个文件描述符就绪时,内核会采用类似callback的回调机制,迅速激活这个文件描述符,当进程调用epoll_wait()时便得到通知。

      所以市面上上见到的所谓的异步IO,比如nginx、Tornado、等,我们叫它异步IO,实际上是IO多路复用。

模块 selector

# select 模拟一个socket server,注意socket必须在非阻塞情况下才能实现IO多路复用。
# 接下来通过例子了解select 是如何通过单进程实现同时处理多个非阻塞的socket连接的。
#server端 import select
import socket
import queue server = socket.socket()
server.bind(('localhost',9000))
server.listen(1000) server.setblocking(False) # 设置成非阻塞模式,accept和recv都非阻塞
# 这里如果直接 server.accept() ,如果没有连接会报错,所以有数据才调他们
# BlockIOError:[WinError 10035] 无法立即完成一个非阻塞性套接字操作。
msg_dic = {}
inputs = [server,] # 交给内核、select检测的列表。
# 必须有一个值,让select检测,否则报错提供无效参数。
# 没有其他连接之前,自己就是个socket,自己就是个连接,检测自己。活动了说明有链接
outputs = [] # 你往里面放什么,下一次就出来了 while True:
readable, writeable, exceptional = select.select(inputs, outputs, inputs) # 定义检测
#新来连接 检测列表 异常(断开)
# 异常的也是inputs是: 检测那些连接的存在异常
print(readable,writeable,exceptional)
for r in readable:
if r is server: # 有数据,代表来了一个新连接
conn, addr = server.accept()
print("来了个新连接",addr)
inputs.append(conn) # 把连接加到检测列表里,如果这个连接活动了,就说明数据来了
# inputs = [server.conn] # 【conn】只返回活动的连接,但怎么确定是谁活动了
# 如果server活动,则来了新连接,conn活动则来数据
msg_dic[conn] = queue.Queue() # 初始化一个队列,后面存要返回给这个客户端的数据
else:
try :
data = r.recv(1024) # 注意这里是r,而不是conn,多个连接的情况
print("收到数据",data)
# r.send(data) # 不能直接发,如果客户端不收,数据就没了
msg_dic[r].put(data) # 往里面放数据
outputs.append(r) # 放入返回的连接队列里
except ConnectionResetError as e:
print("客户端断开了",r)
if r in outputs:
outputs.remove(r) #清理已断开的连接
inputs.remove(r) #清理已断开的连接
del msg_dic[r] ##清理已断开的连接 for w in writeable: # 要返回给客户端的连接列表
data_to_client = msg_dic[w].get() # 在字典里取数据
w.send(data_to_client) # 返回给客户端
outputs.remove(w) # 删除这个数据,确保下次循环的时候不返回这个已经处理完的连接了。 for e in exceptional: # 如果连接断开,删除连接相关数据
if e in outputs:
outputs.remove(e)
inputs.remove(e)
del msg_dic[e] #*************************client
import socket
client = socket.socket() client.connect(('localhost', 9000)) while True:
cmd = input('>>> ').strip()
if len(cmd) == 0 : continue
client.send(cmd.encode('utf-8'))
data = client.recv(1024)
print(data.decode()) client.close()

参考:

https://www.cnblogs.com/yuanchenqi/articles/5722574.html