文章目录
1. 并发编程-IO模型
Unix体系结构
- 关于 Unix 操作系统的体系结构可以参考下图,其中内核( kernel )主要用于控制硬件以及提供运行环境,位于操作系统的核心部分。内核提供的接口则称为系统调用( system call ),在系统调用之上分别存在 shell 和公共函数库,应用程序可以使用公共函数库,在部分情况下也可以使用系统调用。shell 是一个命令行解释器程序,可以按照用户的输入执行相关的操作,也可以运行其他程序。
Unix 与 Linux
- Linux 可以称为 Unix 系统的一种实现, 或者更准确的说是一种类 Unix 的操作系统,可以提供 Unix 编程环境,除了 Linux 外,也有 BSD,Mac OS X,Solaris 等 Unix 系统实现。但同时 Linux 也有着自身的特点,比如它支持更多的系统调用,具有更多新的特性。
文件描述符与套接字
-
I/O 设备可以被抽象为文件(正如 Linux 遵循的 “一切皆文件” 的理念),在 I/O 设备上的输入和输出被处理为对相应的文件的读写操作。当打开一个文件时,内核会返回一个非负整数,用来标识该文件,称为文件描述符( file descriptor ),简称 fd 。在此后的读、写等处理过程中,应用程序即可通过这个描述符来访问文件,而不需要记录有关文件的其他信息。
-
在网络编程中常用的套接字( socket )也是一种文件类型,一个套接字便是一个有着对应描述符的打开的文件,它用于和另外一个进程进行网络通信。
用户空间与内核空间
- 从内核安全和可靠性考虑,用户程序不能直接运行内核代码或操作内核数据,为此操作系统有内核空间和用户空间的区分。运行在用户空间的应用程序(比如图形及文本编辑器、音乐播放器等)想要执行某些系统调用时,则需要通过特定的机制来告知内核。
Unix I/O 模型
- 在 Unix 中,目前有 5 种 I/O 模型,分别为
- 阻塞式 I/O( blocking I/O )
- 非阻塞式 I/O( nonblocking I/O )
- I/O 复用( I/O multiplexing )
- 信号驱动式 I/O ( signal-driven I/O )
- 异步 I/O( asynchronous I/O )
- 在这里我们以网络编程为背景,来分别了解这几种模型。
- I/O 中一个输入操作在操作系统层面通常包括两个过程,首先需要等待数据准备就绪,接着由内核向对应进程中进行数据拷贝。对应到网络套接字上,首先则是等待网络中的数据到达,数据到达后,先被拷贝至内核缓冲区,接着再由内核缓冲区拷贝至进程中。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SNkHncwM-1611039506665)(/home/fyccheng/Desktop/IO.png)]
阻塞式 I/O( blocking I/O )
-
阻塞式 I/O 是最常见、并且也是最常用的 I/O 模型。阻塞式 I/O 会因为无法立即完成某个操作而被挂起。对于一个套接字来说,其默认情况下便是阻塞的。当相应的系统调用操作阻塞时(比如
send
、recv
、accept
、connent
等操作),对应的进程则会进入睡眠,直至操作完成后才恢复执行。 -
如下图所示,当应用进程调用
recvfrom
时,其对应的系统调用会阻塞,等待至数据报到达,并复制到应用进程对应的缓冲区后才会返回。对应上述的两个过程,即等待数据和数据拷贝,阻塞式 I/O 在这两个过程中都是阻塞的。 -
在内核实现层面上这种 I/O 模型实现简单,并且能够在数据报准备好后无延迟的返回数据以进行后续处理,但对于用户进程来说往往需要耗费时间来等待操作完成。
常见的网络阻塞
-
recv
,recvfrom
网络I/O阻塞。[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-qWygVtFC-1611039506667)(/home/fyccheng/Desktop/IO2.png)]
# 客户端
import socket
client = socket.socket()
client.connent(('127.0.0.1', 8080))
while True:
client.send(b'hello world')
data = client.recv(1024)
print(data)
# 服务端 为单个客户端服务
import socket
server = socket.socket()
server.bind(('127.0.0.1', 8080)) # IP可以不写
server.listen(128) # 必须得写
while True:
conn, addr = server.accept()
while True:
try:
data = conn.recv(1024)
if len(data) == 0; break # 当客户端发送结束。
print(data)
conn.send(data.upper())
except ConnectionError as e:
break
conn.close()
# 服务端 为多个客户端服务
'''
1.多线程或者多进程,创建多个线程或多个进程。
'''
非阻塞式 I/O( nonblocking I/O )
-
非阻塞 I/O 相关的系统调用无论操作是否完成,总会立即返回。如下图所示,我们可以将套接字设置为非阻塞模式,当应用进程调用
recvfrom
时,若没有数据到达,应用进程无需等待,内核会立即返回一个EWOULDBLOCK
(“期望阻塞”)(在一些系统实现下,也有可能会返回EAGAIN
(“再来一次”))。在非阻塞式 I/O 中,应用进程可以以这种形式不断轮询( polling )内核,通过循环调用recvfrom
以查看数据报是否准备好,在每次轮询内核返回后,应用进程可以选择进行一些其他任务的处理后再次发起轮询。 -
非阻塞式 I/O 可以在等待数据准备就绪的过程中不被阻塞(但在数据从内核复制到用户空间的过程中仍是阻塞的),从而可以在等待数据的过程中执行其他的任务,但与此同时,由于应用进程按照一定的频率进行轮询,数据准备好的时间点可能位于两次轮询之间,从而导致数据到达后不能及时的被后续过程处理,存在一定延迟。同时这种通过应用进程主动不断轮询内核数据是否就绪,往往存在多次轮询时并没有数据就绪,这也会造成 CPU 资源多余的消耗(通常非阻塞 I/O 需要结合另外的 I/O 通知机制一起发挥作用,比如 I/O 复用等)。
# 服务端
import socket
server = socket.socket()
server.bind(('127.0.0.1', 8080)) # IP可以不写
server.listen(128) # 必须得写
# 将所有的网络阻塞变成非阻塞
server.setblocking(False)
c_list = []
d_list = []
while True:
try:
conn, addr = server.accept()
c_list.append(cpnn)
except BlockigIOError as e:
# 可以做其他事情
# print('hello')
for conn in c_list:
# 创建try来让recv变成飞阻塞
try:
data = conn.recv(1024)
# print(data)
if len(data) == 0:
conn.close()
d_list.append(conn)
continue
conn.send(data.upper())
except BlockigIOError as e:
continue
except ConectionResetError as e:
conn.close()
d_list.append(conn)
for conn in d_list:
c_list.remove(conn)
I/O 多路复用( I/O multiplexing )
-
在不考虑多线程的情况下,如果我们想要在单个进程内处理多个文件描述符的话,显然在阻塞式 I/O 下,我们无法同时在多个文件描述符对应的阻塞调用上同时进行阻塞。比如,当打开多个套接字时,当在某个套接字上调用了
recvfrom
但无数据报准备好时,进程会阻塞以等待数据到达,那么这个时候就无法处理已经准备好数据的描述符,整个程序的执行效率会比较低。 -
那么,非阻塞 I/O 是否可以解决这个问题?在非阻塞 I/O 中,应用进程的系统调用不会阻塞,而是返回某种特定的错误,但就像上面的代码示例一样,应用进程需要不断地轮询内核以期对应的文件描述符准备好,这种方式效率低下,会消耗大量的 CPU 时间。但试想,如果我们在应用进程中循环的查询多个非阻塞模式下的描述符状态,然后在任意一个描述符数据就绪时进行处理,这样子便可以处理多个文件描述符。
import socket import select ''' select监听以下两个 server = socket.socket() conn, addr = server.accept() select 机制 windows 和 linux 都有 poll 机制 Linux poll可以监管数量比select更多 epoll 机制 Linux 添加回调机制 一旦有响应,回调机制立刻触发。 selectors 模块 ''' server = socket.socket() server.bind(('127.0.0.1', 8080)) server.listen(128) server.setblocking(False) read_list = [server] while True: # 帮你监管,一旦有人连接,立刻返回对应的监管对象 rlist, wlist, xlist = select.select(read_list, [], []) for i in rlist: if i is server: conn, addr = i.accpet() read_list.append(conn) else: res = i.recv(1024) if len(res) == 0: i.close() read_list.remove(i) print(res) i.send(b'hello')
异步 I/O( asynchronous I/O )
-
我们将下图和上面的几个图示简单对比,便能看出一个显著的区别是在异步 I/O 中不需要应用进程调用
recvfrom
来完成数据的复制过程。这也是异步 I/O 最主要的工作机制:异步 I/O 相关函数通知内核进行 I/O 操作,在内核执行完包括等待数据和将数据复制到用户空间等所有 I/O 操作后再通知我们。下图中所示,比如当我们调用aio_read
函数时,会将描述符以及相关的数据传递给内核,并将整个操作完成后的通知方式告知内核,该调用会马上返回,进程不会阻塞。import asyncio # 用在爬虫中较多 import threading # 单线程实心并发 协程 # 效率最高 @asyncio.coroutine def task(): print('hello %s' % threading.current_thread()) yield from asyncio.sleep(1) # 模拟IO操作 print('hello $s' % threading.current_thread()) loop = asyncio.get_event_loop() tasks = [task(), task()] loop.run_until_complete(asyncio.wait(tasks)) loop.close()
- 可以看到,异步 I/O 和上面的信号驱动式 I/O 都包含内核通知的过程,但这两种 I/O 模型有显著的区别。首先,对于信号驱动式 I/O 来说,内核产生信号通知我们的信息是何时执行 I/O 操作,I/O 操作需要我们自己完成,而异步 I/O 则不同,内核通知我们的信息是何时完成 I/O 操作,I/O 操作由内核来完成。其次,前者在数据准备就绪时便产生信号,后者在数据准备好并复制完成时才产生信号
IO 模型对比
简要对比
- 下图中的第一阶段是指等待数据过程,第二阶段指将数据从内核复制到用户空间过程。
阻塞和非阻塞、同步和异步
-
上面的内容中,我们多次提到阻塞和非阻塞,一个阻塞操作会将对应的进程挂起直至操作完成才恢复执行。对于一个正在执行的进程,可能会由于资源未到位、操作未完成等原因而阻塞。
-
在操作系统层面,同步 I/O 操作( synchronous I/O operation )会导致请求进程阻塞,直至 I/O 操作完成;异步 I/O 操作( asynchronous I/O operation )则在整个过程中都不会导致请求进程阻塞。对比上述 5 种 I/O 模型,其中阻塞式 I/O、非阻塞式 I/O、I/O 复用和信号驱动式 I/O 都属于同步 I/O ,因为在这四种 I/O 模型中,都存在阻塞进程的 I/O 操作,而异步 I/O 模型则属于异步 I/O 操作,整个进程在等待数据和数据复制的过程中均不会阻塞。
-
最后重要的一点,针对于我们在应用层面( user-level ,并非我们上面所说的操作系统层面或内核层面 kernel-level )上常说的异步网络库或异步框架(我们会在后面的小节中深入了解),这些框架可以提供异步调用的接口(意味着某些任务的执行过程可以独立于主程序流程,常用回调或者协程的编码方式),但在操作系统( Unix/Linux )这一层,这些框架所使用的接口通常都是内核中成熟的同步 I/O。结合应用层面来看,阻塞和非阻塞的概念强调的是调用者在调用后的一种运行状态,是挂起还是继续执行;而同步和异步的概念强调的是执行结果返回的通知方式,在同步模型中调用者在调用后等待直至返回结果,而在异步模型中调用者在调用后立即返回,执行结果通常会通过其他机制通知到调用者。
文章目录
1. 并发编程-IO模型
Unix体系结构
- 关于 Unix 操作系统的体系结构可以参考下图,其中内核( kernel )主要用于控制硬件以及提供运行环境,位于操作系统的核心部分。内核提供的接口则称为系统调用( system call ),在系统调用之上分别存在 shell 和公共函数库,应用程序可以使用公共函数库,在部分情况下也可以使用系统调用。shell 是一个命令行解释器程序,可以按照用户的输入执行相关的操作,也可以运行其他程序。

