爬虫养成记--千军万马来相见(详解多线程)

1,312 阅读8分钟

本文由图雀社区成员 灿若星空 写作而成,欢迎加入图雀社区,一起创作精彩的免费技术教程,予力编程行业发展。

如果您觉得我们写得还不错,记得 点赞 + 关注 + 评论 三连🥰🥰🥰,鼓励我们写出更好的教程💪

前情回顾

在上篇教程爬虫养成记--顺藤摸瓜回首掏(女生定制篇)中我们通过分析网页之间的联系,串起一条线,从而爬取大量的小哥哥图片,但是一张一张的爬取速度未免也有些太慢,在本篇教程中将会与大家分享提高爬虫速率的神奇技能——多线程。

慢在哪里?

首先我们将之前所写的爬虫程序以流程图的方式将其表示出来,通过这种更直观的方式来分析程序在速度上的瓶颈。下面程序流程图中红色箭头标明了程序获取一张图片时所要执行的步骤。

程序流程图
大多数的程序设计语言其代码执行顺序都是同步执行(JavaScript为异步),也就是说在Python程序中只有上一条语句执行完成了,下一条语句才会开始执行。从流程图中也可以看出来,只有第一页的图片抓取完成了,第二页的图片才会开始下载…………,当整个图集所有的图片都处理完了,下一个图集的图片才会开始进行遍历下载。此过程如串行流程图中蓝色箭头所示:
串行流程图

从图中可以看出当程序入到每个分叉点时也就是进入for循环时,在循环队列中的每个任务(比如遍历图集or下载图片)就只能等着前面一个任务完成,才能开始下面一个任务。就是因为需要等待,才拖慢了程序的速度。

这就像食堂打饭一样,如果只有一个窗口,每个同学打饭时长为一分钟,那么一百个学生就有99个同学需要等待,100个同学打饭的总时长为1+2+3+……+ 99 + 100 = 5050分钟。如果哪天食堂同时开放了100个窗口,那么100个同学打饭的总时间将变为1分钟,时间缩短了五千多倍!

如何提速?

我们现在所使用的计算机都拥有多个CPU,就相当于三头六臂的哪吒,完全可以多心多用。如果可以充分发掘计算机的算力,将上述串行的执行顺序改为并行执行(如下并行流程图所示),那么在整个程序的执行的过程中将消灭等待的过程,速度会有质的飞跃!

并行执行图

从单线程到多线程

单线程 = 串行 从串行流程图中可以看出红色箭头与蓝色箭头是首尾相连,一环扣一环。这称之为串行。

多线程 = 并行 从并行流程图中可以看出红色箭头每到一个分叉点就直接产生了分支,多个分支共同执行。此称之为并行。

当然在整个程序当中,不可能一开始就搞个并行执行,串行是并行的基础,它们两者相辅相成。只有当程序出现分支(进入for循环)此时多线程可以派上用场,为每一个分支开启一个线程从而加速程序的执行。对于萌新可以粗暴简单地理解:没有for循环,就不用多线程。对于有一定编程经验的同学可以这样理解:当程序中出现耗时操作时,要另开一个线程处理此操作。所谓耗时操做比如:文件IO、网络IO……。

动手实践

定义一个线程类

Python3中提供了threading模块用于帮助用户构建多线程程序。我们首先将基于此模块来自定义一个线程类,用于消灭遍历图集时所需要的等待。

线程ID

程序执行时会开启很多个线程,为了后期方便管理这些线程,可以在线程类的构造方法中添加threadID这一参数,为每个线程赋予唯一的ID号

所执行目标方法的参数

一般来说定义一个线程类主要目的是让此线程去执行一个耗时的方法,所以这个线程类的构造方法中所需要传入所要执行目的方法的参数。比如 handleTitleLinks 这个类主要用来执行getBoys() (参见文末中的完整代码)这一方法。getBoys() 所需一个标题的链接作为参数,所以在handleTitleLinks的构造方法中也需要传入一个链接。

调用目标方法

线程类需要一个run(),在此方法中传入参数,调用所需执行的目标方法即可。

class handleTitleLinks (threading.Thread):
    def __init__(self,threadID,link):
        threading.Thread.__init__(self)
        self.threadID = threadID
        self.link = link
    def run(self):
        print ("start handleTitleLinks:" + self.threadID)
        getBoys(self.link)
        print ("exit handleTitleLinks:" + self.threadID)

实例化线程对象代替目标方法

当把线程类定义好之后,找到曾经耗时的目标方法,实例化一个线程对象将其代替即可。

def main():
    baseUrl = "https://www.nanrentu.cc/sgtp/"
    response = requests.get(baseUrl,headers=headers)
    if response.status_code == 200:
        with open("index.html",'w',encoding="utf-8") as f:
            f.write(response.text)
        doc = pq(response.text)
        # 得到所有图集的标题连接
        titleLinks = doc('.h-piclist > li > a').items()
        # 遍历这些连接
        for link in titleLinks:
        	# 替换目标方法,开启线程
            handleTitleLinks(uuid.uuid1().hex,link).start()
            # getBoys(link)

如法炮制

我们已经定义了一个线程去处理每个图集,但是在处理每个图集的过程中还会有分支(参见程序并行执行图)去下载图集中的图片。此时需要再定义一个线程用来下载图片,即定义一个线程去替换getImg()。

