05.并发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()
会挂起调用线程,直到有可用空间。
多线程的需求来自于需要等待每个套接字变为就绪状态(可读或可写)。如果有一种方法可以一次性等待多个套接字,然后在它们就绪时进行read/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),受可用空间限制
这就涉及到三种操作系统机制:
- 就绪通知:等待多个套接字,当一个或多个就绪时返回。“就绪”意味着读缓冲区不为空或写缓冲区未满。
- 非阻塞读:假设读缓冲区不为空,从中消费数据。
- 非阻塞写:假设写缓冲区未满,想其中放入一些数据。
这被称为事件循环。每次循环迭代都会等待任何就绪事件,然后非阻塞地对事件做出反应,从而使所有套接字都能被及时处理。
基于回调的编程
回调在JS中很常见。要在JS中读默写东西,首先要为其某个事件注册一个回调,然后数据会被传递给该回调。这就是我们接下来要做的事情。不同的是,在JS中事件循环是隐藏的,而在项目中,事件循环是我们自己编码的。这样,我们就会更好的了解这个重要的机制。
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是一个socket连接套接字
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); // 设置标志位
// TODO: 处理 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()
示例奴性的专属机制。与poll()
不同,文件描述符列表不会作为参数传递,而是直接存储在内核中。需通过epoll_ctl()
来添加或修改该列表。由于操作大量文件描述符时传递数据效率低下,epoll
比poll()
更具有可扩展性。kqueue()
则是BSD系统的专属功能,其原理类似于epoll
,但需要的系统调用更少,因此能批量更新文件描述符列表。
我们将使用poll()
,因为它是最简单的。但请注意,epoll
是Linux上的默认选择,因为它更具伸缩性,在真实项目中应该使用它。所有的就绪状态API只是形式不同,使用起来并无太大差异。
就绪状态API不能用于文件
所有的就绪状态API只能用于套接字、管道和一些特殊的东西(如signalfd
)。它们不能用于磁盘文件,因为当一个套接字准备好读取时,意味着数据已经存在于缓冲区中,所以读取操作就不会阻塞。但对于磁盘文件,内核中不存在这样的缓冲区,所以磁盘文件的“就绪”状态是不存在的。
这些API将总是报告磁盘文件已“就绪”,但IO操作仍然会阻塞。因此,文件IO必须在事件循环之外,通过一个线程池来完成,我们稍后会学习。
在Linux上,使用io_uring
也许可以在事件循环内进行文件IO,它是一个统一了文件IO和套接字IO的接口。但io_uring
是一个非常不同的API,所以我们不会对此深入研究。
5.5 并发IO技术总结
类型 | 方法 | API | 可伸缩性 |
---|---|---|---|
socket | 每个连接一个线程 | pthread | 低 |
socket | 每个连接一个进程 | fork() | 低 |
socket | 事件循环 | poll() ,epoll() | 高 |
file | 线程池 | pthread | |
any | 事件循环 | io_uring | 高 |