从recvfrom看BIO和NIO的区别

转眼又是新的一年,时间过得真是太快了,今天继续来聊聊网络 IO 模型的问题。网络 IO 模型是一个比较抽象的概念,你可以很容易地找到很多介绍网络 IO 模型的文章,也有很多介绍网络 IO 系统调用实现的文章。但是前者往往仅限于抽象的概念介绍,缺少关键的细节。后者则往往介绍过多的细节,让人难以把握住重点。本文将会对比 BIO 和 NIO 两种网络 IO 模型的原理和优缺点,然后通过 recvfrom 系统调用介绍两种 IO 模型在使用上的区别,让你不再被各种高级的类库封装所迷惑。为了防止太多细节干扰你的理解,我将仅挑选关键的代码进行介绍。此外,我会在文章末尾贴出完整的代码,以便你进行测试和验证。

recvfrom 是 linux 下的一个系统调用,用于从 socket 接收网络传输的数据,它支持 BIO 和 NIO 两种模式。需要注意的是,本文中说的 NIO 是 Nonblocking IO,即非阻塞 IO,它是相对于 BIO 阻塞 IO 而言。java 中的 NIO(New IO)是指的新的 IO 模型 api,它是基于 select、poll 等系统调用实现的 IO 多路复用 IO,请不要将它们搞混。

重温BIO和NIO

我们知道,网络 IO 的速度远远低于 cpu 的运行速度,在发起 IO 请求之后,如果让 cpu 停下来等待 IO 数据准备好,那将会严重浪费 cpu 的计算资源。

一种解决方案是:如果 IO 数据还没准备好,那么就直接返回一个错误码。当前进程发现数据未准备好,就先去执行其他的计算任务,等过段时间再来查询,直到数据准备好并返回,这就是 NIO 的做法。这样做的好处是提高了 cpu 的利用率,不至于占用了 cpu 却又让 cpu 无事可做。它的缺点是需要多次轮询,以查看数据是否准备好。每次轮询都需要从用户态切换到内核态,然后再从内核态切换回用户态,频繁的上下文切换也是对资源的一种浪费。如果降低轮询的频次,那么就会增大 IO 延迟,即数据已经到达,但是还未到下次轮询时间,这期间的时间越长则延迟越大,甚至还可能导致缓冲区写满。

另一种解决方案是:既然数据还未准备好,那么就先把当前进程挂起,待数据准备好后再唤醒当前进程,然后将数据复制到用户空间,完成一次 IO 操作,这就是 BIO。和 NIO 相比,BIO 避免了多次轮询的开销,同时由于唤醒等待的进程是基于事件驱动的,IO 延迟也相对更小。

接下来以 recvfrom 系统调用为例,分析两种 IO 模型的交互过程。

BIO 模型的交互过程

用户进程通过未设置 flag 的 recvfrom 系统调用获取 IO 数据,

  1. 用户进程发起 recvfrom 调用后阻塞,等待数据到达
  2. 对方发送数据
  3. 待数据到达后,网卡通过 DMA 机制将数据复制到 socket 对应的缓冲区
  4. 内核唤醒该 socket 上所有等待读数据的进程
  5. 被唤醒的进程继续在内核态执行 recvfrom 系统调用,完成将数据复制到用户空间的操作
  6. 系统调用返回成功提示,用户进程获取到 IO 数据,继续执行
BIO模型

NIO 模型的交互过程

用户进程在发起 recvfrom 系统调用时,需要将 flag 设置为 MSG_DONTWAIT

1
2
3
4
5
6
7
8
MSG_DONTWAIT (since Linux 2.2)
Enables nonblocking operation; if the operation would block, the call fails with
the error EAGAIN or EWOULDBLOCK. This provides similar behavior to setting the
O_NONBLOCK flag (via the fcntl(2) F_SETFL operation), but differs in that MSG_DONT-
WAIT is a per-call option, whereas O_NONBLOCK is a setting on the open file de-
scription (see open(2)), which will affect all threads in the calling process and
as well as other processes that hold file descriptors referring to the same open
file description.
  1. 用户进程发起 recvfrom 调用,因为数据还未准备好,该系统调用直接返回 EWOULDBLOCK
  2. 对方发送数据
  3. 用户进程通过 recvfrom 轮询,在数据准备好前,都是直接返回 EWOULDBLOCK
  4. 待数据到达后,网卡通过 DMA 机制将数据复制到 socket 对应的读缓冲区
  5. 用户进程的下一次 recvfrom 调用时,会在内核态将数据复制到用户空间,然后返回到用户态
  6. 用户进程获取到 IO 数据,继续执行
