线程同步锁死锁递归锁信号量GIL

Posted zhuangyl23

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了线程同步锁死锁递归锁信号量GIL相关的知识,希望对你有一定的参考价值。

线程同步锁、死锁、递归锁、信号量、GIL

一、同步锁

所有线程同一时间读写同一个数据,有的线程已经对数据进行修改了,造成有的线程拿到的数据时旧的数据,而不是修改后的数据,造成结果不正确,于是引入了同步锁解决问题, 同步锁的原理是同一时间只能有一个线程读写数据。

锁通常被用来实现对共享资源的同步访问。从threading模块导入一个Lock类,为每一个共享资源创建一个Lock对象,当你需要访问该资源时,调用acquire方法来获取锁对象(如果其他线程已经获得了该锁,则当前线程需等待其被释放),待资源访问完后,再调用release方法释放锁。

下面这个例子就需要用到同步锁:

from threading import Thread
x = 0
def task():
    global x
    for i in range(20000):
        x=x+1
        # t1 的 x刚拿到0 保存状态 就被切了
        # t2 的 x拿到0 进行+1       1
        # t1 又获得运行了  x = 0  +1  1
        # 思考:一共加了几次1? 加了两次1 真实运算出来的数字本来应该+2 实际只+1
        # 这就产生了数据安全问题.    
if __name__ == '__main__':
    t1 = Thread(target=task)
    t2 = Thread(target=task)
    t3 = Thread(target=task)
    t1.start()
    t2.start()
    t3.start()

    t1.join()
    t2.join()
    t3.join()
    print(x)

上述代码输出结果是60000,看起来程序是没问题,但是当把for循环的20000改成更大的数的话,会出现错误的结果,比如改成200000,每次结果都是小于600000,这就出现了数据安全问题,这就需要用到同步锁,同一时间只能一个线程来操作x变量,t2只能等t1操作完再运行,只需要给对x变量的操作代码加个同步锁,具体使用同步锁看下面的代码:

from threading import Thread,Lock
x = 0
mutex = Lock()  
def task():
    global x
    mutex.acquire()    # 加锁,加锁之后,同一时间只能有一个线程操作下面的代码
    for i in range(100000):
        x += 1
    mutex.release()    # 释放锁

if __name__ == '__main__':
    t1 = Thread(target=task)
    t2 = Thread(target=task)
    t3 = Thread(target=task)

    t1.start()
    t2.start()
    t3.start()
    t1.join()
    t2.join()
    t3.join()
    print(x)

二、死锁

在线程间共享多个资源的时候,如果两个线程分别占有一部分资源并且同时等待对方的资源释放后才能继续往下执行,就会造成死锁。因为系统判断这部分资源都正在使用,所以这两个线程在无外力作用下将一直等待(阻塞)下去。

一旦出现死锁,整个程序既不会发生任何异常,也不会给出任何提示,只是所有线程都处于阻塞状态,无法继续。死锁是很容易发生的,尤其是在系统中出现多个同步锁的情况下。下面的代码就会出现死锁问题:

from threading import Thread,Lock
mutex1 = Lock()  # 同步锁也叫互斥锁
mutex2 = Lock()
import time
class MyThreada(Thread):  # 用继承Thread类来创建线程类
    def run(self):
        self.task1()
        self.task2()
    def task1(self):
        mutex1.acquire()
        print(f'self.name 抢到了 锁1')
        mutex2.acquire()
        print(f'self.name 抢到了 锁2')
        mutex2.release()
        print(f'self.name 释放了 锁2')
        mutex1.release()
        print(f'self.name 释放了 锁1')

    def task2(self):
        mutex2.acquire()
        print(f'self.name 抢到了 锁2')
        time.sleep(1)
        mutex1.acquire()
        print(f'self.name 抢到了 锁1')
        mutex1.release()
        print(f'self.name 释放了 锁1')
        mutex2.release()
        print(f'self.name 释放了 锁2')

for i in range(3):
    t = MyThreada()
    t.start()
    
------------------------------------------------------------------------------
Thread-1 抢到了 锁1
Thread-1 抢到了 锁2
Thread-1 释放了 锁2
Thread-1 释放了 锁1
Thread-1 抢到了 锁2
Thread-2 抢到了 锁1

上述程序就是出现死锁问题,在线程2抢到锁1后阻塞住了。线程1拿到了(锁头2)想要往下执行需要(锁头1);线程2拿到了(锁头1)想要往下执行需要(锁头2)。两个线程互相都拿到了彼此想要往下执行的必需条件,互相都不释放手里的锁头,这就是死锁问题。

死锁是不应该出现在程序中的,所以在进行多线程编程时应该采取措施避免出现死锁。下面有几种常见的方式用来解决死锁问题。

  • 避免多次锁定:尽量避免同一个线程对多个Lock进行锁定。
  • 具有相同的加锁顺序:如果多个线程需要对多个Lock进行锁定,则应该保证它们以相同的顺序请求加锁。
  • 使用定时锁:程序在调用acquire()方法加锁时可以指定timeout参数,该参数指定超过timeout秒后会自动释放对Lock的锁定,这样就可以解开死锁了。
  • 死锁检测:死锁监测是一种依靠算法机制来实现的死锁预防机制,它主要是针对那些不可能实现按序加锁,也不能使用定时锁的场景的。
  • 递归锁(Rlock):为了支持在同一线程中多次请求同一资源,python提供了递归锁来解决死锁问题。

