高性能爬虫原理与应用

时间:2022-04-11 18:07:03

目录:

 

一 高性能爬虫本质

       爬虫的本质就是一个socket客户端与服务端的通信过程,如果我们有多个url待爬取,只用一个线程且采用串行的方式执行,那只能等待爬取一个结束后才能继续下一个,效率会非常低。

       需要强调的是:对于单线程下串行N个任务,并不完全等同于低效,如果这N个任务都是纯计算的任务,那么该线程对CPU的利用率仍然会很高,之所以单线程下串行多个爬虫任务低效,是因为爬虫任务是明显的IO密集型程序。

 

二 高性能爬虫相关理论点

  • 同步

    同步调用:即提交一个任务后,CPU会等待这个任务结束才会拿到执行这个任务的结果在继续执行其它任务,效率低下

     

     1 import requests
    2
    3 def parse_page(res):
    4 print('解析 %s' %(len(res)))
    5
    6 def get_page(url):
    7 print('下载 %s' %url)
    8 response=requests.get(url)
    9 if response.status_code == 200:
    10 return response.text
    11
    12 urls=['https://www.baidu.com/','http://www.sina.com.cn/','https://www.python.org']
    13 for url in urls:
    14 res=get_page(url) #调用一个任务,就在原地等待任务结束拿到结果后才继续往后执行
    15 parse_page(res)

     

  • 异步

    通过多线程或多进程:在服务器端使用多线程(或多进程)。多线程(或多进程)的目的是让每个连接都拥有独立的线程(或进程),这样任何一个连接的阻塞都不会影响其他的连接。

     1 #IO密集型程序应该用多线程
    2 import requests
    3 from threading import Thread,current_thread
    4
    5 def parse_page(res):
    6 print('%s 解析 %s' %(current_thread().getName(),len(res)))
    7
    8 def get_page(url,callback=parse_page):
    9 print('%s 下载 %s' %(current_thread().getName(),url))
    10 response=requests.get(url)
    11 if response.status_code == 200:
    12 callback(response.text)
    13
    14 if __name__ == '__main__':
    15 urls=['https://www.baidu.com/','http://www.sina.com.cn/','https://www.python.org']
    16 for url in urls:
    17 t=Thread(target=get_page,args=(url,))
    18 t.start()

     

    该方案存在的问题:

    开启多进程或多线程的方式,我们是无法无限制地开启多线程或多进程的。在遇到要同时响应成百上千路的连接请求,则无论多进程还是多线程都会严重占据系统资源,降低系统的响应效率,而且线程与进程本身也更容易进入假死状态。

  • 回调机制

    线程池或简称持+异步调用:提交一个任务后并不会等待任务结束,而是继续其他任务

    线程池旨在减少创建和销毁线程的频率,其维持一定合理数量的线程,并让空闲的线程重新承担新的执行任务。连接池维持连接的缓存池,尽量重用已有的连接、减少创建和关闭连接的频率。这两种技术都可以很好的降低系统开销,都被广泛应用很多大型系统,如websphere、tomcat和各种数据库等。

    

 1 import requests
2 from threading import current_thread
3 from concurrent.futures import ThreadPoolExecutor,ProcessPoolExecutor
4
5 def parse_page(res):
6 res=res.result()
7 print('%s 解析 %s' %(current_thread().getName(),len(res)))
8
9 def get_page(url):
10 print('%s 下载 %s' %(current_thread().getName(),url))
11 response=requests.get(url)
12 if response.status_code == 200:
13 return response.text
14
15 if __name__ == '__main__':
16 urls=['https://www.baidu.com/','http://www.sina.com.cn/','https://www.python.org']
17
18 pool=ThreadPoolExecutor(50)
19 # pool=ProcessPoolExecutor(50)
20 for url in urls:
21 pool.submit(get_page,url).add_done_callback(parse_page)
22
23 pool.shutdown(wait=True)

 

        这种方法也存在着一些问题:

        线程池和连接池技术也只是在一定程度上缓解了频繁调用IO接口带来的资源占用。而且,所谓“池”始终有上限,当请求大大超过上限时,“池”构成的系统对外界的响应并不比没有池的时候效果好多少。所有使用“池”必须考虑其面临的响应规模,并根据响应规模调整“池”的大小。

        对应上例中的所面临的可能同时出现的上千甚至上万次的客户端请求,“线程池”和“连接池”或许可以缓解部分压力,但是不能解决所有问题。总之,多线程模型可以方便高效的解决小规模的服务请求,但面对大规模的服务请求,多线程模型也会遇到瓶颈,可以用非阻塞接口来尝试解决这个问题

 

