从爬图虫入手学习多线程(原理分析)


学Python不写爬虫和咸鱼有什么区别!!!!by:Noob_宅

人生苦短,我用Python

阅读此篇文章需具备的知识基础:Python基础(最少要学习过基础教程,能读懂代码),入门级的数据结构基础(知道队列的概念即可)。最好学过C语言,了解一些CPU的工作原理。

当然,如果后三者不太了解的话也不是不行,我会尽量在文中以简明易懂的方式解释,了解的可以跳过解释部分。

一.前言

之前曾经拿别人的脚本学习了一下网络爬图虫怎么写,但是写好后运行起来一直嫌慢。这两天正巧有空学了一下Python多线程,感觉Python的多线程特别适合爬虫这种IO密集型的操作。

所谓的IO就是input和output,输入和输出。或者称为读写,总之就是对数据流的操作。

(这里我们摘取廖雪峰老师的教程中的一段来解释一下IO密集型和计算密集型:

https://www.liaoxuefeng.com/wiki/1016959663602400/1017631469467456

计算密集型 vs. IO密集型

是否采用多任务的第二个考虑是任务的类型。我们可以把任务分为计算密集型和IO密集型。

计算密集型任务的特点是要进行大量的计算,消耗CPU资源,比如计算圆周率、对视频进行高清解码等等,全靠CPU的运算能力。这种计算密集型任务虽然也可以用多任务完成,但是任务越多,花在任务切换的时间就越多,CPU执行任务的效率就越低,所以,要最高效地利用CPU,计算密集型任务同时进行的数量应当等于CPU的核心数。

计算密集型任务由于主要消耗CPU资源,因此,代码运行效率至关重要。Python这样的脚本语言运行效率很低,完全不适合计算密集型任务。对于计算密集型任务,最好用C语言编写。

第二种任务的类型是IO密集型,涉及到网络、磁盘IO的任务都是IO密集型任务,这类任务的特点是CPU消耗很少,任务的大部分时间都在等待IO操作完成(因为IO的速度远远低于CPU和内存的速度)。对于IO密集型任务,任务越多,CPU效率越高,但也有一个限度。常见的大部分任务都是IO密集型任务,比如Web应用。

IO密集型任务执行期间,99%的时间都花在IO上,花在CPU上的时间很少,因此,用运行速度极快的C语言替换用Python这样运行速度极低的脚本语言,完全无法提升运行效率。对于IO密集型任务,最合适的语言就是开发效率最高(代码量最少)的语言,脚本语言是首选,C语言最差。)

看完这个应该就能很好地理解为什么爬虫适合多线程了,这里一定要清楚记得IO密集型的特性,后文会多次提到。

二.学习多线程(基于threading库)

当时我学习多线程其实主要是从视频上学习基础知识的,之后又看了很多博客,结合我对计算机硬件和系统的一些粗浅了解,我也写出了自己的一些见解,可能讲的不深。

我当时看的视频:https://www.bilibili.com/video/av16944429

没错,就是哔哩哔哩。这充分说明了B站是学习的地方(手动滑稽)。个人感觉讲的挺好的,值得一看。

这里我们就以视频中代码为例来慢慢展示多线程。

1. 创建线程,线程监控和join()方法

First.py

First.py

PEP8规范不是没道理的,的确看着舒服很多。

这段代码里主要使用了如下方法:

threading.Thread() # 创建线程,主要参数target(用于指定子线程工作的位置,不能为空);name(用于指定线程的名称,可以为空);args(用于向线程工作的地方,也就是target处传递参数,可以为空)。

thread.start() # 启动线程,就像是发动汽车的引擎一样,必须有的步骤。

threading.active_count() # 激活的线程数

threading.enumerate() # 查看激活的线程名

threading.current_thread() # 查看当前正在运行线程(同时显示了线程名)

运行结果

这里也贴上运行的结果进行对照,可以很明确的看出各项参数对应的信息。

实际上,在我后来编写爬虫脚本的时候最经常用到的方法为current_thread(),至于原因……自己编写一个脚本就知道啦(其实主要就是为了debug和测试)。

接下来就是一个重点啦:join()方法

简单来讲Join()方法是用来判断指定线程是否执行完毕的,如果执行完毕的话,代码才会继续向下执行,否则就一直维持执行。为什么我们说是指定线程呢???

