likes
comments
collection
share

建立爬虫大军之多协程

作者站长头像
站长
· 阅读数 25

前言

如果我们想要爬取的是成千上万条的数据,那么就会遇到一个问题:因为程序是一行一行地依次执行,要等待很久,才能拿到想要的数据。

既然一个爬虫爬取大量数据要爬很久,那我们能不能让多个爬虫一起爬取?这样无疑能提高爬取的效率,就像一个人干不完的活儿,组个团队一起干,活儿一下被干完了。

那具体怎么用Python实现这事儿呢?

同步与异步

当需要爬取多个URL时,爬虫每发起一个请求,都要等服务器返回响应后,才能执行下一步。而由于网页请求获得响应过程比较耗费时间,这导致爬虫会浪费大量时间在等待上。

建立爬虫大军之多协程

图中所示的就是同步爬虫的运行模式。

而与之对应的异步爬虫则是并发并行的,各个任务可以独立运行,一个任务的运行不受另一个任务影响。(可以理解成同一时间有多个事情在做,但有先后顺序)

我们可以采取异步的爬虫方式,让多个爬虫在执行任务时保持相对独立,彼此不受干扰,这样就可以免去等待时间,提高爬虫的效率和速度。

什么是多协程

刚才提到:

异步爬虫是并发并行

这两者有什么区别呢?

计算机利用单个核心在多个任务之间来回切换,称为并发;利用CPU的多核同时执行多个任务,称为并行。

并发并行
一个人合理利用时间,做多件事情,例如:同时炒菜和煲汤多个人做多件事情,例如:一个人备菜,一个人炒菜
宏观上同时发送宏观上同时发生
微观上交替运行微观上同时发生

本文将讲述实现并发的一种方式——多协程,主要提高IO并发。

它的原理是:对于IO密集任务,如果遇到等待,可以随时中断,先去执行其他的任务,当等待结束,再回来继续之前的那个任务(可以自由切换),大大减少IO的等待时间。

这整个过程看似是多线程,像多个任务在被同时执行一样,但实际上协程只有一个线程执行,属于单线程高并发

多协程的优势

  • 执行效率极高,资源占用低。因为子程序切换(任务函数)不是线程切换,由程序自身控制,没有切换线程的开销,充分利用了IO等待时间。与多线程相比,线程的数量越多,协程性能的优势越明显。
  • 不需要多线程的锁机制。因为只有一个线程,也不存在同时写变量冲突,在控制共享资源时也不需要加锁。
  • 开发者可以指定切换的时机,同时调度多个协程(非抢占式并发)。

怎么用多协程

使用gevent库可以实现多协程。gevent是一个基于greenlet实现的网络库,通过greenlet实现协程。

基本思想是:一个greenlet可以认为是一个协程,当一个greenlet遇到IO操作的时候,就会自动切换到其他的greenlet,待IO操作完成,在适当的时候切换回来继续执行。gevent可以保证总有greenlet在运行,而不是等待IO操作。

同时,当我们用多协程来爬虫,需要创建大量任务时,我们可以借助queue模块,让“任务排个队”。

gevent库

导入gevent库前,得先安装它。

window电脑:pip install gevent
mac电脑:pip3 install gevent

先上个没有queue模块的代码,可以看一下注释:

# 从gevent库里导入monkey模块
from gevent import monkey
# monkey.patch_all()能把程序变成协作式运行
monkey.patch_all()

# 导入gevent库来实现多协程,导入time模块来记录爬取所需时间,导入requests模块实现爬取10个网站。
import gevent,time,requests

# 记录程序开始时间
start = time.time()

# 把10个网站封装成列表
url_list = ['https://www.baidu.com/',
'https://www.sina.com.cn/',
'http://www.sohu.com/',
'https://www.qq.com/',
'https://www.163.com/',
'http://www.iqiyi.com/',
'https://www.tmall.com/',
'https://www.zhihu.com/',
'https://www.ipe.org.cn/',
'https://www.youku.com/']


#定义一个函数
def crawler(url):
    #用requests.get()爬取网站
    r = requests.get(url)
    # 打印网址、请求运行时间、状态码
    print(url,time.time()-start,r.status_code)

# 创建空的任务列表
tasks_list = [ ]

# 遍历url_list
for url in url_list:
    
    # 用gevent.spawn()创建任务,参数为crawler函数名和它自身的参数url。
    task = gevent.spawn(crawler,url)
    
    # 往任务列表添加任务
    tasks_list.append(task)
    
    
# 执行任务列表里的所有任务,就是让爬虫开始爬取网站
gevent.joinall(tasks_list)


# 记录程序结束时间
end = time.time()
#打印程序最终所需时间
print(end-start)

因为requests库是阻塞式的,只有将requests库阻塞式更改为非阻塞,异步操作才能实现

而gevent库中的猴子补丁(monkey patch)能够修改标准库里面大部分的阻塞式系统调用。这样在不改变原有代码的情况下,将应用的阻塞式方法,变成协程式的(异步)

