面试必问: 从文件描述符开始教会你IO多路复用, 超多细节

文件描述符与Socket

文件描述符(File Descriptor,简称FD)是操作系统内核用于访问可以进行 I/O 的资源的一个抽象标识符。

Linux 万物皆文件, 在操作系统看来, 一个 Socket 对象就是一个可以 IO 的资源, 发送数据就是对 Socket 进行写操作, 接收数据就是读 Socket;

文件描述符是一个非负整数,代表一个已经打开的文件、管道、网络套接字或其他 I/O 资源;

例如, 当一个程序通过系统调用(如opensocket等)打开一个文件或创建一个网络连接时,操作系统会返回一个文件描述符。这个描述符用于标识该文件或连接;

后续, 该程序就可以使用该文件描述符来执行读写操作;

 int fd = open("example.txt");
 char buffer[100];
 ssize_t bytesWritten = write(fd, buffer, bytesRead);

当不再需要访问 IO 资源时,程序应调用close系统调用关闭文件描述符,以释放系统资源。

文件描述符可以用于标识多种I/O资源,包括:

  1. 普通文件:磁盘上的常规文件。

  2. 管道:用于进程间通信的管道。

  3. 网络套接字:用于网络通信的套接字。

  4. 设备:如打印机, 扫描仪等设备。

每个进程都有一个文件描述符表,操作系统通过该表管理进程打开的所有文件。

在生产环境下, 高并发系统必须配置同时能打开的文件描述符的最大数量, 来支持超大量的并发连接; 默认情况下, 一个进程能同时打开的文件描述符数量最大为1024; 通过修改 limits.conf 文件, 修改软硬极限值, 比如设置为 100万;

IO 时到底发生了什么

整个内存空间, 可以分为用户空间和内核空间, 每个用户程序, 都会在内存中分配一块用户空间, 放用户堆栈和代码段;

而操作系统的内核程序, 比如进程调度程序, 比如读写IO设备的程序, 运行在内核空间;

内核空间分为进程共享区域和进程私有区域; 共享区放内核程序的代码段等; 私有其余放每个进程的内核栈;

每个进程在内核态都有自己的内核栈,在执行系统调用时,使用内核栈来保存变量;

操作系统负责管理计算机资源, 系统调用(System Call)是操作系统提供给用户程序的接口, 通过系统调用, 用户程序可以请求操作系统为其提供服务, 例如读取外部设备。

用户程序发起系统调用时, 切换到内核态, 保存用户线程上下文, 然后在用户程序对应的内核栈中, 执行系统调用; 执行完毕后切换回用户态, 返回用户程序;

当前处理器是处于用户态还是内核态, 这一信息保存在CPU的处理器状态寄存器中;

例如, 调用 InputStream 的 read 函数, 最终会到一个 native 方法, 在这个方法中, 发起 read 系统调用

  1. 往 CPU 的一个特定寄存器中写入当前系统调用的类型号, 表示当前要发起一个 read 调用;

  2. 把要读取的文件描述符, 要读取的长度等信息保存到相应的寄存器中;

  3. 执行 int 80 指令, 产生软中断,

  4. 在中断处理程序中

    • 保存当前线程上下文到 TCB, 修改 CPU 状态寄存器, 从用户态切换到内核态, 加载内核态上下文;

    • 然后根据我们第一步写入的系统调用类型号, 和文件描述符等信息, 执行相应的内核程序

    • 将内核空间的缓冲区中的内容拷贝到用户空间的缓冲区中;

    • 恢复用户态上下文, 切换回用户态继续执行用户程序;

  5. 如果内核空间的缓冲区未准备就绪, 根据当前 IO 的类型有不同的处理

    • 如果是阻塞式的 IO, 那么将用户线程阻塞, CPU 向 DMA 发送读取命令; 然后就可以切换到其它线程执行了; 当 DMA 读取到内核缓冲区完毕时, 产生一个DMA中断, 中断处理程序中再去唤醒被阻塞的用户线程, 用户线程继续执行系统调用, 在内核态将数据从内核缓冲区拷贝到用户缓冲区;

    • 如果是非阻塞IO, 那么系统调用将立即返回一个未准备就绪的标志;

