Spring架构篇--2.3 远程通信基础--IO多路复用select,poll,epoll模型

前言:对于传统的BIO(同步阻塞)模型,当有客户端连接达到服务端,服务端在对改连接进行连接建立,和数据传输过程中,是无法响应其他客户端的,只有当服务端完成对一个客户端处理后,才能去处理其他客户端的连接,管道的读写请求;如果只有几个客户端连接还好,如果现在需要多个客户端都连接到服务端,就很有可能造成多个客户端的阻塞,虽然可以引入多线程技术,每个客户端进入都交由一个线程进行处理,如果有成千上万个客户端就需要维护n多个线程,显然线程并不是越多越好,它除了会占用大量资源,也会造成上下文的频繁切换;那么有没有其它方式来解决呢。

1 同步阻塞IO数据接收过程:
Spring架构篇--2.3 远程通信基础--IO多路复用select,poll,epoll模型_第1张图片

  • 用户进程创建socket 并与服务端建立tcp连接;
  • 用户进程向内核空间所要数据,如果内核空间中没有数据,则将当前进程放入到socket 等待队列(没个socket都有一个等待队列,进程等待队列,存放了服务端的用户进程 A 的进程描述符和回调函数)并挂起当前进程,让出CPU资源;
  • 当服务端有数据返回,达到客户端电脑的网卡,数据进入准备阶段,向cpu 发出中断信息号,并将数据从网卡,拷贝到系统空间,数据准备完成(每个socket 中都有一个数据接收队列,内核收到中断信号后,会将网卡复制到内存的数据,根据数据报文的 IP 和端口号,将其拷贝到内核中对应 socket 的接收队列);
  • cpu将内核空间的数据,拷贝到对应进程下的用户空间中,复制阶段完成,遍历socket 的等待队列,从socket 等待队列中移除对应的进程,将对应的进程放回到工作队列中;
  • 进程获取cpu时间片,从进程下的用户空间获取数据:进程 获取 CPU片 后,会回到之前调用 recvfrom() 函数时阻塞的位置继续执行,这时发现 socket 内核空间的等待队列上有数据,会在内核态将内核空间的 socket 等待队列的数据拷贝到用户空间,然后才会回到用户态执行进程的用户程序,从而真的解除阻塞;

2 CPU 对于任务的处理:

我们知道系统中进程的运行依赖于从CPU获得时间片,只有在工作队列中的进程才有获的CPU时间片的机会:
Spring架构篇--2.3 远程通信基础--IO多路复用select,poll,epoll模型_第2张图片
当一个进程发生阻塞时,会从工作队列中进行移除,并将改进程放入到阻塞队列中:
Spring架构篇--2.3 远程通信基础--IO多路复用select,poll,epoll模型_第3张图片
当发生中断时,会将阻塞队列中满足条件的进程从阻塞队列中移除,并将其重新加入到工作队列中;如果我们将CPU看做是一个服务端,将多个进程看做是与其连接的客户端,是否也可以只有一个线程来管理多个客户端,我们将客户端统一放入到一个地方,只有当客户端满足一定的条件,可以将其看做是可以进行数据处理的客户端,并将其放入一个有效队列中,然后程序中只与有效队列中的客户端进行通信;

3 NIO 多路复用IO:

IO多路复用是指内核一旦发现进程指定的一个或者多个IO条件准备读取,它就通知该进程进行处理;多路复用通过将关心的socket 都注册到一个Select 上,由Select 完成对多个socket 的监听,当发现有通道准备就绪,可读或者可写,就告诉进程来处理;

3.1 select,poll 的实现方式:
本文中socket 内核对象 ≈ fd 文件描述符 ≈ TCP连接,服务端通过将连接到的socket 的文件描述符放入到一个集合中,然后将改socket集合拷贝到内核空间,内核空间通过遍历文件描述符集合的方式,当有事件发生,将对应的socket 标记为可读或可写,然后将socket 集合拷贝到用户空间,用户空间遍历socket 集合找到可读或者可写的socket 进行处理;
过程:
1)当进程A调用select语句的时候,会将socket 集合拷贝到内核空间,并将进程A添加到多个监听socket的等待队列中:
Spring架构篇--2.3 远程通信基础--IO多路复用select,poll,epoll模型_第4张图片

2)当网卡接收到数据,然后网卡通过中断信号通知cpu有数据到达,执行中断程序,中断程序主要做了两件事:

  • 将网络数据写入到对应socket的接收缓冲区里面;
  • 唤醒队列中的等待进程(A),重新将进程A放入工作队列中.
    Spring架构篇--2.3 远程通信基础--IO多路复用select,poll,epoll模型_第5张图片

3)工作队列的线程A 获取cpu时间片,将数据从内核的socket 数据接收队列中将数据拷贝到用户空间,用户空间的进程从用户空间获取数据进行处理;

3.2 select,poll 区别:
select和poll在内部机制方面并没有太大的差异。相比于select机制,poll只是取消了最大监控文件描述符数限制,并没有从根本上解决select存在的问题。

  • select 使用固定长度的 BitsMap,表示文件描述符集合,而且所支持的文件描述符的个数是有限制的,在 Linux 系统中,由内核中的 FD_SETSIZE 限制, 默认最大值为 1024,只能监听 0~1023 的文件描述符。
  • poll 不再用 BitsMap 来存储所关注的文件描述符,取而代之用动态数组,以链表形式来组织,突破了 select 的文件描述符个数限制,当然还会受到系统文件描述符限制。
  • 但是 poll 和 select 并没有太大的本质区别,都是使用「线性结构」存储进程关注的 Socket 集合,因此都需要遍历文件描述符集合来找到可读或可写的 Socket,时间复杂度为 O(n),而且也需要在用户态与内核态之间拷贝文件描述符集合,这种方式随着并发数上来,性能的损耗会呈指数级增长。