观察代码不难发现,added_thread.join(),对应的就是我们创建的T1线程。或者更简单点added_thread线程,以后我们会创建更多的线程,join()前面是谁,那么就判断谁。这点也要着重记忆一下,以后会很有用。

还记得那张代码的运行结果吗???这里我们将added_thread.join()句注释掉,再运行这个脚本,猜猜会发生什么???这里可以先自己想一想,分析一下再往下看。

运行结果

乍看一下两张图没什么区别,但是仔细观察的话发现注释掉join()语句之后,程序先打印了all done,之后才是T1 finished。这里就说明了join()语句的重要作用,如果执行一个程序需要把子线程的数据返回给主线程执行的话,join()语句就是必不可少的啦。

其实仔细想想还能发现一个问题:Python的线程有主线程和子线程,但是两者是分开工作的互不影响,主线程执行完毕并不会使子线程停止。想通这点的话,就能慢慢接触到Python多线程的本质了,并且以后的编程中也会经常遇到这个问题。

至此就粗浅的讲解了一下多线程的基础方法,我们再来做一下回顾。

Python的线程有主线程和子线程,但是两者是分开工作的互不影响,主线程执行完毕并不会使子线程停止。所以说需要使用join()方法来判断线程是否结束,只有结束后才会继续向下运行。如果需要保证所有都运行完毕的话可以设置一个线程调度器。

至于什么是线程调度器,下一章会讲。

2. queue队列和线程调度器

Second.py

Second.py

写多线程其实会经常遇到需要子线程向主线程返回结果的情况,但是线程是没有返回值的,如果使用全局变量又会引出很多问题并且不适合代码量较多的情况,这时候队列就能派上用场了。

使用队列需要引入queue库,在这里我们只简单的讲解一下queue的几个主要方法(实际上原因是我本人对这个也不是太理解,数据结构苦手啊QAQ)。想更多了解的话可以看看网上的各种博客还有教学视频。

queue.Queue() # 创建队列,特点:先入先出(解释:可以理解为一条流水线,先进入的零件会先被组装好之后输出)

# 在这里我们以q代指这个队列(或者说q这个对象已经是一个队列了)

q.put() # 将数据存入队列中

q.get() # 将数据从队列中取出

(PS:好丢人啊,感觉写了不如不写)

接下来就是另一个重点了,线程调度器

有的时候我们需要开辟多个线程,但是如果重复创建线程,启动线程,操作线程,监控线程无疑是效率很低的,但是如果有一个总领的调度器那就很简单了,很容易实现批量创建指定数目线程,并且对线程进行总体管理(暂时没有研究如何对单个线程进行管理,我一般采取的思路是在线程工作的地方对单个子线程进行管理)。

以下是一个简单的创建四个子线程的线程调度器的代码:

threads = []
for i in range(4):
# args用于向分线程工作的地方传参,同时指定i的值,使线程在指定地方(即job)工作
t = threading.Thread(target=job, args=())
t.start()
threads.append(t)
for thread in threads:
thread.join()  # 重点:所有线程运行结束后再向下运行

虽然比较简单,但是这个实现了4个线程的批量创建和简单线程控制,我在写爬虫的时候也用到了这个调度器,还是直接复制粘贴的(所以说啊,存存代码是很有意义的)。

我们来分析一下这个调度器,首先我们定义了一个threads列表用于储存线程(对象),之后创建并启动了四个线程,通过for thread in threads对每一个线程实现了join()方法进行控制。程序的运行结果如下:

结果

最后的数据为程序运行所用时间。可以自己试着分析一下这个程序的结果。

接下来我们进入下一章节。

3. 真·伪多线程,快or慢

可能初看这个标题会比较懵逼,但是这也就是我要讲的Python多线程的实质

初听多线程可能就是感觉程序会在多个线程上运行,从而让程序执行变快。如果你就是这么认为而且从代码的运行结果中也感觉出来了这一点,那么图样图森破,程序设计师的小伎俩又一次成功了。

实际上Python的多线程是一种障眼法:程序本质上还只是运行在CPU的一个线程上,但是程序通过不停地在多个线程中进行切换,同时切换的速度很快,从而造成了一种多线程的假象。实际上程序还是同时只运行一个线程。