为什么要有内核缓冲区和用户缓冲区? 用户直接去读取设备数据不行吗?

  1. 首先, 为什么要有缓冲区? 为了缓和CPU和外设的速度差距; 有了缓冲区, 就可以由 DMA 控制器负责拷贝数据, CPU 可以去做别的事; 拷贝好了再发起中断通知 CPU;

  2. 其次, 为什么要分内核态和用户态, 用户直接去访问外设数据, 是很危险的, 如果程序编写不当, 或者有恶意攻击程序, 可以导致计算机不安全, 所以必须经过内核态来访问系统资源;

内核缓冲区有几个? 用户缓冲区有几个?

  1. 内核缓冲区只有一个

  2. 每个用户进程都有一个独立的用户缓冲区;

IO方式分类

同步 和 异步的侧重点在发起请求的一方

或者说在于 IO 操作到底由谁来完成;

同步: 我发起一个调用, 内核缓冲区的数据准备完毕后, 我需要等待内核把缓冲区数据拷贝到用户空间中;

异步: 我发起一个调用, 然后我就去做别的事情; 操作系统负责完成IO, 负责把数据拷贝到用户空间缓冲区, 实际处理这个调用的组件通过事件, 回调之类的手段通知我;

阻塞 和 非阻塞的侧重点在处理请求的一方

阻塞式IO: 内核如果发现数据没有准备好, 就阻塞用户线程; 等数据准备好了再唤醒;

非阻塞式IO: 如果数据没有准备好, 立即返回一个表示数据没有准备好的标记; 当前线程可以去做别的事, 也可以轮询;

同步阻塞 BIO

用户进程发起系统调用进行IO时, 如果内核的IO缓冲区没有准备好, 就阻塞用户进程, 准备好了再唤醒用户进程;

Java 中的IO流, 都是阻塞式的;

同步非阻塞 NIO

和 Java 的 NewIO 重名, 但不是一个东西, NewIO 支持非阻塞的IO多用复用模型 

用户进程发起系统调用进行IO时, 如果内核的IO缓冲区没有准备好, 直接返回;

如果准备好了, 需要等待你和将数据从内核缓冲区拷贝到用户缓冲区, 拷贝完毕后系统调用返回;

在这个向用户空间拷贝的过程中, 系统调用一直没有返回, 用户程序一直等待, 所以也是同步的;

IO多路复用

基于 select , poll, epoll 的 IO 模型, 既可以设置为阻塞, 也可以设置为非阻塞; 不过无论阻塞还是非阻塞, 都是同步的;

信号驱动IO

异步, 但不彻底;

用户进程提前设置回调函数, 当内核缓冲区数据准备好以后, 通过信号通知进程运行回调函数, 但把数据从内核缓冲区读到用户的缓冲区这个过程, 用户进程还是阻塞的, 同步的;

异步 AIO

异步IO一定是非阻塞的, 阻塞不可能异步;

AIO 是彻底的异步, 用户线程通过系统调用向内核注册某个IO操作, 当IO操作彻底完成, 已经拷贝到用户空间的缓冲区中时, 才去通知用户线程;

无论时等待内核缓冲区准备就绪, 还是将内容从内核缓冲区读到用户缓冲区, 都不会阻塞用户线程;

这显然需要操作系统的支持, 目前, Windows 系统通过 IOCP 实现了真正的异步IO, Linux在2.6版本才引入AIO模型, JDK 对其支持并不完善; 而大多数服务端程序都部署在Linux 服务器上, 所以目前比如 netty, 使用都是 IO 多路复用模型;

IO多路复用

引入

现在, 站在服务器的视角, 假设服务器要处理大量客户端的请求

考虑使用 BIO, 主逻辑如下

 ServerSocket serverSocket = new ServerSocket(17770);
 while (true) {
     Socket client = serverSocket.accept();
     阻塞read(client, buffer);
     handle(buffer);
 }

