80-00 IO 模型 简介

IO 模型介绍

传统的网络 IO 模型包括五种:

  • [阻塞IO(blocking IO)](#阻塞IO(blocking IO))
  • [非阻塞IO(non-blocking IO)](#非阻塞IO(non-blocking IO))
  • [多路复用IO(IO multiplexing)](#多路复用IO(IO multiplexing))
  • 信号驱动IO模型
  • [异步IO(Asynchronous I/O)](#异步IO(Asynchronous I/O))

由于 signal driven IO(信号驱动IO)在实际中并不常用,所以主要介绍其余四种 IO Model,signal driven IO(信号驱动IO)。

说一下 IO 发生时涉及的对象和步骤。对于一个 network IO (这里我们以 read 举例),它会涉及到两个系统对象,一个是调用这个 IO 的 process(or thread),另一个就是系统内核(kernel)。当一个 read 操作发生时,该操作会经历两个阶段:

  1. 等待数据准备,数据拷贝到内核。
  2. 将数据从内核拷贝到进程中。

记住这两点很重要,因为这些 IO 模型的区别就是在两个阶段上各有不同的情况。

阻塞IO(blocking IO)

在 linux 中,默认情况下所有的 socket 都是 blocking,一个典型的读操作流程大概是这样:


当用户进程调用了 recvfrom 这个系统调用,kernel 就开始了 IO 的第一个阶段:准备数据。对于 network IO 来说,很多时候数据不会在你刚发起系统调用就收到数据包,这个时候 kernel 就要等待足够的数据到来。

而在用户进程这边,整个进程会被阻塞。当 kernel 等到数据准备好了,就会将数据从 kernel 中拷贝到用户内存,然后 kernel 返回结果,然后用户进程才解除 block 的状态,重新运行起来。

所以,blocking IO 的特点就是在 IO 执行的两个阶段(等待数据和拷贝数据到用户空间)都被 block 了。

几乎所有的程序员第一次接触到的网络编程都是从 listen()、send()、recv() 等接口开始的,使用这些接口可以很方便的构建服务器/客户机的模型。然而大部分的 socket 接口都是阻塞型的。如下图

ps:所谓阻塞型接口是指系统调用(一般是IO接口)不返回调用结果并让当前线程一直阻塞,只有当该系统调用获得结果或者超时出错时才返回。

实际上,除非特别指定,几乎所有的 IO 接口(包括 socket 接口)都是阻塞型的。这给网络编程带来了一个很大的问题,如在调用 recv(1024) 的同时,线程将被阻塞,在此期间,线程将无法执行任何运算或响应任何的网络请求。

一个简单的解决方案是:在服务器端使用多线程(或多进程)。多线程(或多进程)的目的是让每个连接都拥有独立的线程(或进程),这样任何一个连接的阻塞都不会影响其他的连接。

该方案的问题是:开启多进程或多线程的方式,在遇到要同时响应成百上千路的连接请求,则无论多线程还是多进程都会严重占据系统资源,降低系统对外界响应效率,而且线程与进程本身也更容易进入假死状态。

改进方案:很多程序员可能会考虑使用“线程池”或“连接池”。“线程池”旨在减少创建和销毁线程的频率,其维持一定合理数量的线程,并让空闲的线程重新承担新的执行任务。“连接池”维持连接的缓存池,尽量重用已有的连接、减少创建和关闭连接的频率。这两种技术都可以很好的降低系统开销,都被广泛应用很多大型系统,如websphere、tomcat和各种数据库等。

改进后方案存在问题:“线程池”和“连接池”技术也只是在一定程度上缓解了频繁调用 IO 接口带来的资源占用。而且,所谓“池”始终有其上限,当请求大大超过上限时,“池”构成的系统对外界的响应并不比没有池的时候效果好多少。所以使用“池”必须考虑其面临的响应规模,并根据响应规模调整“池”的大小。

对应上例中的所面临的可能同时出现的上千甚至上万次的客户端请求,“线程池”或“连接池”或许可以缓解部分压力,但是不能解决所有问题。总之,多线程模型可以方便高效的解决小规模的服务请求,但面对大规模的服务请求,多线程模型也会遇到瓶颈,可以用非阻塞接口来尝试解决这个问题。

伪代码

1
2
3
4
5
6
7
8
9
10
listenfd = socket();   // 打开一个网络通信套接字
bind(listenfd); // 绑定
listen(listenfd); // 监听
while(true) {
buf = new buf[1024]; // 读取数据容器
connfd = accept(listenfd); // 阻塞 等待建立连接
int n = read(connfd, buf); // 阻塞 读数据
doSomeThing(buf); // 处理数据
close(connfd); // 关闭连接
}

非阻塞IO(non-blocking IO)

Linux 下,可以通过设置 socket 使其变为 non-blocking。当对一个 non-blocking socket 执行读操作时,流程是这个样子:


从图中可以看出,当用户进程发出 read 操作时,如果 kernel 中的数据还没有准备好,它并不会 block 用户进程,而是立刻返回一个 error。

从用户进程角度讲,它发起一个 read 操作或者 recvform 系统调用后,并不需要等待,而是马上就得到了一个结果。

用户进程判断结果是一个 error 时,它就知道数据还没有准备好,于是用户就可以在本次到下次再发起 read 询问的时间间隔内做其他事情,或者直接再次发送 read 操作。循环往复的进行 recvform 系统调用,这个过程通常被称之为轮询。轮询检查内核数据,直到数据准备好,一旦 kernel 中的数据准备好了,并且又再次收到了用户进程的 read,那么它马上就将数据拷贝到了用户内存(拷贝数据整个过程仍然是阻塞的),然后返回。

所以,在非阻塞式 IO 中,用户进程其实是需要不断的主动询问 kernel 数据准备好了没有。

但是非阻塞 I O模型绝不被推荐。

我们不能否则其优点:能够在等待任务完成的时间里干其他活了(包括提交其他任务,也就是“后台”可以有多个任务在“同时”执行)。但是也难掩其缺点:

  • 循环调用 recv() 将大幅度推高 CPU 占用率。因此我们可能需要使用 time.sleep(1) 来降低调用的频率,否则在低配主机下极容易出现卡机情况。
  • 任务完成的响应延迟增大了,因为每过一段时间才去轮询一次 read 操作,而任务可能在两次轮询之间的任意时间完成。这会导致整体数据吞吐量的降低。

此外,在这个方案中 recv() 更多的是起到检测“操作是否完成”的作用,实际操作系统提供了更为高效的检测“操作是否完成“作用的接口,例如 select() 多路复用模式,可以一次检测多个连接是否活跃。

代码

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
# 服务端
from socket import *

s=socket(AF_INET,SOCK_STREAM)
s.bind(('127.0.0.1',8080))
s.listen(5)
s.setblocking(False) #设置socket的接口为非阻塞
conn_l=[]
del_l=[]
while True:
try:
conn,addr=s.accept()
conn_l.append(conn)
except BlockingIOError:
print(conn_l)
for conn in conn_l:
try:
data=conn.recv(1024)
if not data:
del_l.append(conn)
continue
conn.send(data.upper())
except BlockingIOError:
pass
except ConnectionResetError:
del_l.append(conn)

for conn in del_l:
conn_l.remove(conn)
conn.close()
del_l=[]

#客户端
from socket import *
c=socket(AF_INET,SOCK_STREAM)
c.connect(('127.0.0.1',8080))

while True:
msg=input('>>: ')
if not msg:continue
c.send(msg.encode('utf-8'))
data=c.recv(1024)
print(data.decode('utf-8'))

多路复用 IO(IO multiplexing)

IO multiplexing 这个词可能有点陌生,但是如果说 select/epoll,大概就都能明白了。有些地方也称这种 IO 方式为**事件驱动IO(event driven IO)**。

我们都知道,select/epoll 的好处就在于单个 process 就可以同时处理多个网络连接的 IO。它的基本原理就是 select/poll 这个 系统调用 会不断的轮询所负责的所有 socket,当某个 socket 有数据到达了,就通知用户进程。它的流程如图:


当用户进程调用了 select,那么整个进程会被 block,而同时 kernel 会“监视”所有 select 负责的 socket,当任何一个 socket 中的数据准备好了,select 就会返回。这个时候用户进程再调用 read 操作,将数据从 kernel 拷贝到用户进程。

这个图和 blocking IO 的图其实并没有太大的不同,事实上还更差一些。因为这里需要使用两个系统调用(select 和 recvfrom),而 blocking IO 只调用了一个系统调用(recvfrom)。但是用 select 的优势在于它可以同时处理多个 connection。

强调:

  • 如果处理的连接数不是很高的话,使用 select/epoll 的 web server 不一定比使用 multi-threading + blocking IO 的 web server 性能更好,可能延迟还更大。select/epoll 的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。
  • 在多路复用模型中,对于每一个 socket,一般都设置成为 non-blocking,但是,如上图所示,整个用户的 process 其实是一直被 block 的。只不过 process 是被 select 这个函数 block,而不是被 socket IO 给 block。
  • 结论: select 的优势在于可以处理多个连接,不适用于单个连接

帮助理解

IO复用:为了解释这个名词,首先来理解下复用这个概念,复用也就是共用的意思,这样理解还是有些抽象,为此,咱们来理解下复用在通信领域的使用,在通信领域中为了充分利用网络连接的物理介质,往往在同一条网络链路上采用时分复用或频分复用的技术使其在同一链路上传输多路信号,即公用某个“介质”来尽可能多的做同一类(性质)的事,那 IO 复用的“介质”是什么呢?

我们先来看看服务器编程的模型,客户端发来的请求服务端会产生一个进程来对其进行服务,每当来一个客户请求就产生一个进程来服务,然而进程不可能无限制的产生,因此为了解决大量客户端访问的问题,引入了 IO 复用技术,即:一个进程可以同时对多个客户请求进行服务。

也就是说IO复用的“介质”是进程(准确的说复用的是select和poll,因为进程也是靠调用select和poll来实现的),复用一个进程(select和poll)来对多个IO进行服务,虽然客户端发来的 I O是并发的,但是 IO 所需的读写数据多数情况下是没有准备好的,因此就可以利用一个函数(select和poll)来监听IO所需的这些数据的状态,一旦 IO 有数据可以进行读写了,进程就来对这样的IO进行服务。

理解完 IO 复用后,我们在来看下实现IO复用中的三个API(select、poll和epoll)的区别和联系

select,poll,epoll 都是 IO 多路复用的机制,I/O 多路复用就是通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知应用程序进行相应的读写操作。但 select,poll,epoll 本质上都是同步 I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无3需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。三者的原型如下所示:

  • int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout)
  • int poll(struct pollfd *fds, nfds_t nfds, int timeout)
  • int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout)

这三种IO多路复用模型在不同的平台有着不同的支持,而 epoll 在 windows 下就不支持,好在我们有 selectors 模块,帮我们默认选择当前平台下最合适的

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
# 基于selectors模块实现聊天
# 服务端
from socket import *
import selectors

sel=selectors.DefaultSelector()

def accept(server_fileobj,mask):
conn,addr=server_fileobj.accept()
sel.register(conn,selectors.EVENT_READ,read)


def read(conn,mask):
try:
data=conn.recv(1024)
if not data:
print('closing',conn)
sel.unregister(conn)
conn.close()
return
conn.send(data.upper()+b'_SB')
except Exception:
print('closing', conn)
sel.unregister(conn)
conn.close()


server_fileobj=socket(AF_INET,SOCK_STREAM)
server_fileobj.setsockopt(SOL_SOCKET,SO_REUSEADDR,1)
server_fileobj.bind(('127.0.0.1',8088))
server_fileobj.listen(5)
server_fileobj.setblocking(False) # 设置socket的接口为非阻塞
sel.register(server_fileobj,selectors.EVENT_READ,accept) # 相当于往select的读列表里append了一个文件句柄server_fileobj,并且绑定了一个回调函数accept

while True:
events=sel.select() # 检测所有的fileobj,是否有完成wait data的
for sel_obj,mask in events:
callback=sel_obj.data # callback=accpet
callback(sel_obj.fileobj,mask) # accpet(server_fileobj,1)

#客户端
from socket import *
c=socket(AF_INET,SOCK_STREAM)
c.connect(('127.0.0.1',8088))

while True:
msg=input('>>: ')
if not msg:continue
c.send(msg.encode('utf-8'))
data=c.recv(1024)
print(data.decode('utf-8'))

具体的描述请看我的其他博客

信号驱动 IO 模型

在信号驱动IO模型中,当用户线程发起一个IO请求操作,会给对应的 socket 注册一个信号函数,然后用户线程会继续执行,当内核数据就绪时会发送一个SIGIO信号给用户线程,用户线程接收到信号之后,便在信号函数中调用 recvfrom 来进行实际的IO请求操作。这个一般用于UDP中,对TCP套接口几乎是没用的,原因是该信号产生得过于频繁,并且该信号的出现并没有告诉我们发生了什么事情。

特点:等待数据报到达期间进程不被阻塞。主循环可以继续执行,只要等待来自信号处理函数的通知:既可以是数据已准备好被处理,也可以是数据报已准备好被读取

异步IO(Asynchronous I/O)

Linux 下的 asynchronous IO 其实用得不多,从内核 2.6 版本才开始引入。先看一下它的流程:

用户进程发起 read 操作之后,立刻就可以开始去做其它的事。

另一方面,从 kernel 的角度,当它受到一个 asynchronous read 之后,首先它会立刻返回,所以不会对用户进程产生任何 block。然后,kernel 会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel 会给用户进程发送一个 signal,告诉它 read 操作完成了。

IO模型比较分析

blocking 和 non-blocking 的区别在于:调用 blocking IO 会一直 block 住对应的进程直到准备好数据,而 non-blocking IO 在 kernel 还准备数据的情况下会立刻返回。

synchronous IO 和 asynchronous IO 两者的区别就在于synchronous IO做”IO operation”的时候会将process阻塞。按照这个定义,四个IO模型可以分为两大类, blocking IO,non-blocking IO,IO multiplexing,信号驱动IO 都属于synchronous IO这一类,而 asynchronous I/O后一类。

有人可能会说,non-blocking IO 并没有被 block 啊。这里有个非常“狡猾”的地方,定义中所指的”IO operation”是指真实的IO操作,就是例子中的 recvfrom 这个 system call。

  • non-blocking IO 在执行 recvfrom 时,如果 kernel 的数据没有准备好,这时候不会 block 进程。但是,当kernel 中数据准备好的时候,recvfrom 会将数据从 kernel 拷贝到用户内存中,这个时候进程是被 block 了,在这段时间内,进程是被 block 的。
  • asynchronous IO 则不一样,当进程发起IO 操作之后,就直接返回再也不理睬了,直到 kernel 发送一个信号,告诉进程说IO完成。在这整个过程中,进程完全没有被block。

各个IO Model的比较如图所示:

经过上面的介绍,会发现 non-blocking IO 和 asynchronous IO 的区别还是很明显的。在 non-blocking IO 中,虽然进程大部分时间都不会被 block,但是它仍然要求进程去主动的 check,并且当数据准备完成以后,也需要进程主动的再次调用 recvfrom 来将数据拷贝到用户内存。而 asynchronous IO 则完全不同。它就像是用户进程将整个IO操作交给了他人(kernel)完成,然后他人做完后发信号通知。在此期间,用户进程不需要去检查IO操作的状态,也不需要主动的去拷贝数据。


80-00 IO 模型 简介
https://flepeng.github.io/010-network-80-00-IO-模型-简介/
作者
Lepeng
发布于
2021年3月8日
许可协议