一、前情提要
相信来看这篇深造爬虫文章的同学,大部分已经对爬虫有不错的了解了,也在之前已经写过不少爬虫了,但我猜爬取的数据量都较小,因此没有过多的关注爬虫的爬取效率。这里我想问问当我们要爬取的数据量为几十万甚至上百万时,我们会不会需要要等几天才能将数据全都爬取完毕呢?
唯一的办法就是让爬虫可以 7×24 小时不间断工作。因此我们能做的就是多叫几个爬虫一起来爬数据,这样便可大大提升爬虫的效率。
但在介绍Python 如何让多个爬虫一起爬取数据之前,我想先为大家介绍一个概念——并发。
二、并发的概念
为了让大家简单易懂,我就用例子代替复杂的文章来向大家介绍吧
第一个例子
我们用 requests 成功请求一个网页,实际上 requests 做了三件事:
1、根据链接、参数等组合成一个请求;
2、把这个请求发往要爬取的网站,等待网站响应;
3、网站响应后,把结果包装成一个响应对象方便我们使用。
其中步骤 2 花费的时间是最长的,取决于被爬网站的性能,这个时间可能达到几十到几百毫秒。
对这个程序来说:绿色部分代表代码是在 运行 的,黄色部分(步骤 2)代表程序是 空闲 的,因为在等待网站响应。 所以,爬虫代码真正运行的时间很短,大部分时间都浪费在等待网站响应上了。
第二个例子
我们连续用 requests 请求三个网页 A、B、C,执行的过程如下图所示:
同样的,每次步骤 1、3 和 2 所花费时间的差异很大。我们假设步骤 1 和步骤 3 都要花费 1 毫秒,步骤 2 要花费 98 毫秒。那么一个网页要花费 100 毫秒,爬取 A、B、C 三个网页一共花费了 300 毫秒。
这时我们其实遇到一个问题:整个过程的 300 毫秒里,代码运行的时间只有 6 毫秒,剩下有 294 毫秒我们的程序只是空闲在那里等待着网站响应。
第三个例子
想一想,第一个例子里,顺序必须是 1-2-3,因为步骤 2 依赖步骤 1 的结果,步骤 3 依赖步骤 2 的结果。但是第二个例子里,步骤为什么必须是 A1-A2-A3-B1-B2-B3-C1-C2-C3 呢?「爬取网页 B」的步骤 1 其实和「爬取网页 A」的步骤 3 并没有依赖关系。
这张图是什么意思呢?其实就是:在「爬取网页 A」这个过程进行到步骤 2 的时候,程序空闲下来了,这时我们让「爬取网页 B」的步骤 1 开始执行;同样的,「爬取网页 B」的步骤 1 执行完,程序又空闲下来,于是我们安排「爬取网页 C」开始执行。
依然假设步骤 1 和 3 需要花费 1 毫秒,步骤 2 花费 98 毫秒。算一算,只需要102 毫秒!
我们要爬 10 个或者 20 个网页,现在预计分别只需要 109 毫秒和 119 毫秒。而假如我们用第二个例子里的方式运行,则分别需要 1000 毫秒和 2000 毫秒!
可以看到,我们仅仅是利用了爬虫等待网站响应的空闲时间,爬虫的效率就提升了数十倍。当爬取数据量更大时,爬虫效率提升会更加的显著。
回到问题:什么叫并发?
上面第二个例子就不是并发:我要做三件事,然后我一件一件完成它们。
上面的第三个例子就是并发:我们明明要做三件事,但是在这段时间内,我们交错着做这三件事,就好像在 同时做这些事 !
而上面第一个例子里,我们只需要做一件事情,这时不管我们写并发的代码或者普通的代码,它总是步骤 1-2-3 这样被执行完,没有什么区别。
上面第三种例子这种情况,在计算机中被称为并发
让我们用一段代码,来让大家直观的看看并发是什么:
import time import requests class Adapter(requests.adapters.HTTPAdapter): def send(self, *args, **kwargs): global start print( "步骤 1 结束,耗时", round((time.time() - start) * 1000), "毫秒" ) return super().send(*args, **kwargs) s = requests.Session() s.mount("https://", Adapter()) start = time.time() r = s.get('https://www.baidu.com') end = time.time() print( "步骤 2 结束,耗时", round(r.elapsed.total_seconds() * 1000), "毫秒" ) print( "步骤 3 结束,耗时", int((end -start - r.elapsed.total_seconds()) * 1000), "毫秒" ) //输出结果↓ //步骤 1 结束,耗时 2 毫秒 //步骤 2 结束,耗时 66 毫秒 //步骤 3 结束,耗时 1 毫秒
通过以上的讲解,相信大家已经对并发有一个初步的认识了,接下来我们再来讲讲多线程
三、并发与多线程
操作系统为我们提供了两个东西:进程和线程。利用这两样东西,我们可以轻易地实现代码的并发,而不用考虑细枝末节。
例如,我们把下面三个任务丢到三个线程中,操作系统就能让任务A等待时,启动任务B,任务AB等待时,启动任务C,而当任务A等待结束了,接着回去完成任务A,以此类推,在最短的时间内完成所有的任务,而不用挤占时间。
我们来比较一下,有用多线程和没有用多线程的爬虫程序的耗时究竟相差多少!
import time import requests # 导入 concurrent.futures 这个包 from concurrent import futures # 假设我们要爬取 30 个网页 urls = ["https://wpblog.x0y1.com/?p=34"] * 30 session = requests.Session() # 普通爬虫 start1 = time.time() results = [] for url in urls: r = session.get(url) results.append(r.text) end1 = time.time() print("普通爬虫耗时", end1-start1, "秒") # 多线程爬虫 # 初始化一个线程池,最大的同时任务数是 5 executor = futures.ThreadPoolExecutor(max_workers=5) start2 = time.time() fs = [] for url in urls: # 提交任务到线程池 f = executor.submit(session.get, url) fs.append(f) # 等待这些任务全部完成 futures.wait(fs) # 获取任务的结果 result = [f.result().text for f in fs] end2 = time.time() print("多线程爬虫耗时", end2-start2, "秒") #输出结果↓ 耗时与线上环境和硬件条件有关 #普通爬虫耗时 3.626128673553467 秒 #多线程爬虫耗时 2.0856518745422363 秒
看到结果对比之后就会知道,通常情况下多线程爬虫的效率会比单线程高很多。而且需要处理的任务量越多的时候,这个差异会越明显。
好,我们再来仔细解读一下这部分多线程爬虫代码,我们取出关键部分看看
# 导入 concurrent.futures 这个包 from concurrent import futures # 初始化一个线程池,最大的同时任务数是 5 executor = futures.ThreadPoolExecutor(max_workers=5)
concurrent
是 Python 自带的库,这个库具有线程池和进程池、管理并行编程任务、处理非确定性的执行流程、进程/线程同步等功能。
executor
就是我们刚刚初始化的线程池,我们调用 executor 的 submit() 方法往里面提交任务。第一个参数 session.get 是提交要运行的函数,第二个参数 url 是提交的函数运行时的参数。
fs = [] for url in urls: # 提交任务到线程池 f = executor.submit(session.get, url) fs.append(f)
executor 就是我们刚刚初始化的线程池,我们调用 executor 的 submit() 方法往里面提交任务。第一个参数 session.get 是提交要运行的函数,第二个参数 url 是提交的函数运行时的参数。
executor.submit() 方法会给我们一个返回值,它是一个 future 对象,我们把它赋值给变量 f。
# 等待这些任务全部完成 futures.wait(fs)
fs 是保存了上面所有任务的 future 对象的列表,futures.wait() 方法可以等待直到 fs 里面所有的 future 对象都有结果为止。
# 获取任务的结果 result = [f.result().text for f in fs]
fs 是保存了上面所有任务的 future 对象的列表,我们遍历所有任务的 future 对象,调用 future 对象的 result() 方法,就能得到任务的结果。
那结果是什么类型的呢?取决于提交的任务。比如我们提交的是 session.get(url),它的返回值是一个 response 对象,那我们调用它的 text 属性就能得到响应的完整内容了。
四、线程池
前面我们讲过,线程是操作系统提供给我们的能力,可以把不同的任务放到不同的线程里,这样它们可以同时运行。但是这个能力一定是有限的,并不能无止境的制造线程。如果运行的线程数太多,操作系统在安排这些线程的执行顺序等事情上要花费很大的代价。
我们先来回忆一下一开始的第三个例子,在这个例子里,之所以切换到第二个任务可以提高我们的效率,是因为第一个任务已经处于空闲状态。
但假如我们的线程数非常多,步骤 1 可以一直往图的右下堆叠,直到占满了空闲时间。这时再加线程对爬虫而言是没有意义的,任务同样要排队来运行。
所以线程池其实就是限制了最多同时运行的线程数。比如我们初始化一个最大任务数为 5 的线程池,这样即使我们提交了 100 任务到这个池子里,同时在运行的也只有五个。而一个任务被完成后,也会被移出线程池腾出空间。所以,用线程池可以避免上面提到的两个问题。
其实还有第三个问题,就是考虑到被爬网站的性能和其反爬机制,我们也不应该让机器过快地去运行爬虫。线程池的数量建议可以在 10 左右,电脑性能好而且不担心被爬取网站封禁的可以考虑加到几十,性能差的可以考虑降到 5。
下一篇文章我会介绍一个并发爬取的项目实战,希望有需要的同学来看看!!
文章链接:Python爬虫深造篇(二)——多线程爬取虎扑网页
本次分享到此结束,非常感谢大家阅读!!
有问题欢迎评论区留言!!
更多关于Python多线程爬取网页的资料请关注脚本之家其它相关文章!