io多路复用专题 非常nice

这篇文章主要是参考大佬笔记
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 生成的文件描述符
    • image-20220413083507043
  • 操作系统 这个select函数被调用以后,
    • 首先会去 fd 集合中去检查内存中socket套接字的状态,这个时间复杂度是O(N),然后检查完一遍之后,如果有就绪状态的socket, 那么就会直接返回,不会阻塞当前线程。
    • 否则的话,那个就说明当前指定fd集合对应的socket没有就绪状态,那么就需要阻塞当前调用线程了,直到有某个socket有数据之后,才唤醒线程。

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再大,就非常耗费性能
      • 还有就是系统调用涉及到参数的数据拷贝,如果数据太庞大,他也不利于系统的调用速度

image-20220413083740500

select(…) 深入问题


问题:select (…) 第一遍 O(N) 去检查未发现就绪的 socket ,后续某个 socket 就绪后,select(…) 是如何感知道的?是不断的轮询吗?

铺垫知识

  1. 操作系统调度
  • cpu 同一时刻,它只能运行一个进程,操作系统做主要的任务就是系统调度,就是有 n 个进程,然后让这 n 个进程在 cpu 上切换进行
  • 未挂起的进程都在工作队列内,都有机会获取到 cpu 的执行权
  • 挂起的进程就会从这个工作队列里移除出去,反映到咱们 java 层面就是线程阻塞了
  • linux 系统线程其实就是轻量级进程
  1. 操作系统中断
  • 比如说,咱们用键盘打字,如果 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了, 就会给就绪的socketfd文件描述符打标记,然后select函数就执行完了,返回到 java 层面就涉及到内核态和用户态的转换,后面的事情就是轮询检查每一个 socket 的 fd 是否被打了标记,然后就是处理被打了标记的 socket 就 ok 了

poll() 和 select() 区别


  • 传参不一样

    • select 用的是 bitmap , 它表示需要检查的 socket 集合
    • poll 使用的是 链表结构, 表示需要检查的 socket 集合(主要是为了解决 socket 监听长度超过 1024 的socket的限制)

    image-20220413084103195

epoll 的 产生背景


  • select 和 poll 的共有缺陷
    • 第一个缺陷: selectpoll 函数,这两系统函数每次调用都需要我们提供给它所有的需要监听的 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个进程都被操作系统的工作队列所引用,处于运行状态,会分时执行。

img

工作队列中有A、B和C三个进程

等待队列

当进程A执行到创建socket的语句时,操作系统会创建一个由文件系统管理的socket对象(如下图)。这个socket对象包含了发送缓冲区、接收缓冲区、等待队列等成员。等待队列是个非常重要的结构,它指向所有需要等待该socket事件的进程

img

创建socket

当程序执行到recv时,操作系统会将进程A从工作队列移动到该socket的等待队列中(如下图)。由于工作队列只剩下了进程B和C,依据进程调度,cpu会轮流执行这两个进程的程序,不会执行进程A的程序。所以进程A被阻塞,不会往下执行代码,也不会占用cpu资源

img

socket的等待队列

ps:操作系统添加等待队列只是添加了对这个“等待中”进程的引用,以便在接收到数据时获取进程对象、将其唤醒,而非直接将进程管理纳入自己之下。上图为了方便说明,直接将进程挂到等待队列之下

内核接收网络数据全过程

这一步,贯穿网卡、中断、进程调度的知识,叙述阻塞recv下,内核接收数据全过程。

如下图所示,进程在recv阻塞期间,计算机收到了对端传送的数据(步骤①)。数据经由网卡传送到内存(步骤②),然后网卡通过中断信号通知cpu有数据到达,cpu执行中断程序(步骤③)。此处的中断程序主要有两项功能,先将网络数据写入到对应socket的接收缓冲区里面(步骤④),再唤醒进程A(步骤⑤),重新将进程A放入工作队列中。

img

内核接收数据全过程

唤醒进程的过程如下图所示。

img

唤醒进程

当socket接收到数据后,操作系统将该socket等待队列上的进程重新放回到工作队列,该进程变成运行状态,继续执行代码。也由于socket的接收缓冲区已经有了数据,recv可以返回接收到的数据。

这里留有两个思考题,大家先想一想。

其一,操作系统如何知道网络数据对应于哪个socket?

其二,如何同时监视多个socket的数据?

(——我是分割线,想好了才能往下看哦~)

公布答案的时刻到了。

第一个问题:因为一个socket对应着一个端口号,而网络数据包中包含了ip和端口的信息,内核可以通过端口号找到对应的socket。当然,为了提高处理速度,操作系统会维护端口号到socket的索引结构,以快速读取。

第二个问题是**多路复用的重中之重,**是本文后半部分的重点!

epoll()的实现流程

创建epoll对象

如下图所示,当某个进程调用epoll_create方法时,内核会创建一个eventpoll对象(也就是程序中epfd所代表的对象)。eventpoll对象也是文件系统中的一员,和socket一样,它也会有等待队列。

img内核创建eventpoll对象

创建一个代表该epoll的eventpoll对象是必须的,因为内核要维护“就绪列表”等数据,“就绪列表”可以作为eventpoll的成员。

维护监视列表

创建epoll对象后,可以用epoll_ctl添加或删除所要监听的socket。以添加socket为例,如下图,如果通过epoll_ctl添加sock1、sock2和sock3的监视,内核会将eventpoll添加到这三个socket的等待队列中。

img添加所要监听的socket

当socket收到数据后,中断程序会操作eventpoll对象,而不是直接操作进程。

接收数据

当socket收到数据后,中断程序会给eventpoll的“就绪列表”添加socket引用。如下图展示的是sock2和sock3收到数据后,中断程序让rdlist引用这两个socket。

img给就绪列表添加引用

eventpoll对象相当于是socket和进程之间的中介,socket的数据接收并不直接影响进程,而是通过改变eventpoll的就绪列表来改变进程状态。

当程序执行到epoll_wait时,如果rdlist已经引用了socket,那么epoll_wait直接返回,如果rdlist为空,阻塞进程。

阻塞和唤醒进程

假设计算机中正在运行进程A和进程B,在某时刻进程A运行到了epoll_wait语句。如下图所示,内核会将进程A放入eventpoll的等待队列中,阻塞进程。

imgepoll_wait阻塞进程

当socket接收到数据,中断程序一方面修改rdlist,另一方面唤醒eventpoll等待队列中的进程,进程A再次进入运行状态(如下图)。也因为rdlist的存在,进程A可以知道哪些socket发生了变化。

imgepoll唤醒进程

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 事件信息拷贝到这个数组指针里头
  • 这样返回到上层程序,就能通过这个数组拿到就绪列表
  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值