IO模型

  • 阻塞IO

阻塞程序,IO成功或者失败后才能继续执行。

  • 非阻塞IO

通过轮询的方式,直到IO成功或者失败,浪费CPU资源

  • IO多路复用

三种方式:selectpollepoll,注册socket事件,阻塞等待事件或者超时。

  • 信号驱动IO

数据准备好以后,发送信号通知程序进行IO操作。

  • 异步IO

程序不用等待IO的完成,可执行其他操作,IO完成后内核通知程序已完成。

 

同步IO vs 异步IO

1. 同步IO指的是程序会一直阻塞到IO操作如read、write完成

2. 异步IO指的是IO操作不会阻塞当前程序的继续执行

阻塞IO,非阻塞IO,IO多路复用和信号驱动IO是同步IO,只有异步IO是完全完成了数据的拷贝之后才通知程序进行处理,没有阻塞的数据读写过程。

IO多路复用涉及基础概念

  • 用户空间 / 内核空间

现在操作系统都是采用虚拟存储器,那么对32位操作系统而言,它的寻址空间(虚拟存储空间)为4G(2的32次方)。 操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证用户进程不能直接操作内核(kernel),保证内核的安全,操作系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。

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

  • 进程切换

为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换。因此可以说,任何进程都是在操作系统内核的支持下运行的,是与内核紧密相关的,并且进程切换是非常耗费资源的。

从一个进程的运行转到另一个进程上运行,这个过程中经过下面这些变化:

  1. 保存处理机上下文,包括程序计数器和其他寄存器。
  2. 更新PCB信息。
  3. 把进程的PCB移入相应的队列,如就绪、在某事件阻塞等队列。
  4. 选择另一个进程执行,并更新其PCB。
  5. 更新内存管理的数据结构。
  6. 恢复处理机上下文。
  • 进程阻塞

正在执行的进程,由于期待的某些事件未发生,如请求系统资源失败、等待某种操作的完成、新数据尚未到达或无新工作做等,则由系统自动执行阻塞原语(Block),使自己由运行状态变为阻塞状态。可见,进程的阻塞是进程自身的一种主动行为,也因此只有处于运行态的进程(获得了CPU资源),才可能将其转为阻塞状态。当进程进入阻塞状态,是不占用CPU资源的。

  • 文件描述符

文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。 文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。

  • 缓存I/O

缓存I/O又称为标准I/O,大多数文件系统的默认I/O操作都是缓存I/O。在Linux的缓存I/O机制中,操作系统会将I/O的数据缓存在文件系统的页缓存中,即数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。

缓存 I/O 的缺点:

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

 

IO多路复用定义

  • IO 多路复用是一种同步IO模型,实现一个线程可以监视多个文件句柄;
  • 一旦某个文件句柄就绪,就能够通知应用程序进行相应的读写操作;
  • 没有文件句柄就绪就会阻塞应用程序,交出CPU。
  • 多路是指网络连接,复用指的是同一个线程

 

IO多路复用与BIO、NIO比较

  • BIO(阻塞IO)

服务端采用单线程,当 accept 一个请求后,在 recv 或 send 调用阻塞时,将无法 accept 其他请求(必须等上一个请求处理 recv 或 send 完 )(无法处理并发)

// 伪代码描述

while(true) {
        // accept阻塞

client_fd = accept(listen_fd);
    fds.append(client_fd);
    for(fd in fds) {
    阻塞(会影响上面的accept)

if(recv(fd)) {
       

}
}
}

服务端采用多线程,当 accept 一个请求后,开启线程进行 recv,可以完成并发处理,但随着请求数增加需要增加系统线程,大量的线程占用很大的内存空间,并且线程切换会带来很大的开销,10000个线程真正发生读写实际的线程数不会超过20%,每次accept都开一个线程也是一种资源浪费。

// 伪代码描述

