在内核将数据准备好之前,系统调用会一直等待,所有的套接字,默认都是阻塞方式
阻塞 IO 是最常见的 IO 模型
如果内核还未将数据准备好,系统调用仍然会直接返回,并且返回 EWOULDBLOCK 错误码
非阻塞 IO 往往需要程序员循环的方式反复尝试读写文件描述符,这个过程称为轮询,这对 CPU 来说是较大的浪费,一般只有特定场景下才使用
内核将数据准备好的时候,使用 SIGIO 信号通知应用程序进行 IO 操作
虽然从流程图上看起来和阻塞 IO 类似,实际上最核心在于 IO 多路转接能够同时等待多个文件描述符的就绪状态
由内核在数据拷贝完成时,通知应用程序(而信号驱动是告诉应用程序何时可以开始拷贝数据)
任何 IO 过程中,都包含两个步骤,第一是等待,第二是拷贝;而且在实际的应用场景中,等待消耗的时间往往都远远高于拷贝的时间,让 IO 更高效,最核心的办法就是让等待的时间尽量少
同步和异步关注的是消息通信机制
这里的同步通信和进程之间的同步是完全不相干的概念
以后在看到 "同步" 这个词,一定要先搞清楚大背景是什么,这个同步,是同步通信异步通信的同步,还是同步与互斥的同步
阻塞和非阻塞关注的是程序在等待调用结果(消息,返回值)时的状态
非阻塞 IO,纪录锁,系统 V 流机制,I/O 多路转接(也叫 I/O 多路复用),readv 和 writev 函数以及存储映射 IO(mmap),这些统称为高级 IO
一个文件描述符,默认都是阻塞 IO
函数原型如下
#include <unistd.h>
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* arg */ );
传入的 cmd 的值不同,后面追加的参数也不相同
fcntl 函数有 5 种功能
我们此处只是用第三种功能,获取/设置文件状态标记,就可以将一个文件描述符设置为非阻塞
基于 fcntl,我们实现一个 SetNoBlock 函数,将文件描述符设置为非阻塞
void SetNoBlock(int fd)
{
int fl = fcntl(fd, F_GETFL);
if (fl < 0)
{
perror("fcntl");
return;
}
fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
void SetNoBlock(int fd)
{
int fl = fcntl(fd, F_GETFL);
if (fl < 0)
{
perror("fcntl");
return;
}
fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}
int main()
{
SetNoBlock(0);
while (1)
{
char buf[1024] = {0};
ssize_t read_size = read(0, buf, sizeof(buf) - 1);
if (read_size < 0)
{
perror("read");
sleep(1);
continue;
}
printf("input:%s\n", buf);
}
return 0;
}
系统提供 select 函数来实现多路复用输入/输出模型
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
其实这个结构就是一个整数数组,更严格的说,是一个 "位图",使用位图中对应的位来表示要监视的文件描述符
提供了一组操作 fd_set 的接口,来比较方便的操作位图
void FD_CLR(int fd, fd_set *set); // 用来清除描述词组 set 中相关fd 的位
int FD_ISSET(int fd, fd_set *set); // 用来测试描述词组 set 中相关fd 的位是否为真
void FD_SET(int fd, fd_set *set); // 用来设置描述词组 set 中相关fd 的位
void FD_ZERO(fd_set *set); // 用来清除描述词组 set 的全部位
timeval 结构用于描述一段时间长度,如果在这个时间内,需要监视的描述符没有事件发生则函数返回,返回值为 0
错误值可能为:
常见的程序片段如下:
fs_set readset;
FD_SET(fd,&readset);
select(fd+1,&readset,NULL,NULL,NULL);
if(FD_ISSET(fd,readset)){……}
理解 select 模型的关键在于理解 fd_set,为说明方便,取 fd_set 长度为 1 字节,fd_set 中的每一 bit 可以对应一个文件描述符 fd,则 1 字节长的 fd_set 最大可以对应 8 个 fd
备注:
fd_set 的大小可以调整,可能涉及到重新编译内核,感兴趣的小伙伴可以自己去收集相关资料
只检测标准输入:
#include <stdio.h>
#include <unistd.h>
#include <sys/select.h>
int main()
{
fd_set read_fds;
FD_ZERO(&read_fds);
FD_SET(0, &read_fds);
for (;;)
{
printf("> ");
fflush(stdout);
int ret = select(1, &read_fds, NULL, NULL, NULL);
if (ret < 0)
{
perror("select");
continue;
}
if (FD_ISSET(0, &read_fds))
{
char buf[1024] = {0};
read(0, buf, sizeof(buf) - 1);
printf("input: %s", buf);
}
else
{
printf("error! invaild fd\n");
continue;
}
FD_ZERO(&read_fds);
FD_SET(0, &read_fds);
}
return 0;
}
说明:
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
// pollfd 结构
struct pollfd
{
int fd; /* file descriptor */
short events; /* requested events */
short revents; /* returned events */
};
同 select
不同于 select 使用三个位图来表示三个 fdset 的方式,poll 使用一个 pollfd 的指针实现.
poll 中监听的文件描述符数目增多时
#include <poll.h>
#include <unistd.h>
#include <stdio.h>
int main()
{
struct pollfd poll_fd;
poll_fd.fd = 0;
poll_fd.events = POLLIN;
for (;;)
{
int ret = poll(&poll_fd, 1, 1000);
if (ret < 0)
{
perror("poll");
continue;
}
if (ret == 0)
{
printf("poll timeout\n");
continue;
}
if (poll_fd.revents == POLLIN)
{
char buf[1024] = {0};
read(0, buf, sizeof(buf) - 1);
printf("stdin:%s", buf);
}
}
}
按照 man 手册的说法: 是为处理大批量句柄而作了改进的 poll.
它是在 2.5.44 内核中被引进的(epoll(4) is a new API introduced in Linux kernel 2.5.44)
它几乎具备了之前所说的一切优点,被公认为 Linux2.6 下性能最好的多路 I/O 就绪通知方法
epoll 有 3 个相关的系统调用
int epoll_create(int size);
创建一个 epoll 的句柄
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epoll 的事件注册函数
events 可以是以下几个宏的集合:
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
收集在 epoll 监控的事件中已经发送的事件
struct eventpoll
{
....
/*红黑树的根节点,这颗树中存储着所有添加到 epoll 中的需要监控的事件*/
struct rb_root rbr;
/*双链表中则存放着将要通过 epoll_wait 返回给用户的满足条件的事件*/
struct list_head rdlist;
....
};
struct epitem
{
struct rb_node rbn; // 红黑树节点
struct list_head rdllink; // 双向链表节点
struct epoll_filefd ffd; // 事件句柄信息
struct eventpoll *ep; // 指向其所属的 eventpoll 对象
struct epoll_event event; // 期待发生的事件类型
}
总结一下,epoll 的使用过程就是三部曲:
网上有些资料说,epoll 中使用了内存映射机制
这种说法是不准确的,我们定义的 struct epoll_event 是我们在用户空间中分配好的内存,势必还是需要将内核的数据拷贝到这个用户空间的内存中的
epoll 有 2 种工作方式-水平触发(LT)和边缘触发(ET)
假如有这样一个例子:
epoll 默认状态下就是 LT 工作模式
如果我们在第 1 步将 socket 添加到 epoll 描述符的时候使用了 EPOLLET 标志,epoll 进入 ET 工作模式
select 和 poll 其实也是工作在 LT 模式下,epoll 既可以支持 LT,也可以支持 ET
LT 是 epoll 的默认行为
使用 ET 能够减少 epoll 触发的次数,但是代价就是强逼着程序猿一次响应就绪过程中就把所有的数据都处理完
相当于一个文件描述符就绪之后,不会反复被提示就绪,看起来就比 LT 更高效一些,但是在 LT 情况下如果也能做到每次就绪的文件描述符都立刻处理,不让这个就绪被重复提示的话,其实性能也是一样的
另一方面,ET 的代码复杂程度更高了
使用 ET 模式的 epoll,需要将文件描述设置为非阻塞,这个不是接口上的要求,而是 "工程实践" 上的要求
假设这样的场景:服务器接收到一个 10k 的请求,会向客户端返回一个应答数据,如果客户端收不到应答,不会发送第二个 10k 请求
如果服务端写的代码是阻塞式的 read,并且一次只 read 1k 数据的话(read 不能保证一次就把所有的数据都读出来,参考 man 手册的说明,可能被信号打断),剩下的 9k 数据就会待在缓冲区中
此时由于 epoll 是 ET 模式,并不会认为文件描述符读就绪,epoll_wait 就不会再次返回,剩下的 9k 数据会一直在缓冲区中,直到下一次客户端再给服务器写数据,epoll_wait 才能返回
但是问题来了
所以,为了解决上述问题(阻塞 read 不一定能一下把完整的请求读完),于是就可以使用非阻塞轮训的方式来读缓冲区,保证一定能把完整的请求都读出来
如果是 LT 没这个问题,只要缓冲区中的数据没读完,就能够让 epoll_wait 返回文件描述符读就绪
epoll 的高性能是有一定的特定场景的,如果场景选择的不适宜,epoll 的性能可能适得其反
例如,典型的一个需要处理上万个客户端的服务器,例如各种互联网 APP 的入口服务器,这样的服务器就很适合 epoll
如果只是系统内部,服务器和服务器之间进行通信,只有少数的几个连接,这种情况下用 epoll 就并不合适,具体要根据需求和场景特点来决定使用哪种 IO 模型
将epoll和socket转化成“一切皆连接”来处理