这篇文章主要是参考大佬笔记
B 站视频:【电话面试】io 多路复用专题面试,阿里 70w 年薪程序员大神教科书式面试回答问题
原笔记链接:
2022年4月13日再次更新,这个文章差不多把epoll和io复用解释清楚了,包括背后的原理,对应的数据结果和实现过程
里面涉及到操作系统还有计算机系统相关知识,之后打算弄成框架,系统性的整理
BIO 的缺陷
- BIO 中的 B 是
Blocking
的阻塞的意思 - 作为服务端开发,使用
ServerSocket
绑定端口号之后会监听该端口, 等待accept
事件,accept
是会阻塞当前线程 - 当我们收到
accept
事件的时候,程序就会拿到客户端与当前服务端连接的Socket
- 针对这个
socket
我们可以进行读写,但是呢,这个socket
读写都是会阻塞当前线程的。 - 一般我们会有使用多线程方式进行 c/s 交互,但是这样很难做到 C10K(比如说:1W 个客户端就需要和服务端用 1W 个线程支持,这样的话 CPU 肯定就爆炸了,同时线程上下文切换也会把机器负载给拉飞。)
select(…) 实现原理
- 每次调用
kernel
的 select 函数,都会涉及到用户态 / 内核态的切换,还需要传递需要检查的 socket 集合,其实就是需要检查的fd
(文件描述符) 集合- 因为咱的程序都是运行在 linux 或者 unix 操作系统上,这种操作系统,一切皆文件,
socket
也不例外,这里传递的 fd 其实就是文件系统中对应 socket 生成的文件描述符
- 因为咱的程序都是运行在 linux 或者 unix 操作系统上,这种操作系统,一切皆文件,
- 操作系统 这个
select
函数被调用以后,- 首先会去 fd 集合中去检查内存中
socket
套接字的状态,这个时间复杂度是O(N)
的,然后检查完一遍之后,如果有就绪状态的socket
, 那么就会直接返回,不会阻塞当前线程。 - 否则的话,那个就说明当前指定
fd
集合对应的socket
没有就绪状态,那么就需要阻塞当前调用线程了,直到有某个socket
有数据之后,才唤醒线程。
- 首先会去 fd 集合中去检查内存中
select(…) 对监听 socket 有 1024 的大小限制
- 这个是因为
fd
集合这个结构是一个 **bitmap
位图的结构 **,这个位图结构就是一个长的二进制数, 类似0101
这种- 这个
bitmap
默认长度是1024个bit
, 想要修改长度非常麻烦,需要重新编译操作系统内核
- 这个
- 处于某种性能考虑,
select
函数做了两件事- 第一件事,跑到就绪状态的
socket
对应的 fd 文件中设置一个标记 mask, 表示这个 fd 对应的socket
就绪了 - 第二件事,返回
select
函数,对应的也就是唤醒线程,他会收到一个 int 结果值,表示有几个 socket 处于就绪状态- 但是具体是哪个
socket
就绪,线程是不知道的,所以接下来会是一个O(N)
的系统调用,检查 fd 集合中每一个 socket 的就绪状态,其实就是检查文件系统中指定socket
的文件描述符的状态,涉及到用户态和内核态的来回切换,如果bitmap
再大,就非常耗费性能 - 还有就是系统调用涉及到参数的数据拷贝,如果数据太庞大,他也不利于系统的调用速度
- 但是具体是哪个
- 第一件事,跑到就绪状态的
select(…) 深入问题
问题:select (…) 第一遍 O(N) 去检查未发现就绪的 socket ,后续某个 socket 就绪后,select(…) 是如何感知道的?是不断的轮询吗?
铺垫知识
- 操作系统调度
- cpu 同一时刻,它只能运行一个进程,操作系统做主要的任务就是系统调度,就是有 n 个进程,然后让这 n 个进程在 cpu 上切换进行
- 未挂起的进程都在工作队列内,都有机会获取到 cpu 的执行权
- 挂起的进程就会从这个工作队列里移除出去,反映到咱们 java 层面就是线程阻塞了
- linux 系统线程其实就是轻量级进程
- 操作系统中断
-
比如说,咱们用键盘打字,如果 cpu 正执行其他程序,一直不释放,那咱这个打字就也没法打了
-
咱们都知道,不是这样的情况,因为就是有系统中断的存在,当按下一个键以后会给主板发送一个电流信号,主板感知到以后,它就会触发这个 cpu 中断、
-
所以中断 其实就是让 cpu 给正在执行的进程先保留程序上下文,然后避让出 cpu,给中断程序绕道
-
中断程序就会拿到 cpu 的执行权限,进行相应代码的执行,比如说键盘的中断程序,就会执行输出的逻辑
回到最开始的问题
- 这个
select
函数,它第一遍轮询,没有发现就绪状态的 socket 的话,它就会把当前进程保留给需要检查的 socket 等待队列中 socket
结构 有三块核心区域,分别就是读缓存,写缓存还有这个等待队列- 这个
select
函数,它会把当前进程保留到每个需要检查的 socket 的等待队列中,就会把当前进程从工作队列里面移除了,移除了之后其实就是挂起了当前线程,然后这个 select 函数也就不会再运行了 - 下一个阶段,假设我们客户端往当前服务器发送了数据,数据通过网线到网卡,网卡再到 DMA 硬件的这种方式直接将数据写到内存里面,然后整个过程,CPU 是不参与的
- 当传输完成以后,它就会触发网络数据传输完毕的中断程序,这个中断程序它会把 cpu 正在执行的进程给顶掉,然后 cpu 就会执行咱这个中断程序的逻辑
- 对应的逻辑是:根据内存中的数据包,然后分析出来数据包是哪个 socket 的数据,
- 同时 tcp/ip 它又是保证传输的时候是有端口号的,然后根据端口号就能找到对用的 socket 实例,找到 socket 实例以后,就会把数据导入到 socket 读缓冲里面
- 导入完成以后,它就开始去检查 socket 等待队列,看是不是有等待者,如果有等待者的话,就会把等待者移动到工作队列里面去,中断程序到这一步就执行完了
- 这样咱们的进程就又回到了工作队列,又有机会获取到 cpu 时间片了
- 然后当前进程执行的
select
函数再次检查,就会发现这个就绪的socket了
, 就会给就绪的socket
的fd
文件描述符打标记,然后select
函数就执行完了,返回到 java 层面就涉及到内核态和用户态的转换,后面的事情就是轮询检查每一个 socket 的 fd 是否被打了标记,然后就是处理被打了标记的 socket 就 ok 了
poll() 和 select() 区别
-
传参不一样
- select 用的是 bitmap , 它表示需要检查的 socket 集合
- poll 使用的是 链表结构, 表示需要检查的 socket 集合(主要是为了解决 socket 监听长度超过 1024 的
socket
的限制)
epoll 的 产生背景
- select 和 poll 的共有缺陷
- 第一个缺陷:
select
和poll
函数,这两系统函数每次调用都需要我们提供给它所有的需要监听的 socket 文件描述符集合,而且主线程是死循环调用select/poll
函数的, 这里面涉及到用户空间数据到内核空间拷贝的过程- 咱们需要监听的 socket 集合,数据变化非常小
- 每次就一到两个 socket_fd 需要更改,但是没有办法,因为 select 和 poll 函数,只是一个很单纯的函数
- 它在 kernel 层面,不会保留任何的数据信息,所以说每次调用都进行了数据拷贝
- 第二个缺陷: select 和 poll 函数它的返回值都是 int 整型值,只能代表有几个 socket 就绪或者有错误了,它没办法表示具体是哪个 socket 就绪了
- 这就导致了程序被唤醒以后,还需要新的一轮系统调用去检查哪个 socket 是就绪状态的,然后再进行 socket 数据处理逻辑,这里走了不少弯路(同时还存在用户态和内核态的切换,这样缺陷就更严重了)
- 第一个缺陷:
epoll 就是为了解决这两个问题
epoll (…) 实现原理
- epoll 函数在内核空间内,它有一个对应的数据结构(红黑树)去存储一些数据,这个数据结构其实就是 eventpoll 对象
- 这个
eventpoll
可以通过一个系统函数epoll_create()
函数去创建的
- 这个
- 创建完成之后,系统函数返回一个
eventpoll
对象的 id,相当于我们在内核空间开辟了一小块空间,并且我们也知道这块空间的位置
先说下 eventpoll 的数据结构:三块重要的区域
- 一块是存放需要监听的 socket_fd 描述符列表
- 另一块就是就绪列表,存放就绪状态的 socket 信息
- eventpoll 还有一块空间是
eventpoll
的等待队列,这个等待队列保存的就是调用epoll_wait
的进程 - 另外呢还提供了两个函数,一个是
epoll_ctl
函数,一个是epoll_wait
函数 - 其中存放的 socket 集合信息采用的是红黑树的数据结构,socket 集合信息经常用增删改查的,这种红黑树再适合不过了,保持了时间复杂度为 O(logN)
epoll_ctl()
- 它可以根据
eventpoll-id
去增删改内核空间上eventpoll
对象的检查列表(socket 信息)
*epoll_wait() *
- 它主要的参数是
eventpoll-id
表示此次系统调用需要检测的socket_fd
集合,是eventpoll
中已经指定好的那些socket
信息 epoll_wait
默认情况下会阻塞系统的调用线程,直到eventpoll
对象中关联的某个或者某些个 socket 就绪以后,epoll_wait
函数才会返回- 返回值是
Int
类型的- 返回 0,表示没有就绪的 socket
- 返回大于 0,表示有几个就绪的 socket
- 返回 - 1 表示异常
进程阻塞为什么不占用cpu资源?
了解epoll本质的第三步,要从操作系统进程调度的角度来看数据接收。阻塞是进程调度的关键一环,指的是进程在等待某事件(如接收到网络数据)发生之前的等待状态,recv、select和epoll都是阻塞方法。了解“进程阻塞为什么不占用cpu资源?”, 也就能够了解这一步。
为简单起见,我们从普通的recv接收开始分析,先看看下面代码
//创建socket
int s = socket(AF_INET, SOCK_STREAM, 0);
//绑定
bind(s, ...)
//监听
listen(s, ...)
//接受客户端连接
int c = accept(s, ...)
//接收客户端数据
recv(c, ...);
//将数据打印出来
printf(...)
这是一段最基础的网络编程代码,先新建socket对象,依次调用bind、listen、accept,最后调用recv接收数据。recv是个阻塞方法,当程序运行到recv时,它会一直等待,直到接收到数据才往下执行
那么阻塞的原理是什么?
工作队列
操作系统为了支持多任务,实现了进程调度的功能,会把进程分为“运行”和“等待”等几种状态。运行状态是进程获得cpu使用权,正在执行代码的状态;等待状态是阻塞状态,比如上述程序运行到recv时,程序会从运行状态变为等待状态,接收到数据后又变回运行状态。操作系统会分时执行各个运行状态的进程,由于速度很快,看上去就像是同时执行多个任务。
下图中的计算机中运行着A、B、C三个进程,其中进程A执行着上述基础网络程序,一开始,这3个进程都被操作系统的工作队列所引用,处于运行状态,会分时执行。
工作队列中有A、B和C三个进程
等待队列
当进程A执行到创建socket的语句时,操作系统会创建一个由文件系统管理的socket对象(如下图)。这个socket对象包含了发送缓冲区、接收缓冲区、等待队列等成员。等待队列是个非常重要的结构,它指向所有需要等待该socket事件的进程。
创建socket
当程序执行到recv时,操作系统会将进程A从工作队列移动到该socket的等待队列中(如下图)。由于工作队列只剩下了进程B和C,依据进程调度,cpu会轮流执行这两个进程的程序,不会执行进程A的程序。所以进程A被阻塞,不会往下执行代码,也不会占用cpu资源。
socket的等待队列
ps:操作系统添加等待队列只是添加了对这个“等待中”进程的引用,以便在接收到数据时获取进程对象、将其唤醒,而非直接将进程管理纳入自己之下。上图为了方便说明,直接将进程挂到等待队列之下。
内核接收网络数据全过程
这一步,贯穿网卡、中断、进程调度的知识,叙述阻塞recv下,内核接收数据全过程。
如下图所示,进程在recv阻塞期间,计算机收到了对端传送的数据(步骤①)。数据经由网卡传送到内存(步骤②),然后网卡通过中断信号通知cpu有数据到达,cpu执行中断程序(步骤③)。此处的中断程序主要有两项功能,先将网络数据写入到对应socket的接收缓冲区里面(步骤④),再唤醒进程A(步骤⑤),重新将进程A放入工作队列中。
内核接收数据全过程
唤醒进程的过程如下图所示。
唤醒进程
当socket接收到数据后,操作系统将该socket等待队列上的进程重新放回到工作队列,该进程变成运行状态,继续执行代码。也由于socket的接收缓冲区已经有了数据,recv可以返回接收到的数据。
这里留有两个思考题,大家先想一想。
其一,操作系统如何知道网络数据对应于哪个socket?
其二,如何同时监视多个socket的数据?
(——我是分割线,想好了才能往下看哦~)
公布答案的时刻到了。
第一个问题:因为一个socket对应着一个端口号,而网络数据包中包含了ip和端口的信息,内核可以通过端口号找到对应的socket。当然,为了提高处理速度,操作系统会维护端口号到socket的索引结构,以快速读取。
第二个问题是**多路复用的重中之重,**是本文后半部分的重点!
epoll()的实现流程
创建epoll对象
如下图所示,当某个进程调用epoll_create方法时,内核会创建一个eventpoll对象(也就是程序中epfd所代表的对象)。eventpoll对象也是文件系统中的一员,和socket一样,它也会有等待队列。
内核创建eventpoll对象
创建一个代表该epoll的eventpoll对象是必须的,因为内核要维护“就绪列表”等数据,“就绪列表”可以作为eventpoll的成员。
维护监视列表
创建epoll对象后,可以用epoll_ctl添加或删除所要监听的socket。以添加socket为例,如下图,如果通过epoll_ctl添加sock1、sock2和sock3的监视,内核会将eventpoll添加到这三个socket的等待队列中。
添加所要监听的socket
当socket收到数据后,中断程序会操作eventpoll对象,而不是直接操作进程。
接收数据
当socket收到数据后,中断程序会给eventpoll的“就绪列表”添加socket引用。如下图展示的是sock2和sock3收到数据后,中断程序让rdlist引用这两个socket。
给就绪列表添加引用
eventpoll对象相当于是socket和进程之间的中介,socket的数据接收并不直接影响进程,而是通过改变eventpoll的就绪列表来改变进程状态。
当程序执行到epoll_wait时,如果rdlist已经引用了socket,那么epoll_wait直接返回,如果rdlist为空,阻塞进程。
阻塞和唤醒进程
假设计算机中正在运行进程A和进程B,在某时刻进程A运行到了epoll_wait语句。如下图所示,内核会将进程A放入eventpoll的等待队列中,阻塞进程。
epoll_wait阻塞进程
当socket接收到数据,中断程序一方面修改rdlist,另一方面唤醒eventpoll等待队列中的进程,进程A再次进入运行状态(如下图)。也因为rdlist的存在,进程A可以知道哪些socket发生了变化。
epoll唤醒进程
eventpoll 对象就绪列表的维护
select 函数调用的流程:
- socket 对象有三块区域
- 读缓冲区
- 写缓冲区
- 等待队列
- select 函数调用的时候会把当前进程从工作队列里面拿出来
- 然后把进程引用追加到当前进程关注的每一个 socket 对象的等待队列中
- 然后当 socket 连接的客户端发送完数据之后,数据还是通过硬件 DMA 的方式把数据写入到内存,然后相应的硬件向 CPU 发出中断信号, CPU 就会让出当前进程位置去执行网络数据就绪的中断程序,
- 这个中断程序就会把内存中的网络数据写入到对应的 socket 读缓冲区里面,之后把这个 socket 等待队列中的进程全部移动到工作队列中,再然后 select 函数返回
epoll 函数流程非常相似
-
当我们调用系统函数
epoll_ ctl
时候,比如我们新添加一个需要关注的 socket, 其实内核程序会把当前的eventpoll
对象追加到这个socket
的等待队列里头 -
然后当
socket
连接的客户端发送完数据之后,数据还是通过硬件 DMA 的方式把数据写入到内存,然后相应的硬件向 CPU 发出中断信号, CPU 就会让出当前进程位置去执行网络数据就绪的中断程序, -
这个中断程序就会把内存中的网络数据写入到对应的
socket
读缓冲区里面,然后它发现这个socket
的等待队列里头不是进程,而是一个eventpoll
对象的引用 -
这个时候呢,他就会根据这个
eventpoll
对象的引用,将当前socket
的引用追加到 eventpoll 的就绪链表的末尾(eventpoll
还有一块空间是eventpoll
的等待队列,这个等待队列保存的就是调用epoll_wait
的进程) -
然后,当中断程序把
socket
的引用追加到就绪列表的末尾之后,就继续检查eventpoll
对象的等待队列,如果有进程,就会把进程转移到工作队列中 -
转移完毕之后,进程就有获取到 CPU 执行的时间片了,然后就是调用
epoll_wait
函数,他这个函数就返回到 java 层面了
总结:
- eventpoll 对象等待队列里面,它有调用 epoll_wait(,) 函数进去的进程
- 然后再把这个进程,从这个 eventpoll 的等待队列里面迁移到工作队列里面
epoll_wait() 获取就绪的 socket
epoll_wait() 返回值是 Int 类型的
- 返回 0,表示没有就绪的 socket
- 返回大于 0,表示有几个就绪的 socket
- 返回 - 1 表示异常
那么获取就绪的 socket 是怎么实现的呢?
epoll_wait
函数,调用的时候会传入一个epoll_event
事件数组指针epoll_wait
函数正常返回之前,会把就绪的 socket 事件信息拷贝到这个数组指针里头- 这样返回到上层程序,就能通过这个数组拿到就绪列表