三、递归锁(Rlock)

为了支持在同一线程中多次请求同一资源,python提供了"递归锁":threading.RLock。

RLock内部维护着一个Lock和一个counter变量,counter记录了acquire的次数,从而使得资源可以被多次acquire。直到一个线程所有的acquire都被release,其他的线程才能获得资源.

下面用递归锁来解决上述的死锁问题:

# 递归锁 在同一个线程内可以被多次acquire
# 如何释放: 内部相当于维护了一个计数器 也就是说同一个线程 acquire了几次就要release几次

from threading import Thread,Lock,RLock
mutex1 = RLock()  # 递归锁
mutex2 = mutex1

import time
class MyThreada(Thread):
    def run(self):
        self.task1()
        self.task2()
    def task1(self):
        mutex1.acquire()
        print(f'self.name 抢到了 锁1 ')
        mutex2.acquire()
        print(f'self.name 抢到了 锁2 ')
        mutex2.release()
        print(f'self.name 释放了 锁2 ')
        mutex1.release()
        print(f'self.name 释放了 锁1 ')

    def task2(self):
        mutex2.acquire()
        print(f'self.name 抢到了 锁2 ')
        time.sleep(1)
        mutex1.acquire()
        print(f'self.name 抢到了 锁1 ')
        mutex1.release()
        print(f'self.name 释放了 锁1 ')
        mutex2.release()
        print(f'self.name 释放了 锁2 ')


for i in range(3):
    t = MyThreada()
    t.start()
    
------------------------------------------------------------------------------
Thread-1 抢到了 锁1 
Thread-1 抢到了 锁2 
Thread-1 释放了 锁2 
Thread-1 释放了 锁1 
Thread-1 抢到了 锁2 
Thread-1 抢到了 锁1 
Thread-1 释放了 锁1 
Thread-1 释放了 锁2 
Thread-2 抢到了 锁1 
Thread-2 抢到了 锁2 
Thread-2 释放了 锁2 
Thread-2 释放了 锁1 
Thread-2 抢到了 锁2 
Thread-2 抢到了 锁1 
Thread-2 释放了 锁1 
Thread-2 释放了 锁2 
Thread-3 抢到了 锁1 
Thread-3 抢到了 锁2 
Thread-3 释放了 锁2 
Thread-3 释放了 锁1 
Thread-3 抢到了 锁2 
Thread-3 抢到了 锁1 
Thread-3 释放了 锁1 
Thread-3 释放了 锁2 

上面我们用一把递归锁,就解决了多个同步锁导致的死锁问题。大家可以把RLock理解为大锁中还有小锁,只有等到内部所有的小锁,都没有了,其他的线程才能进入这个公共资源。

思考:如果我们都加锁也就是单线程了,那我们还要开多线程有什么用呢?

  • 这里解释下,在访问共享资源的时候,锁是一定要存在的。
  • 但是我们的代码中,不总是在访问公共资源的,还有一些其他的逻辑可以使用多线程。
  • 所以我们在代码里面加锁的时候,要注意在什么地方加,对性能的影响最小,这个就靠对逻辑的理解了。

四、信号量(Semphare)

它控制同一时刻多个线程访问同一个资源的线程数

原理:

  • 实例化时,指定使用量。
  • 其内置计数器,锁定时+1, 释放时-1,计数器为0则阻塞。
  • acquire(blocking=True,timeout=None) 加锁
  • release()释放锁
from threading import Thread,currentThread,Semaphore
import time

def task():
    sm.acquire()
    print(f'currentThread().name 在执行')
    time.sleep(3)
    sm.release()

sm = Semaphore(5)  # 规定一次只能有5个线程执行
for i in range(15):
    t = Thread(target=task)
    t.start()
    
------------------------------------------------------------------------------
Thread-1 在执行
Thread-2 在执行
Thread-3 在执行
Thread-4 在执行
Thread-5 在执行

Thread-7 在执行
Thread-6 在执行
Thread-8 在执行
Thread-9 在执行
Thread-10 在执行

Thread-12 在执行
Thread-15 在执行
Thread-14 在执行
Thread-13 在执行
Thread-11 在执行

五、GIL(全局解释器锁)

在Cpython解释器中有一把GIL锁(全局解释器锁),GIL锁本质是一把互斥锁。导致了同一个进程下,同一时间只能运行一个线程,无法利用多核优势。同一个进程下多个线程只能实现并发不能实现并行。

为什么在多线程中不能实现真正的并行操作呢?

  • GIL:全局解释器锁 无论你启多少个线程,你有多少个CPU, Python在执行的时候只会的在同一时刻只允许一个线程(线程之间有竞争)拿到GIL在一个CPU上运行。
  • 当线程遇到IO等待或到达这轮时间的时候,CPU会切换,把CPU的时间片让给其他线程执行.
  • CPU切换需要消耗时间和资源,所以计算密集型的功能(比如加减乘除)不适合多线程,因为CPU线程切换太多,IO密集型比较适合多线程。

