蛮荆

I/O 模型的阻塞/非阻塞, 同步/异步

2022-01-09

I/O 模型

Unix 有五种 I/O 模型

  1. 阻塞式 I/O
  2. 非阻塞式 I/O
  3. I/O 多路复用(select, poll, epoll)
  4. 信号驱动式 I/O
  5. 异步 I/O(AIO)

1. 阻塞式 I/O

应用进程执行系统调用时被阻塞,直到数据从内核缓冲区复制到应用进程缓冲区中才返回。

阻塞不意味着整个操作系统都被阻塞,在阻塞的过程中,其它应用进程还可以执行,所以阻塞本身不消耗 CPU 时间,这种模型的 CPU 利用率会比较高。

下面将 阻塞式 I/O 工作流程翻译为简单的伪代码。

while True:
    # 阻塞等待客户端连接
    connection, client_address = sock.accept()
    try:
        while True:
            # 阻塞等待数据
            # 读取数据
            data = connection.recv(16)
            # 执行其他操作

2. 非阻塞式 I/O

应用进程执行系统调用时,内核直接返回一个错误码,然后应用进程可以继续向下运行,但是需要不断的执行系统调用来获取 I/O 操作是否完成,也称为轮询(polling)。

由于 CPU 要处理更多的系统调用,因此这种模型的 CPU 利用率比较低。

下面将 非阻塞式 I/O 工作流程翻译为简单的伪代码。

while True:
    try:
        # 内核直接返回一个错误吗
        connection, client_address = sock.accept()
        # 继续向下执行
        connection.setblocking(0)
    except BlockingIOError:
        # 处理错误
        ...

    try:
        # 读取数据
        data = connection.recv(16)
        # 执行其他操作
        ...
    except BlockingIOError:
        # 处理错误
        ...

3. I/O 多路复用

使用 select, poll, epoll 等待多个套接字 (文件描述符) 中的任何一个或多个变为就绪,等待过程会阻塞,当某一个套接字就绪后,再把数据从内核空间 (缓冲期) 复制数据到用户空间 (进程) 。当然,epoll 的工作方式和 select, poll 有些差异,不过这里先不做细节上的深究,统一当作 I/O 多路复用的实现来看待。

这种模型可以让单个 进程/线程 具有处理多个 I/O 事件的能力,也称为事件驱动 I/O。

如果一个 Web Server 没有 I/O 多路复用,那么每一个 Socket 连接都需要创建一个线程去处理,如果同时有几万个连接,那么就需要创建相同数量的线程 (可能导致的后果就是操作系统直接崩溃),相比之下,I/O 多路复用不需要多个进程/线程创建、上下文切换的开销,系统负载更小。

下面将 I/O 多路复用 工作流程翻译为简单的伪代码。

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_address = ('localhost', 8080)

...

inputs = [sock]
outputs = []

while inputs:
    # 阻塞等待套接字就绪
    readable, writable, exceptional = select.select(inputs, outputs, inputs)

    # 获取到已经就绪的套接字
    # 遍历处理读事件
    for s in readable:
        ...

    # 遍历处理写事件
    for s in writable:
        ...

    # 遍历处理其他事件
    for s in exceptional:
        ...

4. 信号驱动 I/O

应用进程执行系统调用时,内核直接返回,然后应用进程可以继续向下运行,也就是说等待数据阶段应用进程是非阻塞的。

内核在数据到达时向应用进程发送信号,应用进程收到信号之后,在信号处理程序中执行系统调用,将数据从内核空间 (缓冲期) 复制数据到用户空间 (进程),这种模型的 CPU 利用率会比较高。

# 信号回调函数
def handler(signum, frame):
    # 读取数据
    data, addr = sock.recvfrom(1024)
    # 执行其他操作
    ...

# 初始化 socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(('localhost', 8080))

...

# 注册信号回调函数
signal.signal(signal.SIGIO, handler)

...

while True:
    # 等待信号通知
    signal.pause()

5. 异步 I/O

应用进程执行系统调用时,内核直接返回,然后应用进程可以继续向下运行,不会被阻塞,内核会在所有操作 (包括从内核空间复制数据到用户空间) 完成之后向应用进程发送信号。

异步 I/O 与信号驱动 I/O 的区别在于,异步 I/O 的信号是通知应用进程 I/O (数据复制) 已经完成,而信号驱动 I/O 的信号是通知应用进程可以开始 I/O (数据复制) 。

# 异步回调函数
# 通知应用进程 I/O (数据复制) 已经完成
async def handle_client(reader, writer):
    while True:
        # 直接读取数据即可
        data = await reader.read(100)

        # 执行其他操作
        ...

    writer.close()

async def main():
    # 监听端口并注册异步回调函数
    server = await asyncio.start_server(handle_client, 'localhost', 10000)

阻塞/非阻塞,同步/异步

如何判断一个 I/O 模型是同步还是异步?

I/O 事件数据在内核空间和用户空间来回复制时,是否会阻塞当前线程?

如果会阻塞当前线程,则为同步 I/O, 否则就是异步 I/O。

所以前文中提到的 5 种 I/O 模型中,只有最后 1 种 异步 I/O 模型 是真正的异步 I/O, 其他 4 种都是同步 I/O。

一个 I/O 读取操作通常包括两个步骤:

  1. 等待网络数据到达网卡(读就绪) -> 等待网卡可写(写就绪) –> 读取/写入到内核缓冲区
  2. 从内核空间缓冲区复制数据 –> 用户空间(读), 或者 从用户空间 (进程) 复制数据 -> 内核缓冲区(写)

4 种同步 I/O 模型的主要区别在 I/O 操作的第一步: 等待网络数据到达网卡(读就绪) -> 等待网卡可写(写就绪) –> 读取/写入到内核缓冲区,除了 阻塞式 I/O 模型外,其他 3 种同步 I/O 模型在第一步不会发生阻塞。

换句话说,只有 阻塞式 I/O 模型是阻塞的,其他 3 种模型都是非阻塞的。


小结

阻塞 / 非阻塞的主语是 I/O 操作调用者(应用进程),而同步 / 异步的主语是 I/O 操作执行者(操作系统)。

I/O 模型 是否阻塞 是否同步
阻塞式 I/O
非阻塞式 I/O
I/O 多路复用
信号驱动式 I/O
异步 I/O

Reference

转载申请

本作品采用 知识共享署名 4.0 国际许可协议 进行许可,转载时请注明原文链接,图片在使用时请保留全部内容,商业转载请联系作者获得授权。