Unix 与 Linux
- Linux 可以称为 Unix 系统的一种实现, 或者更准确的说是一种类 Unix 的操作系统,可以提供 Unix 编程环境,除了 Linux 外,也有 BSD,Mac OS X,Solaris 等 Unix 系统实现。但同时 Linux 也有着自身的特点,比如它支持更多的系统调用,具有更多新的特性。
文件描述符与套接字
-
I/O 设备可以被抽象为文件(正如 Linux 遵循的 “一切皆文件” 的理念),在 I/O 设备上的输入和输出被处理为对相应的文件的读写操作。当打开一个文件时,内核会返回一个非负整数,用来标识该文件,称为文件描述符( file descriptor ),简称 fd 。在此后的读、写等处理过程中,应用程序即可通过这个描述符来访问文件,而不需要记录有关文件的其他信息。
-
在网络编程中常用的套接字( socket )也是一种文件类型,一个套接字便是一个有着对应描述符的打开的文件,它用于和另外一个进程进行网络通信。
用户空间与内核空间
- 从内核安全和可靠性考虑,用户程序不能直接运行内核代码或操作内核数据,为此操作系统有内核空间和用户空间的区分。运行在用户空间的应用程序(比如图形及文本编辑器、音乐播放器等)想要执行某些系统调用时,则需要通过特定的机制来告知内核。
Unix I/O 模型
-
在 Unix 中,目前有 5 种 I/O 模型,分别为
- 阻塞式 I/O( blocking I/O )
- 非阻塞式 I/O( nonblocking I/O )
- I/O 复用( I/O multiplexing )
- 信号驱动式 I/O ( signal-driven I/O )
- 异步 I/O( asynchronous I/O )
-
在这里我们以网络编程为背景,来分别了解这几种模型。
-
I/O 中一个输入操作在操作系统层面通常包括两个过程,首先需要等待数据准备就绪,接着由内核向对应进程中进行数据拷贝。对应到网络套接字上,首先则是等待网络中的数据到达,数据到达后,先被拷贝至内核缓冲区,接着再由内核缓冲区拷贝至进程中。
阻塞式 I/O( blocking I/O )
-
阻塞式 I/O 是最常见、并且也是最常用的 I/O 模型。阻塞式 I/O 会因为无法立即完成某个操作而被挂起。对于一个套接字来说,其默认情况下便是阻塞的。当相应的系统调用操作阻塞时(比如
send
、recv
、accept
、connent
等操作),对应的进程则会进入睡眠,直至操作完成后才恢复执行。 -
如下图所示,当应用进程调用
recvfrom
时,其对应的系统调用会阻塞,等待至数据报到达,并复制到应用进程对应的缓冲区后才会返回。对应上述的两个过程,即等待数据和数据拷贝,阻塞式 I/O 在这两个过程中都是阻塞的。 -
在内核实现层面上这种 I/O 模型实现简单,并且能够在数据报准备好后无延迟的返回数据以进行后续处理,但对于用户进程来说往往需要耗费时间来等待操作完成。

