Synchronous vs. Asynchronous, Blocking vs. Non-Blocking I/O

背景

在Unix环境下,有5种基本的I/O模型:

  • blocking I/O
  • nonblocking I/O
  • I/O multiplexing (select and poll)
  • signal driven I/O (SIGIO)
  • asynchronous I/O (the POSIX aio_ functions)

对于一个操作I/O的程序来说,首先,程序会由用户态向内核发出请求,由内核态去对相关的硬件进行交互。在数据已经准备完毕后,再由内核拷贝到相应的进程中。这是一个两段执行的过程。

假设我们目前进行的是Network I/O,我们用Socket去进行数据的读取。首先,我们会等待数据达到我们的网络设备,当数据到达以后,它将会被存储在内核的缓存中。然后,这个数据会从内核的缓存中拷贝到程序的缓存中,以供使用。

释义

  • Blocking:在进行阻塞I/O调用(e.g. recv)时,直到该调用获得了数据并返回或者存在error前,当前进程将会被阻塞住,也即被挂起进入睡眠状态,此时进程不能进行其它的操作,并且不会占用CPU资源。
  • Non-Blocking: 在进行非阻塞I/O调用(e.g. set socket to NONBLOCKING)时,每次进行调用都会直接获得一个返回值。若数据可用,则返回相应的数据;若数据不可用,则会返回对应的error。此时进程没有被阻塞或者挂起,它仍然占据着CPU,并且可以进行其他的计算、处理。 同时也可以通过调用原来的非阻塞调用,去检查该操作是否完成。
  • Synchronous:根据POSIX给出的说明,一个同步的I/O调用会使得当前的请求进入阻塞,直到I/O操作完成。个人觉得,此处的同步与阻塞有着一样的含义。同时,同步也含义着对于整个操作的流程,你需要在当下处理当下的请求。
  • Asynchronous: 根据POSIX给出的说明,一个异步的I/O调用不会使得当前的请求进入阻塞,而是通过其它的机制去检测操作是否完成。个人觉得,此处的异步更多表达的是整个进程在这个过程中不会阻塞,而是可以继续的进行其它的计算、处理,然后通过后续机制去获取I/O调用完成的情况和内容,更多描述的是一个操作的流程,异步允许你不用在当下处理当下的请求,而可以后续去试图处理多个请求。而非阻塞更多强调的是当前这个I/O操作是不阻塞的,它不会让你在这里进入等待,更多描述的是当下调用的这个I/O请求。异步更多的是由系统后台或者其它线程完成了工作,然后通过回调或者信号来通知当前线程此前的操作已经完成。

在几种模式的组合下,我们可以对I/O模型有基本的总结(虽然select等I/O multiplexing调用是阻塞的,但是其对应的I/O调用是非阻塞的,因而可以同时管理多个I/O操作,所以将其放在异步阻塞类):

Blocking Non-Blocking
Synchronous send/recv send/recv
(O_NONBLOCK)
Asynchronous I/O
multiplexing
aio

blocking I/O

标准的I/O操作例如send,recv等都是阻塞的。其中,当数据没有准备好的时候,用户态调用是阻塞的,此时进程被挂起,不占用CPU;当数据在内核上准备好时,这个将内存从内核拷贝到用户空间上的过程也是阻塞的,此时进程占用CPU,但是这个内存拷贝速率比较快,可以基本忽略。其流程示意为:

blockingIO

nonblocking I/O

默认的Socket操作都是阻塞的,我们可以通过调用fcntl来设置非阻塞特性。在非阻塞的Socket上调用例如recvfrom的函数时,若数据未准备完成,将会返回-1。可以通过反复调用recvfrom,检测返回值来确认当前操作是否完成。其中,在数据未准备好的时候,调用操作是非阻塞的,此时进程仍然占用CPU在执行相应的处理。但是当数据准备好,要从内核拷贝到进程中的时候,获取数据的这一段也是如上述一样是一个阻塞的过程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <sys/socket.h>
#include <fcntl.h>
...

int main(int argc, char **argv)
{
...

// 1. load up local address info
getaddrinfo(NULL, MYPORT, &hints, &servinfo);

...

// 2. bind to the socket
sockfd = socket(servinfo->ai_family, servinfo->ai_socktype, servinfo->ai_protocol);
fcntl(sockfd, F_SETFL, O_NONBLOCK);

...

// 3. recv
while( (numbytes = recvfrom(sockfd, buf, MAXBUFLEN - 1, 0, (struct sockaddr *)&client_addr, &addr_len)) == -1);

...

return 0;
}

其操作流程示意为:
nonblockingIO

若只是针对单次操作,我们需要不断的去调用检测I/O是否完成,这会占用了相当的CPU资源,如果只是单次I/O调用,这将会非常的低效。但是我们可以通过使用非阻塞I/O调用,结合其它处理流程来解决同步阻塞调用所带来的进程阻塞的影响。