问题: 如果一个先建立了连接的客户端迟迟不发送请求, 那么Server就被阻塞在 read 函数; 无法再接收建立连接的请求;

如何解决这个问题呢?

我可以给每个建立连接的客户端分配一个线程, 专门负责读取请求处理请求返回结果;

这样的话如果同时大量请求到来, 就需要创建大量线程去处理, 如果有10k个请求到来, 就要分配10k个线程去处理, 单机的操作系统是无法支撑10k个线程的, 这就是 c10k 问题;

如何解决c10k问题呢? 考虑使用同步非阻塞, 一个线程负责监听连接建立请求, 并将建立连接得到的 Socket 保存到一个集合中(比如链表);

另一个线程不断循环遍历 Socket 集合, 非阻塞的方式去读 Socket; 如果发现读成功, 就处理, 否则向后遍历;

也可以做到同一个线程里;

// 线程1
 ServerSocket serverSocket = new ServerSocket(17770);
 while (true) {
     Socket client = serverSocket.accept();
     list.add(client);
 }
 ​
 // 线程2
 while(true){
     Socket client = nextSocket();
     if((非阻塞read(client, buffer)) == -1)
         continue;
     else
         handle(buffer);
 }

现在, 不会再因为某个 Socket 阻塞而影响对其它 Socket 的处理; 也不需要为每个连接分配一个线程;

但是, 每遍历到一个 Socket, 就需要调用一次 read 函数, 就需要从用户态切换到内核态; 大量的切换状态操作带来额外开销;

并且即使没有任何连接建立, CPU也会一直空转, 浪费CPU资源;

这时就可以使用IO多路复用了;

select, poll, epoll 都是IO多路复用的具体实现;

IO多路复用, 由一个或者几个线程去监控多个网络请求,当检测到有数据准备就绪之后再分配对应的线程去读取数据;

select

一句话总结: 和非阻塞 IO 模型相似, 但是把整个遍历操作转移到内核态进行; 减少了用户态和内核态之间切换的次数;

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

fd_set, 本质上是一个 long 数组实现的位图; 每一位对应一个 fd 文件描述符;

select 会将三个 fd_set 中的文件描述符遍历一遍; 根据是否有遍历到可用 fd 决定阻塞还是返回;

参数:

  • readfds:内核检测该集合中的IO资源是否可读。如果检测某个 IO 是否可读,需要手动把文件描述符加入该集合。

  • writefds:内核检测该集合中的IO是否可写。同readfds,需要手动加入

  • exceptfds:内核检测该集合中的IO是否异常。同readfds,需要手动加入

  • nfds:以上三个集合中最大的文件描述符数值 + 1,例如集合是{0,1,4},那么 maxfd 就是 5

  • timeout:用户线程调用select的超时时长。

    • 设置成NULL,表示如果没有 I/O 事件发生,则将调用 select 的线程阻塞, 有数据可用后再唤醒。

    • 设置为非0的值,这个表示等待固定的一段时间后从 select 阻塞调用中返回。

    • 设置成 0,表示非阻塞,检测完毕立即返回。

函数返回值:

  • 大于0:成功,返回集合中已就绪的IO总个数

  • 等于-1:调用失败

  • 等于0:没有就绪的IO

到底哪些 IO 资源可用:

  • 内核复用了传入的 readfds, writefds 和 exceptfds;

  • 将可用的 fd 对应的位置为1, 来作为可用的标记;

优点: 不需要对每个 fd 都进行一次系统调用, 解决用户态和内核态频繁切换的问题;

缺点:

  • 单个进程能监听的 fd 数量有限, fd_set 的大小是 1024 位; 最多可以监听 1024 个 fd;

  • 每次调用都需要将所有 fd 从用户态拷贝到内核态;

  • 返回后, 需要遍历 fd_set 的每一位判断到底是哪些 fd 就绪;

  • fd_set 每次调用后都会被修改, 每次调用都要重新设置一遍;

poll

Poll 和 Select 基本类似。和 Select 相比,它使用了不同的方式存储文件描述符,解决文件描述符的个数限制。

