11.12 理解事件驱动的IO

问题

你应该已经听过基于事件驱动或异步 I/O 的包,但是你还不能完全理解它的底层到底是怎样工作的, 或者是如果使用它的话会对你的程序产生什么影响。

解决方案

事件驱动 I/O 本质上来讲就是将基本 I/O 操作(比如读和写)转化为你程序需要处理的事件。 例如,当数据在某个 socket 上被接受后,它会转换成一个 receive 事件,然后被你定义的回调方法或函数来处理。 作为一个可能的起始点,一个事件驱动的框架可能会以一个实现了一系列基本事件处理器方法的基类开始:

class EventHandler:
    def fileno(self):
        'Return the associated file descriptor'
        raise NotImplemented('must implement')

    def wants_to_receive(self):
        'Return True if receiving is allowed'
        return False

    def handle_receive(self):
        'Perform the receive operation'
        pass

    def wants_to_send(self):
        'Return True if sending is requested'
        return False

    def handle_send(self):
        'Send outgoing data'
        pass

这个类的实例作为插件被放入类似下面这样的事件循环中:

事件循环的关键部分是 select() 调用,它会不断轮询文件描述符从而激活它。 在调用 select() 之前,时间循环会询问所有的处理器来决定哪一个想接受或发生。 然后它将结果列表提供给 select() 。然后 select() 返回准备接受或发送的对象组成的列表。 然后相应的 handle_receive()handle_send() 方法被触发。

编写应用程序的时候,EventHandler 的实例会被创建。例如,下面是两个简单的基于 UDP 网络服务的处理器例子:

测试这段代码,试着从另外一个 Python 解释器连接它:

实现一个 TCP 服务器会更加复杂一点,因为每一个客户端都要初始化一个新的处理器对象。 下面是一个 TCP 应答客户端例子:

TCP 例子的关键点是从处理器中列表增加和删除客户端的操作。 对每一个连接,一个新的处理器被创建并加到列表中。当连接被关闭后,每个客户端负责将其从列表中删除。 如果你运行程序并试着用 Telnet 或类似工具连接,它会将你发送的消息回显给你。并且它能很轻松的处理多客户端连接。

讨论

实际上所有的事件驱动框架原理跟上面的例子相差无几。实际的实现细节和软件架构可能不一样, 但是在最核心的部分,都会有一个轮询的循环来检查活动 socket,并执行响应操作。

事件驱动 I/O 的一个可能好处是它能处理非常大的并发连接,而不需要使用多线程或多进程。 也就是说,select() 调用(或其他等效的)能监听大量的 socket 并响应它们中任何一个产生事件的。 在循环中一次处理一个事件,并不需要其他的并发机制。

事件驱动 I/O 的缺点是没有真正的同步机制。 如果任何事件处理器方法阻塞或执行一个耗时计算,它会阻塞所有的处理进程。 调用那些并不是事件驱动风格的库函数也会有问题,同样要是某些库函数调用会阻塞,那么也会导致整个事件循环停止。

对于阻塞或耗时计算的问题可以通过将事件发送个其他单独的现场或进程来处理。 不过,在事件循环中引入多线程和多进程是比较棘手的, 下面的例子演示了如何使用 concurrent.futures 模块来实现:

在代码中,run() 方法被用来将工作提交给回调函数池,处理完成后被激发。 实际工作被提交给 ThreadPoolExecutor 实例。 不过一个难点是协调计算结果和事件循环,为了解决它,我们创建了一对 socket 并将其作为某种信号量机制来使用。 当线程池完成工作后,它会执行类中的 _complete() 方法。 这个方法再某个 socket 上写入字节之前会讲挂起的回调函数和结果放入队列中。 fileno() 方法返回另外的那个 socket。 因此,这个字节被写入时,它会通知事件循环, 然后 handle_receive() 方法被激活并为所有之前提交的工作执行回调函数。

下面是一个简单的服务器,演示了如何使用线程池来实现耗时的计算:

运行这个服务器,然后试着用其它 Python 程序来测试它:

你应该能在不同窗口中重复的执行这个程序,并且不会影响到其他程序,尽管当数字便越来越大时候它会变得越来越慢。

你应该选择一个可以完成同样任务的高级框架。 不过,如果你理解了基本原理,你就能理解这些框架所使用的核心技术。 作为对回调函数编程的替代,事件驱动编码有时候会使用到协程,参考 12.12 小节的一个例子。

Last updated

Was this helpful?