asyncio

notion image

异步IO进化之路

同步阻塞方式

notion image
在示例代码中有两个关键点。一是第10行的 sock.connect((‘example.com’, 80)),该调用的作用是向example.com主机的80端口发起网络连接请求。 二是第14行、第18行的sock.recv(4096),该调用的作用是从socket上读取4K字节数据。
注:总体耗时约为4.5秒。

改进方式:多进程

notion image
注:总体耗时约为 0.6 秒。
总体耗时并没有缩减到原来的十分之一,而是九分之一左右,还有一些时间耗到哪里去了?进程切换开销。
CPU从一个进程切换到另一个进程,需要把旧进程运行时的寄存器状态、内存状态全部保存好,再将另一个进程之前保存的数据恢复。对CPU来讲,几个小时就干等着。当进程数量大于CPU核心数量时,进程切换是必然需要的。
除了切换开销,多进程还有另外的缺点。一般的服务器在能够稳定运行的前提下,可以同时处理的进程数在数十个到数百个规模。如果进程数量规模更大,系统运行将不稳定,而且可用内存资源往往也会不足。
除了切换开销大,以及可支持的任务规模小之外,多进程还有其他缺点,如状态共享等问题,后文会有提及,此处不再细究。

继续改进:多线程

notion image
注:总体运行时间约0.43秒。
Python中的多线程因为GIL的存在,它们并不能利用CPU多核优势,一个Python进程中,只允许有一个线程处于运行状态。然而在做阻塞的系统调用时,例如sock.connect(),sock.recv()时,当前线程会释放GIL,让别的线程有执行机会。但是单个线程内,在阻塞调用上还是阻塞的。
除了GIL之外,所有的多线程还有通病。它们是被OS调度,调度策略是抢占式的,以保证同等优先级的线程都有均等的执行机会,那带来的问题是:并不知道下一时刻是哪个线程被运行,也不知道它正要执行的代码是什么。所以就可能存在竞态条件。

非阻塞方式

notion image
注:总体耗时约4.3秒。
虽然 connect() 和 recv() 不再阻塞主程序,空出来的时间段CPU没有空闲着,但并没有利用好这空闲去做其他有意义的事情,而是在循环尝试读写 socket (不停判断非阻塞调用的状态是否就绪)。还得处理来自底层的可忽略的异常。也不能同时处理多个 socket 。

非阻塞改进

OS将I/O状态的变化都封装成了事件,如可读事件、可写事件。并且提供了专门的系统模块让应用程序可以接收事件通知。这个模块就是select。让应用程序可以通过select注册文件描述符和回调函数。当文件描述符的状态发生变化时,select 就调用事先注册的回调函数。
select因其算法效率比较低,后来改进成了poll,再后来又有进一步改进,BSD内核改进成了kqueue模块,而Linux内核改进成了epoll模块。这四个模块的作用都相同,暴露给程序员使用的API也几乎一致,区别在于kqueue 和 epoll 在处理大量文件描述符时效率更高。
把I/O事件的等待和监听任务交给了 OS,那 OS 在知道I/O状态发生改变后(例如socket连接已建立成功可发送数据),它又怎么知道接下来该干嘛呢?只能回调。
notion image
notion image
notion image
注:总体耗时约0.45秒。
上述代码异步执行的过程:
  1. 创建Crawler 实例;
  1. 调用fetch方法,会创建socket连接和在selector上注册可写事件;
  1. fetch内并无阻塞操作,该方法立即返回;
  1. 重复上述3个步骤,将10个不同的下载任务都加入事件循环;
  1. 启动事件循环,进入第1轮循环,阻塞在事件监听上;
  1. 当某个下载任务EVENT_WRITE被触发,回调其connected方法,第一轮事件循环结束;
  1. 进入第2轮事件循环,当某个下载任务有事件触发,执行其回调函数;此时已经不能推测是哪个事件发生,因为有可能是上次connected里的EVENT_READ先被触发,也可能是其他某个任务的EVENT_WRITE被触发;(此时,原来在一个下载任务上会阻塞的那段时间被利用起来执行另一个下载任务了)
  1. 循环往复,直至所有下载任务被处理完成
  1. 退出事件循环,结束整个下载程序

Python 对异步I/O的优化之路

 
 

参考文章

上一篇
NCCL通信
下一篇
CUDA编程
Loading...
文章列表

加载中