非阻塞式 I/O( nonblocking I/O )
-
非阻塞 I/O 相关的系统调用无论操作是否完成,总会立即返回。如下图所示,我们可以将套接字设置为非阻塞模式,当应用进程调用
recvfrom
时,若没有数据到达,应用进程无需等待,内核会立即返回一个EWOULDBLOCK
(“期望阻塞”)(在一些系统实现下,也有可能会返回EAGAIN
(“再来一次”))。在非阻塞式 I/O 中,应用进程可以以这种形式不断轮询( polling )内核,通过循环调用recvfrom
以查看数据报是否准备好,在每次轮询内核返回后,应用进程可以选择进行一些其他任务的处理后再次发起轮询。 -
非阻塞式 I/O 可以在等待数据准备就绪的过程中不被阻塞(但在数据从内核复制到用户空间的过程中仍是阻塞的),从而可以在等待数据的过程中执行其他的任务,但与此同时,由于应用进程按照一定的频率进行轮询,数据准备好的时间点可能位于两次轮询之间,从而导致数据到达后不能及时的被后续过程处理,存在一定延迟。同时这种通过应用进程主动不断轮询内核数据是否就绪,往往存在多次轮询时并没有数据就绪,这也会造成 CPU 资源多余的消耗(通常非阻塞 I/O 需要结合另外的 I/O 通知机制一起发挥作用,比如 I/O 复用等)。

I/O 复用( I/O multiplexing )
-
在不考虑多线程的情况下,如果我们想要在单个进程内处理多个文件描述符的话,显然在阻塞式 I/O 下,我们无法同时在多个文件描述符对应的阻塞调用上同时进行阻塞。比如,当打开多个套接字时,当在某个套接字上调用了
recvfrom
但无数据报准备好时,进程会阻塞以等待数据到达,那么这个时候就无法处理已经准备好数据的描述符,整个程序的执行效率会比较低。 -
那么,非阻塞 I/O 是否可以解决这个问题?在非阻塞 I/O 中,应用进程的系统调用不会阻塞,而是返回某种特定的错误,但就像上面的代码示例一样,应用进程需要不断地轮询内核以期对应的文件描述符准备好,这种方式效率低下,会消耗大量的 CPU 时间。但试想,如果我们在应用进程中循环的查询多个非阻塞模式下的描述符状态,然后在任意一个描述符数据就绪时进行处理,这样子便可以处理多个文件描述符。

异步 I/O( asynchronous I/O )
- 我们将下图和上面的几个图示简单对比,便能看出一个显著的区别是在异步 I/O 中不需要应用进程调用
recvfrom
来完成数据的复制过程。这也是异步 I/O 最主要的工作机制:异步 I/O 相关函数通知内核进行 I/O 操作,在内核执行完包括等待数据和将数据复制到用户空间等所有 I/O 操作后再通知我们。下图中所示,比如当我们调用aio_read
函数时,会将描述符以及相关的数据传递给内核,并将整个操作完成后的通知方式告知内核,该调用会马上返回,进程不会阻塞。

