IO多路复用 select, poll,epoll

内核kernel

操作系统负责整个系统运行的调度管理,包括管理各个硬件和系统上运行的各个应用程序。计算机启动启动时,首先启动操作系统内核,启动后将会注册GDT表(储存内存的分段信息)记录操作系统中各个程序的内存空间,其中记录了一段内存空间是操作系统单独拥有的,我们将其称之为内核空间,这部分空间记录了操作系统运行需要的重要数据,只有操作系统内核程序可以操作,其他的用户程序无法直接修改这段内存中的数据,从而保证了操作系统的稳定运行。

如果一个应用程序需要使用某个操作系统管理的硬件设备,就需要向操作系统发起使用申请,然后等待操作系统执行后返回。这就执行了一次系统调用。系统调用也是会操作系统提供的预留接口来实现某个特定的功能。并且这个接口调用时,是交给操作系统执行的,因为只有操作系统可以管理那些设备和那段内核内存。因此程序会经用户程序执行转变为操作系统执行返回,即从用户态转变为内核态,程序操作的内存空间也由用户空间转变为内核空间。

在linux系统中,这些操作系统管理的硬件设备都被抽象为文件对象,每一个硬件设备对应为一个文件描述符,即一个编号。通过对文件描述符的操作,来实现对设备读写操作。

strace工具获取系统调用

使用strace工具可以将进程执行过程中的系统调用记录到指定的日志文件中。

strace -ff -o ./record  python file.py 

使用python解释器执行一个py程序,然后通过strace 将该程序执行过程中的使用到的系统调用指令,按照进程id进行分类,保存到当前目录下以record为前缀的文件中。该进程启动后可能会有其他的辅助进程,所以文件可能不止一个。

该python程序内容如下:

import socket
import threading

def worker(sock):
    # 阻塞接受客户端的数据。
    data = sock.recv(1024)
    print(data)
     
sock = socket.socket()

ip_addr = ("127.0.0.1", 8000)

sock.bind(ip_addr)
sock.listen()

print("开始监听127.0.0.1:8000")
# 每接受一个请求,开启新的线程与客户端进行通信,主线程阻塞等待新的连接
while True: 
    s, addr =  sock.accept()
    t = threading.Thread(target=worker, args=(s, ))
    t.start()

print("end-------")

在strace产生的文件中,找到记录主进程系统调用的文件。可以找到下面内容,其中包括了几个核心的系统调用,包括socket, bind, listen, write等。

 

上述的功能逻辑,可以简单的理解为,在socket对象实例化时,操作系统会创建一个socket ,然后将该socket关联上一个文件描述符,在之后执行sock.bind()或者sock.listen()语句时,实际上是将该文件描述符绑定到本地8000端口并监听该文件描述符,再通过sock.accept()阻塞等待该文件描述符接收新的连接。新连接到来后,会创建新线程处理通信。使用伪代码表示为。

socket fd4          # 创建一个socket时,关联一个文件描述符fd4
bind  8000          # socket绑定端口 
listen fd4          # 监听该socket,即fd4文件描述符

while True:
    accept fd4 =>  fd5    # accept会阻塞等待,新的连接到来,创建一个新的socket关联fd5文件描述符,与客户端建立连接
    new Thread ->  send fd5, recv fd5    # 开启一个新的线程来与客户端send或recv数据,主线程继续accept 监听fd4等待新的连接。

BIO模型

以上的通过多线程处理阻塞的IO的模型就是BIO模型(Blocking IO 即阻塞IO)由于 recv 和 accpet 都是阻塞的,所以要想服务器能同时处理多个客户端的连接,就只能开辟新的线程来实现每个客户端的数据通信。

这种模型的问题有:
1. 线程太多:每个socket需要开启一个线程,大量客户端同时连接将耗费服务端大量的资源,同时增加cpu的调度。
2. 系统调用太多:每个线程中socket 每次进行 send 和 recv 都是一次系统调用,开闭线程也是系统调用。

因为操作系统独立使用一份内存空间,所以每次发生系统调用时,实际上会程序会由用户态转变为内核态执行,如果需要用户空间中的数据,在内核态中需要使用用户态的数据,需要从用户空间拷贝数据到内核空间才能使用,因此执行一次系统调用的开销会比程序正常执行运算更耗费资源。

BIO模型的优点:

  1. 延时低,因为每个线程的单独处理这个socket,当有数据到来时候,该线程被调起即可获取内部的数据。
  2. 擅长处理比较耗时的连接,因为每个连接由单个线程处理,尽量利用cpu多核优势。

NIO模型

BIO模型由于每个socket都使用一个单独的线程进行执行,耗费了大量的资源。为了避免线程过多,于是将所有的socket使用非阻塞的方式运行,然后使用单线程去循环遍历所有的socket,逐个检查是否有数据到来,再执行accpet和recv 操作 。于是就诞生了NIO的模型(NonBlocking IO) 

这个过程的伪代码:

socket fd4 
bind 127.0.0.1:8000
listen fd4

list = []         # 创建一个容器。
while True:
    accept fd4 = new fd       # accept 是非阻塞,如果有新的连接,得到新的fd,否则直接跳过即可,系统调用通过一个参数即可指定为非阻塞。
    append fd to list         # 如果得到了新的fd,将他添加到列表中,遍历列表,对每个socket执行send 和 recv操作即可
    for fd in list:
        send  fd        # 非阻塞的执行send和recv (有数据操作,没有数据则跳过)
        recv  fd   

在NIO(非阻塞IO) 的模型下,可以使用一个线程去管理所有的sokcet,相比于BIO节约了线程的开销,但是同样存在问题。
1. 使用遍历的方式对每一个socket 执行send 和 recv,这两个方法都会执行系统调用,并且在大多数的情况下,大部分的socket是没有数据的,也就是,我们对所有的socket轮询一次,可能只有1%的socket需要接收数据,其余的系统调用属于浪费。
2. 有延迟,相对于BIO中某个socket收到数据,对应的线程将会被激活,然后调度执行,获取数据。而在NIO的模式下,只有遍历到该socket,才能从中获取数据,如果列表很大,例如10000个socket。遍历到第 10000 个socket时第9999个socket来数据了,只能等待下一轮将前面所有的9998个都执行一遍send或者recv操作之后,才能处理这个数据,因此时效性较差。

NIO模型与BIO对比:

  1. 只需要单个线程处理连接,线程资源占用少,大量连接时,比BIO资源节省资源,但是由于轮询有延迟。
  2. NIO适合处理一些简单的连接,耗时短,这样单个IO触发的业务不会阻塞太长时间,才能及时遍历处理后面的IO。

IO多路复用

selector

int select (int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

select是内核提供了一个系统调用函数,该函数要求提供需要被监听的文件描述符fd集合,然后由内核对这个fd集合t进行遍历,再将有数据的fd集合返回给用户应用端,用户端通过遍历返回的fd集合,逐个进行处理。执行select时,只进行了一次系统调用,完成了对所有fdt的管理,相比NIO有性能的提高。但在系统内部,仍然使用的是遍历的方式来处理这些fd,并且有个数限制,32位机默认是1024个,64位机默认是2048。

socket fd4 
bind 127.0.0.1:8000
listen fd4

list = [ fd4 ]         # 创建集合
while True:
    select list  =>  use_list       # 将socket集合交给select,返回一个有数据的socket集合
    for fd in  use_list:      # 遍历这些可用的sokcet,执行send 或 recv获取数据即可
        send  fd       
        recv  fd

基于上面的执行方式,所以select被称为多路复用器,即执行一次系统调用,可以同时监听了多个socket IO。
这样的方式同样存在问题:

  1. fd个数限制
  2. 每次调用select时,每次为了获取可用的IO,需要对整个集合进行一次遍历,这是O(N)复杂度的操作。
  3. 每次调用select 都需要传入全部的fd,都需要从用户空间拷贝到内核空间,fd数量很大时,开销很大。
  4. 内核中的fd集合在使用后被置位过,与传入的fd集合不同,所以内核中的fd集合不可重用,每次select需要回收创建新的fd集合

NIO是在用户态进行遍历,每个fd分别进行一次系统调用检查该fd是否有消息返回了。而IO多路复用是操作系统提供的一个可以同时监视多个阻塞IO的接口。

poll

int poll (struct pollfd *fds, unsigned int nfds, int timeout);
select使用三个bitmap位图来表示三个fdset,而poll使用一个pollfd的指针实现。
struct pollfd {
    int fd;         /* 文件描述符 */
    short events;   /* 要监视的event*/
    short revents;  /* 发生的event*/
};

pollfd结构中包含了要监视的event事件和发生的event事件,不再使用select “参数-值” 传递的方式。同时,pollfd并没有最大数量限制(但是数量过大后性能也是会下降)。 和select函数一样,poll返回后,需要轮询 pollfd来获取就绪的描述符,再进行处理。
优点:

  1. poll用pollfd数组代替了bitmap,没有最大数量限制,解决select有次数限制的缺点
  2. 利用结构体pollfd,每次置位revents字段,每次只需恢复revents即可。pollfd结构体可重用,降低系统开销

缺点:

  1. 与select相同,每次调⽤用poll,都需要把pollfd数组从用户态拷贝到内核态,这个开销在fd很多时会很大。
  2. 和select函数一样,poll返回后,需要轮询pollfd来获取就绪的描述符。事实上,同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降。

epoll

相对于select和poll来说,epoll更加灵活,没有描述符限制。epoll使用一个文件描述符管理多个描述符,将用户关心的文件描述符的事件存放到内核的一个事件表中。该事件表创建后,用户程序只需要进行一次数据拷贝,将用户程序对应的用户空间的所有事件数据拷贝到内核空间,内核将会始终记录,在后续的监听过程中不需要再进行数据的拷贝,直到整个程序调用epoll关闭事件监听器的接口。

 

epoll提供了4个相关的系统调用:关于epoll的详细https://blog.csdn.net/petershina/article/details/50614877

epool_create  == >  int epoll_create(int size) 开辟一个空间(事件表),返回与该空间关联的文件描述符epfd 
epoll_ctl     == >  int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);   管理epfd中的数据
       第一个参数是epoll_create()的返回值,也就是内核中用来储存sokcet空间的文件描述符。
       第二个参数表示执行的动作,用三个宏来表示:
        EPOLL_CTL_ADD:注册新的fd到epfd中;
        EPOLL_CTL_MOD:修改已经注册的fd的监听事件;
        EPOLL_CTL_DEL:从epfd中删除一个fd
       第三个参数是需要监听的fd。
       第四个参数是告诉内核需要监听什么事件,可以是以下几个宏的集合。
        EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭)
        EPOLLOUT:表示对应的文件描述符可以写;
        EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来)
        EPOLLERR:表示对应的文件描述符发生错误;
        EPOLLHUP:表示对应的文件描述符被挂断;
    