三 Python中高性能相关模块

        简述

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

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

        asyncio模块

  1. 在Python3.3之后新增了asyhcio模块,可以帮我们检测IO(只能检测网络IO)实现应用程序级别的切换 

    基本使用 

     1 import asyncio
    2
    3 @asyncio.coroutine
    4 def task(task_id, seconds):
    5 print('%s is start' % task_id)
    6 yield from asyncio.sleep(seconds) # 只能检测网络IO, 检测到IO后切换到其他任务执行
    7 print('%s is end' % task_id)
    8
    9
    10 tasks = [task(task_id='任务1', seconds=3), task("任务2", 2), task("任务3", seconds=1)]
    11
    12 loop = asyncio.get_event_loop()
    13 loop.run_until_complete(asyncio.wait(tasks))
    14 loop.close()

     

  2. 但是asyncio模块只能发TCP级别的请求,不能发http协议,因此,在我们需要发送http请求的时候,需要我们自定义http报头 

     1 import asyncio
    2 import requests
    3 import uuid
    4
    5
    6 user_agent='Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/49.0.2623.221 Safari/537.36 SE 2.X MetaSr 1.0'
    7
    8
    9 def parse_page(host, res):
    10 print('%s 解析结果 %s' % (host, len(res)))
    11 with open('%s.html' % (uuid.uuid1()), 'wb') as f:
    12 f.write(res)
    13
    14
    15 @asyncio.coroutine
    16 def get_page(host, port=80, url='/', callback=parse_page, ssl=False):
    17 print('下载 http://%s:%s%s' % (host, port, url))
    18 # 步骤1 (IO阻塞):发起TCP连接,是阻塞操作,因此需要yield from
    19 if ssl:
    20 port = 443
    21 connect = asyncio.open_connection(host=host, port=port, ssl=ssl)
    22 print(connect)
    23 recv, send = yield from connect
    24 # 步骤2 封装http协议的报头,因为asyncio模块只能封装并发送TCP包,因此这一步需要我们自己封装http协议的包
    25 request_headers = """GET %s HTTP/1.0\r\nHost: %s\r\nUser-agent: %s\r\n\r\n""" % (url, host, user_agent)
    26 # POST请求
    27 # request_headers = """POST %s HTTP/1.0\r\nHost: %s\r\n\r\nname=lucy&password=123""" % (url, host,)
    28 request_headers = request_headers.encode('utf-8')
    29
    30 # 步骤3 (IO阻塞):发送http请求包
    31 send.write(request_headers)
    32 yield from send.drain
    33
    34 # 步骤4 (IO阻塞):接收响应头
    35 while True:
    36 line = yield from recv.readline()
    37 if line == b'\r\n':
    38 break
    39 print('%s Response headers: %s' %(host, line))
    40
    41 # 步骤5 (IO阻塞):接收响应体
    42 text = yield from recv.read()
    43
    44 # 步骤6 :执行回调函数
    45 callback(host, text)
    46
    47 # 步骤7: 关闭套接字
    48 send.close() # 没有recv.close()方法,因为是四次挥手断开连接,双向连接的两端,一段发完整数据后执行send.close()另外一段就被动地断开
    49
    50
    51 if __name__ == '__main__':
    52 tasks = [
    53 get_page('www.baidu.com', url='/s?wd=Python', ssl=True),
    54 get_page('www.cnblogs.com', url='/', ssl=True),
    55 ]
    56
    57 loop = asyncio.get_event_loop()
    58 loop.run_until_complete(asyncio.wait(tasks))
    59 loop.close()

     

    aiohttp模块

  3. 用来封装http报头的一个模块,使用asyncio检测IO实现切换

     1 import aiohttp
    2 import asyncio
    3
    4
    5 @asyncio.coroutine
    6 def get_page(url):
    7 print('GET:%s' % url)
    8 response = yield from aiohttp.request('GET', url)
    9
    10 data = yield from response.read()
    11
    12 print(url, data)
    13 response.close()
    14
    15
    16 tasks = [
    17 get_page('https://www.python.org/doc'),
    18 # get_page('https://www.baidu.com'),
    19 # get_page('https://www.taobao.com')
    20 ]
    21
    22 loop = asyncio.get_event_loop()
    23 results = loop.run_until_complete(asyncio.gather(*tasks))
    24 loop.close()
    25
    26 print("===>", results)

     

  4. 此外,还可以将requests.get函数传给asyncio,就能够被检测了

     1 import requests
    2 import asyncio
    3
    4
    5 @asyncio.coroutine
    6 def get_page(func, *args):
    7 print('GET:%s' % args[0])
    8 loop = asyncio.get_event_loop()
    9 furture = loop.run_in_executor(None, func, *args)
    10 response = yield from furture
    11
    12 print(response.url, len(response.text))
    13 return 1
    14
    15
    16 tasks = [
    17 get_page(requests.get, 'https://www.python.org/doc'),
    18 ]
    19
    20 loop = asyncio.get_event_loop()
    21 results = loop.run_until_complete(asyncio.gather(*tasks))
    22 loop.close()
    23
    24 print('==>', results)

     

    gevent模块

  5. gevent+requests    

     1 from gevent import monkey; monkey.patch_all()
    2 import gevent
    3 import requests
    4
    5
    6 def get_page(url):
    7 print('GET: %s' % url)
    8 response = requests.get(url)
    9 print(url, len(response.text))
    10 return 1
    11
    12
    13 g1 = gevent.spawn(get_page, 'http://www.python.org/doc')
    14 g2 = gevent.spawn(get_page, 'http://www.openstack.org')
    15 gevent.joinall([g1, g2, ])
    16 print(g1.value, g2.value) # 拿到返回值
    17
    18 # 协程池
    19 from gevent.pool import Pool
    20
    21
    22 pool = Pool(2)
    23 g1 = pool.spawn(get_page, 'https://www.python.org/doc')
    24 g2 = pool.spawn(get_page, 'https://openstack.org')
    25
    26 gevent.joinall([g1, g2, ])
    27 print(g1.value, g2.value, ) # 拿到返回值

    grequest模块

  6. 此模块是封装了gevent+requests得来的

     

     1 import grequests
    2
    3
    4 request_list = [
    5 grequests.get('http://www.pyt.org/doc'),
    6 grequests.get('http://www.baidu.com'),
    7 ]
    8
    9
    10 # 执行并获取响应列表
    11 response_list = grequests.map(request_list)
    12 print(response_list)
    13
    14 # 执行并获取响应列表(处理异常)
    15 def exception_handler(request, exception):
    16 print(request, exception)
    17 print("%s Request failed" % request.url)
    18
    19
    20 response_list = grequests.map(request_list, exception_handler=exception_handler)
    21 print(response_list)

     

    twisted

  7. Python写的一个网络框架,其中一个功能是发送异步请求,检测IO并自动切换

     1 # # twisted基本用法
    2 #
    3 # from twisted.web.client import getPage, defer
    4 # from twisted.internet import reactor
    5 #
    6 #
    7 # def all_done(arg):
    8 # print(arg)
    9 # reactor.stop()
    10 #
    11 #
    12 # def callback(res):
    13 # print(res)
    14 # return 1
    15 #
    16 #
    17 # defer_list = []
    18 # urls = [
    19 # 'http://www.baidu.com',
    20 # 'http://www.bing.com',
    21 # ]
    22 # for url in urls:
    23 # obj = getPage(url.encode('utf8'), )
    24 # obj.addCallback(callback)
    25 # defer_list.append(obj)
    26 #
    27 #
    28 # defer.DeferredList(defer_list).addBoth(all_done)
    29 #
    30 # reactor.run()
    31
    32
    33 # twisted的getPage的详细用法
    34 from twisted.internet import reactor
    35 from twisted.web.client import getPage
    36 import urllib.parse
    37
    38
    39 def one_done(arg):
    40 print(arg)
    41 reactor.stop()
    42
    43
    44 post_data = urllib.parse.urlencode({'check_data': 'adf'})
    45 post_data = bytes(post_data, encoding='utf8')
    46 headers = {b'Content=Type': b'application/x-www-from-urlencoded'}
    47 response = getPage(bytes('http://dig.chouti.com/login', encoding='utf8'),
    48 method=bytes('POST', encoding='utf8'),
    49 postdata=post_data,
    50 cookies={},
    51 headers=headers)
    52
    53 response.addBoth(one_done)
    54
    55 reactor.run()

     tornado

    

 1 from tornado.httpclient import AsyncHTTPClient
