之前写过 Socket编程(三)——多进程、Socket编程(四)——IO复用和更多IO函数、Socket编程(五)——多线程这几篇与Socket编程有关的博客,然而当时是按照理解难度由浅到深编写的,总的来说还是不够系统。本篇博客就系统性地介绍一下Socket编程中的5种IO模型,并介绍如何使用这些模型实现高并发。它们分别为:
- 阻塞IO模型;
- 非阻塞IO模型;
- 多路复用IO模型;
- 信号驱动IO模型;
- 异步IO模型;

阻塞IO模型
阻塞IO,顾名思义,在读写操作时,当且进程会陷入阻塞,直到相关的资源就绪。以 read
为例,当缓冲区中没有数据时,进程会阻塞直至有新的数据到来。

在单个进程(单个线程)中,阻塞式的IO模型是非常底效的,因为当前进程只能服务一个请求,在服务期间其他请求都会被搁置。想要在阻塞IO模型下实现高并发,可以使用多进程、多线程的方式来完成。然而,使用多进程和多线程模型都存在一定的缺陷:
- 多进程意味着需要为每一个服务对象提供一个进程,这会给服务器带来巨大的进程切换开销;
- 虽然多线程的系统开销比多进程小,但是线程的创建和销毁同样会带来系统开销。此时可能会考虑使用线程池来复用已有的连接,减少创建和关闭连接的频率。
非阻塞IO
非阻塞IO和阻塞IO是对应的。还是以系统调用 read
为例,在进程中调用 read
后,当缓冲区中没有数据时,会立即返回一个 EWOULDBLOCK
或 EAGAIN
错误,表示缓冲区中没有数据。此时进程就不会阻塞,从而可以执行其他计算。通常来说,非阻塞IO会与 while
循环共同使用,重复检查资源是否就绪。

IO复用模型
严格来说,IO复用模型可以是阻塞的,也可以是非阻塞的。
IO复用算是解决高并发问题的一个非常有效的手段了。在Unix编程中,实现IO复用的系统调用主要是 select
和 epoll
,前者可以理解为使用一个数组或集合来实现,后者可以理解为使用一个链表+红黑树实现,这里主要介绍更高效的 epoll
。
前面说过IO复用模型可以是阻塞的,也可以是非阻塞的。“可以是阻塞的”主要是体现在对IO端口的监听上,但是这并不会影响 epoll
的效率,因为它可以同时对多个端口进行监听。
下面是一段 epoll
使用的示例代码:
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
| #include <sys/epoll.h> #include <stdio.h> #include <unistd.h> #include <stdlib.h> #include <string.h> #include <fcntl.h>
#define MAX_EVENTS 10
int main() { int sock = socket(AF_INET, SOCK_STREAM, 0);
int epoll_fd = epoll_create1(0); if (epoll_fd < 0) { perror("epoll_create1"); exit(1); }
struct epoll_event event; event.events = EPOLLIN; event.data.fd = sock; epoll_ctl(epoll_fd, EPOLL_CTL_ADD, sock, &event);
struct epoll_event events[MAX_EVENTS];
while (1) { int n_ready = epoll_wait(epoll_fd, events, MAX_EVENTS, -1); if (n_ready < 0) { perror("epoll_wait"); exit(1); }
for (int i = 0; i < n_ready; i++) { int fd = events[i].data.fd; if (fd == sock) { int client_fd = accept(sock, NULL, NULL); fcntl(client_fd, F_SETFL, O_NONBLOCK);
event.events = EPOLLIN | EPOLLET; event.data.fd = client_fd; epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &event); } else { char buffer[1024]; while (1) { ssize_t n = read(fd, buffer, sizeof(buffer)); if (n > 0) { write(fd, buffer, n); } else if (n == 0 || (n < 0 && errno != EAGAIN)) { close(fd); epoll_ctl(epoll_fd, EPOLL_CTL_DEL, fd, NULL); break; } else { break; } } } } } return 0; }
|
每次重新看 epoll
时都会非常地迷惑,搞不清楚这个系统调用到底在做什么,这里还是举一个之前博客中的例子,也是《TCP/IP》编程中的例子
把代码中的 events
看班级,我们的服务端socket是班主任,客户端socket是同学。但是同学想要被服务,需要先加入我们这个班级,所以班级里既有班主任也有学生。这就是为什么遍历处理所有就绪事件时,需要判断到底是服务端的sock还是客户端的sock的原因。
当班主任触发事件时,说明有新同学要进来,我们就把新同学加入到 events
中;
当班里同学触发事件时,说明班里的同学要提问题了,就由我们来解决问题并答复。
这样解释,epoll
存在的意义似乎就说得通了。
触发模式
需要注意,epoll
还支持设置触发模式,包含边缘触发和水平触发。具体可以看 IO复用 这篇博客。
信号驱动IO
信号IO理解起来也很简单,就是提前告诉操作系统,当某个事件触发时,向进程发送 SIGIO
信号。进程需要注册这个信号的处理函数,触发异步处理。信号驱动IO的一个很大缺点就是,只能得知有事件发生,但无法得知是哪个事件。所以对于TCP这种触发事件较多的协议,信号驱动IO很难派的上用场。相反,信号驱动IO更多用于UDP套接字。
下面的一段信号驱动IO的实例代码:
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
| #include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <signal.h> #include <fcntl.h> #include <sys/socket.h> #include <arpa/inet.h> #include <errno.h> #include <string.h>
int sock;
void sigio_handler(int signo) { struct sockaddr_in client_addr; socklen_t addr_len = sizeof(client_addr); char buffer[1024];
ssize_t n = recvfrom(sock, buffer, sizeof(buffer), 0, (struct sockaddr*)&client_addr, &addr_len); if (n > 0) { printf("Received %zd bytes from %s:%d\n", n, inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port)); sendto(sock, buffer, n, 0, (struct sockaddr*)&client_addr, addr_len); } else if (n < 0 && errno != EAGAIN) { perror("recvfrom error"); } }
int main() { sock = socket(AF_INET, SOCK_DGRAM, 0); if (sock < 0) { perror("socket error"); exit(1); }
struct sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_ANY); addr.sin_port = htons(8080); if (bind(sock, (struct sockaddr*)&addr, sizeof(addr)) < 0) { perror("bind error"); exit(1); }
signal(SIGIO, sigio_handler);
int flags = fcntl(sock, F_GETFL, 0); fcntl(sock, F_SETFL, flags | O_NONBLOCK | O_ASYNC);
fcntl(sock, F_SETOWN, getpid());
printf("Waiting for data...\n"); while (1) { sleep(1); }
close(sock); return 0; }
|
信号驱动式IO的并发性很差,并且编程复杂性高,但是实时性很强,比较适合低频率的实时控制。
异步IO
异步IO是一种并发性更高的IO模型,其原理主要是:应用程序发起IO操作后立即返回,内核在整个IO操作完成后通知应用,无须应用主动读写。遗憾的是Linux原生的异步IO机制io_uring较为复杂,学习曲线比较陡峭。现代开发者对异步IO的实现,更多依赖的事语言和框架的异步抽象(如Python、Go、Csharp等)。
总结

现代服务端对高并发的要求越来越高,为满足这样的需求,使用更多的还是IO复用模型。当然,具体使用哪个IO模型,还是需要结合具体的应用场景。
参考链接
网络编程 — Socket编程与IO模型
《TCP/IP编程》尹圣雨