epoll_wait    == > int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout); 
    epoll通过wait该epfd,得知该epfd是否被唤醒,而唤醒的条件是,该epfd内部的任意一个socket被激活。
epoll_close   == > 关闭epfd文件描述符的方法 

使用epoll过程的伪代码

epoll_create() => epfd

socket  fd4
bind 127.0.0.1:8000
listen fd4 epoll_ctl(epfd, fd4, epoll_add)
# 将fd4添加到epfd对应的内核空间中 whiel True: epoll_wati() => fd_list for fd in fd_list: recv fd send fd epoll_ctl(epfd, fd4, epoll_add) # 如果是新的fd,添加到epfd中

这个过程可以简单的理解为:首先使用epool_create在内核空间中开启一个epfd文件描述符对应的空间,然后将需要监听socket对象通过EPOLL_CTL的ADD操作将fd添加到epfd空间中,添加后执行wait操作,当空间中有sokcet被激活时,将会通过这个socket的回调机制,将这个socket添加到一个就绪链表中,最后通过遍历整个就绪链表,得到被操作的socket对象,再分别执行socket的recv或者send方法操作即可。如果要对空间中的socket操作,在应用端,还可以使用EPOLL_CTL函数中第二个参数的MOD 和 DEL 来进行fd的修改删除。

select中使用遍历的方式来获取那些socket被激活,而epoll基于事件驱动模型,事件驱动可以简单描述为:当监听的事件接受到一个消息,将产生一个消息事件,该消息事件会通知操作系统,产生一个系统中断,此时操作系统会中断正在执行的其他操作,找到这个事件对应的中断号以及初始绑定的回调函数,执行该回调操作,该回调在epoll中就是将socket从未激活区域调用到激活的队列中去,这样实现了哪个socket有消息,将会被中断的回调调用到激活区域中。应用程序将可以读取这个数据。

通过这种方式将可以不用的持续遍历内核空间中的socket,而是有消息的socket自动触发,通过回调事件,进入激活区域。而在应用端,可以通过阻塞或者非阻塞的方式从这个队列中获取激活的socket。

当然wait方法同样指定为阻塞或者非阻塞模型,同样详细说明:https://blog.csdn.net/petershina/article/details/50614877

信号驱动I/O模型(signal driven I/O, SIGIO)

  首先我们允许套接口进行信号驱动I/O,并安装一个信号处理函数,进程继续运行并不阻塞。当数据准备好时,进程会收到一个SIGIO信号,可以在信号处理函数中调用I/O操作函数处理数据。当数据准备好读取时,内核就为该进程产生一个SIGIO信号。我们随后既可以在信号处理函数中调用recvfrom读取数据,并通知主循环数据已准备好待处理,也可以立即通知主循环,让它来读取数据报。无论如何处理SIGIO信号,这种模型的优势在于等待数据报到达(第一阶段)期间,进程可以继续执行,不被阻塞。免去了select的阻塞与轮询,当有活跃套接字时,由注册的handler处理。

 

异步I/O模型(AIO, asynchronous I/O)

  进程发起read操作之后,立刻就可以开始去做其它的事。而另一方面,从kernel的角度,当它收到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何block。然后,kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个signal,告诉它read操作完成了。

  这个模型工作机制是:告诉内核启动某个操作,并让内核在整个操作(包括第二阶段,即将数据从内核拷贝到进程缓冲区中)完成后通知我们。

这种模型和前一种模型区别在于:信号驱动I/O由内核使用一个信号通知用户程序某个数据以准备完毕,用户程序可以开始处理。而异步I/O模型是由内核通知用户程序某个I/O操作已经完完成,不在需要用户程序做其他工作。

 

posted @ 2020-07-18 11:32  没有想象力  阅读(202)  评论(0编辑  收藏  举报