-
阻塞
- 阻塞状态指程序未得到所需计算资源时被挂起的状态。
- 程序在等待某个操作完成期间,自身无法继续处理其他的事情,则称该程序在该操作上是阻塞的。
- 常见的阻塞形式有:网络 I/O 阻塞、磁盘 I/O 阻塞、用户输入阻塞等。
- 阻塞是无处不在的,包括 CPU 切换上下文时,所有的进程都无法真正处理事情,它们也会被阻塞。
- 如果是多核 CPU 则正在执行上下文切换操作的核不可被利用。
-
非阻塞
- 程序在等待某操作过程中,自身不被阻塞,可以继续处理其他的事情,则称该程序在该操作上是非阻塞的。
- 非阻塞并不是在任何程序级别、任何情况下都可以存在的。仅当程序封装的级别可以囊括独立的子程序单元时,它才可能存在非阻塞状态。
- 非阻塞的存在是因为阻塞存在,正因为某个操作阻塞导致的耗时与效率低下,我们才要把它变成非阻塞的。
-
同步
- 不同程序单元为了完成某个任务,在执行过程中需靠某种通信方式以协调一致,我们称这些程序单元是同步执行的。
- 例如购物系统中更新商品库存,需要用“行锁”作为通信信号,让不同的更新请求强制排队顺序执行,那更新库存的操作是同步的。
- 简言之,同步意味着有序。
-
异步
- 为完成某个任务,不同程序单元之间过程中无需通信协调,也能完成任务的方式,不相关的程序单元之间可以是异步的。
- 例如,爬虫下载网页。调度程序调用下载程序后,即可调度其他任务,而无需与该下载任务保持通信以协调行为。
- 不同网页的下载、保存等操作都是无关的,也无需相互通知协调。这些异步操作的完成时刻并不确定。
- 简言之,异步意味着无序。
-
多进程
- 多进程就是利用 CPU 的多核优势,在同一时间并行地执行多个任务,可以大大提高执行效率。
-
协程
-
协程,英文叫作 Coroutine,又称微线程、纤程,协程是一种用户态的轻量级线程。
-
协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈。
-
因此协程能保留上一次调用时的状态,即所有局部状态的一个特定组合,每次过程重入时,就相当于进入上一次调用的状态。
-
协程本质上是个单进程,协程相对于多进程来说,无需线程上下文切换的开销,无需原子操作锁定及同步的开销,编程模型也非常简单。
-
我们可以使用协程来实现异步操作,比如在网络爬虫场景下,我们发出一个请求之后,需要等待一定的时间才能得到响应,
-
但其实在这个等待过程中,程序可以干许多其他的事情,等到响应得到之后才切换回来继续处理,
-
这样可以充分利用 CPU 和其他资源,这就是协程的优势。
-
-
协程用法
- 接下来,我们来了解下协程的实现,从 Python 3.4 开始,Python 中加入了协程的概念,
- 但这个版本的协程还是以生成器对象为基础的,在 Python 3.5 则增加了 async/await,使得协程的实现更加方便。
- Python 中使用协程最常用的库莫过于 asyncio,所以本文会以 asyncio 为基础来介绍协程的使用。
-
首先我们需要了解下面几个概念。
- event_loop:
- 事件循环,相当于一个无限循环,我们可以把一些函数注册到这个事件循环上,
- 当满足条件发生的时候,就会调用对应的处理方法。
- coroutine:
- 中文翻译叫协程,在 Python 中常指代为协程对象类型,我们可以将协程对象注册到时间循环中,它会被事件循环调用。
- 我们可以使用 async 关键字来定义一个方法,这个方法在调用时不会立即被执行,而是返回一个协程对象。
- task:
- 任务,它是对协程对象的进一步封装,包含了任务的各个状态。
- future:
- 代表将来执行或没有执行的任务的结果,实际上和 task 没有本质区别。
- 另外我们还需要了解 async/await 关键字,它是从 Python 3.5 才出现的,专门用于定义协程。
- 其中,async 定义一个协程,await 用来挂起阻塞方法的执行
- event_loop:
-
asyncio库
-
导入 asyncio
-
async 关键字来定义一个方法,这个方法在调用时不会立即被执行,而是返回一个协程对象。
-
await 用来挂起阻塞方法的执行
-
async 定义的方法就会变成一个无法直接执行的 coroutine 对象,必须将其注册到事件循环中才可以执行。
- 上面# 1节我们还提到了 task,它是对 coroutine 对象的进一步封装,
- 它里面相比 coroutine 对象多了运行状态,
- 比如 running、finished 等,我们可以用这些状态来获取协程对象的执行情况。
-
参考009文件夹中案例001,002,003,004
-
多任务协程,wait方法
- 004案例是单任务协程
- 定义一个 task 列表,然后使用 asyncio 的 wait 方法即可执行
- 使用一个 for 循环创建了五个 task,组成了一个列表,
- 然后把这个列表首先传递给了 asyncio 的 wait() 方法,
- 然后再将其注册到时间循环中,就可以发起五个任务了。
- 多任务协程案例参考009文件夹中案例005,006
-
aiohttp 是一个支持异步请求的库,利用它和 asyncio 配合我们可以非常方便地实现异步请求操作
- aiohttp 库需要先自行安装,然后配合 asyncio 的 await 方法实现异步请求
- 官方文档链接为:https://aiohttp.readthedocs.io/,
- 它分为两部分,一部分是 Client,一部分是 Server
-
多任务协程,异步请求,await方法
-
006案例已经变成了多任务协程,但是还是顺序请求,未实现异步请求
-
实现异步处理,我们得先要有挂起的操作,当一个任务需要等待 IO 结果的时候,
-
可以挂起当前任务,转而去执行其他任务,这样我们才能充分利用好资源
-
要实现异步,接下来我们需要了解一下 await 的用法,
-
使用 await 可以将耗时等待的操作挂起,让出控制权。
-
当协程执行的时候遇到 await,时间循环就会将本协程挂起,
-
转而去执行别的协程,直到其他的协程挂起或执行完毕。
-
await 后面的对象必须是如下格式之一:
- A native coroutine object returned from a native coroutine function,一个原生 coroutine 协程对象。使用async构造的函数就是一个原生的协程对象
- A generator-based coroutine object returned from a function decorated with types.coroutine,一个由 types.coroutine 修饰的生成器,这个生成器可以返回 coroutine 对象。
- An object with an await method returning an iterator,一个包含 await 方法的对象返回的一个迭代器。
- 详情参考官方文档:https://www.python.org/dev/peps/pep-0492/#await-expression
- 也可以直接python搜索,asyncio await 找到上面的官方文档内容
-
-
参考009文件夹中案例007,006案例进行升级,await 后面接的是协程对象,使用async构造
-
500 次异步请求耗时测试:参考009文件夹中案例008
-
await方法执行原理,结合007案例查看:
-
遇到了 await,那么就会将当前协程挂起,转而去执行其他的协程,直到其他的协程也挂起或执行完毕,再进行下一个协程的执行,继续向下执行。
-
开始运行时,时间循环会运行第一个 task,针对第一个 task 来说,当执行到第一个 await 跟着的 get 方法时,它被挂起,
-
但这个 get 方法第一步的执行是非阻塞的,挂起之后立马被唤醒,所以立即又进入执行,
-
创建了 ClientSession 对象,接着遇到了第二个 await,调用了 session.get 请求方法,
-
然后就被挂起了,由于请求需要耗时很久,所以一直没有被唤醒。
-
当第一个 task 被挂起了,那接下来该怎么办呢?事件循环会寻找当前未被挂起的协程继续执行,
-
于是就转而执行第二个 task 了,也是一样的流程操作,直到执行了第十个 task 的 session.get 方法之后,
-
全部的 task 都被挂起了。所有 task 都已经处于挂起状态,怎么办?只好等待了。
-
0点几秒之后,几个请求几乎同时都有了响应,然后几个 task 也被唤醒接着执行,输出请求结果,最后总耗时,0.9 秒!
-
这就是异步操作的便捷之处,当遇到阻塞式操作时,任务被挂起,程序接着去执行其他的任务,而不是傻傻地等待,
-
这样可以充分利用 CPU 时间,而不必把时间浪费在等待 IO 上。
-
是否可以实现无限个task任务:
- 服务器在同一时刻接受无限次请求都能保证正常返回结果,也就是服务器无限抗压,
- 另外还要忽略 IO 传输时延,确实可以做到无限 task 一起执行且在预想时间内得到结果。
- 但由于不同服务器处理的实现机制不同,可能某些服务器并不能承受这么高的并发,因此响应速度也会减慢。
-
-
asyncio 模块内部实现了对 TCP、UDP、SSL 协议的异步操作,
-
但是对于 HTTP 请求的异步操作来说, 我们就需要用到 aiohttp 来实现了。
-
aiohttp 是一个基于 asyncio 的异步 HTTP 网络模块,它既提供了服务端,又提供了客户端。
-
其中我们用服务端可以搭建一个支持异步处理的服务器,用于处理请求并返回响应,
-
类似于 Django、Flask、Tornado 等一些 Web 服务器。
-
而客户端我们就可以用来发起请求,就类似于 requests 来发起一个 HTTP 请求然后获得响应,
-
但 requests 发起的是同步的网络请求,而 aiohttp 则发起的是异步的。
-
参考009文件夹中案例009案例:
- aiohttp和requests请求方法的定义有了明显的区别,主要有如下几点:
- 区别1:
- 首先在导入库的时候,我们除了必须要引入 aiohttp 这个库之外,
- 还必须要引入 asyncio 这个库,因为要实现异步爬取需要启动协程,
- 而协程则需要借助于 asyncio 里面的事件循环来执行。
- 除了事件循环,asyncio 里面也提供了很多基础的异步操作。
- 区别2:
- 异步爬取的方法的定义和之前有所不同,在每个异步方法前面统一要加 async 来修饰。
- with as 语句前面同样需要加 async 来修饰,在 Python 中,
- with as 语句用于声明一个上下文管理器,能够帮我们自动分配和释放资源,
- 而在异步方法中,with as 前面加上 async 代表声明一个支持异步的上下文管理器。
- 区别3:
- 对于一些async返回 coroutine 协程对象的的操作,前面需要加 await 来修饰,
- 如 response 调用 text 方法,查询 API 可以发现其返回的是 coroutine 协程对象,那么前面就要加 await;
- 而对于状态码来说,其返回值就是一个数值类型,那么前面就不需要加 await。
- 所以,这里可以按照实际情况处理,参考官方文档说明,看看其对应的返回值是怎样的类型,然后决定加不加 await 就可以了。
- 区别4:
- 最后,定义完爬取方法之后,实际上是 main 协程方法调用了 fetch 协程方法(await 后面要接协程对象)。
- 要运行的话,必须要启用事件循环,事件循环就需要使用 asyncio 库,
- 然后使用 loop循环 run_until_complete 方法来运行上面的main方法。
- 注意:
- 注意在 Python 3.7 及以后的版本中,我们可以使用 asyncio.run(main()) 来代替最后的启动操作,
- 不需要显式声明事件循环,run 方法内部会自动启动一个事件循环。
- 但这里为了兼容更多的 Python 版本,依然还是显式声明了事件循环。
- 另外 aiohttp 还支持其他的请求类型,如 POST、PUT、DELETE 等等,这个和 requests 的使用方式有点类似,示例如下:
- session.post('http://httpbin.org/post', data=b'data')
- session.put('http://httpbin.org/put', data=b'data')
- session.delete('http://httpbin.org/delete')
- session.head('http://httpbin.org/get')
- session.options('http://httpbin.org/get')
- session.patch('http://httpbin.org/patch', data=b'data')
- 参考009文件夹中案例010/011/012案例
- 由于 aiohttp 可以支持非常大的并发,比如上万、十万、百万都是能做到的,
- 但这么大的并发量,目标网站是很可能在短时间内无法响应的,而且很可能瞬时间将目标网站爬挂掉。
- 所以我们需要控制一下爬取的并发量。
- 在一般情况下,我们可以借助于 asyncio 的 Semaphore 来控制并发量
- 参考009文件夹中案例013案例
-
我们要完成的目标有:
- 使用 aiohttp 完成全站的书籍数据爬取。
- 将数据通过异步的方式保存到 MongoDB 中。
-
开始之前,请确保你已经做好了如下准备工作:
- 安装好了 Python(最低为 Python 3.6 版本,最好为 3.7 版本或以上),并能成功运行 Python 程序。
- 了解了 Ajax 爬取的一些基本原理和模拟方法。
- 了解了异步爬虫的基本原理和 asyncio 库的基本用法。
- 了解了 aiohttp 库的基本用法。
- 安装并成功运行了 MongoDB 数据库,并安装了异步存储库 motor。
- 注:这里要实现 MongoDB 异步存储,需要异步 MongoDB 存储库,叫作 motor,安装命令为:pip install motor
-
分析网站:
- 打开图书网站,查看源码,发现源码里面并没有图书信息,只有一些css和js文件引用
- 说明图书列表都是ajax动态请求加载的,寻找真实的请求地址
- 使用Firefox,network里面选择xhr类型,选择持续记录,然后多打开几个页面,就可以发现规律
- 每次打开页面有两个XHR请求,其中一个重定向另外一个
- 真实网址:https://dynamic5.scrape.cuiqingcai.com/api/book?limit=18&offset={offset}
- 每一页就是offset后面的值不同,每次加上18,offset=18*(page-1), 参数limit是每页显示的数量
- 列表页 Ajax 接口返回的数据里 results 字段包含当前页 18 本书的信息,其中每本书的数据里面包含一个字段 id,这个 id 就是书本身的 ID,可以用来进一步请求详情页。
- 详情页的 Ajax 请求接口格式为:https://dynamic5.scrape.cuiqingcai.com/api/book/{id},id 即为书的 ID,可以从列表页的返回结果中获取
-
异步爬取思路:
- 其实一个完善的异步爬虫应该能够充分利用资源进行全速爬取,其思路是维护一个动态变化的爬取队列,
- 每产生一个新的 task 就会将其放入队列中,有专门的爬虫消费者从队列中获取 task 并执行,
- 能做到在最大并发量的前提下充分利用等待时间进行额外的爬取处理。
-
爬取实现方法:
- 将爬取的逻辑拆分成两部分,第一部分为爬取列表页,第二部分为爬取详情页。
- 由于异步爬虫的关键点在于并发执行,所以我们可以将爬取拆分为两个阶段:
- 第一阶段为所有列表页的异步爬取,我们可以将所有的列表页的爬取任务集合起来,声明为 task 组成的列表,进行异步爬取。
- 第二阶段则是拿到上一步列表页的所有内容并解析,拿到所有书的 id 信息,组合为所有详情页的爬取任务集合,
- 声明为 task 组成的列表,进行异步爬取,同时爬取的结果也以异步的方式存储到 MongoDB 里面。