python多线程爬虫入门-互斥锁以及GIL

CSDN博客地址:https://blog.csdn.net/superDE009/article/details/107881944

python多线程爬虫入门 -互斥锁以及GIL

在上篇文章中,我们详细了解了多线程编程中queue消息队列的使用,实现了多个不同任务线程间相互通讯,多个线程同步运行相互配合的功能。但是在实际运用中,部分特殊功能的线程是不允许同步执行的,如当一个A线程正在修改X文件时,B线程同时修改X文件,就会导致文件写入的内容错误,甚至出现乱码。所以,我们需要一种机制来禁止某些特殊功能的线程同步执行,如禁止上文中A线程B线程同步执行,即两个线程同时只能执行一个。这种禁止的机制,即为锁(lock)

一、何谓锁?

要理解什么是锁,首先我们来看一个例子:
在一个餐桌边,由2个人,这两个人的任务都是吃饭,但此时桌上只有一双筷子,那么只有抢到了筷子的那个人才能吃饭,而另一个人就只能等到前一个人吃完饭,放下筷子后,才能等到筷子去吃饭。
在以上的例子中,人就是线程,吃饭即为线程的任务,而筷子,就是锁了,只有当获取到锁后,线程才能执行,执行完成后释放锁,在当前线程释放锁之前,其他需要锁的线程等待(不需要锁的线程则不影响)。
这就是锁的基本使用原理。
在python中,我们可以使用以下语句来创建和使用锁。

 lock = threading.Lock()#创建‘lock’为锁对象
 lock.acquire()#线程进入阻塞状态,尝试获取‘lock’锁对象,当获取成功后,线程解除阻塞,继续运行
 lock.release()#线程执行完毕后,释放锁(使用该语句前提必须是该线程已经获取过锁)

通常在线程中,并不是整个线程的代码都是禁止同步运行的,而只是其中的一部分,所以通常我们会在执行那部分代码前获取锁,并在其执行完毕后释放锁。并不会让整个线程都在获取锁的状态下执行,以保证多线程的执行效率。

二、为什么要用锁?

防止多线程对数据的脏写脏读。

这一点在序中我们已经提及过一个写入文件的例子了。但其实无论是内存中的变量还是系统中的文件,在被多线程同时修改时,都会出现脏写或者脏读的问题。

何为脏写脏读?

首先在计算机中,线程之间通常是并发或并行的,

脏写

在多个线程同时需要写入同一个文件/变量时,由于交替写入的问题,如线程A先写入两个字节,之后切换到线程B再写入两个字节,这就导致写入的数据是断开,不完整的,甚至会出现乱码。

脏读

在多个线程同时操作同一个变量/文件时,线程A准备修改变量X的值,线程B需要读取变量X的值,当线程A还未写入完成时,CPU切换运行线程B,而此时变量X的值还是旧的值,导致线程B也就读取了旧(错误)的值。即脏读。

操作原子性

在以上的脏写脏读中,其实都存在一个问题,那就是线程A对变量/文件的修改的操作不是一个原子性的操作,也就是,线程A的修改操作是可以在执行过程中打断的。
而原子操作为:该操作要么就完全执行,要么就全不执行,不会出现执行到一般被中断的问题。
在本文中,对原子操作不再做展开。

三、线程死锁

1.两个线程互相等待导致死锁

概念

在我们使用锁时,如线程A拥有lockA,同时acquire lockB,而线程B拥有lockB,acquirc lockA,由之前讲过的acquire方法,我们可知,这两个线程会相互等待对方线程释放自己所需的锁,于是都处于阻塞状态,从而导致线程死锁。从运行界面来看,就是整个进程处于无限等待的情况。

举个例子

这里还有吃饭和筷子的例子来讲:
餐桌上有中餐和西餐,一双筷子和一副刀叉,桌边有A和B两人,A先用筷子吃中餐,B用刀叉吃西餐,A中餐吃到一半(没有放下筷子)想要吃西餐,便等待B把刀叉放下来后,用刀叉吃西餐,而此时B西餐吃到一般(也没有放下刀叉)想要吃中餐,便等待A把筷子放下。于是两人开始无限的相互等待。
上例中,AB人即为线程, 刀叉和筷子为lockAB。这样以来应该就很好理解了。

解决方法

为了防止以上线程死锁,在使用锁时,

  1. 在acquire另一个锁前,尽量先释放当前占有的锁
  2. 尽量不要将两个都需要锁的功能放入同一个线程中运行。

2. 单个线程等待自己导致死锁

概念

如线程A在占有lockA时,又运行了一次lockA.acquire方法,此时就会导致线程死锁。因为线程A此时正占有着lockA,所有当acquire方法执行后,会发现lockA正在被占有,于是就开始等待lockA被释放,从而导致死锁

举个例子

这里还有吃饭和筷子的例子来讲:
A人正在用餐具吃饭,突然想再要一副餐具,但是餐具总共只有一副,于是A人开始等待餐具被放下,然而餐具此时正在A人手上,于是无限等待。

