一、写在开头
虽然用scrapy框架来爬信息已经够快了,再用aiohttp来爬链家有点重复造轮子的嫌疑,但还是有助于我对异步编程的理解。以下内容都是出于自己对于异步的理解写出来的,毕竟不是计算机专业,没法用专业的语言来表述,用的都是通俗口语化的文字,其中肯定有些地方也写的并不对,但目前只能这样了,待以后有更深入理解之后再来完善吧。
这是最终的效果(代码放在最后):
同步方式
异步方式
二、几个概念
为了尽可能的发挥出cpu的性能,使程序运行的更有效率,软件跑的更快,可以通过多种方法来实现,比如多进程、多线程或者异步等等。
先解释一下这几个概念:进程,线程、协程。专业的可以参照《进程、线程与协程的比较》一文。
举个例子来粗浅的理解这几个概念,假设在有一间屋子需要打扫卫生,打扫卫生包含了擦桌子、扫地、拖地、擦窗户。
如果是按顺序一件一件的完成这些事,那么这就是同步编程,打扫卫生的人就是进程;
如果有2个人来一起来打扫卫生,那么就有了2个进程,打扫的速度自然就快了一倍;
如果每个人的左手和右手也可以分别擦桌子和擦窗户,那么每个进程就有了2个线程,打扫速度又快了很多;
但上面都是按顺序来打扫卫生的(同步编程),先擦桌子,再扫地,再拖地,最后擦窗户。假设完成每件事情需要5分钟,其中拖地要拖5分钟然后等地面干燥10分钟,那么1个人完成所有事情的时间就是5+5+15+5=30分钟,2个进程则需要15分钟。
如果需要更快呢?
那就可以在等地面干燥时,先去擦窗户,擦完窗户之后再看看地面有没有干,如果干了,则事情就完成了。如果是1个人打扫房间,整个时间就直接缩短为擦桌子5分钟,扫地5分钟,5分钟拖地,5分钟擦窗户,再等待5分钟,共计25分钟。
这就是异步编程。
但对于程序来说,cpu的计算速度实在是太快了,速度只能用快的飞起来形容,大部分任务中耗时的是磁盘读取和写入、等待服务器响应等等,所以cpu通常都是1秒就做完了自己的事情(其实远没有1秒,可能只要毫秒或者纳秒),而要傻傻的等磁盘或者网络加载到天荒地老。
此时在编程中就需要采取异步的思想,cpu做完自己的事情后,可以先去干别的事情,让磁盘或者网络慢慢的去写入和加载,cpu只需要偶尔过来瞄一眼看看磁盘有没有做完,如果磁盘做完了事情,那么cpu就继续往下做事,如果磁盘还没做完,cpu就继续做别的事情,直到磁盘做完。
一个cpu就是一个进程,一个进程可以分支出多个线程,每个线程可以采取异步的方式来完成任务。如果把这几个特点进行结合起来使用,应该可以有效的提升效率。然而在Python中,由于该语言在当初设计时存在GIL,所以并不能很好的利用多线程。
另外,也不是所有的程序在设计时都需要采用多进程、多线程或者异步思想,盲目的使用可能还会降低效率。
因为任务在不同进程(线程)之间切换也需要消耗时间和资源,对于一些以cpu计算为主的任务(cpu密集型),cpu绝大部分时间已经花在运算上了,来回切换进程(线程)反而降低了效率;而对于大量从磁盘读取写入文件或者对网站服务器发起请求并等待响应的任务(IO密集型),cpu大部分时间处于闲置等待状态,这时采用多进程(线程)则可以显著提升软件的运行时间。
CPU密集型操作
IO密集型操作
网络请求密集型操作
线性操作
94.91824996469
22.46199995279
7.3296000004
多线程操作
101.1700000762
24.8605000973
0.5053332647
多进程操作
53.8899999857
12.7840000391
0.5045000315
三、Python中实现异步
Python中关于异步的一个模块是asyncio,是英文asynchronous异步的,input输入和output输出三个单词的缩写。
Python3.6以前,async def只能用装饰器的方式来实现,await要通过yield from来实现。
import asyncio
@asyncio.coroutine
def to_do_something():
print('do something')
yield from asyncio.sleep(2)
print('something done!')
在Python3.6后,可以通过关键词async def来定义一个coroutine协程,协程就相当于未来需要完成的任务,多个协程就是多个需要完成的任务,多个协程可以进一步封装到一个task对象中,task就是一个储存任务的盒子。此时,装在盒子里的任务并没有真正的运行,需要把它接入到一个监视器中使它运行,同时监视器还要持续不断的盯着盒子里的任务运行到了哪一步,这个持续不断的监视器就用一个循环对象loop来实现。
那么,整个过程应该就是通过下面这种方式来实现:
import asyncio
import time
#定义第1个协程,协程就是将要具体完成的任务,该任务耗时3秒,完成后显示任务完成
async def to_do_something(i):
print('第{}个任务:任务启动...'.format(i))
#遇到耗时的操作,await就会使任务挂起,继续去完成下一个任务
await asyncio.sleep(i)
print('第{}个任务:任务完成!'.format(i))
#定义第2个协程,用于通知任务进行状态
async def mission_running():
print('任务正在执行...')
start = time.time()
#创建一个循环
loop = asyncio.get_event_loop()
#创建一个任务盒子tasks,包含了3个需要完成的任务
tasks = [asyncio.ensure_future(to_do_something(1)),
asyncio.ensure_future(to_do_something(2)),
asyncio.ensure_future(mission_running())]
#tasks接入loop中开始运行
loop.run_until_complete(asyncio.wait(tasks))
end = time.time()
print(end-start)
程序运行的过程看起来就像是这个样子:
紫框--tasks中的第一个任务;
黄框--tasks中的第二个任务;
绿框--tasks中的第三个任务;
红线--程序运行路线
虚线--程序阻塞,出让函数的控制权
遇到需要等待的地方时,当前任务就会挂起,先去做第2个任务,然后继续挂起,再继续做第3个任务,最终完成任务时,总耗时则应该是2秒,而不是1秒+2秒=3秒。
程序实际运行的状态也确实如此,时间是2秒多一点,多出来的时间可能就是cpu本身的运算时间。
需要说明的是,3个任务的运行顺序是按照tasks列表里的顺序进行的,如果把顺序换一下,上面的程序运行图就不一样了。
#下面两个tasks运行的结果是不一样的
tasks = [asyncio.ensure_future(to_do_something(1)),
asyncio.ensure_future(to_do_something(2)),
asyncio.ensure_future(mission_running())]
####################################################
tasks = [asyncio.ensure_future(to_do_something(1)),
asyncio.ensure_future(mission_running()),
asyncio.ensure_future(to_do_something(2))]
如果想通过异步的方式对网络发起请求,则还需要借助aiohttp模块,这个模块相当于requests模块的异步版本,使用方法极其相似,有小部分差异,只要习惯就好。
import asyncio
import aiohttp
async def get_info(url):
async with aiohttp.ClientSession() as session:
async with session.get(url,timeout=5) as resp:
if resp.status != 200:
url_lst_failed.append(url)
else:
url_lst_successed.append(url)
r = await resp.text()
四、其它一些
为什么要用到循环loop?
程序是按照设定的顺序从头执行到尾,运行的次数也是完全按照设定。当在编写异步程序时,必然其中有部分程序的运行耗时是比较久的,需要先让出当前程序的控制权,让其在背后运行,让另一部分的程序先运行起来。当背后运行的程序完成后,也需要及时通知主程序已经完成任务可以进行下一步操作,但这个过程所需的时间是不确定的,需要主程序不断的监听状态,一旦收到了任务完成的消息,就开始进行下一步。loop就是这个持续不断的监视器。
对比以前的版本,可以手动输入价格区间,采用二分法自动切割价格区间,将各个区间房屋数量缩到3000以内(链家限制了显示条目,最多只有3000)。
另外也还存在一个问题,在对比同步爬取和异步爬取得结果时,发现当url数量比较多时,会有较大一部分的url在采用异步爬取时不会被发起请求,导致异步的结果数量比同步的结果数量少了很多。希望有人能够解答这个问题。
我最后采用了一个笨办法解决了这个问题,将首次运行后未被发起请求的url再放入一个列表中,然后再次循环,直至所有url都被发起请求,最终实现抓取全部信息。
五、参考资料