并发 IO 模型
先决条件:需具备基本的操作系统概念,如线程、进程、并发等。
5.1 基于线程的并发
面向连接的请求-响应协议可用于任意数量的请求-响应交互对,并且客户端可以根据自身需求长时间保持连接。因此,有必要同时处理多个连接,因为当服务器在等待某个客户端时,它无法处理其他客户端的请求。这可以通过多线程来解决。伪代码如下:
fd = socket()
bind(fd, address)
listen(fd)
while True:
conn_fd = accept(fd)
new_thread(do_something_with, conn_fd)
# 继续接受下一个客户端连接,而不会阻塞
def do_something_with(conn_fd):
while not_quiting(conn_fd):
req = read_request(conn_fd) # 阻塞线程
res = process(req)
write_response(conn_fd, res) # 阻塞线程
close(conn_fd)
为什么线程不够用?
我们不会过多关注线程相关内容,因为大多数现代服务器应用程序使用事件循环来处理并发IO,而无需创建新线程。基于线程的IO有哪些缺点呢?
- • 内存使用:大量线程意味着大量的栈空间。栈用于存储局部变量和函数调用,每个线程的内存使用情况难以控制。
- • 开销:像PHP应用程序这样的无状态客户端会创建许多短生命周期的连接,这会增加延迟和CPU使用的开销。
创建新进程的方式比多线程出现得更早,而且成本更高,它与多线程处于同一范畴。当不需要扩展到大量连接时,多线程和多进程仍然会被使用,并且它们相较于事件循环有一个很大的优势:它们更简单且不易出错。
5.2 基于事件的并发
即使不使用线程,并发IO也是可行的。让我们从研究read()
系统调用开始。Linux TCP栈会透明地处理IP数据包的发送和接收,将接收到的数据放入每个套接字对应的内核缓冲区中。read()
仅仅是将数据从内核缓冲区复制出来,当缓冲区为空时,read()
会暂停调用线程,直到有更多数据准备好。
类似地,write()
并不直接与网络交互,它只是将数据放入内核缓冲区,供TCP栈使用,当缓冲区满时,write()
会暂停调用线程,直到有可用空间。
之所以需要多线程,是因为需要等待每个套接字准备好(进行读取或写入操作)。如果有一种方法可以同时等待多个套接字,然后对准备好的套接字进行读写操作,那么只需要一个线程就够了!
while running:
want_read = [...] # 套接字文件描述符
want_write = [...] # 套接字文件描述符
can_read, can_write = wait_for_readiness(want_read, want_write) # 阻塞!
for fd in can_read:
data = read_nb(fd) # 非阻塞,仅从缓冲区中读取数据
handle_data(fd, data) # 无IO操作的应用逻辑
for fd in can_write:
data = pending_data(fd) # 由应用程序生成的数据
n = write_nb(fd, data) # 非阻塞,仅将数据追加到缓冲区
data_written(fd, n) # n <= len(data),受可用空间限制
这涉及到三种操作系统机制:
- • 就绪通知:等待多个套接字,当一个或多个套接字准备好时返回。“准备好” 意味着读缓冲区不为空或写缓冲区不满。
- • 非阻塞读取:假设读缓冲区不为空,从其中读取数据。
- • 非阻塞写入:假设写缓冲区不满,将一些数据放入其中。
这被称为事件循环。每次循环迭代都会等待任何就绪事件,然后在不阻塞的情况下对事件做出反应,从而可以无延迟地处理所有套接字。
基于回调的编程
回调在JavaScript中很常见。在JavaScript中要从某个源读取数据,首先在某个事件上注册一个回调函数,然后数据会被传递到该回调函数中。这就是我们接下来要做的事情。只不过在JavaScript中,事件循环是隐藏的,而在这个项目中,事件循环是由我们自己编写的。我们将对这个重要机制有更深入的理解。
5.3 非阻塞IO
非阻塞读写行为
如果读缓冲区不为空,阻塞和非阻塞读取操作都会立即返回数据。否则,非阻塞读取操作会返回并将 errno
设置为 EAGAIN
,而阻塞读取操作会等待更多数据。可以多次调用非阻塞读取操作来完全清空读缓冲区。
如果写缓冲区不满,阻塞和非阻塞写入操作都会填充写缓冲区并立即返回。否则,非阻塞写入操作会返回并将 errno
设置为 EAGAIN
,而阻塞写入操作会等待更多空间。可以多次调用非阻塞写入操作来完全填满写缓冲区。如果数据量大于可用的写缓冲区大小,非阻塞写入操作会进行部分写入,而阻塞写入操作可能会阻塞。
非阻塞 accept()
accept()
与 read()
类似,它只是从队列中取出一个项目,所以它也有非阻塞模式,并且可以提供就绪通知。
for fd in can_read:
if fd is a listening socket:
conn_fd = accept_nb(fd) # 非阻塞accept()
handle_new_conn(conn_fd)
else: # fd是一个连接套接字
data = read_nb(fd) # 非阻塞read()
handle_data(fd, data)
启用非阻塞模式
非阻塞读写操作与阻塞读写操作使用相同的系统调用。O_NONBLOCK
标志可以将套接字设置为非阻塞模式。
static void fd_set_nonblock(int fd) {
int flags = fcntl(fd, F_GETFL, 0); // 获取标志
flags |= O_NONBLOCK; // 修改标志
fcntl(fd, F_SETFL, flags); // 设置标志
// 待办事项:处理errno
}
fcntl()
系统调用用于获取和设置文件标志。对于套接字,仅接受 O_NONBLOCK
标志。
5.4 就绪API
等待IO就绪是与平台相关的,在Linux上有几种实现方式。
can_read, can_write = wait_for_readiness(want_read, want_write)
在Linux上,最简单的是 poll()
函数。
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
struct pollfd {
int fd;
short events; // 请求:想要读取、写入,还是两者都要?
short revents; // 返回:可以读取吗?可以写入吗?
};
poll()
接受一个文件描述符数组,每个描述符都有一个输入标志和一个输出标志:
- •
events
标志表示你是想要读取(POLLIN
)、写入(POLLOUT
),还是两者都要(POLLIN|POLLOUT
)。 - • 系统调用返回的
revents
标志表示就绪状态。 - •
timeout
参数稍后用于实现定时器。
其他就绪API
- •
select()
类似于poll()
,在Windows和Unix系统上都存在,但它只能使用1024个文件描述符,这是一个非常小的数量,不应该使用它! - •
epoll_wait()
是Linux特有的。与poll()
不同,文件描述符列表不是作为参数传递的,而是存储在内核中。epoll_ctl()
用于添加或修改文件描述符列表。它比poll()
更具扩展性,因为传递大量文件描述符是低效的。 - •
kqueue()
是BSD特有的。它类似于epoll
,但由于它可以批量更新文件描述符列表,所以需要的系统调用更少。
我们将使用 poll()
,因为它是最简单的。但请注意,在Linux上,epoll
是默认的选择,因为它更具扩展性,在实际项目中应该使用它。所有的就绪API只是形式上有所不同,使用它们的方式并没有太大差异。
就绪API不能用于文件
所有的就绪API只能用于套接字、管道,以及一些特殊的东西,如 signalfd
。它们不能用于磁盘文件!为什么呢?因为当一个套接字准备好读取时,意味着数据已经在读取缓冲区中,所以读取操作保证不会阻塞,但是对于磁盘文件,内核中不存在这样的缓冲区,所以磁盘文件的就绪状态是未定义的。
这些API总是会报告磁盘文件为就绪状态,但IO操作会阻塞。所以文件IO必须在事件循环之外,在线程池中进行,我们稍后会学习这部分内容。
在Linux上,使用 io_uring
可能可以在事件循环中进行文件IO,io_uring
是一个用于文件IO和套接字IO的统一接口。但是 io_uring
是一个非常不同的API,所以我们不会深入研究它。
5.5 并发IO技术总结
类型 | 方法 | API | 可扩展性 |
套接字 | 每个连接一个线程 | pthread |
低 |
套接字 | 每个连接一个进程 | fork() |
低 |
套接字 | 事件循环 | poll() , epoll |
高 |
文件 | 线程池 | pthread |
- |
任意 | 事件循环 | io_uring |
高 |