这也就引出了一个问题:多线程其实有的时候不是那么的有效率,甚至还不如单线程。可能这时候你就想直接左上角或者右上角了。但是别急,回忆一下前言提到的计算密集型和IO密集型的概念,如果忘了的话再回去看个两三次。

是不是意识到了什么????

毫无疑问,最适合Python多线程的地方其实是IO密集型的操作。

如果还不理解什么是IO密集型的话那我就举个栗子。

—————————栗子的分割线—————————————-

假栗子

爪巴

拿错了……

真栗子

比如说你(CPU)和别人聊天,但是和你聊天的人并不会你说一句他就立刻回一句(除非你本人是个妹子而对面是一只单身狗,或者是你基(姬)友),但是你一次只能做一件事(CPU线程是唯一的)。这时候你有两个选择:一直拿着手机或者电脑等待对方发信息(线程一直在处理但是CPU在等待),或者回复完对方的信息之后就直接去干别的事,等到出现消息提示音再回来看信息(将CPU释放处理其他工作)。

—————————-栗子的分割线————————————-

可能我比较笨,让你看的更晕了,那么接下来我还是引用廖老师的原句来解释一下这个问题吧。

———————————–专业的分割线——————————-

这类任务(IO密集)的特点是CPU消耗很少,任务的大部分时间都在等待IO操作完成(因为IO的速度远远低于CPU和内存的速度)。

好了,总算解释完了IO密集型的问题。那就继续吧,接下来我们来说一下为什么多线程的效率有时候不如单线程。

接下来还是廖老师的原句,先体会一下。

线程切换

无论是多进程还是多线程,只要数量一多,效率肯定上不去,为什么呢?

我们打个比方,假设你不幸正在准备中考,每天晚上需要做语文、数学、英语、物理、化学这5科的作业,每项作业耗时1小时。

如果你先花1小时做语文作业,做完了,再花1小时做数学作业,这样,依次全部做完,一共花5小时,这种方式称为单任务模型,或者批处理任务模型。

假设你打算切换到多任务模型,可以先做1分钟语文,再切换到数学作业,做1分钟,再切换到英语,以此类推,只要切换速度足够快,这种方式就和单核CPU执行多任务是一样的了,以幼儿园小朋友的眼光来看,你就正在同时写5科作业。

但是,切换作业是有代价的,比如从语文切到数学,要先收拾桌子上的语文书本、钢笔(这叫保存现场),然后,打开数学课本、找出圆规直尺(这叫准备新环境),才能开始做数学作业。操作系统在切换进程或者线程时也是一样的,它需要先保存当前执行的现场环境(CPU寄存器状态、内存页等),然后,把新任务的执行环境准备好(恢复上次的寄存器状态,切换内存页等),才能开始执行。这个切换过程虽然很快,但是也需要耗费时间。如果有几千个任务同时进行,操作系统可能就主要忙着切换任务,根本没有多少时间去执行任务了,这种情况最常见的就是硬盘狂响,点窗口无反应,系统处于假死状态。

所以,多任务一旦多到一个限度,就会消耗掉系统所有的资源,结果效率急剧下降,所有任务都做不好。

—————————–专业与非专业的分割线——————————

实际上线程切换还和一个东西密切相关:GIL(全局解析器锁)

那么什么是全局解析器锁呢?这里我引用一个博客,写的很不错,很好的分析了并行和并发,以及Python多线程的切换问题。

链接:https://blog.csdn.net/weixin_41594007/article/details/79485847

GIL即全局解释器锁(global interpreter lock),每个线程在执行时候都需要先获取GIL,保证同一时刻只有一个线程可以执行代码,即同一时刻只有一个线程使用CPU,也就是说多线程并不是真正意义上的同时执行。

但是加锁也是有代价的,都需要CPU进行操作,所以说一定程度上会拖慢程序运行的时间,但总归上是利大于弊的。

这里我也总结一下:进程(线程)切换这个问题实际上就关乎多线程(进程)和单线程效率的判断,总有一个最适区间。通过我自己编写其它测试脚本发现,即使是CPU计算密集型,如果跑的数据够大够多,那么多进程的效率要远高于多线程(这个数据和核心数目有关系),同时多线程和单线程差距又不大,但是多线程仍旧比单线程慢,这说明多进程还是强(可能有点题外话了,在之后的文章里应该会讲,如果我不咕咕咕的话)。

