一上来就说多路复用,估计有点懵逼,我们先从用户空间和内核空间,以及为什么会有多路复用说起吧。
我们熟知的Ubuntu和CentOS,都是基于Linux操作系统,在上面包装了一层壳而已。他们统称为Linux的发行版。Linux成为内核。而我们用户应用,比如MySQL、Redis、各类语言编译器等等没办法访问底层的计算机硬件,都是需要通过Linux内核来操作计算机硬件。如下图:
- 计算机硬件,主要包括CPU、RAM、NetWork等等,内核通过设备驱动,是可以操作计算机硬件的,但是也需要一些管理,比如内存管理、进程、网络管理等等。
- 硬件如何访问呢?那必须封装一些系统接口,比如网络接口、内存接口等等给上层应用调用。
- 用户应用,就可以调用这些接口,从而间接的访问硬件。
我们的操作系统对于硬件来说,也是一个应用,它也是占用各类资源,比如CPU、内存等等,如果上层user应用随机的去调用系统资源,去占用完资源,那系统完蛋了,系统一旦完蛋,那全完蛋了。所以,需要把用户的资源和内核的(系统)的资源隔离开,避免用户应用导致内核奔溃。
用户空间和内核空间
所以,操作系统会把用户空间和内核空间进行分离的。进程的寻找空间也会划分为:内核空间和用户空间两种。
寻址空间:我们内核和用户应用,都没办法直接访问实际的内存,而是先给他们划分一片虚拟的内存,通过虚拟的内容来映射到物理内存。所以访问虚拟的内存的时候,就需要一个虚拟的地址了,这个虚拟的地址就是一个无符号的整数,从0开始,最大值取决于CPU地址总线和内存带宽,比如一个32位的系统,它的带宽就是32,所以他的地址最大值就是2^32,也就是寻址的范围,就是从0到2^32这个空间,这个空间就是寻址空间,而内存地址每一个值,都是一个存储单元,也就是一个字节byte。2^32字节=4GB,也就是一个32位系统,它最大的内存限制也就是4GB。
- 内核空间和用户空间的总集代表整个内存的4GB空间,从0开始到0xFFFFFFFF(一起4GB),其中0-0xBFFFFFFF(3GB)成为用户空间,称为用户态。高位的0xC0000000-0xFFFFFFFF(1GB)划分成内核空间,称为内核态。
- 一般来说,用户的应用,都是运行在用户空间,内核的运行在内核空间。
- cpu有些命名是比较机密,一旦运行可能导致灾难,在Linux系统有两种命令(Ring0和Ring3),Ring0等级最高,可以调用一切系统资源,所以只能在内核空间上执行。Ring3为受限制的命令,不能直接调用系统资源,只能通过内核提供的接口来访问。
切换流程
但是有的用户空间可能需要调用系统资源,怎办呢?那就可能这个线程会在内核态和用户态之间切换,具体的切换流程如下:
以读写磁盘文件为例,Linux系统为了提高IO效率,会在用户空间和内核空间都加入缓冲区 buffer。
- 读数据,用户空间先发起一个命令到内核空间,说read读数据,到达内核空间后,内核空间先判断是否有数据(去磁盘寻址),这个过程中,需要等待(wait for data)数据就绪。
- 如果有数据,就读取数据,比如从磁盘或者从网卡中读取,通过设备驱动,先把数据读取到buffer中。此时就算是等待就绪了。
- 然后把内核空间的buffer数据拷贝到用户空间的buffer中,用户再对buffer进行操作。
- 写数据也是一样,用户先把文件写入buffer中,所以先把用户buffer中的数据拷贝到内核buffer,然后通过内核buffer再写入到磁盘。
这可以是一个整个流程的缩影,可以看出,影响效率的,一个是等待,wait for data,别人没有数据来的时候,你就等着吧,一个是拷贝,就是从磁盘拷贝到内核空间,从内核空间拷贝到用户空间,这些都是影响性能对。如果想提高io的效率,就是这里两个核心优化,
- 减少无效等待
- 减少内核到用户态无效的拷贝。
那么,Linux就提供了五种,来优化无效等待的机制。如下介绍:
IO模型
在《UNIX网络编程》一书中,总结归纳了5种IO模型:
- 阻塞IO(Blocking IO)
- 非阻塞IO(Nonblocking IO)
- IO多路复用(IO Multiplexing)
- 信号驱动IO(Signal Driven IO)
- 异步IO(Asynchronous IO)
阻塞IO
顾名思义,阻塞IO就是两个阶段都必须阻塞等待。
阶段一:
用户进程尝试读取数据(比如网卡数据)
此时数据尚未到达,内核需要等待数据
此时用户进程也处于阻塞状态
阶段二:
数据到达并拷贝到内核缓冲区,代表已就绪
将内核数据拷贝到用户缓冲区
拷贝过程中,用户进程依然阻塞等待
拷贝完成,用户进程解除阻塞,处理数据
非阻塞IO
顾名思义,非阻塞IO的recvfrom操作,如果内核态没有数据,会立即返回结构,而不是阻塞用户。
阶段一:
用户进程尝试读取数据(比如网卡数据)
此时数据尚未到达,内核需要等待数据
返回异常给用户进程
用户进程拿到error后,再次尝试读取
循环往复,直到数据就绪
阶段二:
将内核数据拷贝到用户缓冲区
拷贝过程中,用户进程依然阻塞等待
拷贝完成,用户进程解除阻塞,处理数据
非阻塞IO模型中,用户进程在第一个阶段是非阻塞,第二个阶段是阻塞状态。虽然是非阻塞,但性能并没有得到提高。而且忙等机制会导致CPU空转,因为一直在问,CPU使用率暴增。
看上去花里花哨的,其实正常情况下没啥卵用,别看第一阶段立即返回,但是我用户应用也没干其他的,就一直再问再催而已。而且第二个阶段还是阻塞的。
IO多路复用
无论是阻塞IO还是非阻塞IO,用户应用在一阶段都需要调用recvfrom来获取数据,差别在于无数据时的处理:
- 如果调用recvfrom时,恰好没有数据,阻塞IO会使进程阻塞,非阻塞IO使CPU空转,都不能充分发挥CPU的作用,也就是都没有干正事,在摸鱼。
- 如果调用recvfrom时,恰好有数据,则用户进程可以直接进入第二阶段,读取并处理数据。
基于这个,可以举一个生动的例子:阻塞IO和非阻塞IO例子
说的是快餐店点餐,一个有选择困难症的人在那一直磨磨蹭蹭的不点,然后他不确定,后面的人也就只能一直在那等着。如:
- 选择困难症患者在点餐,后面一直等着,对于服务员来说,它是等待数据就绪。
- 需要等到顾客想好了才开始点餐,相当于读取数据了。这是阻塞io
- 如果服务员一直在问顾客,你要吃什么,你点好了没有,快点呢等等,相当于非阻塞io,服务员除了问什么都干不了,还浪费cpu。
所以,一般这种有两种方案解决:
- 增加柜台,如同KFC一样,多人点餐。嗯,多线程,但是开销大。
- 大家都不排队,直接坐下来,通过手机点单,想好吃什么了直接告诉服务员,服务员直接点餐。
好,回到Linux,用户进程如何知道内核中的数据是否就绪了呢? 我点的餐告诉服务员了,我什么时候知道我的饭到了呢?你在点餐的时候,会不会给你一个监听器,你的餐到了会滴滴滴响呢?在linux也一样,它叫做, 文件描述符。
文件描述符(File Descriptor)
简称FD,时一个从0开始递增的无符号整数,用来关联linux中的一个文件,在linux中,一切皆文件,例如常规文件、视频、硬件设备等等,当然也包括socket呢。 在Linux中,有7种文件描述符:
文件类型 | 文件类型描述 | 符号 |
---|---|---|
普通文件 | 最常使用的一类,特点是不包含有文件系统信息的结构信息。按照其内部结构又可分为纯文本文件、二进制文件、数据格式的文件、各种压缩文件。 | REG (-) |
目录文件 | 用于存放文件名以及其相关信息的文件,是内核组织文件系统的基本节点。 | DIR (d) |
块设备 | 就是存储数据以供系统存取的接口设备,简单而言就是硬盘。 | BLK (b) |
字符设备 | 字符设备文件:即串行端口的接口设备,例如键盘、鼠标等等。 | CHR (c) |
套接字 | 这类文件通常用在网络数据连接。可以启动一个程序来监听客户端的要求,客户端就可以通过套接字来进行数据通信。 | SOCK(s) |
管道 | 一种很特殊的文件,主要用于不同进程的信息传递。 | FIFO (p) |
链接 | 一种特殊文件,指向一个真实存在的文件链接,类似于Windows下的快捷方式,链接文件的不同,又可分为硬链接和软链接。 | LNK (l) |
未知 |
IO多路复用详解
就是利用单个线程同时监听多个FD,并在某个FD可读、可写的时候得到通知,从而避免无效的等待,充分利用CPU资源。复用的就是线程,相当于新开一个服务员把多个顾客的取餐器拿到了,然后等到某个客户餐到了之后,直接送到客户手上。
此时开始不是直接调用recvfrom了,因为recvfrom是直接去读取数据了,但是我开始并不知道数据是否就绪了,因为一旦调用recvfrom就阻塞了。
- 用户应用开始调用select函数了,select内部可以接收多个FD,我们把每个客户端对应的socket传到FD中,然后传到内核。
- 内核去检查,看看有没有哪个FD已经就绪了,如果有就绪了,返回readable,直到可读了。
- 通过recvfrom直接去目标拷贝数据,并且返回数据。
- 可能会有多个FD就绪了,那么进程会反复的调用recvfrom,直到全部完成。
IO多路复用,常见的又有多种实现,常见的有select、poll、epoll。
- select和poll只会通知用户进程有FD就绪,但不确定具体是哪个FD,需要用户进程逐个遍历FD来确认。
- epoll则会通知用户进程FD就绪的同时,把已就绪的FD写入用户空间。
IO多路复用-select
select是Linux中最早的I/O多录了复用实现方案
源码如下:
// 定义类型别名 __fd_mask,本质是 long int
typedef long int __fd_mask;
/* fd_set 记录要监听的fd集合,及其对应状态 */
typedef struct {
// fds_bits是long类型数组,长度为 1024/32 = 32
// 共1024个bit位,每个bit位代表一个fd,0代表未就绪,1代表就绪
__fd_mask fds_bits[__FD_SETSIZE / __NFDBITS];
// ...
} fd_set;
// select函数,用于监听fd_set,也就是多个fd的集合
int select(
int nfds, // 要监视的fd_set的最大fd + 1
fd_set *readfds, // 要监听读事件的fd集合
fd_set *writefds,// 要监听写事件的fd集合
fd_set *exceptfds, // // 要监听异常事件的fd集合
// 超时时间,null-用不超时;0-不阻塞等待;大于0-固定等待时间
struct timeval *timeout
);
实现流程:
- 我们以8个bit位来把,1024个太长了。
- 在初始化的时候,每个bit都是0,假设要监听的fd为1,2,5,则从右往左,第几位就监听谁,1表示监听。也就是1024位。就可以监听1024个fd。
- 执行select,传入参数,也就是把rfds集合传入到内核空间。
- 内核遍历fd,然后内核一个个查看,看看标记的fd是否有就绪的。如果没有就绪就休眠。
- 如果有数据就绪,就会被唤醒或者超时。
- 如果内核空间有就绪的,比如就绪1个,就在bit1上面标记下。-
- 然后拷贝到用户空间,用户空间看到有1个已经就绪了。
- 但是并不知道具体是哪个,只是知道有1个就绪,用户空间要一个个遍历,看看具体是哪个就绪。
所以:select模式存在的问题:
- 需要将整个fd_set从用户空间拷贝到内核空间,select结束还要再次拷贝会用户空间
- select无法得知具体是哪个fd就绪,需要遍历整个fd_set.
- fd_set监听的fd数量不能超过1024个,看上去很大,但是随着互联网发展,这个并发量太大了,可能不够用了。
IO多路复用-poll
相对select做了改进,但性能提示不明显,部分代码如下:
// pollfd 中的事件类型
#define POLLIN //可读事件
#define POLLOUT //可写事件
#define POLLERR //错误事件
#define POLLNVAL //fd未打开
// pollfd结构
struct pollfd {
int fd; /* 要监听的fd */
short int events; /* 要监听的事件类型:读、写、异常 */
short int revents;/* 实际发生的事件类型 */
};
// poll函数,参数简单了很多
int poll(
struct pollfd *fds, // pollfd数组,可以自定义大小,大小没有上线。
nfds_t nfds, // 数组元素个数
int timeout // 超时时间
);
IO流程如下:
1:创建pollfd数组,向里面添加关注的fd信息,数组大小自定义(可以超过1024)
2:调用poll函数,将pollfd数组拷贝到内核空间,转链表存储,无上限
3:内核遍历fd,判断是否就绪
4:数据就绪或超时后,拷贝pollfd数组到用户空间,返回就绪fd数量n。(这里还是返回就绪的,并非返回究竟是哪个就绪)。
5:用户进程判断n是否大于0
6:大于0则遍历pollfd数组,找到就绪的fd
所以,较select相比:
- select需要把整个fd拷贝返回,poll同样这样做。
- select没有告诉用户空间具体谁就绪了,poll同样没有。
- select监听的fd是有上限的,但是poll并没有上限,就去掉了上限,可能还有会问题。
- 监听FD越多,每次遍历消耗时间也越久,性能反而会下降
所以poll虽然提供了,但是几乎很少人使用。
epoll
epoll模式是对select和poll的改进,飞跃式的改进,它提供了三个函数:
struct eventpoll {
//...
struct rb_root rbr; // 一颗红黑树,记录要监听的FD
struct list_head rdlist;// 一个链表,记录就绪的FD
//...
};
// 1.创建一个epoll实例,内部是event poll,返回对应的句柄epfd
int epoll_create(int size);
// 2.将一个FD添加到epoll的红黑树中,并设置ep_poll_callback
// callback触发时,就把对应的FD加入到rdlist这个就绪列表中
int epoll_ctl(
int epfd, // epoll实例的句柄
int op, // 要执行的操作,包括:ADD、MOD、DEL
int fd, // 要监听的FD
struct epoll_event *event // 要监听的事件类型:读、写、异常等
);
// 3.检查rdlist列表是否为空,不为空则返回就绪的FD的数量
int epoll_wait(
int epfd, // epoll实例的句柄
struct epoll_event *events, // 空event数组,用于接收就绪的FD
int maxevents, // events数组的最大长度
int timeout // 超时时间,-1用不超时;0不阻塞;大于0为阻塞时间
);
流程:
- 用户空间创建eventpoll实例,里面就包含了要监听的FD,和一个链表,这个链表记录已经就绪的FD。
- 通过调用epollctl,把要监听的FD添加进去,并且设置回调函数,可以理解为把FD挂载到rb_root上的红黑树上面去,FD一旦就绪了,这个fd会转移到list_head的链表上去。
- 添加后,等待FD就绪。等待中有一个空的event数组,用户接收就绪的FD。
- 比如6/8就绪了,一旦就绪就出发callback,然后把6/8fd添加到list_head的链表当中。
- 内核把就绪的数量返回出去。并且把已就绪的FD放到epoll_wait当中的events数组中。
- 用户空间,就知道了具体是哪个fd就绪了。不需要遍历所有的fd。
所以,再较select相比
- select需要把整个fd拷贝到内核,但是epoll不是,epoll把新增的fd添加到epoll_ctl中,关联callback接口,不需要等待了。
- select内核空间fd就绪后,需要把所有的fd数组拷贝到用户空间(不管就不就绪),用户空间再遍历看看谁就绪了,但epoll不是,epoll有个events数组,里面就是已经就绪的fd。
- select最多可以监听1024个fd,但epoll中,采用了红黑树模式,理论上支持的数量无限的,且增删改查的性能不会随着数量的增加而增加。
总结
基于epoll实例中的红黑树保存要监听的FD,理论上无上限,而且增删改查效率都非常高
每个FD只需要执行一次epoll_ctl添加到红黑树,以后每次epol_wait无需传递任何参数,无需重复拷贝FD到内核空间
利用ep_poll_callback机制来监听FD状态,无需遍历所有FD,因此性能不会随监听的FD数量增多而下降
事件通知机制
当FD有数据可读时,我们调用epoll_wait就可以得到通知,那么如何得到通知的呢?有哪些模式呢?
- LevelTriggered:LT,当FD有数据可读时,会重复通知多次,直到数据处理完成,时epoll的默认模式。
- EdgeTriggered:ET,当FD有数据可读时,只会被通知一次,不管数据是否处理完成。
举例说明:
1:假设一个客户端socket对应的FD已经注册到了epoll实例中
2:客户端socket发送了2kb的数据
3:服务端调用epoll_wait,得到通知说FD就绪
4:服务端从FD读取了1kb数据
5:回到步骤3(再次调用epoll_wait,形成循环)
如果我们采用LT模式,因为FD中仍有1kb数据,则第⑤步依然会返回结果,并且得到通知
如果我们采用ET模式,因为第③步已经消费了FD可读事件,第⑤步FD状态没有变化,因此epoll_wait不会返回,数据无法读取,客户端响应超时。
LT:事件通知频率较高,会有重复通知,影响性能
ET:仅通知一次,效率高。可以基于非阻塞IO循环读取解决数据读取不完整问题
基于epoll模式的web服务流程
- 服务端启动后,基于epoll_create,创建一个实例,这个实例里面其实有两个东西,一个是红黑树的rb_root,用来记录监听的FD,一个是一个链表,用于记录已就绪的FD。
- 因为是一个web服务,都是基于TCP协议的,服务端都是一个serverSocket,它在Linux内部也是一个文件,也是一个FD(SSFD),注册到rb_root中,也就添加到红黑树上去了,指定类型为新增。同时绑定一个回调函数(ep_poll_callb1ack)。
- 等待就绪(epoll_wait),如果等待时间内有FD就绪,就会唤醒,然后读取FD后返回,如果没有FD就绪,返回0,重新执行epoll_wait,循环。
- 如果有FD就绪,先判断事件类型,如果是读事件,则再判断ssfd可读,那就说明是真的客户端准备联机了,那就调用accept,接收客户端的socket,得到对应的FD。
- 继续把客户端对应的FD注册到rb_root中,监听回调。也就是客户端会越来越多链接上来,注册的FD也会越来越多。
- 继续监听FD就绪,如果ssfd不可读,就说明是普通的客户端socket过来了,带着请求参数来的,所以服务端就读取请求数据,处理数据,然后写出相应。
信号驱动IO
信号驱动IO与内核建立SIGIO的信号关联并设置回调,当内核有FD就绪时,会发出SIGIO信号通知用户,期间用户应用可以执行其他业务,无序阻塞等待。
阶段一:
用户进程调用sigaction,注册信号处理函数
内核返回成功,开始监听FD
用户进程不阻塞等待,可以执行其它业务
当内核数据就绪后,回调用户进程的SIGIO处理函数
阶段二:
收到SIGIO回调信号
调用recvfrom,读取
内核将数据拷贝到用户空间
用户进程处理数据
这种和非阻塞IO要区分开来,因为阶段一的时候用户进程可以做其他的事情,那感觉这种还挺好的,为什么不用这种呢?当大量IO操作时,信号较多,SIGIO处理函数不能及时处理可能导致信号队列溢出,而且内核空间和用户空间的频繁信号交互性能也较低。 所以使用的不是很多。
异步IO
整个过程都是非阻塞的,用户进程调用完成异步API后就可以去做其他事情,内核等待数据就绪并拷贝到用户空间后才会递交信号,通知用户进程。
阶段一:
用户进程调用aio_read,创建信号回调函数
内核等待数据就绪
用户进程无需阻塞,可以做任何事情
阶段二:
内核数据就绪
内核数据拷贝到用户缓冲区
拷贝完成,内核递交信号触发aio_r
用户进程处理数据
这种IO更加神奇,它根本就没调用recvfrom去拷贝数据,它通过aio_read,去告诉内核它想读哪个FD,然后读取到哪里。然后不管了,躺平了。然后内核去监听FD,准备好了去唤醒,帮你从内核拷贝数据到用户空间,然后告诉你说,成功了,不要躺平了,干活了。内核全方位一条龙服务。
这种听起来很爽,无论一阶段还是二阶段,用户都不阻塞都不管,直接把活排下去等待结构即可。那为什么,不使用这种呢?
因为在高并发的情况下,内核太忙了,内核积累IO读写会越来越多,IO读写效率也低,很有可能导致服务崩溃的情况,所以你要使用异步IO,一定要做好限流。而做限流也比较繁琐,而且,限流了还完啥
总结
IO操作时同步还是异步,如何定义异步?关键是看数据在内核空间与用户空间的拷贝过程,也就是一阶段而是同步还是异步。
也就是,五种前四种都是同步IO,最后才是异步IO。