select,poll 问题:

  • 在网络中往往可以进行通信的只是占所有socket 的一部分,每次调用 Select 都需要将进程加入到所有监视 Socket 的等待队列,而每次唤醒都需要从每个socket等待队列中移除。这里涉及了两次遍历,而且每次都要将整个 FDS 列表传递给内核,有一定的开销。
  • 进程被唤醒后,程序并不知道哪些 Socket 收到数据,还需要遍历一次;

3.3 epoll 的实现方式:
3.3.1 epoll 在原有的select,poll 主要解决,socket多次遍历问题;
Spring架构篇--2.3 远程通信基础--IO多路复用select,poll,epoll模型_第6张图片

1) epoll 中放弃了数组使用红黑树来存放关心的socket ,这样每次添加socket和移除socket 只需要遍历红黑树 时间复杂度 O(logn);epoll 在内核里使用红黑树来跟踪进程所有待检测的文件描述字,把需要监控的 socket 通过 epoll_ctl() 函数加入内核中的红黑树里,红黑树是个高效的数据结构,增删查一般时间复杂度是 O(logn),通过对这棵黑红树进行操作,这样就不需要像 select/poll 每次操作时都传入整个 socket 集合,只需要传入一个待检测的 socket,减少了内核和用户空间大量的数据拷贝和内存分配。

2) epoll 使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个 socket 有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中,当用户调用 epoll_wait() 函数时,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。

epoll 的方式即使监听的 Socket 数量越多的时候,效率不会大幅度降低,能够同时监听的 Socket 的数目也非常的多了,上限就为系统定义的进程打开的最大文件描述符个数.

3.3.2 epoll 的事件触发机制:
epoll 支持两种事件触发模式,分别是边缘触发(edge-triggered,ET)和水平触发(level-triggered,LT);

  • 使用边缘触发模式时,当被监控的 Socket 描述符上有可读事件发生时,服务器端只会从 epoll_wait 中苏醒一次,即使进程没有调用 read 函数从内核读取数据,也依然只苏醒一次,因此我们程序要保证一次性将内核缓冲区的数据读取完;

  • 使用水平触发模式时,当被监控的 Socket 上有可读事件发生时,服务器端不断地从 epoll_wait 中苏醒,直到内核缓冲区数据被 read 函数读完才结束,目的是告诉我们有数据需要读取;

1)如果使用水平触发模式,当内核通知文件描述符可读写时,接下来还可以继续去检测它的状态,看它是否依然可读或可写。所以在收到通知后,没必要一次执行尽可能多的读写操作。

2)如果使用边缘触发模式,I/O 事件发生时只会通知一次,而且我们不知道到底能读写多少数据,所以在收到通知后需要一次性的把缓冲区的数据读完为止,也就是一直读,直到读到EGAIN(EGAIN说明缓冲区已经空了)为止,否则可能出现读取数据不完整的问题。因此,我们会循环从文件描述符读写数据,那么如果文件描述符是阻塞的,没有数据可读写时,进程会阻塞在读写函数那里,程序就没办法继续往下执行。所以,边缘触发模式一般和非阻塞 I/O 搭配使用,程序会一直执行 I/O 操作,直到系统调用(如 read 和 write)返回错误,错误类型为 EAGAIN 或 EWOULDBLOCK。

3)一般来说,边缘触发的效率比水平触发的效率要高,因为边缘触发可以减少 epoll_wait 的系统调用次数,系统调用也是有一定的开销的的,毕竟也存在上下文的切换。

4)select/poll 只有水平触发模式,epoll 默认的触发模式是水平触发,但是可以根据应用场景设置为边缘触发模式。

5)多路复用 API 返回的事件并不一定可读写的(在Linux下,select() 可能会将一个 socket 文件描述符报告为 “准备读取”,而后续的读取块却没有。例如,当数据已经到达,但经检查后发现有错误的校验和而被丢弃时,就会发生这种情况。也有可能在其他情况下,文件描述符被错误地报告为就绪),如果使用阻塞 I/O, 那么在调用 read/write 时则会发生程序阻塞,因此最好搭配非阻塞 I/O,以便应对极少数的特殊情况。

3 总结:

  • 传统的BIO通信为了支持多个客户端通信需要多个线程来维护socket,有多少个客户端就需要有多少个线程,线程过多会造成频繁的cpu 上下文切换,占有大量资源且执行效率低;
  • 为了解决多个线程问题,引入NIO 多路复用,通过将多个感兴趣的socket 注册到selctor 中,然后在拷贝到内核空间,有内核空间监测事件的发生,标记对应的socket 可读可写,然后用户空间的线程在遍历每个socket 对可读或者可写的socket 进行数据通信处理;但是随着socket 的增多显然 感兴趣的socket 集合会越来越大,频繁的遍历和复制socket 集合也会占用资源;
  • 使用epoll 模型通过将感兴趣的socket 放入到红黑树中进行遍历,并将可读可写的socket 单独放入到链表中,这样及时大量的客户端连接,增删socket ,和复制可读可写的socket 链表效率依然很高;
  • select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的;
  • 在早期的JDK1.4和1.5 update10版本之前,Selector基于select/poll模型实现,在JDK1.5 update10和linux core2.6以上版本,sun优化了Selctor的实现,底层使用epoll替换了select/poll;

参考:
1 Select、Poll、Epoll详解;
2 深入学习IO多路复用select/poll/epoll实现原理;
3 这次答应我,一举拿下 I/O 多路复用!

你可能感兴趣的:(java工具篇,java基础篇,spring,架构,网络)