异步编程:阻塞与非阻塞
原文:Asynchronous programming. Blocking I/O and non-blocking I/O
作者:luminousmen
这是关于异步编程系列的第一篇文章。整个系列试着回答一个简单的问题,“什么是异步?”
我在一开始深入研究这个问题的时候,我以为自己了解“异步”。但事实是关于“什么是异步”我并没有什么头绪,所以我们来寻找答案吧!
整个系列:
- 异步编程:阻塞式IO与非阻塞式IO
- 异步编程:协作式多任务
- 异步编程:Await the Future
- 异步编程:Python3.5+
这篇文章中,我们以网络编程来讨论,不过你可以轻松的用其他的IO操作来类比,比如文件操作。虽然文中的例子采用Python,但这些观点并不是仅仅针对某种特定的编程语言(我只想说—Python真香)。
在一般C/S架构的应用中,客户端创建请求发送给服务端时,服务端会处理请求并响应,这个过程中客户端与服务端首先都需要建立与对方通信的连接,这也就是sockets的作用啦。两端为socket绑定端口后服务端就会在自己的socket中监听来自客户端的请求。
如果你看过处理器的处理速度与网络连接的比率,你就知道两者的差异是几个数量级。事实上如果我们的应用在进行I/O操作,那么CPU绝大多数的时间什么都不做,这种应用被称为“I/O-bound”。对于构建高效应用而言这是个大?麻烦,因为其他的动作和I/O操作会一直等待着—事实上这些系统都很懒。
有三种方式操作I/O:阻塞式,非阻塞式,异步。最后一种不适用于网络编程,所以对我们而言只有前两种选择。
阻塞式I/O
这是在UNIX(POSIX)BSD sockets(Windows中类似,称呼可能不同但逻辑是一样的)中应用阻塞式I/O的例子。
在阻塞式I/O中,客户端创建请求发送至服务端时,该链接的socket会被一直阻塞到数据读取或写入完毕。在这些操作完成之前服务端除了等待什么也做不了。由此可知在单个线程中我们无法同时为更多的连接提供服务。默认情况下,TCP sockets 处于阻塞模式。
客户端:
import socket
sock = socket.socket()
host = socket.gethostname()
sock.connect((host, 12345))
data = b"Foo Bar" *10*1024 # Send a lot of data to be sent
assert sock.send(data) # Send data till true
print("Data sent")
复制代码
服务端:
import socket
s = socket.socket()
host = socket.gethostname()
port = 12345
s.bind((host, port))
s.listen(5)
while True:
conn, addr = s.accept()
data = conn.recv(1024)
while data:
print(data)
data = conn.recv(1024)
print("Data Received")
conn.close()
break
复制代码
你会注意到服务端一直在打印消息,并且持续到所有数据发送完毕。在服务端的代码中,“Data Received”不会被打印,这是因为客户端了发送大量的数据,这将一直耗时到socket被阻塞(这一句有点懵逼,原文:which will take time, and until then the socket will get blocked)。
发生了什么?send()方法会从客户端的写缓存持续获取数据并尝试将所有数据发送给服务端。当缓存空了内核才会再次唤醒进程以获取下一个被传输数据块。也就是说你的代码将被阻塞也无法进行其他的操作。
现在要实现并发请求我们需要多线程,即我们为每一个客户端连接分配一个新的线程。我们稍后讨论这个。
非阻塞式I/O
显而易见,从字面意思来看这种方式与上面介绍的差异就是“非阻塞”,对客户端而言任何操作都是立即完成的。非阻塞式I/O就是把请求放入队列后函数立即返回,之后在某个时刻才进行真实的I/O操作。
我们回到刚刚客户端的例子中做些修改:
import socket
sock = socket.socket()
host = socket.gethostname()
sock.connect((host, 12345))
sock.setblocking(0) # Now setting to non-blocking mode
data = b"Foo Bar" *10*1024
assert sock.send(data)
print("Data sent")
复制代码
现在我们运行这段代码,你会发现程序在很短的时间里打印了“Data sent”后就终止了。
为什么会这样?因为客户端并没有发送所有数据,当我们通过setblocking(0)把socket设置为非阻塞式时,它就不会等待操作完成了。所以当我们之后再调用send()方法时,它会尽可能多的写入数据到缓存中然后立即返回。
使用非阻塞式I/O,我们就可以在同一个线程中同时执行不同socket中的I/O操作。但是I/O操作是否准备就绪我们不得而知,所以我们可能不得不遍历每个socket去确认,通常就是采用无限循环。
为了摆脱这种低效的循环我们就需要一套轮询机制,我们可以轮询出所有准备就绪的sockets,还可以知道它们之中哪些可以进行新的I/O操作。当有任意sockets准备就绪后我们将执行入队操作(),之后我们便可以等待为下次I/O操作准备好了的sockets。
有几种不同的轮询机制,它们在性能与细节上有所不同,但通常细节隐藏在“引擎盖下”,对我们来说是不可见的。
相关的关键字
Notifications:
- Level Triggering (state)
- Edge Triggering (state changed)
Mechanics:
select()
,poll()
epoll()
,kqueue()
EAGAIN
,EWOULDBLOCK
多任务
我们的目标是同时管理多个客户端。那么如何确保同时处理多个请求呢?
这有些选择:
独立进程
最简单,也是最早的一种方式就是在将每个请求放在一个独立的进程中处理。我们可以使用之前的阻塞式I/O,如果这个独立进程突然崩了也不会对其他进程造成影响,这很棒对不对?
在形式上这些进程之间几乎没有任何通用的地方,所以我们需要为每次普通的进程间通信做额外的事情。此外在任意时刻都有多个进程在等待客户端请求也是一种资源浪费。
我们看一看在实践中这是如何工作的,通常主进程启动后会进行一些操作,例如,监听,然后设置一些进程作为workers,每个worker可以在同一个socket上等待接受传入的连接。一旦连接建立,这个worker会与该连接绑定,它负责处理该连接从开始到结束的整个过程,关闭与客户端通信的socket后就会重新准备好处理下一次请求。进程可以在建立连接时创建或者提前创建。不同的方式可能会对性能造成影响,不过现在这个问题对我们不重要。
类似的系统有:
- Apache
mod_prefork
; - FastCGI (PHP);
- Phusion Passenger (Ruby on Rails);
- PostgreSQL.
线程(OS)
还有一种就是利用操作系统线程的方式啦。在一个进程中我们可以创建多个线程。
依然可以使用阻塞式I/O,因为只有一个线程会被阻塞。OS已经管理好了这些分在各个进程中的线程。线程比进程更轻量。这代表我们可以创建更多的线程。创建1万个进程很困难,而创建1万个进程则很轻松,并不是说线程相比进程更高效而是更轻量。
另一方面,线程间没有隔离,也就是如果线程崩了其做在的进程也会崩溃。最麻烦的是进程中的内存在工作中的线程间是共享的,这代表我们需要考虑线程安全问题。比如:数据库连接或者连接池。
结论
阻塞方法会同步执行—运行程序时阻塞方法的操作会在调用后立即直接执行。
非阻塞方法会异步执行—运行程序时非阻塞方法会在调用后马上返回,真正的操作会在之后执行。
我们可以通过多线程与多进程实现多任务处理。
下一篇文章我们将讨论协作式多任务处理已经实现。