(一) I/O模型
I/O模型基本可以分为以下五类:
I/O模型基本可以分为以下五类:
- 阻塞式I/O
- 非阻塞式I/O
- I/O复用(select和poll)[事件驱动 event driven IO]
- 信号驱动I/O (SIGIO)[该方式较少使用]
- 异步I/O [linux提供了AIO库函数实现异步,但是用的很少。目前很多开源异步IO库,例如libevent、libev、libuv等]
对于一个套接字上的输入(获取数据)操作,通常包含两种情况:
- 第一步通常涉及等待数据从网络中到达。当所等待分组到达时,它被复制到内核中的某个缓冲区[等待数据准备好];
- 第二步是把数据从内核缓冲区复制到应用进程缓冲区[从内核向进程复制数据]。
(二) 阻塞式I/O模型
阻塞I/O是最常用的模型,也是默认的套接字模型。以UDP套接字为例,可以用如下的图示表示:

在上图中,进程调用recvfrom,其系统调用直到数据报达到且被复制到应用进程的缓冲区中或者发生错误才返回。从上图看出,进程在I/O的两个阶段都被阻塞了。
阻塞I/O在简单环境下较适用,如果没有其他事需要同时进行处理,阻塞I/O也不错。但是,如果存在多个连接的情况,则会引发问题。代码片段如下示例:
对于同步IO,都需要进程主动的调用 recvfrom来将数据拷贝到用户内存。而同步 IO则完全不同。它就像是用户进程将整个IO操作交给了内核(kernel)完成,然后内核做完后发信号通知用户进程。
char buf[1024];
int i, n;
while (i_still_want_to_read()) {
for (i=0; i<n_sockets; ++i) {
n = recv(fd[i], buf, sizeof(buf), 0);
if (n==0)
handle_close(fd[i]);
else if (n<0)
handle_error(fd[i], errno);
else
handle_input(fd[i], buf, n);
}
}
即使fd[2]上最先有数据达到,
对fd[0]和fd[1]的读取操作取得一些数据并且完成之前,程序不会试图从fd[2]进行读取。这就引起了累计的时延,影响整体的接收速度。
有时会用多进程或者多线程解决多个连接的问题。就是每个连接拥有独立的进程或者线程,这样一个连接上的I/O阻塞并不会影响其他任务连接的进程或线程。代码片段如下所示:
void child(int fd)
{
char outbuf[MAX_LINE+1];
size_t outbuf_used = 0;
ssize_t result;
while (1) {
result = recv(fd, outbuf,MAX_LINE, 0);
if (result == 0) {
break;
} else if (result == -1) {
perror("read");
break;
}
}
//use outbuf todo something
}
void run(void)
{
//do something init socket
while (1) {
struct sockaddr_storage ss;
socklen_t slen = sizeof(ss);
int fd = accept(listener, (struct sockaddr*)&ss, &slen);
if (fd < 0) {
perror("accept");
} else {
if (fork() == 0) {
child(fd);
exit(0);
}
}
}
}
int main(int c, char **v)
{
run();
return 0;
}
为每个连接创建一个进程或者线程是否可行呢? 如果作为服务器,连接数达到成千上万个,开如此多的进程或者线程并不是明智的选择。如果不采用该方案,那如何应付高并发的成千上万的连接数?在I/O模型中引入了非阻塞套接字。
(三) 非阻塞式I/O
非阻塞I/O主要思想,就是应用进程不需要进入阻塞,而是直接通过recvfrom返回一个错误。可以用如下的图示表示:
前几次的调用,无可用数据,因此内核会立即返回一个EWOULDBLOCK错误。等recvfrom有一个准备好的数据时,它被复制到应用进程缓冲区,在此过程中recvfrom所在线程会被阻塞,知道复制完成才返回。
当一个月与进程像这样对一个非阻塞描述符循环调用recvfrom时,称之为轮询(polling)。该种模式往往会耗费大量CPU,所以极少情况使用。代码片段如下所示:
int i, n;
char buf[1024];
for (i=0; i < n_sockets; ++i)
fcntl(fd[i], F_SETFL, O_NONBLOCK);
while (i_still_want_to_read()) {
for (i=0; i < n_sockets; ++i) {
n = recv(fd[i], buf, sizeof(buf), 0);
if (n == 0) {
handle_close(fd[i]);
} else if (n < 0) {
if (errno == EAGAIN)
; /* The kernel didn't have any data for us to read. */
else
handle_error(fd[i], errno);
} else {
handle_input(fd[i], buf, n);
}
}
}
以上非阻塞调用,一则耗费CPU,二则需要对每个连接描述符进行内核调用,不论连接上是否有数据。自然,我们想“等待这些连接描述符,直到有数据到来才唤醒应用进程,并且应用进程知道是哪个描述符有数据”。对此,广泛采用的较为古老的方式是使用select()。
(四) I/O复用模型
有了I/O复用,我们就可以调用select或者poll,阻塞在这些系统调用之上。用如下的图示表示:
我们阻塞select调用,等待连接套接字变为可读,当select返回套接字可读这一条件时,我们调用recvfrom把所读的数据复制到应用进程缓冲区。
对于只含有单个连接描述符的情况,select( )基本等同于阻塞I/O模型,但是对于连接数情况,该复用模型具有明显的优势。代码片段如下所示:
同步I/O做I/O操作时将进程阻塞。 之前的阻塞I/O、非阻塞I/O、I/O复用、信号驱动I/O 都属于同步I/O(这里定义的I/O操作就是例子中recvfrom这个系统调用)。异步I/O则不一样,进程发起I/O操作后,就直接返回,直到内核发送一个信号,告诉进程说I/O完成。下图给出各种I/O的比较:
while (i_still_want_to_read()) {
int maxfd = -1;
FD_ZERO(&readset);
for (i=0; i < n_sockets; ++i) {
if (fd[i]>maxfd) maxfd = fd[i];
FD_SET(fd[i], &readset);
}
select(maxfd+1, &readset, NULL, NULL, NULL);
for (i=0; i < n_sockets; ++i) {
if (FD_ISSET(fd[i], &readset)) {
n = recv(fd[i], buf, sizeof(buf), 0);
if (n == 0) {
handle_close(fd[i]);
} else if (n < 0) {
if (errno == EAGAIN)
; /* The kernel didn't have any data for us to read. */
else
handle_error(fd[i], errno);
} else {
handle_input(fd[i], buf, n);
}
}
}
}
select模型是否就是最有效的呢?事实并非如此,因为生成和读取select的位数组的时间与用于select最大fd成正比,随着套接字个数的增加,select调用的开销也将会急剧增加。不同的操作系统为select提供了不同的替代版本,包括poll、epoll、evports、/dev/poll等。
(五) 信号驱动式I/模型O
我们也可以用信号,让内核在描述符就绪时发送SIGIO信号通知我们,该模型即为信号驱动式I/O。
(六) 异步I/O模型
异步I/O模型,该模型工作机制是:告知内核启动某个操作,并让在整个操作(包括将数据从内核复制到我们自己的缓冲区)完成后通知我们。信号驱动式I/O是由内核通知我们何时可以启动一个I/O操作,而异步I/O模型是由内核通知我们I/O操作何时完成。
对于同步IO,都需要进程主动的调用 recvfrom来将数据拷贝到用户内存。而同步 IO则完全不同。它就像是用户进程将整个IO操作交给了内核(kernel)完成,然后内核做完后发信号通知用户进程。
参考阅读:
UNIX网络编程卷1:6.2节 I/O模型