06 高性能异步爬虫

06 高性能异步爬虫

高性能异步爬虫---线程and线程池

引入

很多同学对于异步这个概念只是停留在了“听说很NB”的认知层面上,很少有人能够在项目中真正的使用异步实现高性能的相关操作。本节课,咱们就一起来学习一下,爬虫中如何使用异步实现高性能的数据爬取操作。

背景

其实爬虫的本质就是client发请求批量获取server的响应数据,如果我们有多个url待爬取,只用一个线程且采用串行的方式执行,那只能等待爬取一个结束后才能继续下一个,效率会非常低。需要强调的是:对于单线程下串行N个任务,并不完全等同于低效,如果这N个任务都是纯计算的任务,那么该线程对cpu的利用率仍然会很高,之所以单线程下串行多个爬虫任务低效,是因为爬虫任务是明显的IO密集型(阻塞)程序。那么该如何提高爬取性能呢?

分析处理

- 同步调用:即提交一个任务后就在原地等待任务结束,等到拿到任务的结果后再继续下一行代码,效率低下:

import requestsdef parse_page(res):    print('解析 %s' %(len(res)))def get_page(url):    print('下载 %s' %url)    response=requests.get(url)    if response.status_code == 200:        return response.texturls = [    'http://xmdx.sc.chinaz.net/Files/DownLoad/jianli/201904/jianli10231.rar',    'http://zjlt.sc.chinaz.net/Files/DownLoad/jianli/201904/jianli10229.rar',    'http://xmdx.sc.chinaz.net/Files/DownLoad/jianli/201904/jianli10231.rar']for url in urls:    res=get_page(url) #调用一个任务,就在原地等待任务结束拿到结果后才继续往后执行    parse_page(res)

- 解决同步调用方案之多线程/多进程(不建议使用)

  • 好处:在服务器端使用多线程(或多进程)。多线程(或多进程)的目的是让每个连接都拥有独立的线程(或进程),这样任何一个连接的阻塞都不会影响其他的连接。
    
  • 弊端:开启多进程或都线程的方式,我们是无法无限制地开启多进程或多线程的:在遇到要同时处理成百上千个的连接请求时,则无论多线程还是多进程都会严重占据系统资源,降低系统对外界响应效率,而且线程与进程本身也更容易进入假死状态。

- 解决同步调用方案之线程/进程池(适当使用)

  • 好处:很多程序员可能会考虑使用“线程池”或“连接池”。“线程池”旨在减少创建和销毁线程的频率,其维持一定合理数量的线程,并让空闲的线程重新承担新的执行任务。可以很好的降低系统开销。
  • 弊端:“线程池”和“连接池”技术也只是在一定程度上缓解了频繁创建和销毁线程带来的资源占用。而且,所谓“池”始终有其上限,当请求大大超过上限时,“池”构成的系统对外界的响应并不比没有池的时候效果好多少。所以使用“池”必须考虑其面临的响应规模,并根据响应规模调整“池”的大小。
  • 案例:
    • 对比同步和使用线程池的执行效率
#同步执行import timedef sayhello(str):    print("Hello ",str)    time.sleep(2)name_list =['xiaozi','aa','bb','cc']start_time = time.time()for i in range(len(name_list)):    sayhello(name_list[i])print('%d second'% (time.time()-start_time))

执行结果:

Hello  xiaoziHello  aaHello  bbHello  cc8 second
#异步基于线程池import timefrom multiprocessing.dummy import Pooldef sayhello(str):    print("Hello ",str)    time.sleep(2)start = time.time()name_list =['xiaozi','aa','bb','cc']#实例化线程池对象,开启了4个线程pool = Pool(4)pool.map(sayhello,name_list)pool.close()pool.join()end = time.time()print(end-start)

执行结果:

Hello  xiaoziHello  aaHello  bbHello  cc2.0805933475494385
  • 基于multiprocessing.dummy线程池爬取梨视频的视频信息

