小白也看得懂的 I/O 多路复用解析
共 7775字,需浏览 16分钟
·
2022-05-11 09:35
前言
IO多路复用目前在大厂的面试中,一般在两个地方可能会被问到,一个是在问到网络这一块的时候,另一个是在问到 Redis 这一块的时候,因为 Redis 底层也是使用了IO多路复用,所以整体来说 IO多路复用,也算是一道比较高频的一个面试题,所以今天跟大家来分享一下。
本文内容有视频版本,喜欢看视频的同学可以直接通过下面的二维码观看。如果你对文章的内容有疑惑,可以先看视频的对应内容,视频可能讲的会更细一点。
基础概念
首先我们了解下2个基础概念,这2个概念在后续的文章中会反复用到。
Socket
套接字。百科:对网络中不同主机上的应用进程之间进行双向通信的端点的抽象。
例子1:客户端将数据通过网线发送到服务端,客户端发送数据需要一个出口,服务端接收数据需要一个入口,这两个“口子”就是 Socket。
例子2:两个人通过电话进行通信,两个人都需要持有1个电话,socket 就类似于这个电话。
FD:file descriptor
文件描述符,非负整数。“一切皆文件”,linux 中的一切资源都可以通过文件的方式访问和管理。而 FD 就类似文件的索引(符号、指针),指向某个资源,内核(kernel)利用 FD 来访问和管理资源。
之前在视频中有同学问既然有 socket,为什么文章内容全是用的 FD 来举例,这是因为当我们调用内核函数创建 socket 后,内核返回给我们的是 socket 对应的文件描述符(fd),所以我们对 socket 的操作基本都是通过 fd 来进行。
Socket 通信
接着我们通过一张图来看下客户端和服务器使用 socket 进行通信的核心流程。
图中函数的含义如下:
socket:创建一个套接字
bind:将 socket 绑定到指定地址
listen:使套接字处于监听状态,等待客户端连接到来
accept:接受客户端连接
connect:客户端发起连接
read:从 fd 对应的 socket 中读取数据
write:将数据写入 fd 对应的 socket 中
close:关闭 socket 文件描述符
核心交互流程如下:
1)服务器端通过 socket、bind、listen 对 socket 进行初始化,最后阻塞在 accept 等待客户端请求到来。
2)客户端通过 socket 进行初始化,然后使用 connect 向服务端发起连接请求。此时客户端会和服务端进行 TCP 三次握手,三次握手完成后,客户端和服务端建立连接完毕,开始进入数据传输过程。
3)客户端发起 write 系统调用写入数据,数据从用户空间拷贝到内核空间 socket 缓冲区,最后内核将数据通过网络发送到服务器。
4)数据经过网络传输到达服务器网卡,接着内核将数据拷贝到对应的 socket 接收队列,最后将数据从内核空间拷贝到用户空间。
5)客户端和服务器完成交互后,调用 close 函数来断开连接。
IO模型小例子
接着我们通过一个例子来了解下各种IO模型。
例子:你是一个老师,让学生做作业,学生做完作业后收作业。
同步阻塞:逐个收作业,先收A,再收B,接着是C、D,如果有一个学生还未做完,则你会等到他写完,然后才继续收下一个。
解析:这就是同步阻塞的特点,只要中间有一个未就绪,则你会被阻塞住,从而影响到后面的其他学生。
同步非阻塞:逐个收作业,先收A,再收B,接着是C、D,如果有一个学生还未做完,则你会跳过该学生,继续去收下一个。
解析:可以看到同步非阻塞相较于同步阻塞已经是更好的方案了,你不会因为某个学生未就绪而阻塞住,这样就可以减少对后续学生的影响。但是这个方案也可能会出现其他问题,如果你下去收作业的时候,全部学生都还没做完,则你可能会白走一圈,然后一个作业也没收到。
select/poll:学生写完了作业会举手,但是你不知道是谁举手,需要一个个的去询问。
解析:这个方案相较于同步非阻塞来说有一点好处,就是你是确认有学生做完的,所以你下去肯定能收到作业,但是他有一个不好的点在于你需要一个个的去询问。
epoll:学生写完了作业会举手,你知道是谁举手,你直接去收作业。
解析:这个方案就很高效了,每次都能准确的收到作业。
同步阻塞IO
核心流程:当应用程序发起 read 系统调用时,在内核数据没有准备好之前,应用程序会一直处于阻塞等待状态,直到内核把数据准备好了返回给应用程序
交互流程
我们通过两段代码的一个动图来模拟同步阻塞IO下服务端和客户端的执行流程:
大致流程如下:
1)服务端进行初始化:新建 socket、绑定地址、转为服务端 socket
2)服务端调用 accept,进入阻塞状态,等待客户端连接
3)客户端新建 socket,向服务端发起连接
4)服务端和客户端通过 TCP 三次握手建立连接
5)服务端继续执行 read 函数,进入阻塞状态,等待客户端发送数据
6)客户端向服务端发送数据
7)服务端读取数据,执行逻辑处理
同步阻塞IO模型
我们通过 read 函数来看下服务器内部用户空间和内核空间的调用流程,如下图所示:
大致流程如下:
1)应用进程发起 read 系统调用
2)应用进程阻塞等待数据就绪
3)数据通过网络传输到达网卡,然后再到内核socket缓冲区,当数据被拷贝到内核 socket 缓冲区时,此时处于就绪状态
4)将数据从内核拷贝到应用程序缓冲区,返回成功
多线程版本:文中使用的例子是单线程,如果是多线程则在每个 socket 建立连接后新建线程去负责处理该 socket 后续的流程,这样就不会由于单个 socket 阻塞住而影响到其他 socket。
总结
单线程:某个 socket 阻塞,会影响到其他 socket 处理。
多线程:当客户端较多时,会造成资源浪费,全部 socket 中可能每个时刻只有几个就绪。同时,线程的调度、上下文切换乃至它们占用的内存,可能都会成为瓶颈。
同步非阻塞IO
核心流程:当应用程序发起 read 系统调用时,在内核数据没有准备好之前,内核会直接返回错误,应用程序不断轮询内核,直到内核把数据准备好了返回给应用程序。
交互流程
我们通过两段代码的一个动图来模拟同步阻塞IO下服务端和客户端的执行流程:
大致流程如下:
1)服务端调用 accept,数据未就绪,内核返回-1
2)服务端调用 accept,数据未就绪,内核返回-1
3)服务端调用 accept,数据未就绪,内核返回-1
4)客户端新建 socket,向服务端发起连接
4)服务端调用 accept,服务端和客户端通过 TCP 三次握手建立连接
5)服务端执行后续逻辑处理
我们通过 read 函数来看下服务器内部用户空间和内核空间的调用流程,如下图所示:
大致流程如下:
1)服务端调用 read,数据未就绪,内核返回-1
2)服务端调用 read,数据未就绪,内核返回-1
3)服务端调用 read,数据就绪
4)将数据从内核拷贝到应用程序缓冲区,返回成功
同步非阻塞IO模型
总结:提供了非阻塞调用的方式,从操作系统层面解决了阻塞问题。
优点:单个 socket 阻塞,不会影响到其他 socket
缺点:需要不断的遍历进行系统调用,有一定开销
SELECT
核心流程:
1)应用程序首先发起 select 系统调用,传入要监听的文件描述符集合
2)内核遍历应用程序传入的 fd 集合,如果遍历完一遍后发现没有就绪的 fd 则用户进程会进入阻塞状态,如果有就绪的 fd 则会对就绪的 fd 打标,然后返回
3)应用程序遍历 fd 集合,找到就绪的 fd,进行相应的事件处理
select 接口
/**
* 获取就绪事件
*
* @param nfds 3个监听集合的文件描述符最大值+1
* @param readfds 要监听的可读文件描述符集合
* @param writefds 要监听的可写文件描述符集合
* @param exceptfds 要监听的异常文件描述符集合
* @param timeval 本次调用的超时时间
* @return 大于0:已就绪的文件描述符数;等于0:超时;小于:出错
*/
int select(int nfds,
fd_set *readfds,
fd_set *writefds,
fd_set *exceptfds,
struct timeval *timeout);
交互流程
我们通过一个动图来模拟服务器内部用户空间和内核空间的调用流程,如下图所示:
大致流程如下:
1)用户空间发起 select 系统调用,将监听的 fd 集合从用户空间拷贝到内核空间
2)内核遍历 fd 集合,检查数据是否就绪
3)如果遍历一遍后发现没有 fd 就绪,则会将当前用户进程阻塞,让出 CPU 给其他进程
4)当客户端将数据发送到服务端,进入内核后,会通过数据库包找到对应的socket
PS:客户端发送数据到数据进入服务端内核的流程类似下面 epoll 的流程
5)socket 检查是否有阻塞等待的进程,如果有则唤醒该进程
6)用户进程恢复运行后,会再遍历 fd 集合进行检查,此时它会检查到某些 fd 已经就绪了,它会给这些 fd 打上标记,然后结束阻塞,返回到用户空间
7)用户空间知道有事件就绪,遍历 fd 集合,找到就绪的 fd,进行相应的事件处理,例如将数据从内核缓冲区拷贝到应用程序缓冲区
8)最后执行逻辑处理。
IO多路复用模型
fd_set
fd_set 在 select 的整个调用过程中表达了两种不同的意思。
在入参时,fd_set 表示应用程序要监听哪些 fd;在回参时,fd_set表示哪些 fd 已经就绪了。
应用程序传入的 fd_set 其实是个位图,例如我们要监听 fd = 1、fd = 4,则传入 0000 0101,也就是 5。
这边使用的 long 类型数组来实现位图:1个 long 可以表示64位,则16个long可以表示1024位。
当内核处理完毕,将就绪的 fd 返回时,会将就绪的 fd 对应的位标记为1,然后覆盖掉入参的 fd_set,所以我们最终返回时的 fd_set 表示的是哪些 fd 是就绪的。
总结
将 socket 是否就绪检查逻辑下沉到操作系统层面,避免大量系统调用。
告诉你有事件就绪,但是没告诉你具体是哪个 FD。
优点
不需要每个 FD 都进行一次系统调用,解决了频繁的用户态内核态切换问题
缺点
单进程监听的 FD 存在限制,默认1024
每次调用需要将 FD 从用户态拷贝到内核态
不知道具体是哪个文件描述符就绪,需要遍历全部文件描述符
入参的3个 fd_set 集合每次调用都需要重置
POLL
核心流程:基本同 select。
poll 接口
/**
* 获取就绪事件
*
* @param pollfd 要监听的文件描述符集合
* @param nfds 文件描述符数量
* @param timeout 本次调用的超时时间
* @return 大于0:已就绪的文件描述符数;等于0:超时;小于:出错
*/
int poll(struct pollfd *fds,
unsigned int nfds,
int timeout);
struct pollfd {
int fd; // 监听的文件描述符
short events; // 监听的事件
short revents; // 就绪的事件
}
poll 函数基本同 select,只是对 select 进行了一些小优化,一个是优化了1024个文件描述符上限,另一个是新定义了 pollfd 数据结构,使用两个不同的变量来表示监听的事件和就绪的事件,这样就不需要像 select 那样每次重置 fd_set 了。
总结:跟 select 基本类似,主要优化了监听1024的限制。
优点
不需要每个 FD 都进行一次系统调用,导致频繁的用户态内核态切换
缺点
每次需要将 FD 从用户态拷贝到内核态
不知道具体是哪个文件描述符就绪,需要遍历全部文件描述符
EPOLL
核心流程:
1)应用程序调用 epoll_create,内核会分配一块内存空间,创建一个 epoll,最后将 epoll 的 fd 返回,我们后续可以通过这个 fd 来操作 epoll 对象
2)应用程序不断调用 epoll_ctl 将我们要监听的 fd 维护到 epoll,内核通过红黑树的结构来高效的维护我们传入的 fd 集合
3)应用程序调用 epoll_wait 来获取就绪事件,内核检查 epoll 的就绪列表,如果就绪列表为空则会进入阻塞,否则直接返回就绪的事件。
4)应用程序根据内核返回的就绪事件,进行相应的事件处理
epoll 接口
/**
* 创建一个epoll
*
* @param size epoll要监听的文件描述符数量
* @return epoll的文件描述符
*/
int epoll_create(int size);
/**
* 事件注册
*
* @param epfd epoll的文件描述符,epoll_create创建时返回
* @param op 操作类型:新增(1)、删除(2)、更新(3)
* @param fd 本次要操作的文件描述符
* @param epoll_event 需要监听的事件:读事件、写事件等
* @return 如果调用成功返回0, 不成功返回-1
*/
int epoll_ctl(int epfd,
int op,
int fd,
struct epoll_event *event);
/**
* 获取就绪事件
*
* @param epfd epoll的文件描述符,epoll_create创建时返回
* @param events 用于回传就绪的事件
* @param maxevents 每次能处理的最大事件数
* @param timeout 等待I/O事件发生的超时时间,-1相当于阻塞,0相当于非阻塞
* @return 大于0:已就绪的文件描述符数;等于0:超时;小于:出错
*/
int epoll_wait(int epfd,
struct epoll_event *events,
int maxevents,
int timeout);
交互流程
我们通过一个动图来模拟服务器内部用户空间和内核空间的调用流程,如下图所示:
大致流程如下:
1)用户空间调用 epoll_create ,内核新建 epoll 对象,返回 epoll 的 fd,用于后续操作
2)用户空间反复调用 epoll_ctl 将我们要监听的 fd 维护到 epoll,底层通过红黑树来高效的维护 fd 集合
3)用户空间调用 epoll_wait 获取就绪事件,内核检查 epoll 的就绪列表,如果就绪列表为空则会进入阻塞
4)客户端向服务端发送数据,数据通过网络传输到服务端的网卡
5)网卡通过 DMA 的方式将数据包写入到指定内存中(ring_buffer),处理完成后通过中断信号告诉 CPU 有新的数据包到达
6)CPU 收到中断信号后,进行响应中断,首先保存当前执行程序的上下文环境,然后调用中断处理程序(网卡驱动程序)进行处理:
根据数据包的ip和port找到对应的socket,将数据放到socket的接收队列;
执行 socket 对应的回调函数:将当前 socket 添加到 eventpoll 的就绪列表、唤醒 eventpool 等待队列里的用户进程(设置为RUNNING状态)
7)用户进程恢复运行后,检查 eventpoll 里的就绪列表不为空,则将就绪事件填充到入参中的 events 里,然后返回
8)用户进程收到返回的事件后,执行 events 里的事件处理,例如读事件则将数据从内核缓冲区拷贝到应用程序缓冲区
9)最后执行逻辑处理。
IO多路复用模型
同 select。
总结
epoll 直接将 fd 集合维护在内核中,通过红黑树来高效管理 fd 集合,同时维护一个就绪列表,当 fd 就绪后会添加到就绪列表中,当应用空间调用 epoll_wait 获取就绪事件时,内核直接判断就绪列表即可知道是否有事件就绪。
优点
解决了 select 和 poll 的缺点,高效处理高并发下的大量连接,同时有非常优异的性能。
缺点
跨平台性不够好,只支持 linux,macOS 等操作系统不支持
相较于 epoll,select 更轻量可移植性更强
在监听连接数和事件较少的场景下,select 可能更优
LT VS ET
LT:Level-triggered,水平(条件)触发,默认。epoll_wait 检测到事件后,如果该事件没被处理完毕,后续每次 epoll_wait 调用都会返回该事件。
ET:Edge-triggered,边缘触发。epoll_wait 检测到事件后,只会在当次返回该事件,不管该事件是否被处理完毕。
小结
epoll 和 select、poll 默认都是 LT 模式,LT 模式会更安全一点,而 ET 则是 epoll 为了性能开发的一种新模式,LT 模式下内核在返回就绪事件之前都会进行一次额外的判断,如果 fd 量较大,会有一定的性能损耗。
总结
可以看到从最初的同步阻塞IO,到现在主流的 epoll,其实是一个不断演进的过程,就像我们的业务系统一样。
同步阻塞IO的方式实现比较简单,同时在当时可能已经能满足需求了,因此被最早提出来,然后随着不断的发展,在一些场景下,同步阻塞IO逐渐不能满足需求,于是操作系统底层开始优化,提出了非阻塞的模式。类似的,同步非阻塞IO也存在一定的问题,于是就有了后续的IO多路复用。
现在还有一种更牛逼的IO模型也在发展,叫做异步IO,这种模型下,你只需要一次非阻塞的系统调用,后续的事情全部由内核来帮你完成。不过异步IO当前在 linux 下还不够完善,所以当前 linux 的主流还是 epoll。
推荐阅读
最近我将面试:阿里、字节、美团、快手、拼多多等大厂的高频面试整理出来,并按大厂的标准给出自己的解析。
群里有不少同学看完拿下了阿里、美团等大厂 Offer,希望能助你一臂之力,早日拿下大厂 Offer。
获取方式:关注公众号回复【面试】即可领取,更多大厂面试真题解析 PDF 整理中。