上一篇《Linux网络I/O模型》提到了多路复用是目前实现高并发网络模型的主流方式。那么今天我们就来了解下I/O多路复用的实现原理。
在正式讲解之前,我们必须先来了解一下什么是文件描述符。
什么是文件描述符
在Linux系统中,把所有I/O设备都被抽象为了文件这个概念,一切皆文件。磁盘、网络、终端,甚至进程间通信工具管道pipe等都被当作文件对待。
通过这一抽象,任何I/O操作,都可以通过简单的几个接口来实现。
- open,打开文件
- seek,改变读写位置
- read,write,读写文件
- close,关闭文件
当进程打开现有文件或创建新文件时,内核向进程返回一个文件描述符,简称 fd
(file descriptor)。文件描述符就是内核为了高效管理已被打开的文件所创建的索引,用来指向被打开的文件。有了文件描述符,进程可以对文件一无所知,比如文件在磁盘的什么位置、加载到内存中又是怎样管理的等等,这些信息统统交由操作系统打理,进程无需关心,进程只需要通过文件描述符调用简单的 API 进行操作即可。
因此,当我们打开一个磁盘上的文件进行读取时,过程是这样的:
int fd = open(file_name); // 返回文件描述符
read(fd, buff);
当我们建立一个网络连接接收客户端发送来的数据时,过程是这样的:
int conn_fd = accept(...); // 返回网络连接文件描述符
if (read(conn_fd, request_buff) > 0) {
do_something(request_buff);
}
高并发网络服务实现
了解了文件描述符,继续回到我们本节我们要讨论的问题来,如果让我们自己去实现一个高性能支持高并发的网络服务器,我们该怎么设计呢。
我们能想到的最简单的方式,有可能就是多线程了。采用多线程+阻塞I/O模型,每来一个客户端连接,就创建一个线程进行通信,有数据就读写,没数据就阻塞。
但是多线程方式存在两个最大问题:
- 大量的线程频繁创建和销毁,非常消耗资源。当然可以通过线程池的方式解决。
- 线程之间频繁的切换引起CPU上下文的切换,非常消耗CPU资源。
显然多线程存在的这些固有缺陷导致了它并不是实现高并发服务的最优选择,那么我们只能得把目光转移到单线程。
如果使用单线程,我们就得首先排除阻塞I/O模式:
int iResult = recv(s, buffer,1024);
在阻塞模式下,如果没有数据到来,recv
会一直阻塞在那里,从而导致整个程序都锁死了,什么也干不了。
那非阻塞的模式是不是就能完全解决这个问题了呢?
int iResult = ioctlsocket(s, FIOBIO, (unsigned long *)&ul);
iResult = recv(s, buffer,1024);
上面的 ioctlsocket
把socket设置为了非阻塞模式,所以当调用 recv
时,如果没有数据,也会马上返回,并返回一个错误 EWOULDBLOCK
,表示请求操作没有成功完成,需要再次发起 recv
操作,循环往复。
但这种通过轮询判断有没有数据的方式,太消耗CPU资源,效率很低。
如果有一种方式,不需要我们去频繁询问,而是有数据准备就绪后,主动来通知我们,那该多好啊。就像设计模式中的好莱坞原则,“不要给我们打电话,我们会给你打电话(Don‘t call me, I‘ll call you.)”。这就是我们这节要讲的,I/O多路复用(I/O multiplexing)。
select
select
是I/O多路复用的一种实现方式。它的实现过程大概是这样的:
- 每个连接都会返回一个
fd
,我们需要一个数组fds
记录所有连接的fd
。 - 我们需要一个叫
fd_set
的数据结构,它是一个默认 1024 bit大小的一个位图bitmap结构,每个bit用来标识一个fd
。 - 用户进程通过
select
系统调用把fd_set
结构的数据拷贝到内核,由内核来监视并判断哪些连接有数据到来,如果有连接准备好数据,select
系统调用就返回。 -
select
返回后,用户进程只知道某个或某几个连接有数据,但并不知道是哪个连接。所以需要遍历fds
中的每个fd
, 当该fd
被置位时,代表 该fd
表示的连接有数据需要被读取。然后我们读取该fd
的数据并进行业务操作。 - 重新置位
fd_set
,然后跳转到步骤 3 循环执行。
下面是用 select
实现的服务端部分demo代码
/* 创建socket监听 */
sockfd = socket(AF_INET, SOCK_STREAM, 0);
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(2000);
addr.sin_addr.s_addr = INADDR_ANY;
bind(sockfd, (struct sockaddr*)&addr, sizeof(addr));
listen(sockfd, 5);
/* 接受5个客户端连接 */
int fds[5]; // fd 数组
for (i=0; i<5; i++)
{
memset(&client, 0, sizeof(client));
addrlen = sizeof(client);
fds[i] = accept(sockfd, (struct sockaddr*)&client, &addrlen);
if (fds[i] > max)
max = fds[i]; // 记录最大fd数值
}
fd_set rset;
while (1)
{
FD_ZERO(&rset); // 重置fd_set
for (i=0; i<5; i++)
{
FD_SET(fds[i], &rset);
}
puts("round again");
select(max+1, &rset, NULL, NULL, NULL); // 主角select
for (i=0; i<5; i++)
{
if (FD_ISSET(fds[i], &rset))
{
memset(buffer, 0, MAXBUF);
read(fds[i], buffer, MAXBUF);
puts(buffer); // 业务处理逻辑
}
}
}
fd_set
结构体如下:
#include <sys/select.h>
#define FD_SETSIZE 1024
#define NFDBITS (8 * sizeof(unsigned long))
#define __FDSET_LONGS (FD_SETSIZE/NFDBITS)
typedef struct {
unsigned long fds_bits[__FDSET_LONGS];
} fd_set;
void FD_SET(int fd, fd_set *fdset) //将fd添加到fdset
void FD_CLR(int fd, fd_set *fdset) //从fdset中删除fd
void FD_ISSET(int fd, fd_set *fdset) //判断fd是否已存在fdset
void FD_ZERO(fd_set *fdset) //初始化fdset内容全为0
select
函数的定义
// nfds:fds中最大fd的值加1
// readfds: 读数据文件描述符集合
// writefds: 写数据文件描述符集合
// exceptfds: 异常情况的文件描述符集合
// timeout: 该方法阻塞的超时时间
int select (int nfds, fd_set *readfds,
fd_set *writefds,
fd_set *exceptfds,
struct timeval *timeout);
struct timeval {
long tv_sec; //秒
long tv_usec; //毫秒
}
上述过程有几个关键点需要特别做说明:
-
fd_set
是文件描述符fd
的集合,由于每个进程可打开的文件描述符默认值为1024,fd_set
可记录的fd
个数上限也是1024个。fd_set
采用位图 bitmap 结构,是一个大小为32的 long 型数组,每一个 bit 代表一个描述符是否被监视。 -
select
第一个参数需要传入最大fd
值加1的数值,目的是为了用户能自定义监视的fd
范围,防止不必要资源消耗。 - 为什么每次 while 循环开始都要重置
fd_set
,是因为操作系统会复用用户进程传入的fd_set
变量,来作为出参,所以我们传入的fd_set
返回时已经被内核修改过了。
相比于非阻塞模型中用户进程不断轮询每个 fd
是否有数据,select
的方式选择让内核来帮我们监视这些 fd
,当有数据可读时就通知我们。这种方式在效率方面得到了极大的提高,但仍有不太完美的地方,主要有以下几个原因:
- 可监控的文件描述符数量最大为 1024 个,就代表最大能支持的并发为1024,这个是操作系统内核决定的。
- 用户进程的文件描述符集合
fd_set
每次都需要从用户进程拷贝到内核,有一定的性能开销。 -
select
函数返回,我们只知道有文件描述符满足要求,但不知道是哪个,所以需要遍历所有文件描述符,复杂度为O(n)。
因此我们可以看到,select
机制的这些特性在高并发网络服务器动辄几万几十万并发连接的场景下无疑是低效的。
poll
pool
是另一种I/O多路复用的实现方式,它解决了 select
1024个文件描述符的限制问题。
下面我们先看demo代码
/* 接受5个客户端连接 */
for (i=0; i<5; i++)
{
memset(&client, 0, sizeof(client));
addrlen = sizeof(client);
pollfds[i].fd = accept(sockfd, (struct sockaddr*)&client, &addrlen);
pollfds[i].events = POLLIN; // 关注输入事件
}
while (1)
{
puts("round again");
poll(pollfds, 5, 50000); // 主角poll
for(i=0; i<5; i++)
{
if (pollfds[i].revents & POLLIN) // 输入事件
{
pollfds[i].revents = 0; // 重置标志位
memset(buffer, 0, MAXBUF);
read(pollfds[i].fd, buffer, MAXBUF);
puts(buffer); // 业务处理逻辑
}
}
}
从代码中可以看出,poll
是使用了 pollfd
结构来替代了 select
的 fd_set
位图,以解决 1024 的文件描述符个数限制。
pollfd
的结构定义如下:
struct pollfd
{
int fd; /* file descriptor */
short events; /* requested events */
short revents; /* returned events */
};
第一个变量 fd
表示要监视的文件描述符;
第二个变量 events
表示要监视的事件,比如输入、输出或异常;
第三个变量 revents
表示返回的标志位,标识哪个事件有信息到来,处理完成后记得重置标志位。
poll
函数的定义
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
poll
函数的第一个参数传入了一个自定义的 pollfd
的数组,原则上已经没有了个数的限制。
但 poll
除了解决了 select
存在的文件描述符个数的限制,并没有解决 select
存在的其他问题。
select
和 poll
都会随着监控的文件描述符数量增加而性能下降,因此也不太适合高并发场景。
epoll
epoll
是 select
和 poll
的增强版本,它更加灵活,没有描述符数量的限制。epoll
使用一个文件描述符管理多个描述符,省去了大量文件描述符频繁在用户态和内核态之间拷贝的资源消耗。
epoll
的实现代码大概是这样的:
struct epoll_event events[5];
int epfd = epoll_create(10);
// ...
for (i=0; i<5; i++)
{
static struct epoll_event ev;
memset(&client, 0, sizeof(client));
addrlen = sizeof(client);
ev.data.fd = accept(sockfd, (struct sockaddr*)&client, &addrlen);
ev.events = EPOLLIN;
epoll_ctl(epfd, EPOLL_CTL_ADD, ev.data.fd, &ev);
}
while (1)
{
puts("round again");
nfds = epoll_wait(epfd, events, 5, 10000);
for (i=0; i<nfds; i++)
{
memset(buffer, 0, MAXBUF);
read(events[i].data.fd, buffer, MAXBUF);
puts(buffer);
}
}
epoll
操作过程有三个非常重要的接口,分别是:
#include <sys/epoll.h>
int epoll_create(int size);
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);
epoll_create()
方法生成一个 epoll
专用的文件描述符(创建一个 epoll
的句柄)。参数 size
在新版本中没有具体意义,填一个大于0的任意值即可。
epoll_ctl()
方法是 epoll
的事件注册函数,告诉内核要监视的文件描述符和事件类型。
epfd
:epoll
专用的文件描述符,epoll_create()
的返回值。
op
:表示添加、修改、删除的动作,用三个宏来表示:?
EPOLL_CTL_ADD
:注册新的fd
到epfd
中;?
EPOLL_CTL_MOD
:修改已经注册的fd
的监听事件;?
EPOLL_CTL_DEL
:从epfd
中删除一个fd
;
fd
:需要监听的文件描述符。
event
:告诉内核要监听的事件。
epoll_wait()
方法等待事件的产生,类似 select
调用。
epfd
:epoll
专用的文件描述符,epoll_create()
的返回值。
events
:分配好的epoll_event
结构体数组,epoll
将会把发生的事件赋值到events
数组中。
maxevents
:告诉内核events
数组的大小。
timeout
:超时时间,单位毫秒,为 -1 时,方法为阻塞。
epoll
底层使用了 RB-Tree
红黑树和 list
链表实现。内核创建了红黑树用于存储 epoll_ctl
传来的 socket,另外创建了一个 list 链表,用于存储准备就绪的事件。
当 epoll_wait
调用时,仅仅观察这个 list 链表里有没有数据即可。有数据就返回,没有数据就阻塞。所以,epoll_wait
非常高效,通常情况下即使我们要监控百万计的连接,大多一次也只返回很少量准备就绪的文件描述符而已,所以,epoll_wait
仅需要从内核态拷贝很少的文件描述符到用户态。
epoll
相比于 select
和 poll
,它更高效的本质在于:
- 减少了用户态和内核态文件描述符状态的拷贝,
epoll
只需要一个专用的文件句柄即可; - 减少了文件描述符的遍历,
select
和poll
每次都要遍历所有的文件描述符,用来判断哪个连接准备就绪;epoll
返回的是准备就绪的文件描述符,效率大大提高; - 没有并发数量的限制,性能不会随文件描述符数量的增加而下降。
I/O多路复用总结
本节讲解了三种I/O多路复用的实现方式,以及它们各自的优缺点。
select
是较早实现的一种I/O多路复用技术,但它最明显的缺点就是有 1024 个文件描述符数量的限制,也就导致它无法满足高并发的需求。
poll
一定程度上解决了 select
文件描述符数量的限制,但和 select
一样,仍然存在文件描述符状态在用户态和内核态的频繁拷贝,和遍历所有文件描述符的问题,这导致了在面对高并发的实现需求时,它的性能不会很高。
epoll
高效地解决了以上问题,首先使用一个特殊的文件描述符,解决了用户态和内核态频繁拷贝的问题;其次 epoll_wait
返回的是准备就绪的文件描述符,省去了无效的遍历;再次,底层使用红黑树和链表的数据结构,更加高效地实现连接的监视。
我们工作中常用的 redis、nginx 都是使用了 epoll
这种I/O复用模型,通过单线程就实现了10万以上的并发访问。
另外有很多资料会说 epoll
使用了共享内存(mmap)的方式,其实是不正确的,它并没有用到mmap。
通过上面的讲解,你是不是感觉 epoll
任何情况下一定比 select
高效呢,其实也不一定,需要根据具体场景。比如你的并发不是很高,且大部分都是活跃的 socket,那么也许 select
会比 epoll
更加高效,因为 epoll
会有更多次的系统调用,用户态和内核态会有更加频繁的切换。
所有,没有银弹,一切都是 trade-off。
欢迎关注我的微信公众号【架构小菜】