-
可以看到,异步 I/O 和上面的信号驱动式 I/O 都包含内核通知的过程,但这两种 I/O 模型有显著的区别。首先,对于信号驱动式 I/O 来说,内核产生信号通知我们的信息是何时执行 I/O 操作,I/O 操作需要我们自己完成,而异步 I/O 则不同,内核通知我们的信息是何时完成 I/O 操作,I/O 操作由内核来完成。其次,前者在数据准备就绪时便产生信号,后者在数据准备好并复制完成时才产生信号
-
IO 模型对比
简要对比
- 下图中的第一阶段是指等待数据过程,第二阶段指将数据从内核复制到用户空间过程。

阻塞和非阻塞、同步和异步
-
上面的内容中,我们多次提到阻塞和非阻塞,一个阻塞操作会将对应的进程挂起直至操作完成才恢复执行。对于一个正在执行的进程,可能会由于资源未到位、操作未完成等原因而阻塞。
-
在操作系统层面,同步 I/O 操作( synchronous I/O operation )会导致请求进程阻塞,直至 I/O 操作完成;异步 I/O 操作( asynchronous I/O operation )则在整个过程中都不会导致请求进程阻塞。对比上述 5 种 I/O 模型,其中阻塞式 I/O、非阻塞式 I/O、I/O 复用和信号驱动式 I/O 都属于同步 I/O ,因为在这四种 I/O 模型中,都存在阻塞进程的 I/O 操作,而异步 I/O 模型则属于异步 I/O 操作,整个进程在等待数据和数据复制的过程中均不会阻塞。
-
最后重要的一点,针对于我们在应用层面( user-level ,并非我们上面所说的操作系统层面或内核层面 kernel-level )上常说的异步网络库或异步框架(我们会在后面的小节中深入了解),这些框架可以提供异步调用的接口(意味着某些任务的执行过程可以独立于主程序流程,常用回调或者协程的编码方式),但在操作系统( Unix/Linux )这一层,这些框架所使用的接口通常都是内核中成熟的同步 I/O。结合应用层面来看,阻塞和非阻塞的概念强调的是调用者在调用后的一种运行状态,是挂起还是继续执行;而同步和异步的概念强调的是执行结果返回的通知方式,在同步模型中调用者在调用后等待直至返回结果,而在异步模型中调用者在调用后立即返回,执行结果通常会通过其他机制通知到调用者。