class handleGetImg (threading.Thread):
    def __init__(self,threadID,urlArray):
        threading.Thread.__init__(self)
        self.threadID = threadID
        self.url = url
    def run(self):
        print ("start handleGetImg:" + self.threadID)
        getPic(self.urlArray)
        print ("exit handleGetImg:" + self.threadID)

改造后完整代码如下:

#!/usr/bin/python3
import requests
from pyquery import PyQuery as pq
import uuid
import threading

headers = {
    'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.132 Safari/537.36',
    'cookie': 'UM_distinctid=170a5a00fa25bf-075185606c88b7-396d7407-100200-170a5a00fa3507; CNZZDATA1274895726=1196969733-1583323670-%7C1583925652; Hm_lvt_45e50d2aec057f43a3112beaf7f00179=1583326696,1583756661,1583926583; Hm_lpvt_45e50d2aec057f43a3112beaf7f00179=1583926583'
}
def saveImage(imgUrl,name):
    imgResponse = requests.get(imgUrl)
    fileName = "学习文件/%s.jpg" % name
    if imgResponse.status_code == 200:
        with open(fileName, 'wb') as f:
            f.write(imgResponse.content)
            f.close()

# 根据链接找到图片并下载           
def getImg(url):
    res = requests.get(url,headers=headers)
    if res.status_code == 200:
        doc = pq(res.text)
        imgSrc = doc('.info-pic-list > a > img').attr('src')
        print(imgSrc)
        saveImage(imgSrc,uuid.uuid1().hex)

# 遍历组图链接
def getPic(urlArray):
    for url in urlArray:
        # 替换方法
        handleGetImg(uuid.uuid1().hex,url).start()
        # getImg(url)
    

def createUrl(indexUrl,allPage):
    baseUrl = indexUrl.split('.html')[0]
    urlArray = []
    for i in range(1,allPage):
        tempUrl = baseUrl+"_"+str(i)+".html"
        urlArray.append(tempUrl)
    return urlArray

def getBoys(link):
    # 摸瓜第1步:获取首页连接
    picIndex = link.attr('href')
    #  摸瓜第2步:打开首页,提取末页链接,得出组图页数
    res = requests.get(picIndex,headers=headers)
    print("当前正在抓取的 picIndex: " + picIndex)
    if res.status_code == 200:
        with open("picIndex.html",'w',encoding="utf-8") as f:
            f.write(res.text)
        doc = pq(res.text)
        lastLink = doc('.page > ul > li:nth-last-child(2) > a').attr('href')
        # 字符串分割,得出全部的页数
        if(lastLink is None):
            return
        # 以.html 为分割符进行分割,取结果数组中的第一项
        temp = lastLink.split('.html')[0]
        # 再以下划线 _ 分割,取结果数组中的第二项,再转为数值型
        allPage = int(temp.split('_')[1])
        # 摸瓜第3步:根据首尾链接构造url
        urlArray = createUrl(picIndex,allPage)
        # 摸瓜第4步:存储图片,摸瓜成功
        getPic(urlArray)

def main():
    baseUrl = "https://www.nanrentu.cc/sgtp/"
    response = requests.get(baseUrl,headers=headers)
    if response.status_code == 200:
        with open("index.html",'w',encoding="utf-8") as f:
            f.write(response.text)
        doc = pq(response.text)
        # 得到所有图集的标题连接
        titleLinks = doc('.h-piclist > li > a').items()
        # 遍历这些连接
        for link in titleLinks:
            # 替换方法,开启线程
            handleTitleLinks(uuid.uuid1().hex,link).start()
            # getBoys(link)

# 处理组图链接的线程类
class handleTitleLinks (threading.Thread):
    def __init__(self,threadID,link):
        threading.Thread.__init__(self)
        self.threadID = threadID
        self.link = link
    def run(self):
        print ("start handleTitleLinks:" + self.threadID)
        getBoys(self.link)
        print ("exit handleTitleLinks:" + self.threadID)
# 下载图片的线程类
class handleGetImg (threading.Thread):
    def __init__(self,threadID,url):
        threading.Thread.__init__(self)
        self.threadID = threadID
        self.url = url
    def run(self):
        print ("start handleGetImg:" + self.threadID)
        getImg(self.url)
        print ("exit handleGetImg:" + self.threadID)

if __name__ == "__main__":
    main()

性能对比

单线程100张图片用时
多线程100张图片用时
多线程200张图片用时

因为网络波动的原因,采用多线程后并不能获得理论上的速度提升,不过显而易见的时多线程能大幅度提升程序速度,且数据量越大效果越明显。

总结

至此爬虫养成记系列文章,可以告一段落了。我们从零开始一步一步地学习了如何获取网页,然后从中分析出所要下载的图片;还学习了如何分析网页之间的联系,从而获取到更多的图片;最后又学习了如何利用多线程提高程序运行的效率。

希望各位看官能从这三篇文章中获得启发,体会到分析、设计并实现爬虫程序时的各种方法与思想,从而能够举一反三,写出自己所需的爬虫程序~ 加油!🆙💪

预告

敬请期待爬虫进阶记~

如果您觉得我们写得还不错,记得 点赞 + 关注 + 评论 三连🥰🥰🥰,鼓励我们写出更好的教程💪

想要学习更多精彩的实战技术教程?来图雀社区逛逛吧。