Python爬虫性能优化
一、背景知识
爬虫的本质就是一个socket客户端与服务端的通信过程,如果我们有多个url待爬取,只用一个线程且采用串行的方式执行,那只能等待爬取一个结束后才能继续下一个,效率会非常低。
需要强调的是:对于单线程下串行N个任务,并不完全等同于低效,如果这N个任务都是纯计算的任务,那么该线程对cpu的利用率仍然会很高,之所以单线程下串行多个爬虫任务低效,是因为爬虫任务是明显的IO密集型程序。
二、同步爬虫
同步调用:即提交一个任务后就在原地等待任务结束,等到拿到任务的结果后再继续下一行代码,效率低下
import time
import requests
def get_request(url, header):
response = requests.get(url=url, headers=header)
return response.text
if __name__ == '__main__':
urls = ['https://www.baidu.com/', 'https://www.sina.com.cn/', 'https://www.python.org', 'https://www.baidu.com/',
'https://www.sina.com.cn/', 'https://www.python.org', 'https://www.baidu.com/', 'https://www.sina.com.cn/',
'https://www.python.org', 'https://www.baidu.com/',
'https://www.sina.com.cn/', 'https://www.python.org']
header = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36'
}
start = time.time()
for url in urls:
print(len(get_request(url, header)))
print(f'总耗时为:{time.time() - start}')
# 总耗时为:4.477325201034546
三、多线程
在服务器端使用多线程(或多进程)。多线程(或多进程)的目的是让每个连接都拥有独立的线程(或进程),这样任何一个连接的阻塞都不会影响其他的连接。
import time
import requests
from threading import Thread
def get_request(url, header):
response = requests.get(url=url, headers=header)
return response.text
if __name__ == '__main__':
urls = ['https://www.baidu.com/', 'https://www.sina.com.cn/', 'https://www.python.org', 'https://www.baidu.com/',
'https://www.sina.com.cn/', 'https://www.python.org', 'https://www.baidu.com/', 'https://www.sina.com.cn/',
'https://www.python.org', 'https://www.baidu.com/',
'https://www.sina.com.cn/', 'https://www.python.org']
header = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36'
}
start = time.time()
t_list = []
for url in urls:
t = Thread(target=get_request, args=(url, header))
t_list.append(t)
t.start()
for t in t_list:
t.join()
print(f'总耗时为:{time.time() - start}')
# 总耗时为:1.3147799968719482
该方案的问题是:开启多进程或都线程的方式,我们是无法无限制地开启多进程或多线程的:在遇到要同时响应成百上千路的连接请求,则无论多线程还是多进程都会严重占据系统资源,降低系统对外界响应效率,而且线程与进程本身也更容易进入假死状态。
四、线程池
import time
import requests
from multiprocessing.dummy import Pool
header = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36'
}
def get_request(url):
response = requests.get(url=url, headers=header)
return response.text
if __name__ == '__main__':
urls = ['https://www.baidu.com/', 'https://www.sina.com.cn/', 'https://www.python.org', 'https://www.baidu.com/',
'https://www.sina.com.cn/', 'https://www.python.org', 'https://www.baidu.com/', 'https://www.sina.com.cn/',
'https://www.python.org', 'https://www.baidu.com/',
'https://www.sina.com.cn/', 'https://www.python.org']
start = time.time()
pool = Pool(9)
# pool.map第一个函数为target目标函数,第二个函数为可迭代对象,负责传参
# 如传多个参数,需进行构建参数格式[(url, header),(url, header),]
pool.map(get_request, urls)
print(f'总耗时为:{time.time() - start}')
# 总耗时为:1.3414990901947021
改进后方案其实也存在着问题:“线程池”和“连接池”技术也只是在一定程度上缓解了频繁调用IO接口带来的资源占用。而且,所谓“池”始终有其上限,当请求大大超过上限时,“池”构成的系统对外界的响应并不比没有池的时候效果好多少。所以使用“池”必须考虑其面临的响应规模,并根据响应规模调整“池”的大小。
对应上例中的所面临的可能同时出现的上千甚至上万次的客户端请求,“线程池”或“连接池”或许可以缓解部分压力,但是不能解决所有问题。总之,多线程模型可以方便高效的解决小规模的服务请求,但面对大规模的服务请求,多线程模型也会遇到瓶颈,可以用非阻塞接口来尝试解决这个问题。
五、高性能:协程
上述无论哪种解决方案其实没有解决一个性能相关的问题:IO阻塞,无论是多进程还是多线程,在遇到IO阻塞时都会被操作系统强行剥夺走CPU的执行权限,程序的执行效率因此就降低了下来。
解决这一问题的关键在于,我们自己从应用程序级别检测IO阻塞然后切换到我们自己程序的其他任务执行,这样把我们程序的IO降到最低,我们的程序处于就绪态就会增多,以此来迷惑操作系统,操作系统便以为我们的程序是IO比较少的程序,从而会尽可能多的分配CPU给我们,这样也就达到了提升程序执行效率的目的
import time
import asyncio
import aiohttp
async def get_request(url):
# requests不支持异步请求,改用aiohttp发送get请求
async with aiohttp.ClientSession() as session:
async with await session.get(url=url, headers=header) as response:
return await response.text()
# 回调函数,参数t表示task任务对象
def data_parse(t):
# 使用t.result()可以获取协程函数中的返回值
print(len(t.result()))
if __name__ == '__main__':
header = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36'
}
urls = ['https://www.baidu.com/', 'https://www.sina.com.cn/', 'https://www.python.org', 'https://www.baidu.com/',
'https://www.sina.com.cn/', 'https://www.python.org', 'https://www.baidu.com/', 'https://www.sina.com.cn/',
'https://www.python.org', 'https://www.baidu.com/',
'https://www.sina.com.cn/', 'https://www.python.org']
start = time.time()
tasks = []
# 新建事件管道对象
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
for url in urls:
# get_request(url)返回协程对象
# asyncio.ensure_future()将协程对象包装变成任务对象
task = asyncio.ensure_future(get_request(url), loop=loop)
tasks.append(task)
# 任务对象相比协程对象,可以添加回调函数
task.add_done_callback(data_parse)
# 循环执行事件管道中的任务
# asyncio.wait(tasks)负责将任务挂起,使得任务随时处于cpu可调用状态
loop.run_until_complete(asyncio.wait(tasks))
print(f'总耗时:{time.time() - start}')
# 总耗时:0.7073056697845459
六、aiohttp注意事项
# aiohttp与requests使用差异:
requests:
proxies:{'https':'ip:port'} 使用代理
response.text 获取文本
response.content 获取二进制
aiohttp:
proxy:'https://ip:port' 使用代理
response.text() 获取文本
response.read() 获取二进制
# 在Python3.10之前的版本中,上述部分代码可以这样写
start = time.time()
tasks = []
for url in urls:
task = asyncio.ensure_future(get_request(url))
tasks.append(task)
task.add_done_callback(data_parse)
loop = asyncio.get_event_loop()
loop.run_until_complete(asyncio.wait(tasks))
# 在Python3.10版本之后,有部分变动
start = time.time()
tasks = []
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
for url in urls:
task = asyncio.ensure_future(get_request(url), loop=loop)
tasks.append(task)
task.add_done_callback(data_parse)
loop.run_until_complete(asyncio.wait(tasks))