1、BIO
举个例子,当用read去读取网络的数据时,是无法预知对方是否已经发送数据的。因此在收到数据之前,能做的只有等待,直到对方把数据发过来,或者等到网络超时。
对於单线程的网络服务,这样做就会有卡死的问题。因为当等待时,整个线程会被挂起,无法执行,也无法做其他的工作,导致当前的进程被block。
于是,网络服务为了同时响应多个并发的网络请求,必须实现为多线程的。每个线程处理一个网络请求。线程数随着并发连接数线性增长。但这带来两个问题:
- 线程越多,上下文切换,会无谓浪费大量的CPU。
- 每个线程会占用一定的内存作为线程的栈。比如有1000个线程同时运行,每个占用1MB内存,就占用了1个G的内存。
要是操作IO接口时,操作系统能够总是直接告诉有没有数据,而不是Block去等就好了。于是,NIO登场。
2、NIO
BIO和NIO的区别是什么呢?
在BIO模式下,调用read,如果发现没数据已经到达,就会Block住。
在NIO模式下,调用read,如果发现没数据已经到达,就会立刻返回-1, 并且errno被设为EAGAIN。这样就是可以有一个tomcat 线程完成所有的读任务了,不需要多线程了。
于是,一段NIO的代码,大概就可以写成这个样子。
struct timespec sleep_interval{.tv_sec = 0, .tv_nsec = 1000};
ssize_t nbytes;
while (1) {
/* 尝试读取 */
if ((nbytes = read(fd, buf, sizeof(buf))) < 0) {
if (errno == EAGAIN) { // 没数据到
perror("nothing can be read");
} else {
perror("fatal error");
exit(EXIT_FAILURE);
}
} else { // 有数据
process_data(buf, nbytes);
}
// 处理其他事情,做完了就等一会,再尝试
nanosleep(sleep_interval, NULL);
}
这段代码很容易理解,就是轮询,不断的尝试有没有数据到达,有了就处理,没有就等一小会再试。这比之前BIO好多了,起码程序不会被卡死了。
但这样会带来两个新问题:
- 如果有大量文件描述符都要等,那么就得一个一个的read。这会带来大量的上下文切换,因为read是系统调用,每调用一次就得在用户态和核心态切换一次。
- 休息一会的时间不好把握。这里是要猜多久之后数据才能到。等待时间设的太长,程序响应延迟就过大;设的太短,就会造成过于频繁的重试,干耗CPU而已。
要是操作系统能一口气告诉程序,哪些数据到了就好了。
于是IO多路复用被搞出来解决这个问题。
3、IO多路复用(select)
select长这样:
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
它接受3个文件描述符的数组,分别监听读取(readfds),写入(writefds)和异常(expectfds)事件。那么一个 IO多路复用的代码大概是这样:
struct timeval tv = {.tv_sec = 1, .tv_usec = 0};
ssize_t nbytes;
while(1) {
FD_ZERO(&read_fds);
setnonblocking(fd1);
setnonblocking(fd2);
FD_SET(fd1, &read_fds);
FD_SET(fd2, &read_fds);
// 把要监听的fd拼到一个数组里,而且每次循环都得重来一次...
if (select(FD_SETSIZE, &read_fds, NULL, NULL, &tv) < 0) { // block住,直到有事件到达
perror("select出错了");
exit(EXIT_FAILURE);
}
for (int i = 0; i < FD_SETSIZE; i++) {
if (FD_ISSET(i, &read_fds)) {
/* 检测到第[i]个读取fd已经收到了,这里假设buf总是大于到达的数据,所以可以一次read完 */
if ((nbytes = read(i, buf, sizeof(buf))) >= 0) {
process_data(nbytes, buf);
} else {
perror("读取出错了");
exit(EXIT_FAILURE);
}
}
}
}
首先,为了select需要构造一个fd数组。用select监听了read_fds中的多个socket的读取时间。调用select后,程序会Block住,直到有人告诉select可以select了,或者等到最大1秒钟(tv定义了这个时间长度),然后select需要遍历所有注册的fd,挨个检查哪个fd有事件到达。
select又带来了新的问题:
- select能够支持的最大的fd数组的长度是1024。这对要处理高并发的web服务器是不可接受的。
- fd数组按照监听的事件分为了3个数组,为了这3个数组要分配3段内存去构造,而且每次调用select前都要重设它们;调用select后,这3数组要从用户态复制一份到内核态;事件到达后,要遍历这3数组。很不爽。
- select返回后要挨个遍历fd,找到被“SET”的那些进行处理。这样比较低效。
- select是无状态的,即每次调用select,内核都要重新检查所有被注册的fd的状态。select返回后,这些状态就被返回了,内核不会记住它们;到了下一次调用,内核依然要重新检查一遍。于是查询的效率很低。
于是出现了epoll api。
4、用epoll实现的IO多路复用
第一步:与select不同,要使用epoll是需要先创建一下的。
int epfd = epoll_create(10);
epoll_create在内核层创建了一个数据表,接口会返回一个“epoll的文件描述符”指向这个表。
为什么epoll要创建一个用文件描述符来指向的表呢?这里有个好处:
- epoll是有状态的,不像select和poll那样每次都要重新传入所有要监听的fd,这避免了很多无谓的数据复制。epoll的数据是用接口epoll_ctl来管理的(增、删、改)。避免了全量复制。
第二步:使用epoll_ctl接口来注册要监听的事件。
//其中第一个参数就是上面创建的epfd。
//第二个参数op表示如何对文件名进行操作,共有3种。
-EPOLL_CTL_ADD - 注册一个事件
- EPOLL_CTL_DEL - 取消一个事件的注册
- EPOLL_CTL_MOD - 修改一个事件的注册
//第三个参数是要操作的fd,这里必须是支持NIO的fd(比如socket)。
//第四个参数是一个epoll_event的类型的数据,表达了注册的事件的具体信息。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
通过epoll_ctl就可以灵活的注册/取消注册/修改注册某个fd的某些事件。
第三步,使用epoll_wait来等待事件的发生。
int epoll_wait(int epfd, struct epoll_event *evlist, int maxevents, int timeout);
特别留意,这一步是"block"的。只有当注册的事件至少有一个发生,或者timeout达到时,该调用才会返回。这与select和poll几乎一致。但不一样的地方是evlist,它是epoll_wait的返回数组,里面只包含那些被触发的事件对应的fd,而不是像select和poll那样返回所有注册的fd。
综合起来,一段比较完整的epoll代码大概是这样的。
#define MAX_EVENTS 10
struct epoll_event ev, events[MAX_EVENTS];
int nfds, epfd, fd1, fd2;
// 假设这里有两个socket,fd1和fd2,被初始化好。
// 设置为non blocking
setnonblocking(fd1);
setnonblocking(fd2);
// 创建epoll
epfd = epoll_create(MAX_EVENTS);
if (epollfd == -1) {
perror("epoll_create1");
exit(EXIT_FAILURE);
}
//注册事件
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = fd1;
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, fd1, &ev) == -1) {
perror("epoll_ctl: error register fd1");
exit(EXIT_FAILURE);
}
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, fd2, &ev) == -1) {
perror("epoll_ctl: error register fd2");
exit(EXIT_FAILURE);
}
// 监听事件
for (;;) {
nfds = epoll_wait(epdf, events, MAX_EVENTS, -1);
if (nfds == -1) {
perror("epoll_wait");
exit(EXIT_FAILURE);
}
for (n = 0; n < nfds; ++n) { // 处理所有发生IO事件的fd
process_event(events[n].data.fd);
// 如果有必要,可以利用epoll_ctl继续对本fd注册下一次监听,然后重新epoll_wait
}
}
所有的基于IO多路复用的代码都会遵循这样的写法:注册——监听事件——处理——再注册,无限循环下去。
5、epoll的优势
每次某个被监听的fd一旦有事件发生,内核就直接标记之。epoll_wait调用时,会尝试直接读取到当时已经标记好的fd列表,如果没有就会进入等待状态。
同时,epoll_wait直接只返回了被触发的fd列表,这样上层应用再也不用从大量注册的fd中筛选出有事件的fd了。
简单说就是select和poll的代价是"O(所有注册事件fd的数量)",而epoll的代价是"O(发生事件fd的数量)"。于是,高性能网络服务器的场景特别适合用epoll来实现。