NIO模型

通过recvfrom观察 BIO 和 NIO

recvfrom 的方法签名如下:

1
2
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
struct sockaddr *src_addr, socklen_t *addrlen);

它的第四个参数为 flags,它有多个可选值,其中 MSG_DONTWAIT 用于指定使用 NONBLOCK 模式,即 NIO。

BIO 模型

使用 recvfrom 构建一个 BIO server 的主要步骤如下:

  1. 创建一个 socket

    1
    int sfd = socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol);
  2. 将 socket 绑定到本地指定的端口

    1
    bind(sfd, rp->ai_addr, rp->ai_addrlen)
  3. 使用 recvfrom 接收数据,注意 flags 需要设置为 0

    1
    2
    ssize_t nread = recvfrom(sfd, buf, BUF_SIZE, 0,
    (struct sockaddr *) &peer_addr, &peer_addr_len);
  4. 执行业务逻辑,然后使用 sendto 向对方发送数据

    1
    2
    3
    sendto(sfd, buf, nread, 0,
    (struct sockaddr *) &peer_addr,
    peer_addr_len)

主要代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// create socket
int sfd = socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol);

// bind
bind(sfd, rp->ai_addr, rp->ai_addrlen);

// receive data, would block
ssize_t nread = recvfrom(sfd, buf, BUF_SIZE, 0,
(struct sockaddr *) &peer_addr, &peer_addr_len);

// get remote name and print log
s = getnameinfo((struct sockaddr *) &peer_addr,
peer_addr_len, host, NI_MAXHOST,
service, NI_MAXSERV, NI_NUMERICSERV);
if (s == 0)
printf("Received %zd bytes from %s:%s\n",
nread, host, service);
else
fprintf(stderr, "getnameinfo: %s\n", gai_strerror(s));

// send msg
sendto(sfd, buf, nread, 0,
(struct sockaddr *) &peer_addr,
peer_addr_len)

当 server 启动后,它会阻塞在 recvfrom

client 通过 connect 连接到 server,然后通过 write 写入数据。

server 在收到数据后便会从 recvfrom 返回,继续执行后续流程。

1
2
3
4
5
# 1.启动后阻塞
root@8d245d47d51a /g/n/bio# ./server 8110
# 2.当收到数据后,继续执行printf打印出对方的连接信息
Received 5 bytes from localhost:41091
# 3.然后调用sendto向对方发送数据

NIO 模型

修改上面的程序,将 recvfrom 设置为非阻塞

1
2
ssize_t nread = recvfrom(sfd, buf, BUF_SIZE, MSG_DONTWAIT,
(struct sockaddr *) &peer_addr, &peer_addr_len);

数据未到达

通过 cgdb 单步调试,查看 recvfrom 是否阻塞,以及其返回结果

单步调试recvfrom

通过 b 70 将断点打在第 70 行

然后执行 r 8110 启动,其中 8110 为程序的命令行参数,在这里的含义是绑定到本地的端口

执行 n 下一步,程序并未阻塞,而是执行到第 72 行

此时查看返回结果 nread = -1,errno = 11

通过client发数据

单步调试recvfrom2

通过 client 发送数据,然后继续执行 server

在 recvfrom 返回后查看结果,nread = 5,errno = 11,说明读到的数据长度为 5

相关代码及命令

编译命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 带调试信息的编译
gcc -g server.c -o server
gcc -g client.c -o client

# 运行
./server localhost 8110
./client 8110 test

# debug server
cgdb ./server
b 70
r localhost 8110

# debug client
cgb ./client
b 77
r 8110 test

server 的代码如下

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
#include <sys/types.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/socket.h>
#include <netdb.h>

#define BUF_SIZE 500

int
main(int argc, char *argv[])
{
struct addrinfo hints;
struct addrinfo *result, *rp;
int sfd, s;
struct sockaddr_storage peer_addr;
socklen_t peer_addr_len;
ssize_t nread;
char buf[BUF_SIZE];

if (argc != 2) {
fprintf(stderr, "Usage: %s port\n", argv[0]);
exit(EXIT_FAILURE);
}

memset(&hints, 0, sizeof(hints));
hints.ai_family = AF_UNSPEC; /* Allow IPv4 or IPv6 */
hints.ai_socktype = SOCK_DGRAM; /* Datagram socket */
hints.ai_flags = AI_PASSIVE; /* For wildcard IP address */
hints.ai_protocol = 0; /* Any protocol */
hints.ai_canonname = NULL;
hints.ai_addr = NULL;
hints.ai_next = NULL;

s = getaddrinfo(NULL, argv[1], &hints, &result);
if (s != 0) {
fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(s));
exit(EXIT_FAILURE);
}

