阻塞IO

服务端为了处理客户端的连接和请求的数据,写了如下代码。

  1. listenfd = socket(); // 打开一个网络通信端口
  2. bind(listenfd); // 绑定
  3. listen(listenfd); // 监听
  4. while(1) {
  5. connfd = accept(listenfd); // 阻塞建立连接
  6. int n = read(connfd, buf); // 阻塞读数据
  7. doSomeThing(buf); // 利用读到的数据做些什么
  8. close(connfd); // 关闭连接,循环等待下一个连接
  9. }

这段代码会执行得磕磕绊绊,就像这样。

IO 多路复用 - 图1
服务端的线程阻塞在了两个地方,一个是 accept 函数,一个是 read 函数。
如果再把 read 函数的细节展开,我们会发现其阻塞在了两个阶段。
IO 多路复用 - 图2

整体流程如图:
IO 多路复用 - 图3

如果这个连接的客户端一直不发数据,那么服务端线程将会一直阻塞在 read 函数上不返回,也无法接受其他客户端连接。

  • 阻塞IO的时候,如果连接之后一直接收不到客户端的数据,那么服务端会一直被阻塞下去,不能建立新的连接。

    非阻塞IO

改造函数,每一次创建新的连接都创建一个新的进程,去调用read函数,并作相应处理。

  1. while(1) {
  2. connfd = accept(listenfd); // 阻塞建立连接
  3. pthread_createdoWork); // 创建一个新的线程
  4. }
  5. void doWork() {
  6. int n = read(connfd, buf); // 阻塞读数据
  7. doSomeThing(buf); // 利用读到的数据做些什么
  8. close(connfd); // 关闭连接,循环等待下一个连接
  9. }
  • 这样客户端建立好连接之后,就可以立即等待新客户的连接,不需要阻塞在原客户端的read请求上。

IO 多路复用 - 图4

  • 这并不是非阻塞IO,只是用了多线程使得没有让主线程卡在read函数上而已,read函数依然是阻塞的。

如果要实现真正的非阻塞IO,我们需要操作系统提供一个非阻塞的read函数:

  • 如果没有数据到达,read立即返回-1,而不是被阻塞
    • 操作系统提供了这样的功能,只需要在调用 read 前,将文件描述符设置为非阻塞即可。
      1. fcntl(connfd, F_SETFL, O_NONBLOCK);
      2. int n = read(connfd, buffer) != SUCCESS);
      这样,用户线程循环调用read,直到返回不为-1,在开始处理业务。
      IO 多路复用 - 图5

注意!
这分为两个阶段:

  1. 在数据没有拷贝到内核缓冲区之前,是非阻塞的。
  2. 拷贝到内核缓冲区之后,read依旧是阻塞的

IO 多路复用 - 图6

IO多路复用

为每个客户端创建一个线程,服务器资源消耗太快。
IO 多路复用 - 图7
我们可以每accept一个客户端连接后,将文件描述符放到一个数组里面。

  1. fdlist.add(connfd);

然后让一个新线程不断去遍历这个数组,调用每一个元素的非阻塞read方法。

  1. while(1) {
  2. for(fd : fdlist) {
  3. if(read(fd) != -1) {
  4. doSomeThing();
  5. }
  6. }
  7. }

这样就可以让一个线程处理多个客户端连接。
IO 多路复用 - 图8

但是!
这依旧是个小把戏,每次遍历遇到read返回-1依然是系统调用,很浪费资源。

可以让os提供一个:将一批文件描述符通过一次系统调用传给内核,由内核层去遍历(以至于不会又内核用户态切换的开销)

select

select是一个系统调用,通过它,可以把一个文件描述符数组发给os,让os在内核态去进行遍历,确定哪些文件描述符可以读写,然后告诉我们去处理。

IO 多路复用 - 图9

  1. 服务器一个线程不断接收新的连接请求,并把socket文件描述符放到一个list里面。

image.png

  1. 另一个线程调用select,让os去遍历socket列表

image.png

  1. 不过,当 select 函数返回后,用户依然需要遍历刚刚提交给操作系统的 list。
    1. 只不过,操作系统会将准备就绪的文件描述符做上标识,用户层将不会再有无意义的系统调用开销。
      1. while(1) {
      2. nready = select(list);
      3. // 用户层依然要遍历,只不过少了很多无效的系统调用
      4. for(fd : fdlist) {
      5. if(fd != -1) {
      6. // 只读已就绪的文件描述符
      7. read(fd, buf);
      8. // 总共只有 nready 个已就绪描述符,不用过多遍历
      9. if(--nready == 0) break;
      10. }
      11. }
      12. }
      IO 多路复用 - 图12

总结

  1. select调用需要传入标识符数组拷贝到内核,资源消耗巨大(可以优化为不复制)
  2. select在内核层依然通过便利的方式检查文件描述符的就绪状态,是一个同步的过程,只不过无系统调用上下文切换的开销。(可优化为异步事件通知)
  3. select仅仅返回可读文件描述符的个数,不返回具体哪个文件描述符可用,还需要用户自己遍历。(可以优化为返回具体的可用的文件描述符)

IO 多路复用 - 图13

多个文件描述符只有一次 select 的系统调用 + n 次就绪状态的文件描述符的 read 系统调用。

poll

它和 select 的主要区别就是,去掉了 select 只能监听 1024 个文件描述符的限制。

epoll

epoll 是最终的大 boss,它解决了 select 和 poll 的一些问题。

image.png

  1. 内核保存了一份文件描述符列表,无须用户每次重新传入,只需要告诉内核修改的部分就可。
  2. 内核不再以轮询方式找到就绪的文件描述符,而是通过异步IO事件唤醒。
  3. 内核仅将IO事件的文件描述符返回给用户。

具体,操作系统提供了这三个函数。
第一步,创建一个 epoll 句柄

  1. int epoll_create(int size);

第二步,向内核添加、修改或删除要监控的文件描述符。

  1. int epoll_ctl(
  2. int epfd, int op, int fd, struct epoll_event *event);

第三步,类似发起了 select() 调用

  1. int epoll_wait(
  2. int epfd, struct epoll_event *events, int max events, int timeout);

使用起来,其内部原理就像如下一般丝滑。

IO 多路复用 - 图15