解决方法
Rlock可再入锁

在python中的锁,不只有互斥锁lock,还有一个锁叫做可再入锁:Rlock,用以下语句创建

rLock = threading.RLock()  #RLock对象

Rlock在使用方法上和普通的lock几乎完全一致,不同的是可再入锁Rlock是可以被同一个线程多次acquire和release的。在Rlock内部存在一个初始为0的计数器,每当Rlock被acquire一个计数器+1,每release一次则-1,当计数器归零时则解除锁定。

Rlock和lock使用对比
import threading
lock =threading.lock()
lock.acquire()
lock.acquire()#此时整个线程死锁。
import threading
rLock = threading.RLock()  #RLock对象
rLock.acquire()
rLock.acquire() #在同一线程内,程序不会堵塞。
rLock.release()
rLock.release()

四、锁使用实例

看完上面的概念后,我们来实际操作使用一下锁
首先我们看以下程序
该程序中,我们创建了两个线程同时修改同一个全局变量,
通常来说,最终的输出应该时两个线程运行的总和,即2000000
但事实上,我们收到的输出为一个1-2000000间的随机数据。
这是因为conut+=1语句并不是一个原子操作,导致在多线程并发时,该语句会在运行时被中断,导致数据写入失败。从而使最终的输出小于2000000。

import threading

count = 0

def run_thread():
    global count
    for i in range(1000000):
        count += 1
t1 = threading.Thread(target=run_thread,args=())
t2 = threading.Thread(target=run_thread,args=())
t1.start()
t2.start()
t1.join()
t2.join()
print(count)

输出

1193860

我们在这个线程中加入锁后,再看看:
在每个线程的写入操作前,我们让线程获取锁,在写入后释放锁
此时,我们的输出就一定是2000000了,因为由于锁的存在,每一次的写入操作都是独立运行的。

import threading

count = 0
def run_thread():
    global count
    for i in range(1000000):
        lock.acquire()#写前获取锁
        count += 1
        lock.release()#写后释放锁
lock=threading.Lock()#创建锁
t1 = threading.Thread(target=run_thread,args=())
t2 = threading.Thread(target=run_thread,args=())
t1.start()
t2.start()
t1.join()
t2.join()
print(count)

输出

2000000

锁的使用就如上一般,通常在并发写入文件或变量中常用。

五、全局解释器锁(GIL:global interpreter lock)

以上的锁都是我们可以在python中创建的锁,接下来我们讲讲一个存在于python解释器中的锁:GIL。
以下是官方对于GIL的注释:

全局解释器锁(GIL)是一个互斥锁,可以防止多个本地线程同时执行Python字节码。这个锁是必要的,主要是因为CPython的内存管理不是线程安全的。

通过以上我们知道GIL只允许python的解释器在同一时间内执行同一个线程。

1.为什么要GIL

在多核CPU盛行的今天,使用多线程编程时,为了保证线程间共享内存的数据,甚至是CPU中的cache不被脏读脏写,我们需要保证在同一时段内只能由一个线程对线程共享内存中的数据进行写入/修改。这就是GIL所实现的功能。
(这里的写入是更加底层的写入,不是上文中的conut+=1语句,因为在该语句中,实际执行了三个步骤:

  1. 读取count指向的内存中的数据
  2. 对该值+1
  3. 将count指向内存中+1后的数据 )

这里真正对数据修改的操作只有第二步。

2.GIL存在的利弊

通过以上我们知道GIL只允许python的同一时间内执行同一个线程。这就有效的保证的线程中共享的数据的完整性和同步性。

由于GIL限制了python同时只能执行一个线程,所有的线程只能以并发的模式运行。这导致了多线程的效率大大降低了。

那这是否意味着python的多线程完全没有意义呢?
其实不尽然:

python多线程对CPU密集型任务:效率降低

由于GIL的存在,python多线程由并发模式运行。对于CPU密集型任务来说,每个线程都需要CPU资源,当一个线程在使用CPU时,其他线程只能等待,导致效率十分低下,甚至低于单线程的效率。

python多线程对IO密集型任务:效率提高

在IO密集型任务上(如爬虫),由于IO写入需要时间,在一个线程进行IO写入时,是不需要CPU资源的,此时CPU可由其他线程使用,这样多个线程在IO写入与CPU计算之间交替执行,从而可以实现更高的效率。
所以,对于像爬虫这样的IO密集型任务,python的多线程还是十分有用的。

总结

本文中,我们具体了解了python多线程中锁的概念与使用,一个GIL的存在。
系列文章:
进程与线程:https://blog.csdn.net/superDE009/article/details/107816034
queue与守护线程:https://blog.csdn.net/superDE009/article/details/107847070
互斥锁和GIL:https://blog.csdn.net/superDE009/article/details/107881944

本人多线程爬虫实例:https://github.com/DE009/DaZhongDianPing-mutiThread-
此篇博客为学习总结,如若有错,还请大佬多多指正。


发表评论

邮箱地址不会被公开。 必填项已用*标注