浅入Linux的几种I/O模型

阻塞I/O

阻塞表示一旦调用I/O函数必须等整个I/O完成才返回。正如上面提到的那种情形,当服务器调用了read函数之后,如果不是立即接收到数据,服务器进程会被阻塞,之后一直在等待用户数据到达,用户数据到达后首先会写进内核缓冲区,之后内核缓冲区数据复制到用户进程(服务器进程)缓冲区。完成了上述所有的工作后,才会把执行权限返回给用户(从内核态 -> 用户态)。

很显然,阻塞式I/O的效率实在太低,如果用户输入数据迟迟不到的话,整个服务器就会一直被阻塞(单进程/线程)。为了不影响服务器接收其他进程的连接,我们可以考虑多进程模型,这样当服务器建立连接后为连接的用户创建新线程,新线程即使是使用阻塞式I/O也仅仅是这一个线程被阻塞,不会影响服务器等待接收新的连接。

多线程模型下,主线程等待用户请求,用户有请求到达时创建新线程。新线程负责具体的工作,即使是因为调用了read函数被阻塞也不会影响服务器。我们还可以进一步优化创建连接池和线程池以减小频繁调用I/O接口的开销。但新问题随之产生,每个新线程或者进程(加入使用对进程模型)都会占用大量系统资源,除此之外过多的线程和进程在调度方面开销也会大很对,所以这种模型并不适合大并发量。

非阻塞I/O

阻塞和非阻塞最大的区别在于调用I/O系统调用后,是等整个I/O过程完成再把操作权限返回给用户还是会立即返回。

可以使用以下语句将句柄fd设置为非阻塞I/O:fcntl(fd, F_SETFL, O_NONBLOCK);

非阻塞I/O在调用后会立即返回,用户进程对返回的返回值判断以区分是否完成了I/O。如果返回大于0表示完成了数据读取,返回值即读取的字节数;返回0表示连接已经正常断开;返回-1表示错误,接下来用户进程会不停地询问kernel是否准备完毕。

非阻塞I/O虽然不再会完全阻塞用户进程,但实际上由于用户进程需要不停地询问kernel是否准备完数据,所以整体效率依旧非常低,不适合做并发。

I/O多路复用

前面已经论述了多进程、多进程模型会因为开销巨大和调度困难而导致并不能承受高并发量。但不适用这种模型的话,无论是阻塞还是非阻塞方式都会导致整个服务器停滞。

所以对于大并发量,我们需要一种代理模型可以帮助我们集中去管理所有的socket连接,一旦某个socket数据到达了就执行其对应的用户进程,I/O多路复用就是这么一种模型。Linux下I/O多路复用的系统调用有select,poll和epoll,但从本质上来讲他们都是同步I/O范畴。

  1. select

    • 相关接口:
      • int select (int maxfd, fd_set *readfds, fd_set *writefds, fd_set *errorfds, struct timeval *timeout);
      • FD_ZERO(int fd, fd_set* fds) //清空集合
      • FD_SET(int fd, fd_set* fds) //将给定的描述符加入集合
      • FD_ISSET(int fd, fd_set* fds) //将给定的描述符从文件中删除
      • FD_CLR(int fd, fd_set* fds) //判断指定描述符是否在集合中
    • 参数:
      • maxfd:当前最大文件描述符的值+1(≠ MAX_CONN)。
      • readfds:指向读文件队列集合(fd_set)的指针。
      • writefds:同上,指向读集合的指针。
      • writefds:同上,指向错误集合的指针。
      • timeout:指向timeval结构指针,用于设置超时。
    • 其他:
      判断和操作对象为set_fd集合,集合大小为单个进程可打开的最大文件数1024或2048(可重新编译内核修改但不建议)。
  2. poll

    • 相关接口:
      • int poll(struct pollfd *fds, unsigned int nfds, int timeout);
    • 结构体定义:

      1
      2
      3
      4
      5
      struct pollfd{
      int fd; // 文件描述符
      short events; // 等到的事件
      short revents; // 实际发生的事件
      }
    • 参数:

      • fds:指向pollfd结构体数组的指针。
      • nfds:pollfd数组当前已被使用的最大下标。
      • timeout:等待毫秒数。
    • 其他:判断和操作对象是元素为pollfd类型的数组,数组大小自己设定,即为最大连接数。
  3. epoll

    • 相关接口:
      • int epoll_create(int size); // 创建epoll句柄
      • int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); // 事件注册函数
      • int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
    • 结构体定义:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      struct epoll_event{
      __uint32_t events;
      epoll_data_t data;
      };
      typedef union epoll_data{
      void *ptr;
      int fd;
      __uint32_t u32;
      __uint64_t u64;
      }epoll_data_t;
    • 参数:

      • size:用来告诉内核要监听的数目。
      • epfd:epoll函数的返回值。
      • op:表示动作(EPOLL_CTL_ADD/EPOLL_CTL_FD/EPOLL_CTL_DEL)
      • fd:需要监听的fd。
      • events:指向epoll_event的指针,该结构记录监听的事件。
      • maxevents:告诉内核events的大小。
      • timeout:超时时间(ms为单位,0表示立即返回,-1将不确定)。
  4. select/poll和epoll区别

    • 操作方式及效率:select是遍历,需要遍历fd_set每一个比特位(= MAX_CONN),O(n);poll是遍历,但只遍历到pollfd数组当前已使用的最大下标(≠ MAX_CONN),O(n);epoll是回调,O(1)。
    • 最大连接数:select为1024/2048(一个进程打开的文件数是有限制的);poll无上限;epoll无上限。
    • fd拷贝:select每次都需要把fd集合从用户态拷贝到内核态;poll每次都需要把fd集合从用户态拷贝到内核态;epoll调用epoll_ctl时拷贝进内核并放到事件表中,但用户进程和内核通过mmap映射共享同一块存储,避免了fd从内核赋值到用户空间。
    • 其他:select每次内核仅仅是通知有消息到了需要处理,具体是哪一个需要遍历所有的描述符才能找到。epoll不仅通知有I/O到来还可通过callback函数具体定位到活跃的socket,实现伪AIO。

异步I/O模型

上面三种I/O方式均属于同步I/O。

从阻塞式I/O到非阻塞I/O,我们已经做到了调用I/O请求后立即返回,但不停轮询的操作效率又很低,如果能够既像非阻塞I/O能够立即返回又能不一直轮询的话会更符合我们的预期。

之所以用户进程会不停轮询就是因为在数据准备完毕后内核不会回调用户进程,只能通过用户进程一次又一次轮询来查询I/O结果。如果内核能够在完成I/O后通过消息告知用户进程来处理已经得到的数据自然是最好的,异步I/O就是这么回事。

异步I/O就是当用户进程发起I/O请求后立即返回,直到内核发送一个信号,告知进程I/O已完成,在整个过程中,都没有进程被阻塞。看上去异步I/O和非阻塞I/O的区别在于:判断数据是否准备完毕的任务从用户进程本身被委托给内核来完成。这里所谓的异步只是操作系统提供的一直机制罢了。

坚持原创技术分享,您的支持将鼓励我继续创作!
显示 Gitment 评论