Linux的常用IO模型

内核kernel

操作系统负责整个系统运行的调度管理,包括管理各个硬件(如:cpu, 内存,磁盘,网卡等)以及在系统的上运行的各个应用程序。当计算机从关机状态启动,启动的第一个程序是操作系统内核,内核启动,将会注册GDT表(内存的分段信息),表中会记录操作系统单独拥有的一段内存空间,这部分空间只有操作系统内核可以操作,无法被其他的用户程序更改数据,从而来保证操作系统的稳定运行。由此内存被划分成用户空间和内核空间。

主机上连接的所有的硬件设备,都将被操作系统管理。在linux系统中,这些硬件设备被抽象为文件,所以操作系统在处理一个硬件设备时,其硬件设备对应为一个文件描述符,通过对设备的文件描述符进行读写操作,来表示该对设备中数据的读写操作。为了用户的应用程序可以对硬件进行调用,kernel提供了系统调用接口,用户应用可以通过调用这些接口来实现调用硬件操作。

获取程序系统调用

使用strace工具可以在启动某个程序时候,将该进程执行过程中的系统调用记录并记录这里将调用日志写入到文件中。

strace -ff -o ./record  python 文件名.py

该命令将使用python解释器执行该py程序,并将该进程执行过程中的使用到的系统调用指令,按照每个进程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产生的文件。找到该主进程执行文件,然后可以看到上面的python程序执行过程中执行的几个核心的系统调用,包括socket, bind, listen, write等。

Linux的常用IO模型

 可以简单的理解为在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文件描述符,该socket于客户端建立了连接
    new Thread ->  send fd5, recv fd5    # 开启一个新的线程来与客户端send或recv数据,主线程继续accept 监听fd4等待新的连接。

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

这种模型的问题有:
1. 线程太多:每个socket都需要开启一个线程,大量客户端同时连接将耗费服务端大量的资源,这些线程在内存中有独立的栈空间,堆空间是共享的。同时增加cpu的调度。
2. 系统调用太多:开启和关闭线程也是系统调用。在原send 和 recv系统调用的基础上,增加了开闭线程的系统调用开销。

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

BIO模型的优点是延时低,因为每个线程的单独处理这个socket,当有数据到来时候,该线程被调起即可获取内部的数据。这还需要和下面NIO的方式进行对比更容易理解。

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操作之后,才能处理这个数据,因此时效性较差。

IO多路复用-selector

在NIO的模型中,通过遍历列表,对每一个socket调用send或者recv系统调用,这样产生了大量的系统调用,由于内核态和用户态的原因,会更加的浪费资源。于是内核提供了一个系统调用函数,select, 该函数要求提供需要被监视的socket集合,然后由内核对这个socket进行一次遍历,再将有数据的socket集合返回给用户应用端。通过select,只进行了一次系统调用,完成了对所有socket的管理,相比NIO有性能的提高。但在系统内部,仍然使用的是遍历的方式来处理这些socket,然后将有数据的socket放回交给应用端处理。

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,然后将可用的socket IO返回。
这样的方式同样存在问题:
1. 内核态中执行时,每次为了获取可用的IO,需要对整个集合进行一次遍历,这是O(N)复杂度的操作。
2. 每次调用select 都需要传入全部的socket。

epoll

epoll是在select基础上,针对上述的selector的两个问题来进行了优化。

为了解决的每次传入所有的sokcet对象,epoll在内核中创建一个缓冲区空间,存放所有被监听的socket对象,并绑定了一个文件描述符epfd。

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  fd4bind 127.0.0.1:8000listen 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文件描述符对应的空间,然后将需要监听socketd对象通过EPOLL_CTL的ADD操作将fd添加到epfd空间中,当空间中有sokcet被激活时,通过wait可返回被激活的socket对象的集合,然后分别调用这些socket的recv或者send方法操作即可,如果要对空间中的socket操作,在应用端调用EPOLL_CTL的MOD 和 DEL 操作进行修改删除即可。否则这些socket始终处于内核态中由内核管理。

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

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

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

相关推荐