传入的是 pollfd 数组, 内核实际上转换为链表存储;

 struct pollfd {
     int fd; // 文件描述符
     short events; // 该文件描述符上要监听的事件, 例如可读, 可写, 有错误;
     short revents; // 该文件描述符上发生的si'ji
 };
 // nfds:描述数组 fds 的大小
 // timeout 同select
 int poll(struct pollfd *fds, unsigned long nfds, int timeout);  

优点: 不需要对每个 fd 都进行一次系统调用, 解决用户态和内核态频繁切换的问题;

缺点:

  • 每次调用都需要将所有 fd 从用户态拷贝到内核态;

  • 返回后, 需要遍历 fds 判断到底是哪些 fd 就绪;

epoll

首先调用 epoll_create 创建一个 epoll, 获得其文件描述符;

然后将要监听的 fd事件 封装成 epoll_event, 调用 epoll_ctl, 添加到 epoll中;

之后, 就可以调用 epoll_wait 获取就绪的 epoll_event;

epoll_event 中封装了事件的类型, 对应的文件描述符; 所以获取到 epoll_event 以后就可以分解出就绪的 fd; 可以在调用 epoll_wait 的线程中处理, 也可以另起新线程处理;

epoll_create 在内核态创建一个 eventpoll 数据结构; 即一个 epoll 对应一个 eventpoll;

eventpoll中有三个成员, 分别是

等待队列: 如果一个用户线程调用 epoll_wait 时就绪列表为空, 这个用户线程将被阻塞, 并保存在 eventpoll 的 等待队列中;

就绪列表: 保存就绪的 fd;

红黑树: 保存所有被监听的未就绪的fd;

过程:

  1. 调用 epoll_create 后在内核态创建一个 eventpoll , 对应一个 epoll 实例, 返回一个文件描述符;

  2. 当一个 epoll_event 被添加到 epoll 时, 在内核态进一步封装, 为其指定一个回调函数;

  3. 被添加的 fd 被保存在了内核空间中的 eventpoll 的红黑树中, 以后调用 epoll_wait时, 就不需要再重复传相同的 fd

    并且这个插入的时间复杂度时log2n级别的;

  4. 假设目前就绪队列为空, 调用了一次 epoll_wait, 这时当前用户线程被阻塞, 添加到 epoll 的等待队列中;

  5. 当被监听的一个 fd 就绪时, 触发中断, 在中断处理函数中调用该 fd 对应的回调函数, 该回调函数将就绪的 fd 从红黑树移除, 添加到就绪队列中; 然后唤醒等待队列中的线程;

  6. 而如果是就绪队列非空时调用 epoll_wait, 就会返回就绪队列中就绪的 fd;

优点: 相对于select,

  1. 没有监听 fd 数量的限制;

  2. 每个 fd 仅在 epoll_ctl时拷贝一次, 每次调用 epoll_wait 时不需要将要监听的所有 fd 拷贝到内核态;

  3. 内核态不需要遍历fd来确定哪些fd就绪; 而是由回调函数自动将就绪的fd添加到就绪列表;

  4. 用户线程在 epoll_wait返回之后不需要遍历调用结果来确定到底是哪个 fd 就绪;

缺点:

 1. 只有 Linux 实现了epoll, 其它平台没有;
 2. epoll的空转bug可以去了解一下;
 3. 实现复杂, 在监听的 fd 数量很少的场景下, 性能可能不如 select;

水平触发:

默认工作模式,当 epoll_wait 检测到某描述符事件就绪并通知应用程序时,应用程序可以不立即处理该事件;等到下次调用 epoll_wait 时,会再次通知此事件;

只要读缓冲不空, 或者写缓冲不满, 就一直认为 fd 就绪;

边缘触发:

当 epoll_wait 检测到某描述符事件就绪并通知应用程序时,应用程序必须立即处理该事件。如果不处理,下次调用 epoll_wait 时,不会再次通知此事件;

只有读写缓冲区的有效数据长度发生变化时, 才认为 fd 就绪;

后续会更新高质量Java NIO文章, 请关注

  • 18
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值