为什么要有GIL锁?

  • 因为cpython自带的垃圾回收机制不是线程安全的,一旦变量的引用计数为0,就会被回收。此时GIL锁就是与万恶的垃圾回收机制相抗衡,不让它这么快就过来抢我们暂时无家可归的小可爱(变量)!!!
  • 不过呢,GIL锁也导致了同一个进程同一时间只能运行一个线程,无法利用到多核优势。

如果一个线程抢掉了GIL,如果遇到io或者执行时间过长(cpu被剥夺),会强行释放掉GIL锁,以便其他的线程抢占GIL

分析:我们有四个任务需要处理,处理方式肯定是要玩出并发的效果,解决方案可以是:

  • 方案一:开启四个进程
  • 方案二:一个进程下,开启四个线程

计算密集型 :推荐使用多进程
每个都要计算10s
多线程
在同一时刻只有一个线程会被执行,也就意味着每个10s都不能省,分开每个都要计算10s,共40.ns
多进程
可以并行的执行多个线程,10s+开启进程的时间

IO密集型: 推荐使用多线程
4个任务每个任务90%大部分时间都在io.
多线程
可以实现并发,每个线程io的时间不咋占用cpu, 10s + 4个任务的计算时间
多进程
可以实现并行,10s+1个任务执行的时间+开进程的时间

具体看以下例子:

io密集型

'''采用多进程计时情况'''
from threading import Thread
from multiprocessing import Process
import time

def work1():
    x = 1+1
    time.sleep(5)

if __name__ == '__main__':
    t_list = []
    start = time.time()
    for i in range(4):
        t = Process(target=work1)
        t_list.append(t)
        t.start()
    for t in t_list:
        t.join()
    end = time.time()
    print('多进程',end-start) 

多进程 5.499674558639526

'''采用多线程计时情况'''
from threading import Thread
from multiprocessing import Process
import time

def work1():
    x = 1+1
    time.sleep(5)

if __name__ == '__main__':
    t_list = []
    start = time.time()
    for i in range(4):
        t = Thread(target=work1)
        # t = Process(target=work1)
        t_list.append(t)
        t.start()
    for t in t_list:
        t.join()
    end = time.time()
    print('多线程',end-start) 

多线程 5.004202604293823

小结:你发现了嘛!!!多线程的时间更短,相差0.5秒意味着什么!!!你明白吗???奶茶都可以绕地球两百圈了!!

多线程为什么更快?

  • 因为你看,多进程那么多个人同时去做,意味着卡机的时候都得哭着等。
  • 那线程就不一样了,我们可聪明了,谁要等你,我直接切切切,所以同一段当然更快咯

计算密集型

'''采用多进程计时情况'''
from threading import Thread
from multiprocessing import Process
import time

def work1():
    res=0
    for i in range(100000000): #1+8个0
        res*=i

if __name__ == '__main__':
    t_list = []
    start = time.time()
    for i in range(4):
        t = Process(target=work1)
        t_list.append(t)
        t.start()
    for t in t_list:
        t.join()
    end = time.time()
    print('多进程',end-start)  

多进程 18.062480211257935

'''采用多线程计时情况'''
from threading import Thread
from multiprocessing import Process
import time

def work1():
    res=0
    for i in range(100000000): 
        res*=i

if __name__ == '__main__':
    t_list = []
    start = time.time()
    for i in range(4):
        t = Thread(target=work1)
        # t = Process(target=work1)
        t_list.append(t)
        t.start()
    for t in t_list:
        t.join()
    end = time.time()
    print('多线程',end-start)  

多线程 33.27059483528137

这回居然差了15秒之多,多进程为什么更快?

  • 因为你看,这次不卡机了,所以多进程那么多个人同时去做一件事,意味着一个时间里只需要完成一件事就好啦!(一个任务时间)
  • 那多线程就不一样了,计算工作量大又耗时,但这是必经之路,这跟卡机不一样,因为那只有一个数据在动,而计算整个过程牵一发而动全身,所以一个时刻一条线程不断地切换,耍小聪明既会丢数据又没用!(多个任务时间)

总结:

  • IO密集型
    • 各个线程都会都各种的等待,多线程比较适合
    • 也可以采用多进程+协程
  • 计算密集型
    • 线程在计算时没有等待,这时候去切换,就是无用的切换,python不太适合开发这类功能
    • 推荐使用多进程

以上是关于线程同步锁死锁递归锁信号量GIL的主要内容,如果未能解决你的问题,请参考以下文章

GIL全局解释器锁死锁递归锁信号量Event事件线程Queue

python学习第37天GIL锁死锁现象与递归锁信号量Event时间线程queue

并发编程——GIL全局解释器锁死锁现象与递归锁信号量Event事件线程queue

Python入门学习-DAY36-GIL全局解释器锁死锁现象与递归锁信号量Event事件线程queue

并发编程小结

并发&并行 同步&异步 GIL 任务 同步锁 死锁 递归锁