一、什么是高效 IO
1、什么是 IO
在网络中,向网络读或写是都会遇到阻塞问题的,因此如何使服务器读得更快就是一个问题。而我们知道,无论是从网络读写,还是普通的文件读写,当我们IO(调用 write 或 read 函数)时,本质都是操作系统先等有没有数据或有没有空间,然后再让上层的进程把用户缓冲区的数据拷到 OS 里或让进程把 OS 缓冲区里的数据拷到用户缓冲区里。所以,其实 IO 的过程就是等 + 读写数据的过程。
2、再谈高效 IO
由上面可知,无论是 read 还是 write,本质上都要经历等待 + 读/写。而什么是高效 IO 呢?其实就是在单位时间内等的时间比重变少,把尽可能多的时间都给到读/写上。
二、IO 的 5 大模型
- 单进程阻塞 IO:等数据到了后,才进行 IO,然后才干自己的事。
- 单进程非阻塞 IO:一个进程 a.先干自己的事,干完后再自己看看收到数据没有。
- 单进程信号驱动式 IO:一个进程 a.没收到信号时干自己的事,收到后才进行 IO。
- 单进程多路转接/多路复用:一个进程 a.但有多个文件描述符,一旦OS缓冲区有数据,直接读。
- 多进程异步 IO:一个 a 进程、一个 b 进程。b 进程参与 IO,但 a 进程不参与 IO,只拿 IO 数据。
1、阻塞 IO VS 非阻塞 IO
其实阻塞和非阻塞的等待时间是一样长的,只不过非阻塞在等的同时可以做其他事,所以效率就高了。
2、同步 IO VS 异步 IO
同步 IO 就是进程本身参与了 IO 的过程,即进程自己参与了等待或把 OS 缓冲区的数据读上来。而异步 IO 就是进程本身不参与 IO 的过程,只会从用户缓冲区拿数据,或向用户缓冲区写数据。
3、什么是多路复用
一个进程,但管理着多个文件描述符。利用多个文件描述符读写数据。
4、多路复用的 3 个模式
4.0. 就绪事件
对于一个文件来说,它的用途就只有三种:读、写、异常。
对于读,当 OS 缓冲区有数据后才能读,这时就叫读事件就绪;而如果 OS 缓冲区没有数据,那么文件就读不出数据,此时就叫读事件未就绪。
对于写,当 OS 缓冲区有空间后才能写,这时就叫写事件就绪;而如果 OS 缓冲区没有空间存数据,那么文件就读不出数据,此时就叫写事件未就绪。
4.1. select 模式
4.1.1. select 模式原理
#include <sys/select.h>
#include <sys/time.h>
#include <unistd.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds,
struct timeval *timeout);
struct timeval
{
long tv_sec; // 秒(Seconds)
long tv_usec; // 微秒(Microseconds)
};
在规定的时间内阻塞等待,时间一到,返回已经就绪的 fd 和就绪 fd 的数量。
4.1.2. set_fd
set_fd 类型其实是一个位图。最低位对应的 fd 为 0,次低位对应的 fd 为 1……
对 set_fd 的相关操作:
#include <sys/select.h>
void FD_CLR(int fd, fd_set *set); // 从位图里移除 fd
void FD_SET(int fd, fd_set *set); // 把 fd 加入到位图里
int FD_ISSET(int fd, fd_set *set); // 看看 fd 是否在位图里
void FD_ZERO(fd_set *set); // 给位图清零
4.1.3. select 模式的缺点
- 耗时严重。因为 readfds/writefds/exceptfds 都是输入输出型参数。因此 readfds/writefds/exceptfds 是一定会被改变的,所以我们要自己额外创建 fd 数组,里面填的都是需要被关心的 fd,然后每次调完 select 后都要重新设置位图。而每次都要重新设置位图这个操作是 O(n) 的,还是很费时间的。除此之外,内核检测 fd 就绪,也是要遍历的。
- 用户要额外使用设置数组来保存需要关心的 fd,非常麻烦。
- 等待的 fd 数量有限。因为 select 用的是位图,而位图的大小已经被系统定死了,最多就 1024 位,用户是没法控制的。
- 输出型参数太多,数据拷贝的频率太高。每调一次 select 前就要把用户维护的 fd 数组拷到位图里,然后调完后还要将用户的数据拷回用户维护的 fd 数组。
4.2. poll 模式
4.2.1. poll 模式原理
#include <poll.h>
int poll(struct pollfd fds[], nfds_t nfds, int timeout);
struct pollfd
{
int fd; /* file descriptor */
short events; /* events to look for */
short revents; /* events returned */
};
参数介绍:
- fds:需要关心的 fd 数组。
- nfds:表示 fds 数组中的元素个数。
- timeout:超时时间(单位:毫秒),用来控制等待的时间长度。timeout > 0:等待指定的毫秒数。timeout == 0:立即返回,不阻塞。timeout < 0:无限期等待,直到有 fd 就绪。
- 返回值:就绪的 fd 数量。
用户传需要关心的 fd 数组,然后系统会在规定的时间内不断对数组进行遍历,当某个 fd 就绪后,系统只会改 pollfd 的 revents,并不会改 pollfd 的 events,因此我们就不用在每次调完 poll 函数之后重置数组了;当时间到了后,就会通过 pollfd 数组和就绪 fd 数量。
常见事件类型(宏)
POLLIN | 有数据可读 |
POLLOUT | 有空间可写 |
POLLERR | 有异常 |
4.2.2. poll 模式的优点
4.2.3. poll 模式缺点
poll 函数内部还是通过遍历 fds 数组来看哪些 fd 就绪,时间复杂度还是 O(n)。
4.3. epoll 模式
4.3.1. epoll 模型
图中的红黑树是用来存需要关心的 fd 和 fd 的事件类型。就绪队列就是存已经就绪了的 fd。而就绪队列 + 红黑树就组成了一个 epoll 模型。然后 struct file 中有一个指针指向这个 epoll 模型,于是这个进程就可以通过 fd 来访问这个 epoll 模型啦。
4.3.2. epoll 模型原理
一旦网卡上有数据,网卡就会通过硬件中断给操作系统发信号,同时把数据输入到网卡的驱动层(数据链路层)。然后操作系统就会调这个网卡对应的回调函数。
4.3.3. 回调函数
其实上面提到的回调函数,每个 fd 都会关联一个回调函数。操作系统一旦收到某个硬件的硬件中断后,就会调这个硬件对应的回调函数。然后这个回调函数就会把硬件驱动层的数据拷到上层的接收缓冲区(即 TCP 的那个接收队列);接着回调函数就会去 epoll 模型的红黑树找自己的 fd 关心的事件是不是 EPOLLIN 或 EPOLLOUT,如果和 fd 状态匹配,那么就为这个 fd 构造就绪节点,插入到就绪队列中。
所以 epoll 的检测就绪的效率是比 select 和 poll 要高的。因为 select 和 poll 都要遍历 fd 数组,但 epoll 可以理解成一旦有 fd 收到数据,fd 就会直接通知操作系统,然后操作系统直接调这个 fd 的处理方法,根本不用遍历所有的 fd 。
4.3.3. epoll 的优势
a. 检测就绪的时间复杂度为 O(1),获取就绪的时间复杂度为 O(n)。因为检测就绪就看就绪队列是不是空的就行了,所以是 O(1);但获取就绪要把就绪队列的节点拷到数组里,因此获取就绪是 O(n) 的。不过这已经比 select 和 poll 高效了。因为 select 和 poll 检测就绪都是通过遍历被关心的 fd 数组来查看有哪些 fd 是就绪的;但 epoll 是通过每个 fd 都有的回调函数直接构建就绪队列的节点,并不用遍历红黑树来看哪些 fd 就绪,因此是比 select 和 poll 高效的。
b. 因为就绪队列和红黑树都是由节点组成的,因此能管理的 fd 和 event 是可以无限多的。
c. 因为返回值是就绪队列的节点数,再加上 epoll_wait 函数输出就绪的 fd 数组。所以我们只需要遍历这个就绪 fd 数组可以拿到所有就绪的 fd 了。但 poll 和 select 都要遍历整个被关心的 fd 数组才能知道全部就绪的 fd ,而就绪 fd 数组的大小是远远小于整个被关心的 fd 数组的。
4.3.4. 函数介绍
4.3.4.1. 创建 epoll 模型
int epoll_create(int size);
参数介绍
- size:该参数已经被废弃了,传一个大于 0 的数进去就行了。
- 返回值:成功时,返回 epoll 模型的 fd;失败返回 -1.
4.3.4.2. 获取就绪队列
#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
附1:
struct epoll_event
{
uint32_t events; // 就绪的事件类型,如 EPOLLIN, EPOLLOUT 等
epoll_data_t data; // 用户数据,通常存储文件描述符或指针
};
附2:
typedef union epoll_data
{
void *ptr; // 存储任意指针
int fd; // 存储文件描述符
uint32_t u32; // 存储 32 位无符号整数
uint_t u; // 存储 位无符号整数
} epoll_data_t;
此类型为联合体,一般用这里面 4 个数据中的一个
参数介绍
- epfd:epoll 模型对应的 fd。
- events:可以理解成存需要关心的 fd 和 fd 事件类型的数组
- maxevents:events 数组的元素个数。
- timeout: 等待事件的超时时间,单位为毫秒(ms)。timeout = -1:阻塞等待,直到有事件发生。timeout = 0:立即返回,无论是否有事件发生。timeout > 0:等待指定的超时时间后返回。
- 返回值:就绪 fd 的数量
4.3.4.3. 操作 epoll 模型的红黑树
#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
参数介绍
- epfd:epoll 模型对应的 fd。
- op:操作类型,
EPOLL_CTL_ADD:向 epoll 实例中注册新的文件描述符。
EPOLL_CTL_MOD:修改已注册文件描述符的监听事件。
EPOLL_CTL_DEL:从 epoll 实例中删除指定的文件描述符。 - fd:你要操作的 fd。
- event:输入型参数。fd 的事件类型。不过如果 op 为 EPOLL_CTL_DEL 时,传 nullptr。
- 返回值:成功为 0,失败为 -1.