2 from tornado.httpclient import HTTPRequest
3 from tornado import ioloop
4
5
6 def handle_response(response):
7 """
8 处理返回值内容(需要维护计数器,来停止IO循环),调用 ioloop.IOLoop.current().stop()
9 :param response:
10 :return:
11 """
12 if response.error:
13 print("Error:", response.error)
14 else:
15 print(response.body)
16
17
18 def func():
19 url_list = [
20 'http://www.baidu.com',
21 'http://www.bing.com',
22 ]
23 for url in url_list:
24 print(url)
25 http_client = AsyncHTTPClient()
26 http_client.fetch(HTTPRequest(url), handle_response)
27
28
29 ioloop.IOLoop.current().add_callback(func)
30 ioloop.IOLoop.current().start()
31
32
33
34
35 #发现上例在所有任务都完毕后也不能正常结束,为了解决该问题,让我们来加上计数器
36 from tornado.httpclient import AsyncHTTPClient
37 from tornado.httpclient import HTTPRequest
38 from tornado import ioloop
39
40 count=0
41
42 def handle_response(response):
43 """
44 处理返回值内容(需要维护计数器,来停止IO循环),调用 ioloop.IOLoop.current().stop()
45 :param response:
46 :return:
47 """
48 if response.error:
49 print("Error:", response.error)
50 else:
51 print(len(response.body))
52
53 global count
54 count-=1 #完成一次回调,计数减1
55 if count == 0:
56 ioloop.IOLoop.current().stop()
57
58 def func():
59 url_list = [
60 'http://www.baidu.com',
61 'http://www.bing.com',
62 ]
63
64 global count
65 for url in url_list:
66 print(url)
67 http_client = AsyncHTTPClient()
68 http_client.fetch(HTTPRequest(url), handle_response)
69 count+=1 #计数加1
70
71 ioloop.IOLoop.current().add_callback(func)
72 ioloop.IOLoop.current().start()
73
74 Tornado