[转]: I/O多路复用模型及Linux中的应用

IO多路复用模型广泛的应用于各种高并发的中间件中,那么区别于其他模式他的优势是什么、其核心设计思想又是什么、其在Linux中是如何实现的?

I/O模型

I/O模型主要有以下五种:

  1. 同步阻塞I/O:I/O操作将同步阻塞用户线程
  2. 同步非阻塞I/O:所有操作都会立即返回,但需要不断轮询获取I/O结果
  3. I/O多路复用:一个线程监听多个I/O操作是否就绪,依然是阻塞I/O,需要不断去轮询是否有就绪的fd
  4. 信号驱动I/O:当I/O就绪后,操作系统发送SIGIO信号通知对应进程,避免空轮询导致占用CPU(linux中的信号驱动本质还是使用的epoll
  5. 异步I/O:应用告知内核启动某个操作,并让内核在整个操作完成之后,通知应用,这种模型与信号驱动模型的主要区别在于,信号驱动IO只是由内核通知我们可以开始下一个IO操作,而异步IO模型是由内核通知我们操作什么时候完成

聊聊Linux 五种IO模型

其中应用最广的当属I/O多路复用模型,其核心就是基于Reactor设计模式,仅一个线程就可以监听多个I/O事件,使得在高并发场景下节约大量线程资源

Reactor设计模式

处理WEB通常有两种请求模型:

  1. 基于线程:每个请求都创建一个线程来处理。并发越高,线程数越多,内存占用越高,性能也会越低,线程上下文切换造成性能损耗,线程等待IO也会浪费CPU时间。一般应用于并发量少的小型应用。
  2. 事件驱动:每个请求都由Reactor线程监听,当I/O就绪后,由Reactor将任务分发给对用的Handler。

显然事件驱动模型更适用于目前动辄几十万并发的场景。

网络服务器的基本处理模型如下:建立连接->读取请求->解析请求->处理服务->编码结果->返回结果。

基于网络服务器的基本模型,Reactor衍生出了以下三种模型。

1.单线程模型

Reactor单线程模型,指的是所有的I/O操作都在同一个NIO线程上面完成,NIO线程的职责如下:

  • 作为NIO服务端,接收客户端的TCP连接
  • 作为NIO客户端,向服务端发起TCP连接
  • 读取通信对端的请求或者应答消息
  • 向通信对端发送消息请求或者应答消息

Reactor线程负责多路分离套接字,Accept新连接,并分派请求到处理器链中。该模型 适用于处理器链中业务处理组件能快速完成的场景。不过,这种单线程模型不能充分利用多核资源,所以实际使用的不多。

2.多线程模型

Reactor多线程模型与单线程模型最大区别就是引入了线程池,负责异步调用Handler处理业务,从而使其不会阻塞Reactor,它的流程如下:

  1. Reactor 对象通过 select 监控客户端请求事件,收到事件后,通过 dispatch 进行分发
  2. 如果是建立连接请求,则由 Acceptor 通过 accept 处理连接请求,然后创建一个 Handler 对象处理完成连接后的各种事件
  3. 如果不是连接请求,则由 Reactor 对象会分发调用连接对应的 Handler 来处理
  4. Handler 只负责响应事件,不做具体的业务处理,通过 read 读取数据后,会分发给后面的 Worker 线程池的某个线程处理业务
  5. Worker 线程池会分配独立线程完成真正的业务,并将结果返回给 Handler
  6. Handler 收到响应后,通过 send 将结果返回给 Client

3.主从多线程模型

将连接请求句柄和数据传输句柄分开处理,使用单独的Reactor来处理连接请求句柄,提高数据传送句柄的处理能力。

服务端用于接收客户端连接的不再是1个单独的NIO线程,而是一个独立的NIO线程池。Acceptor接收到客户端TCP连接请求处理完成后(可能包含接入认证等),将新创建的SocketChannel注册到I/O线程池(sub reactor线程池)的某个I/O线程上,由它负责SocketChannel的读写和编解码工作。

著名的Netty即采用了此种模式

Linux中的I/O多路复用

linux实现I/O多路复用,主要涉及三个函数select、poll、epoll,目前前两个已经基本不用了,但作为面试必考点还是应该知晓其原理。

几个重要概念:

  1. 用户空间和内核空间:为保护linux系统,将可能导致系统崩溃的指令定义为R0级别,仅允许在内核空间的进程使用,而普通应用则运行在用户空间,当应用需要执行R0级别指令时需要由用户态切换到内核态(极其耗时)。
  2. 文件描述符(File descriptor):当应用程序请求内核打开/新建一个文件时,内核会返回一个文件描述符用于对应这个打开/新建的文件,其fd本质上就是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。

select

int select(int maxfd1,			// 最大文件描述符个数,传输的时候需要+1
		   fd_set *readset,	// 读描述符集合
		   fd_set *writeset,	// 写描述符集合
		   fd_set *exceptset,	// 异常描述符集合
		   const struct timeval *timeout);// 超时时间

select通过数组存储用户关心的fd并通知内核,内核将fd集合拷贝至内核空间,遍历后将就绪的fd集合返回

其缺点主要有以下几点:

  1. 最大支持的fd_size为1024(有争议?),远远不足以支撑高并发场景
  2. 每次涉及fd集合用户态到内核态切换,开销巨大
  3. 遍历fd的时间复杂度为O(n),性能并不好

poll

int poll(struct pollfd *fds, 	        // fd的文件集合改成自定义结构体,不再是数组的方式,不受限于FD_SIZE
		 unsigned long nfds,     // 最大描述符个数
				int timeout);// 超时时间

struct pollfd {
	int fd;			// fd索引值
	short events;		// 输入事件
	short revents;		// 结果输出事件
};

poll技术与select技术实现逻辑基本一致,重要区别在于其使用链表的方式存储描述符fd,不受数组大小影响

说白了对于select的缺点poll只解决了第一点,依然存在很大性能问题

epoll

// 创建保存epoll文件描述符的空间,该空间也称为“epoll例程”
int epoll_create(int size);    // 使用链表,现在已经弃用
int epoll_create(int flag);    // 使用红黑树的数据结构

// epoll注册/修改/删除 fd的操作
long epoll_ctl(int epfd,                        // 上述epoll空间的fd索引值
               int op,                         // 操作识别,EPOLL_CTL_ADD |  EPOLL_CTL_MOD  |  EPOLL_CTL_DEL
               int fd,                          // 注册的fd
               struct epoll_event *event);      // epoll监听事件的变化
struct epoll_event {
	__poll_t events;
	__u64 data;
} EPOLL_PACKED;

// epoll等待,与select/poll的逻辑一致
epoll_wait(int epfd,                            // epoll空间
           struct epoll_event *events,           // epoll监听事件的变化
           int maxevents,                        // epoll可以保存的最大事件数
        int timeout);                         // 超时时间

为了解决select&poll技术存在的两个性能问题,epoll应运而生

  1. 通过epoll_create函数创建epoll空间(相当于一个容器管理),在内核中存储需要监听的数据集合,通过红黑树实现,插入删除的时间复杂度为O(nlogn)
  2. 通过epoll_ctl函数来注册对socket事件的增删改操作,并且在内核底层通过利用mmap技术保证用户空间与内核空间对该内存是具备可见性,直接通过指针引用的方式进行操作,避免了大内存数据的拷贝导致的空间切换性能问题
  3. 通过ep_poll_callback回调函数,将就绪的fd插入双向链表fd中,避免通过轮询的方式获取,事件复杂度为O(1)
  4. 通过epoll_wait函数的方式阻塞获取rdlist中就绪的fd

EPOLL事件有两种模型 Level Triggered (LT) 和 Edge Triggered (ET):

  1. LT(level triggered,水平触发模式)是缺省的工作方式,并且同时支持 block 和 non-block socket。在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。如果你不作任何操作,内核还是会继续通知你的,所以,这种模式编程出错误可能性要小一点。
  2. ET(edge-triggered,边缘触发模式)是高速工作方式,只支持no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,等到下次有新的数据进来的时候才会再次出发就绪事件。

Don't let emotion cloud your judgment.
不要让情绪影响你的判断。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章