from multiprocessing.dummy import Pool
import requests
from lxml import etree
import re
#需求爬取梨视频的视频数据
headers = {
 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.121 Safari/537.36'
}
#原则:线程池处理的是阻塞且较为耗时的操作
#对下述url发起请求解析出视频详情页的url和视频的名称

url = 'https://www.pearvideo.com/category_2'

page_text=requests.get(url=url,headers=headers).text
tree=etree.HTML(page_text)

li_list =tree.xpath('//ul[@id="listvideoListUl"]/li')
#储存所有视频的链接
urls=[]
for li in li_list:
    detail_url = 'http://www.pearvideo.com/' + li.xpath('./div/a/@href')[0]
    title = li.xpath('./div/a/div[2]/text()')[0] + '.mp4'
    # print(detail_url,title)
    #请求详情页面 获取下载链接
    detail_text = requests.get(url=detail_url, headers=headers).text
    #解析视频地址url  是js加载来的,不在本页面.(bs4和xpath只能解析标签)
    video_url=re.findall('srcUrl="(.*?)",vdoUrl',detail_text)[0]
    dic={
        'title':title,
        'url':video_url,
    }
    urls.append(dic)

def get_video_data(dic):
    url = dic['url']
    title = dic['title']
    print(title, 'download -ing...')
    data=requests.get(url=url,headers=headers).content

    #持久化存储
    with open(title,'wb') as f:
        f.write(data)
        print(title,'download -success!!')

#使用线程池进行视频数据的请求
pool=Pool(4)
pool.map(get_video_data,urls) #func,可迭代对象
pool.close()
pool.join()

高性能异步爬虫---异步协程

引入

上一小节中无论哪种解决方案其实没有解决一个性能相关的问题:IO阻塞,无论是多进程还是多线程,在遇到IO阻塞时都会被操作系统强行剥夺走CPU的执行权限,程序的执行效率因此就降低了下来。

解决这一问题的关键在于,我们自己从应用程序级别检测IO阻塞然后切换到我们自己程序的其他任务执行,这样把我们程序的IO降到最低,我们的程序处于就绪态就会增多,以此来迷惑操作系统,操作系统便以为我们的程序是IO比较少的程序,从而会尽可能多的分配CPU给我们,这样也就达到了提升程序执行效率的目的。

在python3.4之后新增了asyncio模块,可以帮我们检测IO阻塞,然后实现异步IO。注意:asyncio只能发tcp级别的请求,不能发http协议。

什么是异步IO

所谓「异步 IO」,就是你发起一个 IO阻塞 操作,却不用等它结束,你可以继续做其他事情,当它结束时,你会得到通知。

实现异步IO的方式

单线程+异步协程实现异步IO操作

异步协程用法

从 Python 3.4 开始,Python 中加入了协程的概念,但这个版本的协程还是以生成器对象为基础的,在 Python 3.5 则增加了 async/await,使得协程的实现更加方便。首先我们需要了解下面几个概念:

  • event_loop:事件循环,相当于一个无限循环,我们可以把一些函数注册到这个事件循环上,当满足某些条件的时候,函数就会被循环执行。程序是按照设定的顺序从头执行到尾,运行的次数也是完全按照设定。当在编写异步程序时,必然其中有部分程序的运行耗时是比较久的,需要先让出当前程序的控制权,让其在背后运行,让另一部分的程序先运行起来。当背后运行的程序完成后,也需要及时通知主程序已经完成任务可以进行下一步操作,但这个过程所需的时间是不确定的,需要主程序不断的监听状态,一旦收到了任务完成的消息,就开始进行下一步。loop就是这个持续不断的监视器。
  • coroutine:中文翻译叫协程,在 Python 中常指代为协程对象类型,我们可以将协程对象注册到事件循环中,它会被事件循环调用。我们可以使用 async 关键字来定义一个方法,这个方法在调用时不会立即被执行,而是返回一个协程对象。
  • task:任务,它是对协程对象的进一步封装,包含了任务的各个状态。
  • future:代表将来执行或还没有执行的任务,实际上和 task 没有本质区别。

