3.7 异步编程入门
我们在之前简单地提到了'异步'这个概念,今天我们针对异步编程再进行简要的学习。
所谓异步是相对于同步(Synchronous)的概念来说的,之所以容易造成混乱,是因为刚开始接触这两个概念时容易把同步看做是同时,而同时不是意味着并行(Parallel)吗(一开始,本人就在'多进程'、'异步'这些概念上犯糊涂了)?
然而实际上同步或者异步是针对于时间轴的概念,同步意味着顺序、统一的时间轴,而异步则意味着乱序、效率优先的时间轴。比如在爬虫运行时,先抓取 A 页面,然后从中提取下一层页面 B 的链接,此时的爬虫程序的运行只能是同步的,B 页面只能等到 A 页面处理完成之后才能抓取;然而对于独立的两个页面 A1 和 A2,在处理 A1 网络请求的时间里,与其让 CPU 空闲而 A2 等在后面,不如先处理 A2,等到谁先完成网络请求谁就先来进行处理,这样可以更加充分地利用 CPU,但是 A1 和 A2 的执行顺序则是不确定的,也就是异步的。
1. IO 模型类型
文章开始前,先简单介绍下各种 IO 模型:
阻塞 IO
即读写数据时,需要等待操作完成,才能继续执行。进阶的做法就是用多线程来处理需要 IO 的部分,缺点是开销会有些大。
非阻塞 IO
即读写数据时,如果暂时不可读写,则立刻返回,而不等待。因为不知道什么时候是可读写的,所以轮询时可能会浪费 CPU 时间。
IO 复用
即在读写数据前,先检查哪些描述符是可读写的,再去读写。select 和 poll 就是这样做的,它们会遍历所有被监视的描述符,查看是否满足,这个检查的过程是阻塞的。而 epoll、kqueue 和 /dev/poll 则做了些改进,事先注册需要检查哪些描述符的哪些事件,当状态发生变化时,内核会调用对应的回调函数,将这些描述符保存下来;下次获取可用的描述符时,直接返回这些发生变化的描述符即可。
信号驱动
即描述符就绪时,内核发送 SIGIO 信号,再由信号处理程序去处理这些信号即可。不过信号处理的时机是从内核态返回用户态时,感觉也得把这些事件收集起来才好处理,有点像模拟 IO 复用了。
异步 IO
只发出IO指令,并不等待IO结果,然后就去执行其他代码了。一段时间后,当IO返回结果时,再通知CPU进行处理。即读写数据时,只注册事件,内核完成读写后(读取的数据会复制到用户态),再调用事件处理函数。
异步IO模型需要一个消息循环,在消息循环中,主线程不断地重复“读取消息-处理消息”这一过程。
主线程不停地读取并处理消息,所有的消息都被发送到程序的消息队列中,然后由程序的主线程处理。同时,在消息模型中,处理一个消息必须非常迅速,否则,主线程将无法及时处理消息队列中的其他消息,导致程序看上去停止响应。
所以,在消息模型中,当遇到IO操作时,代码只负责发出IO请求,不等待IO结果,然后直接结束本轮消息处理,进入下一轮消息处理过程。当IO操作完成后,将收到一条“IO完成”的消息,处理该消息时就可以直接获取IO操作结果。
2. 事件循环(Event Loop)
Event Loop 是一个很重要的概念,指的是计算机系统的一种运行机制。
Event Loop
一般的单线程程序中,所有任务需要排队,前一个任务结束,才会执行后一个任务。如果前一个任务耗时很长,后一个任务就不得不一直等着。
如果排队是因为计算量大,CPU忙不过来,倒也算了,但是很多时候CPU是闲着的,因为IO设备(输入输出设备)很慢(比如Ajax操作从网络读取数据),不得不等着结果出来,再往下执行。
那么这时主线程完全可以不管IO设备,挂起处于等待中的任务,先运行排在后面的任务。等到IO设备返回了结果,再回过头,把挂起的任务继续执行下去。
于是,所有任务可以分成两种,一种是同步任务(synchronous),另一种是异步任务(asynchronous)。同步任务指的是,在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务;异步任务指的是,不进入主线程、而进入”任务队列”(task queue)的任务,只有”任务队列”通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。 具体来说,异步执行的运行机制如下。(同步执行也是如此,因为它可以被视为没有异步任务的异步执行。)
- 所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。
- 主线程之外,还存在一个"任务队列"(task queue)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。
- 一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
- 主线程不断重复上面的第三步。
下图就是主线程和任务队列的示意图。
只要主线程空了,就会去读取”任务队列”,这个过程会不断重复。
3. 为什么要使用异步编程?
CPU的速度远远快于磁盘、网络等IO。在一个线程中,CPU执行代码的速度极快,然而,一旦遇到IO操作,如读写文件、发送网络数据时,就需要等待IO操作完成,才能继续进行下一步操作。这种情况称为同步IO。
在IO操作的过程中,当前线程被挂起,而其他需要CPU执行的代码就无法被当前线程执行了。
因为一个IO操作就阻塞了当前线程,导致其他代码无法执行,所以我们必须使用多线程或者多进程来并发执行代码,为多个用户服务。每个用户都会分配一个线程,如果遇到IO导致线程被挂起,其他用户的线程不受影响。
多线程和多进程的模型虽然解决了并发问题,但是系统不能无上限地增加线程。由于系统切换线程的开销也很大,所以,一旦线程数量过多,CPU的时间就花在线程切换上了,真正运行代码的时间就少了,结果导致性能严重下降。
由于我们要解决的问题是CPU高速执行能力和IO设备的龟速严重不匹配,多线程和多进程只是解决这一问题的一种方法。
另一种解决IO问题的方法是异步IO。当代码需要执行一个耗时的IO操作时,它只发出IO指令,并不等待IO结果,然后就去执行其他代码了。一段时间后,当IO返回结果时,再通知CPU进行处理。
4. 协程与异步
协程,又称微线程,纤程,英文名Coroutine。协程的作用,是在执行函数A时,可以随时中断,去执行函数B,然后中断继续执行函数A(可以自由切换)。但这一过程并不是函数调用(没有调用语句),这一整个过程看似像多线程,然而协程只有一个线程执行。
现在,我们知道协程的特点在于是一个线程执行,那和多线程比,协程有何优势?
最大的优势就是协程极高的执行效率。因为子程序切换不是线程切换,而是由程序自身控制,因此,没有线程切换的开销,和多线程比,线程数量越多,协程的性能优势就越明显。而对于单个线程,协程的性能还是有所欠缺的。
第二大优势就是不需要多线程的锁机制,因为只有一个线程,也不存在同时写变量冲突,在协程中控制共享资源不加锁,只需要判断状态就好了,所以执行效率比多线程高很多。
因为协程是一个线程执行,那怎么利用多核CPU呢?最简单的方法是多进程+协程,既充分利用多核,又充分发挥协程的高效率,可获得极高的性能。所以在爬虫之中运用协程可以有效提升效率,在进一步学习异步爬虫之前,我们需要了解 python3.5 之后出现的 async/await 关键字 以及 asyncio 库的简单使用,下面是一些学习资料: