Linux网络模型
1. 用户空间和内核空间
在Linux中,应用都需要通过Linux内核和计算机硬件来进行交互,操作系统分为用户空间和内核空间,在用户空间不能直接调用系统资源,需要通过系统调用从用户态切换到内核态,通过操作系统内核来调用系统资源。
为了避免用户应用导致冲突甚至内核崩溃,用户应用与内核是分离的:
- 进程的寻址空间会划分为两部分:内核空间、用户空间
- 用户空间只能执行受限的命令(Ring3),而且不能直接调用系统资源,必须通过内核提供的接口来访问
- 内核空间可以执行特权命令(Ring0),调用一切系统资源
假设有一个进程要将数据写入到磁盘,他会先将数据写入到用户空间的数据缓区,执行系统调用后切换到内核空间,而内核空间的数据缓冲区里没有这个数据,就会将用户缓冲区的数据复制到内核缓冲区里。同理假设一个进程要从网卡里读取数据,也要切换至内核态,假设数据还没有来,就需要阻塞等待。
而这些IO阻塞等待和数据在内核空间和用户空间之间的拷贝是非常消耗时间的,所以Linux有着多种不同的IO来针对这些操作来做优化。
2. 阻塞IO
阻塞IO通过名称就可以知道,该IO模型是在两个阶段都必须阻塞等待数据的。
第一个阶段:
- 用户进程尝试读取数据(比如磁盘或者网卡)
- 此时此时磁盘还在寻地址数据还没有准备好
- 此时进程则需要阻塞等待数据
第二个阶段:
- 数据已经从磁盘读取出来放到了内核缓冲区
- 将内核缓冲区的数据拷贝的用户缓冲区
- 在拷贝过程中,进程依旧需要阻塞等待
- 拷贝完成后用户进程停止阻塞,开始处理数据
显然阻塞IO模型在等待数据一直处于等待状态,性能是比较低的。
3. 非阻塞IO
非阻塞IO在recvfrom操作会立即返回结果而不是阻塞用户进程,也就是说非阻塞IO只有一个阶段处于阻塞状态。
第一个阶段:
- 用户进程尝试读取数据(如网卡)
- 此时数据尚未未到达,内核需要等待数据
- 返回异常给用户进程,告诉用户进程数据还没有准备好
- 用户进程拿到error后,再次尝试读取
- 循环往复,知道数据就绪
第二个阶段:
- 将内核数据拷贝到用户缓冲区
- 拷贝过程中,用户进程依然阻塞等待
- 拷贝完成,用户进程解除阻塞,处理数据
非阻塞IO在尝试读取数据的时候是非阻塞的,但是在数据拷贝的过程中是处于阻塞的。非阻塞IO模型的性能显然和阻塞IO是差不多的,因为非阻塞IO的用户进程会不断去尝试从内核缓冲区中读取数据,这就是忙等,浪费CPU资源。
4. IO多路复用
无论是阻塞IO还是非阻塞IO,用户应用在一阶段都需要调用recvfrom来获取数据,差别在于无数据时的处理方案:
- 如果调用recvfrom时,恰好没有数据,阻塞IO会使CPU阻塞,非阻塞IO使CPU空转,都不能充分发挥CPU的作用。
- 如果调用recvfrom时,恰好有数据,则用户进程可以直接进入第二阶段,读取并处理数据
而在单线程情况下,只能依次处理IO事件,如果正在处理的IO事件恰好未就绪(数据不可读或不可写),线程就会被阻塞,所有IO事件都必须等待,性能自然会很差。
比如说饭店排队点餐,此时只有一个收银台服务,而顾客需要排队一个个点餐,如果前面的客户点餐非常磨唧,就会导致后面的顾客一直在等。
那么点餐就会分为两步:
- 顾客思考要吃什么(等待数据读取)
- 顾客想好了,开始点餐(读取数据)
提高效率有两种办法:
- 增加收银台(多线程)
- 不排队,谁想好吃什么了(数据就绪),服务员就给谁点餐(用户应用就去读取数据,不阻塞等待)
用户进程如何知道内核中数据是否就绪呢?
文件描述符(File Descriptor):简称FD,是一个从0 开始的无符号整数,用来关联Linux中的一个文件。在Linux中,一切皆文件,例如常规文件、视频、硬件设备等,当然也包括网络套接字(Socket)。
也就是说每打开一个文件都会关联一个文件描述表,建立Socket连接也会为其 创建一个对应的文件描述符表。
IO多路复用:是利用单个线程来同时监听多个FD,并在某个FD可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源。
阶段一:
- 用户进程调用select函数,指定要监听的FD集合
- 内核监听FD对应的多个socket
- 任意一个或多个socket数据就绪则返回readable
- 此过程中用户进程阻塞
阶段二:
- 用户进程找到就绪的socket
- 依次调用recvfrom读取数据
- 内核将数据拷贝到用户空间
- 用户进程处理数据
select函数可以监听多个FD,只要有一个FD的数据准备好了,就会返回readable告诉进程可以读取数据了,那么此时就会调用recvfrom来拷贝数据。
- 虽然这里在等待数据何拷贝数据的的时候也需要等待,这是不可避免的
- 但是因为select是监听多个FD,而阻塞IO是只监听一个FD属于死等,假设此时等待数据还没有准备好,而其它的FD数据已经准备好了,而此时还在做无效的等待,无法读取已经准备好的数据,所以阻塞IO还是出于排队的状态
- 而select 是监听多个,哪个先准备好就去拷贝哪个数据,显然性能比阻塞IO和非阻塞IO高不少
不过监听FD的方式、通知的方式又有多种实现,常见的有:
- select
- poll
- epoll
差异:
- select和poll只会通知用户进程有FD就绪,但不确定具体是哪个FD,需要用户进程逐个遍历FD来确认。就类似于小餐馆人多的时候点餐,老板做完后就去一直问这个菜是谁点的。
- epoll则会在通知用户进程FD就绪的同时,把已就绪的FD写入用户空间,类似于饭店桌子上有小程序二维码,扫码点餐后,老板做好之间就送过来了并不需要问。
select
select是Linux最早是由的I/O多路复用技术
select函数模型
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;
int select(
int nfds, // 要监听的fb_set的最大fd+1
fd_set *readfds, // 要监听读事件的fd集合
fd_set *writefds,// 要监听写事件的fd集合
fd_set *exceptfds, // 要监听异常事件集合
struct timeval *timeout// 超时时间,NULL:不使用超时、0:不阻塞等待、大于0:指定固定阻塞时间
);
通过一个long int 数组fds_bits来表示FD是否就绪,类似于位图,通过一个二进制位表示一个FD,因为FD是从0开始自增的,也就是第0个Bit位表示的就是第一个FD,以此类推。
在调用select需要等待FD就绪 timeval就是等待时间
timeout取值:
- NULL:则表示select()没有timeout, select将一直被阻塞,直到某个文件描述符上发生了事件,返回已经就绪的FD;
- 0:仅检测描述符集合的状态,然后立即返回就绪的FD数量,并不等待外部事件的发生,接着继续循环的等待FD就绪
- 特定的时间值:如果在指定的时间段里没有事件发生, select将超时返回
select执行流程
这里以监听读事件的FD为例子
- 首先需要创建一个fd_set的结构体,来设置要监听的FD,一个32长度的数组一共可以监听1024个FD
- 调用select函数并传递参数
select(最大FD编号+1,rfds,NULL,NULL,等待超时时间)
- 执行select后会从用户态切换至内核态,并把对应的fd_set给拷贝过来
- 而内核空间并不知道要监听的是哪些FD,就会从低位遍历fd_set到设置的最大FD编号位,判断哪个比特位为1,就知道要监听哪个FD
- 如果此时没有就绪的FD就会根据设置的等待时间判断是否休眠
- 如果此时用FD就绪,就会将fd_set中就绪FD的比特位设置为1,select函数返回已经就绪的FD数量,没有就绪的设置为0并复制给用户空间,覆盖用户空间的fd_set,
- 而此时用户空间只是知道有几个FD就绪了,并不知道是哪个FD,所以又要遍历fd_set找出就绪FD
- 最后将就绪的FD数据从内核缓冲区中拷贝的用户缓冲区
select的缺点
- 每次执行select都会涉及到两次拷贝,一个是将fd_set从用户空间拷贝到内核空间,另一个是将fd_set从内核空间复制到用户空间
- 每次执行select都会涉及到两次fd_set的遍历,一个是在内核空间时内核不知道要监听的是哪一个FD需要遍历,另次遍历是将fd_set从内核空间拷贝用户空间后,用户空间并不知道哪个FD已经就绪。
- fd_set的数量最多为1024
poll
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; // 超时时间
);
poll的流程:
- 创建pollfd数组,向其中添加关注的fd信息,数组大小自定义
- 调用poll函数,将pollfd数组拷贝到内核空间,转链表存储,无上限
- 内核遍历fd,判断是否就绪
- 数据就绪或超时后,拷贝pollfd数组到用户空间,返回就绪fd数量n
- 用户进程判断n是否大于0
- 大于0则遍历pollfd数组,找到就绪的fd
与select对比:
依旧是两次遍历两次拷贝
select模式中的fd_set大小固定为1024,而pollfd在内核中采用链表,理论上无上限
监听FD越多,每次遍历消耗时间也越久,性能反而会下降
epoll
epoll模式是对select和poll的优化和改进。
struct eventpoll {
//...
struct rb_root rbr;// 红黑树,记录了所有要监听的FD
struct list_head rblist; // 一个链表,记录就绪的FD
//...
};
//1.创建一个epoll实例,内部是event poll,返回对应的句柄epfd
int epoll_create(int size);
// 2. 将一个FD添加到epoll的红黑树中,并设置ep_poll_callback
// callback 触发时,就把对应的FD加入到rblist这个就绪列表中
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, //event数组的最大长度
int timeout // 超时时间,-1:永不超时,0:不阻塞,大于0为指定的阻塞时间
);
- 通过
epoll_create
在内核空间创建一个epoll实例,epoll是一个结构体主要包含两个东西,一个是记录了所有要监听的FD的红黑树,另外一个是记录已经就绪的FD epoll_ctl
是对红黑树的一些操作(增删改),在执行添加操作时候会为对应FD设置一个回调函数ep_poll_callback
,这个回调函数的作用是当某个FD就绪时,将这个就绪的FD移动到rblist
也就是记录就绪FD的链表上epoll_wait
是检查rblist
链表上是否有已经就绪的FD,没有返回空,如果有就返回已经就绪的FD的数量,需要注意的是在调用epoll_wait
函数的时候,会传递一个叫events
的空数组,也是一个指针,当在内核中间的就绪FD链表中查询到有就绪的FD时,就可以将已经就绪的FD复制到该指针指向的空间。
select存在的三个问题
- 能监听的FD最大不超过1024个
- 每次select都需要把所有要监听的FD都拷贝到内核空间
- 每次都要遍历所有的FD来判断就绪状态
poll存在的问题
- poll利用链表解决了select中监听FD上限的问题,但依然要遍历所有FD,如果监听的FD较多,遍历链表的时间就会增加,从而导致性能下降
epoll模式中如何解决这些问题?
- 基于epoll实例中的红黑树保存要监听的FD,就算FD数量遍历,查询的时间复杂度也是比较低的,并且,增删改的效率同样很高
- 每个FD只需要执行一个
epoll_ctl
添加到红黑树,以后每次epoll_wait
无需传递任何参数,无需重复拷贝FD到内核空间 ep_poll_callback
机制来监听FD状态,无需遍历所有FD,因此性能不会随监听的FD数量增多而下降
事件通知机制
当FD有数据可读时,我们调用epoll_wait(或者select、poll)可以得到通知。但是事件通知的模式有两种:
- LevelTriggered:简称LT,也叫做水平触发。当FD有数据可读时,会重复通知多次,直至数据处理完成。是epoll的默认模式。
- EdgeTriggered:简称ET,也叫做边沿触发。当FD有数据可读时,只会被通知一次,不管数据是否处理完成。
举个例子:比如说你的快递到了,快递公司发短信通知你。如果是LT,过了几天你的快递还没有取,就又会发短信告诉你有快递没有取,只要不取就一直发短信。而如果是FD,就只是在快递到了驿站发一条短信,后续就不会再发了。
又比如说:
- 假设一个客户端socket对应的FD已经注册到了epoll实例中
- 客户端socket发送了2kb的数据
- 服务端调用epoll_wait,得到通知说FD就绪
- 服务端从FD读取了1kb数据
- 回到步骤3(再次调用epoll_wait,形成循环)
针对上面的例子,如果是ET模式,在读取完1KB数据后,内核会直接将这个FD从链表中删除。而如果是LT模式,当读取完1KB数据后,发现你的数据没有读取完,内核会将这个FD再次添加到链表中,等到下次调用epoll_wait
读取数据的时候,发现还有数据就把剩下的1KB读取走。
也就是会说ET模式会导致数据残留,导致有些数据读取不到。有以下两个种解决办法:
- 既然内核不帮我们将移除的FD添加到链表中,我们可以手动的将FD添加到链表中,使用
epoll_ctl
函数将FD的状态进行修改成就绪,在修改时eventpoll会检查对应的FD是不是真的有数据或者是由对应的就绪事件,如果有就会将对应FD添加到就绪链表中。这就是等同于LT模式 - 既然每次只能读取1KB,那么我们就可以循环读取数据,不过需要注意的是循环读取的时候不能使用阻塞IO,而是要配合非阻塞IO使用,因为阻塞IO读取的时候如果读取不到数据的时候就会阻塞,阻塞到下次有数据的时候。而非阻塞IO无论有没有数据,都会返回一个通知告诉进程是否有数据。
LT&ET
- 由于LT是重复通知,就会在一定程度上的影响性能
- LT存在“惊群”问题,当多个进程同时监听了同一个FD并且都调用了
epoll_wait
函数,当FD就绪后,由于LT是通知多次的就会导致所以进程被唤醒,当前面的进程把数据处理完毕后,后面的进程处理不到了,而这些后面唤醒的进程就没有必要了。 - 而ET模式只通知一个次,一个进程读取完就直接删除了,并不会出现“惊群”
- ET模式实现虽然复杂,但性能会更好一些
5. 信号驱动IO
信号驱动IO是与内核建立SIGIO的信号关联并设置回调,当内核有FD就绪时,会发出SIGIO信号通知用户,期间用户应用可以执行其它业务,无需阻塞等待。
第一阶段:
- 用户进程调用sigaction,注册信号处理函数
- 内核返回成功,开始监听FD
- 用户进程不阻塞等待,可以执行其他业务
- 当内核数据就绪后,回调用户进程的SIGIO处理函数
第二阶段:
- 收到SIGIO回调信号
- 调用recvfrom读取
- 内核将数据拷贝到用户空间
- 用户进程处理数据
该模型的缺点:
当有大量IO操作时,信号较多,SIGIO处理函数不能及时处理可能导致信号队列溢出,而且内核空间与用户空间的频繁信号交互性能也较低。
6. 异步IO
异步IO的整个过程都是非阻塞的,用户进程调用完异步API后就可以去做其它事情,内核等待数据就绪并拷贝到用户空间后才会递交信号,通知用户进程。
可以看到,异步IO模型中,用户进程在两个阶段都是非阻塞状态。
第一阶段:
- 用户进程调用aio_read,创建信号回调函数
- 内核等待数据就绪
- 用户进程无需阻塞,可以做任何事情
第二阶段:
- 内核数据就绪
- 内核数据拷贝到用户缓冲区
- 拷贝完成,内核递交信号触发aio_read中的回调函数
- 用户进程处理数据
虽然看起来异步IO非常完美,因为用户进程并不需要阻塞,进程就可以一直处理用户的请求,但如果在高并发的场景下,进程不断地去处理用户请求,让内核去处理数据,这就会导致内核无法处理这么多数据。
同步VS异步
IO操作是同步还是异步,关键看数据在内核空间与用户空间的拷贝过程(数据读写的IO操作),也就是第二阶段是同步还是异步,显然除了异步IO在数据拷贝的时候没有阻塞,其他模型在数据拷贝的时候都发生了阻塞。所以区分IO模型是同步还是异步是通过判断其数据拷贝时是同步还是异步。