I/O multiplexing

这是一个nonblocking的I/O操作与blocking notification的结合。通过I/O multiplexing,服务端可以在处理旧有I/O请求的同时继续接收新来的请求。

select或者poll函数给予了同时监控多个I/O描述符的能力,它可以告诉你那些I/O描述符是可读,可写或者是异常, 从而服务端可以进而根据对应情况进行后续处理。其中select或者poll函数是阻塞的,当有读、写或者异常文件描述符有反馈时,或者select超时后,这个函数就会有返回。服务端可以进而读取相应的一个或者多个文件描述符去进行相应的处理,而注册在select监控中用来I/O的多个socket操作将不会阻塞进程。

如下是一个简易的用select实现的聊天室应用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
/* 
* selectserver.c - multiperson chat server using select
* usage - telnet localhost 9034
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <netdb.h>

#define PORT "9034" // port we're listening on

// get sockaddr, IPv4 or IPv6:
void *get_in_addr(struct sockaddr *sa)
{
if (sa->sa_family == AF_INET)
{
return &(((struct sockaddr_in *)sa)->sin_addr);
}

return &(((struct sockaddr_in6 *)sa)->sin6_addr);
}

int main(int argc, char **argv)
{
// !!! in this case, we assume all the function call is success and we discard all the error checking codes !!!

fd_set global; // global file descriptors set
fd_set read_fds; // read file descriptors set for select()
int fd_max; // maximum file descriptor number
int server; // server socket fd
int new_fd; // file descriptor for newly connected sockets
struct sockaddr_storage client_addr; // client address
socklen_t addrlen;
char buf[256];
int nbytes;
char client_ip[INET6_ADDRSTRLEN];
struct addrinfo hints, *res;

// 1. load local address info
memset(&hints, 0, sizeof hints);
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
hints.ai_flags = AI_PASSIVE; // flags for local
getaddrinfo(NULL, PORT, &hints, &res);

// 2. bind to socket & listen
server = socket(res->ai_family, res->ai_socktype, res->ai_protocol);
bind(server, res->ai_addr, res->ai_addrlen);
listen(server, 10);
freeaddrinfo(res);

// 3. add server fd to global fd set
FD_ZERO(&global);
FD_ZERO(&read_fds);
FD_SET(server, &global);
fd_max = server;

// 4. use select for message handling
while (1)
{
read_fds = global; // select on all the fds
select(fd_max + 1, &read_fds, NULL, NULL, NULL); // fd_max + 1 to present the count
for (int i = 0; i <= fd_max; ++i)
{
if (FD_ISSET(i, &read_fds)) // got a readable fd
{
if (i == server) // server handling
{
// new connections
addrlen = sizeof client_addr;
new_fd = accept(server, (struct sockaddr *)&client_addr, &addrlen); // new socket fd, discard error handling here

FD_SET(new_fd, &global);
if (new_fd > fd_max)
{
fd_max = new_fd;
}
printf("selectserver: new connection from %s on socket %d\n",
inet_ntop(client_addr.ss_family,
get_in_addr((struct sockaddr *)&client_addr),
client_ip, INET6_ADDRSTRLEN),
new_fd);
}
else // client message handling
{
nbytes = recv(i, buf, sizeof buf, 0);

if (0 == nbytes) // closed connection
{
printf("selectserver: socket %d hung up\n", i);
// close fd locally
close(i);
FD_CLR(i, &global);
}
else // got a new message
{
// send to every one
for (int j = 0; j <= fd_max; ++j)
{
if (FD_ISSET(j, &global) && j != server && j != i) // this client socket is connected to the server
{
send(j, buf, nbytes, 0);
}
}
}
}
}
}
}
return 0;
}

其操作流程示意为:
iomultiplexing

signal driven I/O

使用I/O multiplexing,个人觉得更像是一种“主动去检查文件描述符是否可用,有可用的就调用阻塞操作去拷贝数据到当前进程”的方式,而使用signal driven I/O则不是一种主动检查,而是使用信号,由内核向我们发送信号来告诉我们它准备好了,然后我们再对数据进行阻塞操作去拷贝。这个操作可以通过向sigaction系统调用注册一个信号处理函数来实现。当I/O准备完成,相应的信号会发送过来,然后我们的主循环就可以进行相应的读、写操作。这也是一种非阻塞的调用方式。

其操作流程示意为:
sigdriven

asynchronous I/O

更近一步,我们有了AIO的操作。不像之前的I/O multiplexing 或者 Signal Driven I/O,这回我们只需要发出调用,然后系统会在整体数据准备完成并且拷贝到当前进程空间后通知我们。整个过程中,不需要第二段的阻塞调用拷贝的过程,而是一个整体异步的过程。整个过程中我们只关心的是:向系统发出调用,然后系统通知我们的时候我们就可以直接使用相应的数据了。

其操作流程示意为:
aio

Summary

summary