似乎最近说了比较多的IO,未来也可能有几篇文章与IO也有所关联,为了便于大家理解,我决定先草拟一篇介绍各种IO的文章,免得大家看到诸如阻塞式IO、非阻塞式IO、同步IO、异步IO、IO复用等概念后理不清思路。
请注意,本文仅涉及各类IO的概念以及简单的原理介绍,旨在帮助大家更好地区分和理解,具体如何使用这些IO不属于本文的内容。同时本文也是我个人对于各类IO学习的记录,有所疏漏是难以避免的,如果大家发现了问题,还请让我知道,我会第一时间修正。希望本文对大家有用所价值!
0x00 概要与背景
本文的背景是Linux网络IO,当然绝大多数内容也适合UNIX-like系统。
在《UNIX网路编程 卷一》一书中,介绍了如下几种IO模型:
- 阻塞式IO
- 非阻塞式IO
- IO复用
- 信号驱动IO
- 异步IO
Linux系统分为用户空间和内核空间,绝大多数情况下,我们所说的system programming是在用户空间中完成的,使用系统调用(system call),获取内核服务,完成一些底层的操作。
因此对于上述IO而言,基本都分为两个主要阶段:
- 用户空间通过系统调用通知内核准备数据 -> 等待数据
- 从内核空间复制数据到用户空间 -> 拷贝数据
由于拷贝数据实在内存中进行的,因此速度较快,而等待数据则是两个阶段中最消耗时间的。
0x01 阻塞式IO模型
阻塞式IO是一种很干脆的做法,它也是这几种IO中最容易理解的。虽然等待数据很费时间,但我们不急啊,那就等着它完成呗。
原理图:
原理图中所描述的也和我们的预期一样,通过一个系统调用完成了等待数据与拷贝数据两个阶段的任务。
简单代码示例:
char* buf = malloc(256);
int ret = read(fd, buf, 256);
if(ret == -1) {
perror();
exit(-1);
}
由于阻塞式IO会在系统调用上阻塞,如果没有开启多条线程的话,那么整个进程就会处于阻塞状态,无法继续别的任务,因此单线程/单进程阻塞式IO不适合开发网络服务类应用。好在我们可以通过为每个connection开启一个线程/进程来解决,只是资源利用率并不是那么高,著名的Apache httpd就是这种模型,这类模型适用于开发计算密集型应用。
0x02 非阻塞式IO模型
通过给fd设置O_NONBLOCK,我们可以开启非阻塞式IO的支持。
原理图:
从原理图我们可以看到,非阻塞IO模型的拷贝数据阶段和阻塞式IO一致,只不过等待数据阶段不同,系统调用并不会长时间等待,如果当前缓冲区并未处于“读就绪”或“写就绪”状态,该系统调用就会返回EAGAIN或EWOULDBLOCK,这两个errno的值在linux中都是11。
非阻塞IO模型使得程序员能有机会规避等待,利用这段时间执行别的任务。只不过我们依旧无法保证当其他任务执行完毕时数据是否已经准备完毕。如果尚未准备就绪,则依旧会返回EAGAIN,否则执行拷贝数据阶段的任务。
简单代码示例:
// 开启fd的非阻塞模式
flag = fcntl(0, F_GETFL, 0);
fcntl(0, F_SETFL, flag | O_NONBLOCK);
// 此处应该添加错误处理
char* buf = malloc(256);
while(1) {
int ret = read(fd, buf, 256);
if(ret == -1) {
if(errno == EAGAIN) {
// 执行其他任务
continue;
} else {
perror();
exit(-1);
}
}
break;
}
可以看到,非阻塞IO模型的代码会更加麻烦,但是它在一定程度上提高了程序的执行效率。处理过程中,我们对于错误的处理需要更加细化,因为严格来说EAGAIN/EWOULDBLOCK并不算错误。由于EAGAIN打断函数的执行,有些情况下我们可能不得不保存点执行状态,以便能够继续进入执行,此处的问题可以通过协程来解决,关于协程,大家可以参考下D语言的协程一文。
0x03 IO复用模型
阻塞式IO和非阻塞式IO模型都是针对单个fd的,大家可以想象下如何仅仅依靠上述内容来写个服务器软件,总不能放在一个数组内遍历一遍吧?当然不行,我们得有个方法来管理大量fd的数据等待与数据拷贝。
IO复用就是用来完成这个任务的,当然能使用的绝对不只是select,还有poll,epoll(Linux),kqueue(FreeBSD)等。
原理图:
相比于阻塞式IO,IO复用采用了两个系统调用,因此如果仅仅是管理单个fd,IO复用根本没有任何优势,因为它也需要在等待数据阶段阻塞,当然应该也没有人会这么做,太任性了!当管理大量fd的时候,它的优势就出现了,同样是一段时间的等待,由于fd数量大,平均等待时间就下来了(可以这么粗略地理解一下)。
简单代码示例:
/* SELECT示例 */
fd_set rset;
FD_ZERO(&rset);
// 设置监控的fd
FD_SET(fd, &rset);
// 开始等待
while(1) {
select(maxfd, &rset, NULL, NULL, NULL);
// 筛选数据就绪的fd。。。
if(FD_ISSET(fd, &rset)) {
// 对fd做读写处理
}
}
/* POLL示例 */
struct pollfd client[maxfd];
client[0].fd = fd;
client[0]/events = POLLIN;
while(1) {
nready = poll(client, maxfd + 1, INFTIM);
for(i = 0; i < nready; ++i) {
if(client[i].revents & POLLIN) {
// 读取数据
}
}
}
/* EPOLL示例 */
epfd = epoll_init1(0);
event.events = EPOLLET | EPOLLIN;
event.data.fd = serverfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, serverfd, &event);
count = epoll_wait(epfd, &events, MAXEVENTS, timeout);
for(i = 0; i < count; ++i) {
if(events[i].events & EPOLLERR || events[i].events & EPOLLHUP)
// 处理错误
if(events[i].data.fd == serverfd)
// 为接入的连接注册事件
else if(events[i].events & EPOLLIN)
// 处理可读的缓冲区
read(events[i].data.fd, buf, len);
event.events = EPOLLET | EPOLLOUT;
event.data.fd = events[i].data.fd;
epoll_ctl(epfd, EPOLL_CTL_MOD, events[i].data.fd, &event);
else
// 处理可写的缓冲区
write(events[i].data.fd, buf, len);
// 后续可以关闭fd或者MOD至EPOLLOUT
}
}
事先声明,上述只是简单示例,大家可千万别当模板直接拿过来用。。。上述代码中没有kqueue的示例,据说它比epoll更简洁,无奈我还没用到,等今后有机会再添加吧。
简单分析下代码,它们都会阻塞,这是肯定的,但是它们也能设置timeout,这么一来使用方式就很灵活了。三者之中,select和poll都是线性的,我们需要自己检查fd的状态,因此当fd数量大了,大家应该不难想象会出现什么后果的。
反观epoll,它只返回满足条件的fd,因此我们可以放心地直接读写,并且当fd数量增大后,性能不会有明显衰减,但用起来依旧有点小麻烦,更多关于epoll的细节,可以参考Linux epoll详解一文。
0x04 信号驱动IO模型
信号驱动IO模型用的比较少,这是有原因的,接下来会解释。
原理图:
从原理图来看,它似乎和异步IO有点像,只不过异步IO更加干脆,它把数据复制也完成了,而信号驱动IO则不然,依旧需要我们自己使用系统调用来完成。相比于之前的几种IO,这种模型能够避免等待数据阶段的阻塞。另外,有一点需要注意,信号驱动IO模型是在SIGIO信号处理函数中完成数据的拷贝的。
至于为何说它用的较少,是这样的,此模型基本不适用TCP应用,因为TCP能够产生SIGIO信号的位置多达7处(来自书中原文):
- 监听套接字上某个链接请求已经完成
- 某个断连请求已经发起
- 某个断连请求已经完成
- 某个断连请求已经关闭
- 数据到达套接字
- 数据已经从套接字发送
- 发生某个异步错误
你能分清吗?但是它用在UDP中就很完美了,因为UDP中只有2处会产生SIGIO信号(依旧是来自书中):
- 数据报到达套接字
- 套接字上发生异步错误
坦白说,我没用过这个模型,因为很少用UDP。不过还是贴一下示例,这个是我从资料中总结的,仅供参考。
简单代码示例:
// 处理SIGIO和SIGHUP信号的函数
static void sig_io(int signo) {
// 处理报文数据读取
++nq;
}
static void sig_hup(int signo) {
// 可以实现一些诊断任务
}
// 注册信号处理函数
signal(SIGHUP, sig_hup);
signal(SIGIO, sig_io);
fcntl(sockfd, F_SETOWN, getpid());
int on = 1;
// 信号驱动IO的指定参数
ioctl(sockfd, FIOASYNC, &on);
// 设置为非阻塞
ioctl(sockfd, FIONBIO, &on);
sigset_t zeromask, newmask, oldmask;
// 初始化信号集
sigemptyset(&zeromask);
sigemptyset(&oldmask);
sigemptyset(&newmask);
// 设置我们希望阻塞的信号
sigaddset(&newmask, SIGIO);
sigprocmask(SIG_BLOCK, &newmask, &oldmask);
while(1) {
// 如果没有收到数据,则阻塞进程
while(nq == 0) sigsuspend(&zeromask);
// 解除SIGIO的阻塞
sigprocmask(SIG_SETMASK, &oldmask, NULL);
// 发送数据
// 阻塞SIGIO,使nq可以安全读写
sigprocmask(SIG_BLOCK, &newmask, &oldmask);
--nq;
}
这里选取的例子是书中的一个echo服务器,比较简单,但是代码没给全,这里贴出来只是为了提醒大家,当主进程和信号处理函数存在共享数据时,需要注意阻塞SIGIO信号,以免出现莫名其妙的错误。
0x05 异步IO模型
异步IO是我比较看好的东西,但是Linux下似乎只用它来处理磁盘IO,并且为何不把它和epoll统一,这一点也有点令人摸不着头脑。。。
原理图:
可以看到,异步IO模型,通过一个系统调用告知内核应该做什么,然后返回,你可以继续做别的事情,等到内核操作完成的时候,它会用aio_read中指定的信号或者回调函数来通知我们。
目前Linux下有2种aio的实现,分别是:
- POSIX AIO:头文件为aio.h,编译时需要使用librt(-lrt)
- libaio:头文件为libaio.h,编译时需要使用libaio(-laio)
虽然都叫aio,但是两者有本质上的不同:
- POSIX AIO是一个用户空间的aio实现,通过使用多线程和阻塞式IO(当然还有buffer),它对于各种文件系统的兼容性更好,并且不需要强制设置O_DIRECT。
- libaio是内核aio的实现,io请求存储于内核中的一个队列中,然后根据不同的磁盘调度来响应请求,它对各种文件系统的兼容性不如前者,并且需要为fd设置O_DIRECT。
由于存在两种aio实现,并且内部细节比较多,这里就不贴示例了,接下来应该会另写一篇文章来专门描述Linux下的AIO,敬请期待!
好文
总结的很赞。
good