while(true) {
  // accept阻塞

client_fd = accept(listen_fd)
  // 开启线程read数据(fd增多导致线程数增多)

newThreadfunc() {
    // recv阻塞(多线程不影响上面的accept)

if(recv(fd)) {
      // logic

}

}

  • NIO(非阻塞IO)

服务器端当 accept 一个请求后,加入 fds 集合,每次轮询一遍 fds 集合 recv (非阻塞)数据,没有数据则立即返回错误,每次轮询所有 fd (包括没有发生读写实际的 fd)会很浪费 CPU。

// 伪代码描述

while(true) {

  // accept非阻塞(cpu一直忙轮询)

  client_fd = accept(listen_fd)

  if (client_fd != null) {

    // 有人连接

    fds.append(client_fd)

  } else {

    // 无人连接

  } 

  for (fd in fds) {

    // recv非阻塞

    setNonblocking(client_fd)

    // recv 为非阻塞命令

    if (len = recv(fd) && len > 0) {

      // 有读写数据

      // logic

    } else {

       无读写数据

    }

  } 

}

  • IO多路复用

服务器端采用单线程通过 select/poll/epoll 等系统调用获取 fd 列表,遍历有事件的 fd 进行 accept/recv/send ,使其能支持更多的并发连接请求。

// 伪代码描述

while(true) {

  // 通过内核获取有读写事件发生的fd,只要有一个则返回,无则阻塞

  // 整个过程只在调用select、poll、epoll这些调用的时候才会阻塞,accept/recv是不会阻塞

  for (fd in select(fds)) {

    if (fd == listen_fd) {

        client_fd = accept(listen_fd)

        fds.append(client_fd)

    } elseif (len = recv(fd) && len != -1) {

      // logic

    }

  } 

}

IO多路复用的三种实现

  • select

它仅仅知道了,有I/O事件发生了,却并不知道是哪那几个流(可能有一个,多个,甚至全部),我们只能无差别轮询所有流,找出能读出数据,或者写入数据的流,对他们进行操作。所以select具有O(n)的无差别轮询复杂度,同时处理的流越多,无差别轮询时间就越长。

  • select调用过程

    (1)使用copy_from_user从用户空间拷贝fd_set到内核空间

(2)注册回调函数__pollwait

(3)遍历所有fd,调用其对应的poll方法(对于socket,这个poll方法是sock_poll,sock_poll根据情况会调用到tcp_poll,udp_poll或者datagram_poll)

(4)以tcp_poll为例,其核心实现就是__pollwait,也就是上面注册的回调函数。

(5)__pollwait的主要工作就是把current(当前进程)挂到设备的等待队列中,不同的设备有不同的等待队列,对于tcp_poll来说,其等待队列是sk->sk_sleep(注意把进程挂到等待队列中并不代表进程已经睡眠了)。在设备收到一条消息(网络设备)或填写完文件数据(磁盘设备)后,会唤醒设备等待队列上睡眠的进程,这时current便被唤醒了。

(6)poll方法返回时会返回一个描述读写操作是否就绪的mask掩码,根据这个mask掩码给fd_set赋值。

(7)如果遍历完所有的fd,还没有返回一个可读写的mask掩码,则会调用schedule_timeout是调用select的进程(也就是current)进入睡眠。当设备驱动发生自身资源可读写后,会唤醒其等待队列上睡眠的进程。如果超过一定的超时时间(schedule_timeout指定),还是没人唤醒,则调用select的进程会重新被唤醒获得CPU,进而重新遍历fd,判断有没有就绪的fd。

(8)把fd_set从内核空间拷贝到用户空间。

 

  • select缺点

select本质上是通过设置或者检查存放fd标志位的数据结构来进行下一步处理。

(1)单个进程所打开的FD是有限制的,通过 FD_SETSIZE 设置,默认1024 ;

(2)每次调用 select,都需要把 fd 集合从用户态拷贝到内核态,这个开销在 fd 很多时会很大;

需要维护一个用来存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大

(3)对 socket 扫描时是线性扫描,采用轮询的方法,效率较低(高并发)

当套接字比较多的时候,每次select()都要通过遍历FD_SETSIZE个Socket来完成调度,不管哪个Socket是活跃的,都遍历一遍。这会浪费很多CPU时间。如果能给套接字注册某个回调函数,当他们活跃时,自动完成相关操作,那就避免了轮询,这正是epoll与kqueue做的。

  • POLL

poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态, 但是它没有最大连接数的限制,原因是它是基于链表来存储的.

  • EPOLL

epoll可以理解为event poll,不同于忙轮询和无差别轮询,epoll会把哪个流发生了怎样的I/O事件通知我们。所以我们说epoll实际上是事件驱动(每个事件关联上fd)的,此时我们对这些流的操作都是有意义的。(复杂度降低到了O(1))

  • 使用示例

int main(int argc, char* argv[])

{

   /*

   * 在这里进行一些初始化的操作,

   * 比如初始化数据和socket等。

   */

 

    // 内核中创建ep对象

    epfd=epoll_create(256);

    // 需要监听的socket放到ep中

    epoll_ctl(epfd,EPOLL_CTL_ADD,listenfd,&ev);

 

    while(1) {

      // 阻塞获取

      nfds = epoll_wait(epfd,events,20,0);

      for(i=0;i<nfds;++i) {

          if(events[i].data.fd==listenfd) {

              // 这里处理accept事件

              connfd = accept(listenfd);

              // 接收新连接写到内核对象中

              epoll_ctl(epfd,EPOLL_CTL_ADD,connfd,&ev);

          } else if (events[i].events&EPOLLIN) {

              // 这里处理read事件

              read(sockfd, BUF, MAXLINE);

              //读完后准备写

              epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev);

          } else if(events[i].events&EPOLLOUT) {

              // 这里处理write事件

              write(sockfd, BUF, n);

              //写完后准备读

              epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev);

          }

      }

    }

    return 0;

}

第一步:epoll_create()系统调用。此调用返回一个句柄,之后所有的使用都依靠这个句柄来标识。

第二步:epoll_ctl()系统调用。通过此调用向epoll对象中添加、删除、修改感兴趣的事件,返回0标识成功,返回-1表示失败。

第三步:epoll_wait()系统调用。通过此调用收集收集在epoll监控中已经发生的事件。

  • epoll 有EPOLLLT 和 EPOLLET 两种触发模式,LT 是默认的模式,ET 是 “高速” 模式。

(1)LT 模式下,只要这个 fd 还有数据可读,每次 epoll_wait 都会返回它的事件,提醒用户程序去操作;

(2)ET 模式下,它只会提示一次,直到下次再有数据流入之前都不会再提示了,无论 fd 中是否还有数据可读。所以在 ET 模式下,read 一个 fd 的时候一定要把它的 buffer 读完,或者遇到 EAGIN 错误。

  • epoll的优点

(1)没有最大并发连接的限制,能打开的FD的上限远大于1024(1G的内存上能监听约10万个端口);

(2)效率提升,不是轮询的方式,不会随着FD数目的增加效率下降。只有活跃可用的FD才会调用callback函数;即Epoll最大的优点就在于它只管你“活跃”的连接,而跟连接总数无关,因此在实际的网络环境中,Epoll的效率就会远远高于select和poll;

(3)内存拷贝,利用mmap()文件映射内存加速与内核空间的消息传递;即epoll使用mmap减少复制开销。