另外我们还需要了解 async/await 关键字,它是从 Python 3.5 才出现的,专门用于定义协程。其中,async 定义一个协程,await 用来挂起阻塞方法的执行。

协程的基本写法 :

async def func():
    print("我是协程")


if __name__ == '__main__':
    # print(func())  # 注意, 此时拿到的是一个协程对象, 和生成器差不多.该函数默认是不会这样执行的

    coroutine = func()
    asyncio.run(coroutine)  # 用asyncio的run来执行协程.
    # lop = asyncio.get_event_loop()
    # lop.run_until_complete(coroutine)   # 这两句顶上面一句
    

还可以这样

async def download(url):
    print("开始抓取")
    await asyncio.sleep(3)  # 我要开始下载了
    print("下载结束", url)
    return "老子是源码你信么"


async def main():
    urls = [
        "http://www.baidu.com",
        "http://www.h.com",
        "http://luoyonghao.com"
    ]
    # 生成任务列表
    tasks = [asyncio.create_task(download(url)) for url in urls]
    # 一次性把所有任务都执行
    done, pedding = await asyncio.wait(tasks)
    for d in done:
        print(d.result())


if __name__ == '__main__':
    asyncio.run(main())

- 定义一个协程:

import asyncio
async def execute(x):
    print('Number:', x)
coroutine = execute(1)
print('Coroutine:', coroutine)
print('After calling execute')
loop = asyncio.get_event_loop()
loop.run_until_complete(coroutine)
print('After calling loop')

运行结果:

Coroutine: 
After calling execute
Number: 1
After calling loop

首先我们引入了 asyncio 这个包,这样我们才可以使用 async 和 await,然后我们使用 async 定义了一个 execute() 方法,方法接收一个数字参数,方法执行之后会打印这个数字。

随后我们直接调用了这个方法,然而这个方法并没有执行,而是返回了一个 coroutine 协程对象。随后我们使用 get_event_loop() 方法创建了一个事件循环 loop,并调用了 loop 对象的 run_until_complete() 方法将协程注册到事件循环 loop 中,然后启动。最后我们才看到了 execute() 方法打印了输出结果。

可见,async 定义的方法就会变成一个无法直接执行的 coroutine 对象,必须将其注册到事件循环中才可以执行。

- task的使用:

上文我们还提到了 task,它是对 coroutine 对象的进一步封装,它里面相比 coroutine 对象多了运行状态,比如 running、finished 等,我们可以用这些状态来获取协程对象的执行情况。

在上面的例子中,当我们将 coroutine 对象传递给 run_until_complete() 方法的时候,实际上它进行了一个操作就是将 coroutine 封装成了 task 对象,我们也可以显式地进行声明,如下所示:

import asyncio
async def execute(x):
    print('Number:', x)
    return x
coroutine = execute(1)
print('Coroutine:', coroutine)
print('After calling execute')
loop = asyncio.get_event_loop()
task = loop.create_task(coroutine)
print('Task:', task)
loop.run_until_complete(task)
print('Task:', task)
print('After calling loop')

运行结果:

Coroutine: 
After calling execute
Task: >
Number: 1
Task:  result=1>
After calling loop

- ensure_future()的使用

另外定义 task 对象还有一种方式,就是直接通过 asyncio 的 ensure_future() 方法,返回结果也是 task 对象,这样的话我们就可以不借助于 loop 来定义,即使我们还没有声明 loop 也可以提前定义好 task 对象,写法如下:

import asyncio
async def execute(x):
    print('Number:', x)
    return x
coroutine = execute(1)
print('Coroutine:', coroutine)
print('After calling execute')
task = asyncio.ensure_future(coroutine)
print('Task:', task)
loop = asyncio.get_event_loop()
loop.run_until_complete(task)
print('Task:', task)
print('After calling loop')

- 绑定回调

也可以为某个 task 绑定一个回调方法,来看下面的例子:

import asyncio
import requests
async def request():
    url = 'https://www.baidu.com'
    status = requests.get(url).status_code
    return status