这里需要注意一点,在导入其他库和模块前,要先把monkey模块导入进来,并运行monkey.patch_all(),这样,才能先给程序打上补丁,使得程序实现异步

掘友们可以自行对比使用多协程和不使用之间的速度差异,这里我不再展示。

小结一下使用gevent实现异步爬虫的关键步骤:

  1. 定义爬虫函数
  2. gevent.spawn()创建任务,gevent.spawn()的参数是:要调用的函数名 及 该函数的参数。
  3. gevent.joinall()执行任务

还有一个问题,当我们要爬的不是10个网站,而是1000个网站,我们应该怎么做?

是用gevent.spawn()创建1000个爬取任务,再用gevent.joinall()执行这1000个任务吗?

这种方法会有问题:执行1000个任务,就是一下子发起1000次请求,这样的恶意请求,会拖垮网站的服务器。

既然这种直接创建1000个任务的方式不可取,那能不能创建成5个任务,但每个任务爬取200个网站?

这么做也还是会有问题的。就算我们用gevent.spawn()创建了5个分别执行爬取200个网站的任务,这5个任务之间是异步执行的,但是每个任务(爬取200个网站)内部是同步的。

那我们应该如何做呢?

queue模块

这就引出了另一个主角——queue,我们可以用queue模块来存储任务,让任务排成队。

协程可以从队列里把任务一一提取出来执行,队列空了,任务也就处理完了。

把上面的代码拆成4部分来演示:

第1部分是导入模块。

# 从gevent库里导入monkey模块
from gevent import monkey

# monkey.patch_all()使得程序实现异步
monkey.patch_all()

# 导入gevent、time、requests
import gevent,time,requests

# 因为gevent库里就带有queue,直接导入`queue`模块即可
from gevent.queue import Queue

第2部分,是如何创建队列,以及怎么把任务存储进队列里。

#记录程序开始时间
start = time.time()

url_list = ['https://www.baidu.com/',
'https://www.sina.com.cn/',
'http://www.sohu.com/',
'https://www.qq.com/',
'https://www.163.com/',
'http://www.iqiyi.com/',
'https://www.tmall.com/',
'https://www.zhihu.com/',
'https://www.ipe.org.cn/',
'https://www.youku.com/']

# 创建队列对象
work = Queue()


# 遍历url_list
for url in url_list:

    # 创建了queue对象后,用put_nowait()函数可以把网址都放进队列里
    work.put_nowait(url)

用Queue()能创建queue对象,如果不加参数,相当于创建了一个不限任何存储数量的空队列。 如果传入参数,比如Queue(10),则表示这个队列只能存储10个任务。

第3部分,是定义爬取函数,和从队列里提取出刚刚存储进去的网址。

def crawler():
    # 当队列不是空的时候,就执行下面的程序 
    while not work.empty():   
        url = work.get_nowait() # 用get_nowait()函数可以把队列里的网址都取出
        r = requests.get(url) # 用requests.get()函数抓取网址       
        print(url,work.qsize(),r.status_code)# 打印网址、队列长度、抓取请求的状态码

代码块中涉及到queue对象的三个方法:

  • empty方法,用来判断队列是否为空的;
  • get_nowait方法,用来从队列里提取数据的;
  • qsize方法,用来判断队列里还剩多少数量的。

附一张queue对象的常用方法,点个收藏。在需要时,可以来查表。

方法作用
put_nowait()往队列里存储数据
get_nowait从队列里提取数据
empty()判断队列是否为空
full()判断队列是否为满
qsize()判断队列还剩多少数量

小结一下queue模块的基操:

  1. Queue()创建队列
  2. put_nowait()存储数据
  3. get_nowait()提取数据

第4部分就是让爬虫用多协程执行任务,爬取队列里的10个网站的代码

# 创建空的任务列表
tasks_list  = [ ]
# 这里创建了3个爬虫
for x in range(3):
 
    # 用gevent.spawn()函数创建执行crawler()函数的任务
    task = gevent.spawn(crawler)
    
    # 往任务列表添加任务
    tasks_list.append(task)
   
# 用gevent.joinall方法,执行任务列表里的所有任务,让爬虫开始爬取
gevent.joinall(tasks_list)

end = time.time()
print(end-start)

用一张图解释这个过程:

建立爬虫大军之多协程

这里创建三个可以异步爬取的爬虫。它们会从队列里取走网址,执行爬取任务。一旦一个网址被一只爬虫取走,其他爬虫就取不到了,另一只爬虫就会取走下一个网址。直至所有网址都被取走,队列为空时,爬虫停止工作。

END

多协程,其实只占用了CPU的一个核运行,没有充分利用到其他核,如果要充分发挥CPU利用率可以结合多进程+协程,后续我会再出一篇文章,围绕其讲述。

本人水平有限,烦请掘友们多多指教。