/* getaddrinfo() returns a list of address structures.
Try each address until we successfully bind(2).
If socket(2) (or bind(2)) fails, we (close the socket
and) try the next address. */

for (rp = result; rp != NULL; rp = rp->ai_next) {
sfd = socket(rp->ai_family, rp->ai_socktype,
rp->ai_protocol);
if (sfd == -1)
continue;

if (bind(sfd, rp->ai_addr, rp->ai_addrlen) == 0)
break; /* Success */

close(sfd);
}

freeaddrinfo(result); /* No longer needed */

if (rp == NULL) { /* No address succeeded */
fprintf(stderr, "Could not bind\n");
exit(EXIT_FAILURE);
}

/* Read datagrams and echo them back to sender. */

for (;;) {
peer_addr_len = sizeof(peer_addr);
nread = recvfrom(sfd, buf, BUF_SIZE, 0,
(struct sockaddr *) &peer_addr, &peer_addr_len);
if (nread == -1)
continue; /* Ignore failed request */

char host[NI_MAXHOST], service[NI_MAXSERV];

s = getnameinfo((struct sockaddr *) &peer_addr,
peer_addr_len, host, NI_MAXHOST,
service, NI_MAXSERV, NI_NUMERICSERV);
if (s == 0)
printf("Received %zd bytes from %s:%s\n",
nread, host, service);
else
fprintf(stderr, "getnameinfo: %s\n", gai_strerror(s));

if (sendto(sfd, buf, nread, 0,
(struct sockaddr *) &peer_addr,
peer_addr_len) != nread)
fprintf(stderr, "Error sending response\n");
}
}

client的代码如下:

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
#include <sys/types.h>
#include <sys/socket.h>
#include <netdb.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>

#define BUF_SIZE 500

int
main(int argc, char *argv[])
{
struct addrinfo hints;
struct addrinfo *result, *rp;
int sfd, s;
size_t len;
ssize_t nread;
char buf[BUF_SIZE];

if (argc < 3) {
fprintf(stderr, "Usage: %s host port msg...\n", argv[0]);
exit(EXIT_FAILURE);
}

/* Obtain address(es) matching host/port. */

memset(&hints, 0, sizeof(hints));
hints.ai_family = AF_UNSPEC; /* Allow IPv4 or IPv6 */
hints.ai_socktype = SOCK_DGRAM; /* Datagram socket */
hints.ai_flags = 0;
hints.ai_protocol = 0; /* Any protocol */

s = getaddrinfo(argv[1], argv[2], &hints, &result);
if (s != 0) {
fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(s));
exit(EXIT_FAILURE);
}

/* getaddrinfo() returns a list of address structures.
Try each address until we successfully connect(2).
If socket(2) (or connect(2)) fails, we (close the socket
and) try the next address. */

for (rp = result; rp != NULL; rp = rp->ai_next) {
sfd = socket(rp->ai_family, rp->ai_socktype,
rp->ai_protocol);
if (sfd == -1)
continue;

if (connect(sfd, rp->ai_addr, rp->ai_addrlen) != -1)
break; /* Success */

close(sfd);
}

freeaddrinfo(result); /* No longer needed */

if (rp == NULL) { /* No address succeeded */
fprintf(stderr, "Could not connect\n");
exit(EXIT_FAILURE);
}

/* Send remaining command-line arguments as separate
datagrams, and read responses from server. */

for (int j = 3; j < argc; j++) {
len = strlen(argv[j]) + 1;
/* +1 for terminating null byte */

if (len > BUF_SIZE) {
fprintf(stderr,
"Ignoring long message in argument %d\n", j);
continue;
}

if (write(sfd, argv[j], len) != len) {
fprintf(stderr, "partial/failed write\n");
exit(EXIT_FAILURE);
}

nread = read(sfd, buf, BUF_SIZE);
if (nread == -1) {
perror("read");
exit(EXIT_FAILURE);
}

printf("Received %zd bytes: %s\n", nread, buf);
}

exit(EXIT_SUCCESS);
}