def callback(task):
    print('Status:', task.result())
coroutine = request()
task = asyncio.ensure_future(coroutine)
task.add_done_callback(callback)
print('Task:', task)
loop = asyncio.get_event_loop()
loop.run_until_complete(task)
print('Task:', task)

运行结果:

Task:  cb=[callback() at demo.py:11]>
Status: 
Task:  result=>

在这里我们定义了一个 request() 方法,请求了百度,返回状态码,但是这个方法里面我们没有任何 print() 语句。随后我们定义了一个 callback() 方法,这个方法接收一个参数,是 task 对象,然后调用 print() 方法打印了 task 对象的结果。这样我们就定义好了一个 coroutine 对象和一个回调方法,我们现在希望的效果是,当 coroutine 对象执行完毕之后,就去执行声明的 callback() 方法。

那么它们二者怎样关联起来呢?很简单,只需要调用 add_done_callback() 方法即可,我们将 callback() 方法传递给了封装好的 task 对象,这样当 task 执行完毕之后就可以调用 callback() 方法了,同时 task 对象还会作为参数传递给 callback() 方法,调用 task 对象的 result() 方法就可以获取返回结果了。

高性能异步爬虫---多任务异步协程

多任务协程

如果我们想执行多次请求应该怎么办呢?我们可以定义一个 task 列表,然后使用 asyncio 的 wait() 方法即可执行。

import asyncio
import time
async def request(url):
    print('正在下载',url)
    #在异步协程中如果出现了同步模块相关的代码,那么就无法实现异步。
    time.sleep(2)
    print('下载完毕',url)
start = time.time()
urls = [
    'www.baidu.com',
    'www.sogou.com',
    'www.goubanjia.com'
]
#任务列表:存放多个任务对象
stasks = []
for url in urls:
    c = request(url)
    task = asyncio.ensure_future(c)
    stasks.append(task)
loop = asyncio.get_event_loop()
#需要将任务列表封装到wait中
loop.run_until_complete(asyncio.wait(stasks))
print(time.time()-start)

这里我们使用一个 for 循环创建了五个 task,组成了一个列表,然后把这个列表首先传递给了 asyncio 的 wait() 方法,然后再将其注册到时间循环中,就可以发起五个任务了。最后我们再将任务的运行结果输出出来,运行结果如下:

正在下载 www.baidu.com
下载完毕 www.baidu.com
正在下载 www.sogou.com
下载完毕 www.sogou.com
正在下载 www.goubanjia.com
下载完毕 www.goubanjia.com
6.009262800216675

上述案例中确实已经实现了多任务协程,但是说好的异步呢?注意:在实现异步环节的编码中不可以出现非异步模块的代码,否则就无法实现真正的异步了。上述案例中的time.sleep就是非异步模块中的代码。因此改写成:

import asyncio
import time
async def request(url):
    print('正在下载',url)
    #在异步协程中如果出现了同步模块相关的代码,那么就无法实现异步。
    # time.sleep(2)
    #当在asyncio中遇到阻塞操作必须进行手动挂起
    await asyncio.sleep(2)
    print('下载完毕',url)
start = time.time()
urls = [
    'www.baidu.com',
    'www.sogou.com',
    'www.goubanjia.com'
]
#任务列表:存放多个任务对象
stasks = []
for url in urls:
    c = request(url)
    task = asyncio.ensure_future(c)
    stasks.append(task)
loop = asyncio.get_event_loop()
#需要将任务列表封装到wait中
loop.run_until_complete(asyncio.wait(stasks))
print(time.time()-start)

执行结果:

正在下载 www.baidu.com
正在下载 www.sogou.com
正在下载 www.goubanjia.com
下载完毕 www.baidu.com
下载完毕 www.sogou.com
下载完毕 www.goubanjia.com
2.0058767795562744

实现了真正的多任务异步协程!

继续

