Python进阶篇之多线程爬取网页

一、前情提要

相信来看这篇深造爬虫文章的同学,大部分已经对爬虫有不错的了解了,也在之前已经写过不少爬虫了,但我猜爬取的数据量都较小,因此没有过多的关注爬虫的爬取效率。这里我想问问当我们要爬取的数据量为几十万甚至上百万时,我们会不会需要要等几天才能将数据全都爬取完毕呢?

唯一的办法就是让爬虫可以 7×24 小时不间断工作。因此我们能做的就是多叫几个爬虫一起来爬数据,这样便可大大提升爬虫的效率。

但在介绍Python 如何让多个爬虫一起爬取数据之前,我想先为大家介绍一个概念——并发。

二、并发的概念

为了让大家简单易懂,我就用例子代替复杂的文章来向大家介绍吧

第一个例子
我们用 requests 成功请求一个网页,实际上 requests 做了三件事:
1、根据链接、参数等组合成一个请求;
2、把这个请求发往要爬取的网站,等待网站响应;
3、网站响应后,把结果包装成一个响应对象方便我们使用。

Python进阶篇之多线程爬取网页_第1张图片

其中步骤 2 花费的时间是最长的,取决于被爬网站的性能,这个时间可能达到几十到几百毫秒。

对这个程序来说:绿色部分代表代码是在 运行 的,黄色部分(步骤 2)代表程序是 空闲 的,因为在等待网站响应。 所以,爬虫代码真正运行的时间很短,大部分时间都浪费在等待网站响应上了。

第二个例子
我们连续用 requests 请求三个网页 A、B、C,执行的过程如下图所示:

Python进阶篇之多线程爬取网页_第2张图片

同样的,每次步骤 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 并没有依赖关系。

Python进阶篇之多线程爬取网页_第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,以此类推,在最短的时间内完成所有的任务,而不用挤占时间。

Python进阶篇之多线程爬取网页_第4张图片

我们来比较一下,有用多线程和没有用多线程的爬虫程序的耗时究竟相差多少!

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 属性就能得到响应的完整内容了。

四、线程池

前面我们讲过,线程是操作系统提供给我们的能力,可以把不同的任务放到不同的线程里,这样它们可以同时运行。但是这个能力一定是有限的,并不能无止境的制造线程。如果运行的线程数太多,操作系统在安排这些线程的执行顺序等事情上要花费很大的代价。

我们先来回忆一下一开始的第三个例子,在这个例子里,之所以切换到第二个任务可以提高我们的效率,是因为第一个任务已经处于空闲状态。

Python进阶篇之多线程爬取网页_第5张图片

但假如我们的线程数非常多,步骤 1 可以一直往图的右下堆叠,直到占满了空闲时间。这时再加线程对爬虫而言是没有意义的,任务同样要排队来运行。

所以线程池其实就是限制了最多同时运行的线程数。比如我们初始化一个最大任务数为 5 的线程池,这样即使我们提交了 100 任务到这个池子里,同时在运行的也只有五个。而一个任务被完成后,也会被移出线程池腾出空间。所以,用线程池可以避免上面提到的两个问题。

其实还有第三个问题,就是考虑到被爬网站的性能和其反爬机制,我们也不应该让机器过快地去运行爬虫。线程池的数量建议可以在 10 左右,电脑性能好而且不担心被爬取网站封禁的可以考虑加到几十,性能差的可以考虑降到 5。

下一篇文章我会介绍一个并发爬取的项目实战,希望有需要的同学来看看!!
文章链接:Python爬虫深造篇(二)——多线程爬取虎扑网页

本次分享到此结束,非常感谢大家阅读!!
有问题欢迎评论区留言!!

更多关于Python多线程爬取网页的资料请关注脚本之家其它相关文章!

你可能感兴趣的:(Python进阶篇之多线程爬取网页)