大家好,我是「云舒编程」,今天我们来聊聊IO多路复用。
文章首发于微信公众号:云舒编程 关注公众号获取: 1、大厂项目分享 2、各种技术原理分享 3、部门内推
通过前面的文章我们已经了解了「数据包从HTTP层->TCP层->IP层->网卡->互联网->目的地服务器」以及「数据包怎么从网线到进程,在被应用程序使用」涉及的知识。 本文将继续介绍网络编程中的各种细节和IO多路复用的原理。
假设应用A想要请求应用B获取数据,那么他会经历以下步骤:
现在我们将注意力放到应用A上,应用A将请求发送出去后,就会开始调用系统调用从TCP读缓冲区读取数据,由于无法知道应用B什么时候会把响应数据返回,那么就会两种情况:
对于选择一,就是我们常说的阻塞式IO。 学术点的说法:当用户进程发起read调用时,如果内核的数据没有准备好,那么操作系统就会把该进程挂起来,进入等待状态(不消耗CPU)。直到数据准备好了或者发生了错误,该进程才会被唤醒。 整体流程如图:
对于选择二,就是我们常说的非阻塞式IO。 学术点的说法:当用户进程发起read调用时,如果内核的数据没有准备好,那么操作系统会返回一个EAGAIN error,用户进程可以根据该error判断出是数据未准备好,可以等会再来问。如果在轮询期间,内核准备好数据了,用户进程就可以把数据拷贝到用户态空间了。 整体流程如图:
阻塞IO和非阻塞IO都是早期最常见的网络编程模型,但是他们有着致命的缺点。考虑如下场景:
然而阻塞IO会导致线程被挂起,非阻塞IO会导致线程一直处于轮询状态。这两种情况都会导致线程无法被释放或者复用。随着用户请求数的增多,应用A不得不创建更多的线程。 然而对于操作系统来说,可以创建的线程是有上限的,并且过多的线程会导致线程切换的时间变多,严重时可能导致系统卡死,无法对外提供服务。这也是著名的C10K问题。
为了解决这个问题,于是人们就提出了方案:由一个或者几个线程去监控多个网络请求,由他们去完成数据准备阶段的操作。当有数据准备就绪之后再分配对应的线程去读取数据,这样就可以使用少量的线程维护大量的网络请求,这就是IO多路复用。
❝ IO 多路复用的复用指的是复用线程,而不是IO连接;目的是让少量线程能够处理多个IO连接。 ❞
IO多路复用又主要由以下函数分别实现,分别是select、poll、epoll。
select是Linux最早支持IO多路复用的函数。
通过select函数可以完成多个IO事件的监听。
#define __FD_SETSIZE 1024
typedef struct {
unsigned long fds_bits[__FD_SETSIZE / (8 * sizeof(long))];
} __kernel_fd_set;
struct timeval {
time_t tv_sec; /* seconds */
suseconds_t tv_usec; /* microseconds */
};
//函数声明
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
函数参数:
函数返回值:
从上述的select函数声明可以看出,fd_set本质是一个数组,为了方便我们操作该数组,操作系统提供了以下函数:
// 将文件描述符fd从set集合中删除
void FD_CLR(int fd, fd_set *set);
// 判断文件描述符fd是否在set集合中
int FD_ISSET(int fd, fd_set *set);
// 将文件描述符fd添加到set集合中
void FD_SET(int fd, fd_set *set);
// 将set集合中, 所有文件描述符对应的标志位设置为0
void FD_ZERO(fd_set *set);
poll是在select之后出现的另一种I/O 多路复用技术。和 select 相比,它使用了不同的方式存储文件描述符,也解决文件描述符的个数限制。
struct pollfd {
int fd; /* file descriptor */
short events; /* events to look for */
short revents; /* events returned */
};
int poll(struct pollfd *fds, unsigned long nfds, int timeout);
函数参数:
函数返回值:
在 select 里面,文件描述符的个数已经随着 fd_set 的实现而固定,没有办法对此进行配置;而在 poll 函数里,我们可以自由控制 pollfd 结构的数组大小,从而突破select中面临的文件描述符个数的限制。
poll 的实现和 select 非常相似,只是poll 使用 pollfd 结构,而 select 使用fd_set 结构,poll 解决了文件描述符数量限制的问题,但是同样需要从用户态拷贝所有的 fd 到内核态,也需要线性遍历所有的 fd 集合,所以它和 select 并没有本质上的区别。
epoll 是 Linux kernel 2.6 之后引入的新 I/O 事件驱动技术,它解决了select、poll在性能上的缺点,是目前IO多路复用的主流解决方案。 《The Linux Programming Interface》中用了一张图直观的展示了 select、poll、epoll在不同数量的文件描述符下的性能。
从上图可以明显地看到,随着文件描述符数量的上涨,epoll 的性能还是很优异。而select、poll则随着描述符数量的上涨性能逐渐变差。
epoll 的 API 非常简洁,由3个系统函数组成:
int epoll_create(int size);
int epoll_create1(int flags);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
struct epoll_event {
__uint32_t events;
epoll_data_t data;
};
union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
};
typedef union epoll_data epoll_data_t;
epoll 主要的结构对象为eventpoll,其主要包含几个重要属性成员:
struct eventpoll {
/* Wait queue used by sys_epoll_wait() */
wait_queue_head_t wq;
/* List of ready file descriptors */
struct list_head rdllist;
/* RB tree root used to store monitored fd structs */
struct rb_root_cached rbr;
}
其工作流程主要如图所示:
在神书《UNIX 网络编程》里,提供了5种IO模型的比较,可以看出无论是同步和异步 I/O,获取数据都分为了两步:
判定一个 I/O 模型是同步还是异步,一般主要看第二步数据拷贝时是否会阻塞用户进程。基于这个原则,从上图可以看出只有异步IO才是异步的。其余的,包括基于IO多路复用的思路的selec、poll、epoll都是同步IO。
关注点是数据,只要读缓冲区不为空,写缓冲区不满,那么epoll_wait就会一直返回就绪,水平触发是epoll的默认工作方式。
关注点是变化,只要缓冲区的数据有变化,epoll_wait就会返回就绪。
这里的数据变化并不单纯指,缓冲区从有数据变为没有数据,或者从没有数据变为有数据,还包括了数据变多或者变少。换句话说,当buffer长度有变化时,就会触发。
假设epoll被设置为了边缘触发,当客户端写入了10个字符,由于缓冲区从0变为了10,于是服务端epoll_wait触发一次就绪,服务端读取了2个字节后不再读取。这个时候再去调用epoll_wait会发现不会就绪,只有当客户端再次写入数据后,才会触发就绪。 这就导致如果使用ET模式,那就必须保证要「一次性把数据读取/写入完」,否则会导致数据长期无法读取/写入。 LT模式则没有这个问题。
前面提到过,如果使用阻塞IO,假如某个文件描述符长期不可读,那么对应的线程就会长期阻塞,导致资源被浪费。
但是当使用了select、poll、epoll后,函数调用返回的fd都是就绪的,这种情况下还需要使用非阻塞IO吗?答案是肯定的,依旧需要使用非阻塞IO,原因如下: 1、fd从返回就绪,到用户线程去read。存在时间间隔,在这段时间,有可能该fd已经被其他线程读取了(惊群问题)。这个时候如果再去读取,如果是阻塞IO,那么用户线程就会被阻塞了。 2、fd还有可能被内核抛弃了,这个时候如果再去读取,如果是阻塞IO,那么用户线程就会被阻塞了。 3、select、poll、epoll只是返回了可读事件,并没有返回可读的数据量。因此,使用非阻塞 IO的一般做法是读多次,直到不能读为止。而阻塞IO却不一样,每次读了数据后都需要重新调用epoll_wait再次判断是否可读,不能连续的读多次。因为如果上一次就把数据读完了,不判断就直接read 就会导致用户线程阻塞。
EPOLLIN | 表示对应的文件描述字可以读; |
---|---|
EPOLLOUT | 表示对应的文件描述字可以写; |
EPOLLRDHUP | 表示套接字的一端已经关闭,或者半关闭; 1、对端发送FIN(调用了close或者shutdown(SHUT_WR)) 2、本端调用shutdown(SHUT_RD),一般不会这么用 有些系统不一定支持EPOLLRDHUP,可以考虑使用「EPOLLIN + read返回0」替代 |
EPOLLHUP | 表示套接字读写都关闭: 1、本端调用shutdown(SHUT_RDWR) 2、本端,对端都调shutdown(SHUT_WR) 3、对端调用close |
EPOLLERR | 发生了错误 |