接下来,咱们就可以尝试的将多任务异步协程应用到爬虫中进行试验,看是否能够实现多任务异步爬虫?为了表现出协程的优势,我们需要先创建一个合适的实验环境,最好的方法就是模拟一个需要等待一定时间才可以获取返回结果的网页,在本地模拟一个慢速服务器,这里我们选用 Flask。

服务器代码如下:

from flask import Flask
import time
app = Flask(__name__)
@app.route('/bobo')
def index_bobo():
    time.sleep(2)
    return 'Hello bobo'
@app.route('/jay')
def index_jay():
    time.sleep(2)
    return 'Hello jay'
@app.route('/tom')
def index_tom():
    time.sleep(2)
    return 'Hello tom'
if __name__ == '__main__':
    app.run(threaded=True)

接下来,将多任务异步协程操作应用在爬虫上:

import requests
import asyncio
import time
start = time.time()
urls = [
    'http://127.0.0.1:5000/bobo','http://127.0.0.1:5000/jay','http://127.0.0.1:5000/tom'
]
async def get_page(url):
    print('正在下载',url)
    response = requests.get(url=url)
    print('下载完毕:',response.text)
tasks = []
for url in urls:
    c = get_page(url)
    task = asyncio.ensure_future(c)
    tasks.append(task)
loop = asyncio.get_event_loop()
loop.run_until_complete(asyncio.wait(tasks))
end = time.time()
print('总耗时:',end-start)

运行结果如下:

正在下载 http://127.0.0.1:5000/bobo
下载完毕: Hello bobo
正在下载 http://127.0.0.1:5000/jay
下载完毕: Hello jay
正在下载 http://127.0.0.1:5000/tom
下载完毕: Hello tom
总耗时: 6.045619249343872

可以发现和正常的请求并没有什么两样,依然还是顺次执行的,耗时 6 秒,平均一个请求耗时 2 秒,说好的异步处理呢?

原因在于requests模块是非异步模块,要想实现真正的异步必须使用基于异步的网络请求模块所以这里就需要 aiohttp 派上用场了。

高性能异步爬虫---aiohttp

aiohttp简介

aiohttp可以实现单线程并发IO操作。

环境安装

pip install aiohttp

aiohttp使用

发起请求

async def fetch():
    async with aiohttp.ClientSession() as session:
        async with session.get('https://www.baidu.com') as resposne:
            print(await resposne.text())
loop = asyncio.get_event_loop()
tasks = [fetch(),]
loop.run_until_complete(asyncio.wait(tasks))

添加请求参数

params = {'key': 'value', 'page': 10}
async def fetch():
    async with aiohttp.ClientSession() as session:
        async with session.get('https://www.baidu.com/s',params=params) as resposne:
            print(await resposne.url)
loop = asyncio.get_event_loop()
tasks = [fetch(),]
loop.run_until_complete(asyncio.wait(tasks))

UA伪装

url = 'http://httpbin.org/user-agent'
headers = {'User-Agent': 'test_user_agent'}
async def fetch():
    async with aiohttp.ClientSession() as session:
        async with session.get(url,headers=headers) as resposne:
            print(await resposne.text())
loop = asyncio.get_event_loop()
tasks = [fetch(),]
loop.run_until_complete(asyncio.wait(tasks))

自定义cookies

url = 'http://httpbin.org/cookies'
cookies = {'cookies_name': 'test_cookies'}
async def fetch():
    async with aiohttp.ClientSession() as session:
        async with session.get(url,cookies=cookies) as resposne:
            print(await resposne.text())
loop = asyncio.get_event_loop()
tasks = [fetch(),]
loop.run_until_complete(asyncio.wait(tasks))

post请求参数

url = 'http://httpbin.org'
payload = {'username': 'zhang', 'password': '123456'}
async def fetch():
    async with aiohttp.ClientSession() as session:
        async with session.post(url, data=payload) as resposne:
            print(await resposne.text())
loop = asyncio.get_event_loop()
tasks = [fetch(), ]
loop.run_until_complete(asyncio.wait(tasks))

设置代理

