浅谈面试常考的I/O模型

1,706 阅读9分钟

对I/O的认识

I/O有好几种,有内存I/O,磁盘I/O等,而我们这里说的是网络I/O。因为我们不同主机上进程之间的通信必须使用socket编程,所以网络I/O本质上也就是socket的读取

网络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

阻塞I/O模型 (Blocking I/O)

应用进程对一个套接字的read操作,应用进程会进行系统调用,如果此时内核没有数据,该应用进程会一直被阻塞,这段期间什么事都做不了,干等数据,直到数据从内核缓冲区复制到应用进程缓冲区才返回。

image-20210329195800409

非阻塞I/O模型 (Nonblocking I/O)

进程发起IO系统调用后,系统返回一个错误码而不会被阻塞。应用进程可以继续执行,但是需要不断的执行系统调用来获知IO是否完成。如果内核缓冲区有数据,内核就会把数据返回进程。虽然应用进程每次发起I/O请求可以立即返回,但是为了等到数据,需要不断地请求,会消耗大量的CPU资源。

image-20210329200102019

信号驱动I/O模型

当进程发起一个IO操作,会向内核注册一个信号处理函数,然后进程返回不阻塞。当内核数据就绪时会发送一个信号给进程,进程便在信号处理函数中调用IO读取数据。

image-20210329202704190

异步I/O模型

当进程发起一个IO操作,进程返回不阻塞,但也不能返回结果;内核把整个IO处理完后,会通知进程结果。如果IO操作成功则进程直接获取到数据。它跟信号驱动I/O类似,都是事件有响应时,内核通知应用进程可以进行I/O了。但是异步I/O是将I/O操作交给内核,当内核做完(拷贝操作)后就去通知应用进程。

image-20210329203146035

IO多路复用模型

当我们使用Socket编程进行通信时,服务端只能一对一进行通信。当服务端还在处理一个客户端的I/O读写时,是无法跟其他客户端进行连接的。一台服务器只能应对一个客户端,未免太浪费资源了吧!但是要应对多客户端,特别是1w+的客户端请求时,又要怎么实现呢?

这时候我们肯定会想到fork出多个进程来应对多个Socket(也就是多个客户端连接),又或者是创建出多个线程来应对。但一旦需要应对1w+的连接,服务器就要维护上万的进程或线程,占用了大量的服务器资源,并且进程/线程上下文切换也是十分消耗资源的。

此时就该I/O多路复用技术登场啦,只需要一个进程就可以应对多个客户端连接请求。那它究竟是使用了什么魔力能应对多个连接呢?现在就揭开它的面纱。

其实它就是通过调用某个函数,叫内核帮忙监视一组socket,如果有就绪的socket,再返回给它,然后它再进行拷贝和遍历,去处理有效的socket读写。这样能应对多个socket,但是在拷贝过去后进行遍历处理这里的性能相比于一对一的情况是有一丢丢下降的,但总体性能还是高很多的,毕竟可以在一段时间内处理很多socket,比只能处理完一个socket再处理下一个socket好太多了,这就有点类似CPU对进程的并发处理了!
而应用进程是通过内核提供的系统调用 selectpollepoll,从内核中拿到多个就绪的socket。

因为在I/O多路复用模型中,只需要一个进程就可以管理多个Socket,并且只有在真正有Socket读写事件进行时,才会使用实际的I/O读写操作。所以它大大减少了资源的占用。

image-20210403200719773

select

select会将TCP中的全连接队列中的Socket对应生成的文件描述符放入到一个集合中,然后复制到内核中,让内核不断去轮询是否有读写事件的产生,一旦有,就把对应的Socket标记为可读/可写,再将全部的文件描述符集合拷贝到用户空间,select函数返回,应用程序需要再一次对文件描述符集合进行遍历,检查是否为可读/可写,对其进行处理。

具体过程

其实这里说细一点,就涉及到了操作系统调度和中断知识了~

当应用进程调用select函数时会陷入内核态,内核程序会去轮询有无产生读写事件的socket,如果没有的话,会将当前应用进程停靠在需要检查的socket的等待队列中(补充:socket的结构有三块:写缓存,读缓存,等待队列),也就是挂起该进程了,CPU切换其他进程运行。

一旦任意一个socket有事件产生,也就是网络数据包到达时,会触发网络数据传输完毕对应的中断,CPU转而执行中断处理程序,分析出该数据包是属于哪个socket,将数据包(根据TCP首部的端口号)放入对应的socket的读缓存中,然后去检查socket的等待队列是否有等待进程,有的话把等待进程移回工作队列中,中断结束。CPU的使用权交还给用户态。刚刚挂起的进程又回到工作队列中,又有机会获得CPU的运行时间片了,然后再次执行select函数,检查是否有读写事件发生的socket,有的话标记为可读,就接下去上面说的步骤啦~

几个缺点:

  1. 使用固定长度的 BitsMap,表示文件描述符集合,而且所支持的文件描述符的个数是有限制的,在 Linux 系统中,由内核中的 FD_SETSIZE 限制, 默认最大值为 1024,只能监听 0~1023 的文件描述符。
  2. 将文件描述符集合从用户态到内核态,有拷贝的开销
  3. 当有数据时select就会返回,但是select函数并不知道哪个文件描述符有数据,后面还需要再次对文件描述符进行遍历,效率比较低。

poll

poll是对select的增强。它采用链表的形式来存储文件描述符,突破了select对文件描述符的限制,只受内核内存大小的限制。

但还是需要经历内核、应用进程对文件描述符集合的遍历检查,内核到应用进程的拷贝开销。

epoll

它使用了两种红黑树和就绪链表两种数据结构解决了select/poll的缺点。在Linux2.5.44版本中就使用了这种I/O复用机制。

主要有三个系统调用API:

// 内核创建epoll实例,包括红黑树和就绪链表
int epoll_create(int size);

// 对红黑树进行修改、删除、增加一个socket节点
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); 

// 内核利用红黑树,快速查找活跃的socket,放入就绪链表
// 再将就绪链表中一定数量的内容拷贝到events
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);  

首先应用进程调用epoll_create创建epoll实例,同时在内核中建立红黑树和就绪链表;

调用epoll_ctl将会对红黑树增删改一个socket节点:

  • ADD 会检查红黑树有无这个socket,有的话加入就绪链表中,没有就会插入该红黑树中维护。
  • DEL 从epoll实例的各个资源删除。
  • MOD 会修改对应socket的状态,并再次检查红黑树,有活跃的socket会加入就绪链表中,没有就注册事件回调函数,每当有事件发生时就通过回调函数把这些socket放入就绪链表中。

epoll_wait会去检查就绪链表有无已经就绪的socket,没有就等待唤醒,有的话就拷贝回用户空间。

由于epoll从内核态仅需要拷贝活跃的socket到用户态,就解决了select/poll的大量socket拷贝开销和无效遍历的缺点。

适用场景

并不是说epoll就一定比select/poll好,每种技术都有适合的场景。如果是并发量比较低且socket都比较活跃的情况下,无需创建红黑树和就绪链表的开销,两次遍历的时间开销不会很大并且充分利用了每个遍历节点,所以select/poll会更适合。而如果是高并发且任一时间只有少数socket是活跃的,那epoll会更适合,因为它每次只拷贝活跃的socket到用户态。

参考资料来源

mp.weixin.qq.com/s/Qpa0qXxuI…
journey-c.github.io/io-multiple…