更多关于速度问题的探讨可以参考这篇博客:https://blog.csdn.net/u011519550/article/details/89857063

4. GIL原理的初步探究

Fourth.py

Fourth.py

这个脚本就是为了探讨GIL的问题:GIL的切换方式,或者说切换条件。

运行后可以观察到:脚本每次运行,总有两三个线程的启动和结束顺序不一样。这也和GIL的释放锁特性有关:在IO操作等可能会引起阻塞的system call之前,可以暂时释放GIL,但在执行完毕后,必须重新获取GIL 。Python 3.x使用计时器(执行时间达到阈值后,当前线程释放GIL),而Python 2.x,则是tickets计数达到100。

在多线程爬虫脚本的编写过程中我也被这个问题困扰了很久,因为不知道线程在切换的时候,网络IO是否还在继续运行。所以我在爬虫脚本里下载图片的地方前后各添加了一个判断当前线程的函数。

添加函数

最后程序运行的结果也是让我大吃一惊

线程不同

线程相同

下载图片的线程和写入图片的线程有时候居然是不一样的,根据原理分析的话答案可能就是:如果下载图片的时间超过一个固定阈值,那么GIL就会释放当前线程,让下一个线程接手上一个线程的任务(真实接盘侠),此时上一个线程去执行其它任务。大体上只从结果分析就是这样,如果真的想要完全知道,那就只能去读一读方法源码了,以后会读一下的,不然也没无从谈起学会了Python。

越是研究就越是为语言编写者的智慧所折服,这玩意真的是一个圣诞节假期就能搞出来的东西吗???Guido van Rossum(吉多·范罗苏姆)是外星人吧。

无力吐槽。不过还是想吐槽一下切换方式。一个渣男追一个妹子,追到一定时间就放弃这个妹子去找下一个,然后还有人去接盘,真实的窒息。理科男流下了单身狗的泪水。

5. LOCK 线程锁

别问我为什么这个脚本叫做Third.py,我现在也很迷QAQ

Third.py

这一个脚本就是为了解释Python多线程中锁的作用。

如果你需要多个线程来处理一个数据,或者上个线程的结果要作为下一个线程的输入,那么加一把锁就是很重要的了。(多线程模式下用这个运算数据纯属脱裤子放屁,有时候直接单线程更好其实)

还是讲一下关于锁的部分的各种方法吧:

threading.Lock() # 创建一个锁,注意threading小写,Lock首字母大写

threading.Lock().acquire() # 为线程加上锁,表示这个线程不能继续使用,需要等待工作完成

threading.Lock().release() # 释放锁,表示任务已完成,可以继续切换线程

实际在运用过程中,锁是一个很有意思的东西。

在IO情况下,如果是磁盘IO那就必须加锁,除非你指定文件句柄(指定句柄一般用于多个线程向一个文件内写入数据,相当厉害的操作)。如果是网络IO那就可以不加锁(至少在下载图片看来是这样,这个在我的脚本里也有体现)。

关于锁其实还有很多要研究,但是我目前只遇到了磁盘IO和网络IO的情况,所以这方面的研究就少了一点,以后补上吧。

6. 代码包

上述所有的代码我都已经打包好了,有需要的话可以自己download下来自己运行一下,全都是基于Python3.7进行编译。

链接:https://pan.baidu.com/s/1dRAFNN1047PUBKty-esXMw

提取码:txsn

至此我们就完成了编写多线程脚本的知识储备,希望你能自己对着代码敲一敲,自己也研究一下,可能就会发现很多有意思的东西。当然我本人也是正在学习的过程中,可能文章里面有些地方会有纰漏。如果有发现这样的错误可以联系我,邮箱地址是:892175736@qq.com,前面就是QQ号,想共同探讨也可以加我QQ,但请写好备注。

接下来我就会拿爬虫脚本来进行分析了,有兴趣的可以继续看看。

感谢我的学长和同学在我写这篇文章的过程中对我的帮助,给我提供了很多宝贵的意见。

此文章为之前的搬运,创作日期为2019.05.不知道多少号。(XD)


文章作者: 丿卟离丶
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 丿卟离丶 !
  目录