url = "http://python.org"
async def fetch():
    async with aiohttp.ClientSession() as session:
        async with session.get(url, proxy="http://some.proxy.com") as resposne:
        print(resposne.status)
loop = asyncio.get_event_loop()
tasks = [fetch(), ]
loop.run_until_complete(asyncio.wait(tasks))

异步IO处理

#环境安装:pip install aiohttp
#使用该模块中的ClientSession
import requests
import asyncio
import time
import aiohttp
start = time.time()
urls = [
    'http://127.0.0.1:5000/bobo','http://127.0.0.1:5000/jay','http://127.0.0.1:5000/tom',
    'http://127.0.0.1:5000/bobo', 'http://127.0.0.1:5000/jay', 'http://127.0.0.1:5000/tom',
    'http://127.0.0.1:5000/bobo', 'http://127.0.0.1:5000/jay', 'http://127.0.0.1:5000/tom',
    'http://127.0.0.1:5000/bobo', 'http://127.0.0.1:5000/jay', 'http://127.0.0.1:5000/tom',
]
async def get_page(url):
    async with aiohttp.ClientSession() as session:
        #get()、post():
        #headers,params/data,proxy='http://ip:port'
        async with await session.get(url) as response:
            #text()返回的是字符串形式的响应数据
            #read()返回的是二进制形式的响应数据
            #json() 返回的就是json对象
            #注意有io操作前一定要使用await手动挂起,此时会切到别的协程任务去执行,
            page_text = await response.text()
            print(page_text)
tasks = []
for url in urls:
    c = get_page(url)
    task = asyncio.ensure_future(c)
    tasks.append(task)
loop = asyncio.get_event_loop()
loop.run_until_complete(asyncio.wait(tasks))
end = time.time()
print('总耗时:',end-start)

在这里我们将请求库由 requests 改成了 aiohttp,通过 aiohttp 的 ClientSession 类的 get() 方法进行请求,结果如下:

Hello tom
Hello jay
Hello bobo
Hello bobo
Hello jay
Hello bobo
Hello tom
Hello jay
Hello jay
Hello tom
Hello tom
Hello bobo
总耗时: 2.037203073501587

成功了!我们发现这次请求的耗时由 6秒变成了 2 秒,耗时直接变成了原来的 1/3。

代码里面我们使用了 await,后面跟了 get() 方法,在执行这五个协程的时候,如果遇到了 await,那么就会将当前协程挂起,转而去执行其他的协程,直到其他的协程也挂起或执行完毕,再进行下一个协程的执行。

开始运行时,时间循环会运行第一个 task,针对第一个 task 来说,当执行到第一个 await 跟着的 get() 方法时,它被挂起,但这个 get() 方法第一步的执行是非阻塞的,挂起之后立马被唤醒,所以立即又进入执行,创建了 ClientSession 对象,接着遇到了第二个 await,调用了 session.get() 请求方法,然后就被挂起了,由于请求需要耗时很久,所以一直没有被唤醒,好第一个 task 被挂起了,那接下来该怎么办呢?事件循环会寻找当前未被挂起的协程继续执行,于是就转而执行第二个 task 了,也是一样的流程操作,直到执行了第五个 task 的 session.get() 方法之后,全部的 task 都被挂起了。所有 task 都已经处于挂起状态,那咋办?只好等待了。3 秒之后,几个请求几乎同时都有了响应,然后几个 task 也被唤醒接着执行,输出请求结果,最后耗时,3 秒!

怎么样?这就是异步操作的便捷之处,当遇到阻塞式操作时,任务被挂起,程序接着去执行其他的任务,而不是傻傻地等着,这样可以充分利用 CPU 时间,而不必把时间浪费在等待 IO 上。

可见,使用了异步协程之后,我们几乎可以在相同的时间内实现成百上千倍次的网络请求,把这个运用在爬虫中,速度提升可谓是非常可观了。

posted @ 2020-04-08 17:52  hanfe1  阅读(466)  评论(0编辑  收藏  举报