python中的锁

Python中的锁

一、全局解释器锁(GIL)

1、什么是全局解释器锁

在同一个进程中只要有一个线程获取了全局解释器(cpu)的使用权限,那么其他的线程就必须等待该线程的全局解释器(cpu)使用权消失后才能使用全局解释器(cpu), 即使多个线程直接不会相互影响在同一个进程下也只有一个线程使用cpu,这样的机制称为全局解释器锁(GIL)。

  • GIL 保证CPython进程中,只有一个线程执行字节码。甚至是在多核CPU的情况下,也只允许同时只能有一个CPU 上运行该进程的一个线程。
  • CPython中
    1. IO密集型,某个线程阻塞,就会调度其他就绪线程;
    2. CPU密集型,当前线程可能会连续的获得GIL,导致其它线程几乎无法使用CPU。
  • 在CPython中由于有GIL存在,IO密集型,使用多线程较为合算;CPU密集型,使用多进程,要绕开GIL。
  • Python中绝大多数内置数据结构的读、写操作都是原子操作。
  • 由于GIL的存在,Python的内置数据类型在多线程编程的时候就变成了安全的了,但是实际上它们本身 不是 线程安全类型。

2、全局解释器锁的好处

1、避免了大量的加锁解锁的好处

2、使数据更加安全,解决多线程间的数据完整性和状态同步

3、全局解释器的缺点

多核处理器退化成单核处理器,只能并发不能并行。

1
同一时刻的某个进程下的某个线程只能被一个cpu所处理,所以在GIL锁下的线程只能被并发,不能被并行。

二、同步锁

1、什么是同步锁?

    同一时刻的一个进程下的一个线程只能使用一个cpu,要确保这个线程下的程序在一段时间内被cpu执,那么就要用到同步锁。

2、为什么用同步锁?

    因为有可能当一个线程在使用cpu时,该线程下的程序可能会遇到io操作,那么cpu就会切到别的线程上去,这样就有可能会影响到该程  序结果的完整性。

3、怎么使用同步锁?

    只需要在对公共数据的操作前后加上上锁和释放锁的操作即可。

4、实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import time
import threading

R = threading.Lock()


def sub():
global num
R.acquire() # 加锁,保证同一时刻只有一个线程可以修改数据
num -= 1
R.release() # 修改完成就可以解锁
time.sleep(1)


num = 100 # 定义一个全局变量
l = [] # 定义一个空列表,用来存放所有的列表
for i in range(100): # for循环100次
t = threading.Thread(target=sub) # 每次循环开启一个线程
t.start() # 开启线程
l.append(t) # 将线程加入列表l
for i in l:
i.join() # 这里加上join保证所有的线程结束后才运行下面的代码
print(num)
# 输出结果为0

5、扩展知识

 1、GIL的作用:多线程情况下必须存在资源的竞争,GIL是为了保证在解释器级别的线程唯一使用共享资源(cpu)。

 2、同步锁的作用:为了保证解释器级别下的自己编写的程序唯一使用共享资源产生了同步锁。

三、递归锁和死锁

1、什么是死锁?

指两个或两个以上的线程或进程在执行程序的过程中,因争夺资源而相互等待的一个现象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import time
import threading

A = threading.Lock()
B = threading.Lock()
import threading


class obj(threading.Thread):
def __init__(self):
super().__init__()

def run(self):
self.a() # 如果两个锁同时被多个线程运行,就会出现死锁现象
self.b()
def a(self):
A.acquire()
print('123')
B.acquire()
print(456)
time.sleep(1)
B.release()
print('qweqwe')
A.release()
def b(self):
B.acquire()
print('asdfaaa')
A.acquire()
print('(⊙o⊙)哦(⊙v⊙)嗯')
A.release()
B.release()
for i in range(2): # 循环两次,运行四个线程,第一个线程成功处理完数据,第二个和第三个就会出现死锁
t = obj()
t.start()

这是python里写一个死锁的标准写法了吧;当b获取了B锁,a获取了A锁,a想要B锁继续,b想要A锁继续,于是就产生了死锁;

2、什么是递归锁?

​ 在Python中为了支持同一个线程中多次请求同一资源,Python提供了可重入锁。这个RLock内部维护着一个Lock和一个counter变量,counter记录了acquire的次数,从而使得资源可以被多次require。直到一个线程所有的acquire都被release,其他的线程才能获得资源。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
import time
import threading

A = threading.RLock() # 这里设置锁为递归锁
import threading


class obj(threading.Thread):
def __init__(self):
super().__init__()

def run(self):
self.a()
self.b()

def a(self): # 递归锁,就是将多个锁的钥匙放到一起,要拿就全拿,要么一个都拿不到
# 以实现锁
A.acquire()
print(str(threading.currentThread().name) + ' 123')
print(str(threading.currentThread().name) +" 456")
time.sleep(1)
print(str(threading.currentThread().name) +' qweqwe')
A.release()

def b(self):
A.acquire()
print(str(threading.currentThread().name) +' asdfaaa')
print(str(threading.currentThread().name) +' (⊙o⊙)哦(⊙v⊙)嗯')
A.release()

if __name__ == '__main__':
for i in range(2):
t = obj()
t.start()

'''
有锁时的输出:

Thread-1 123
Thread-1 456
Thread-1 qweqwe
Thread-1 asdfaaa
Thread-1 (⊙o⊙)哦(⊙v⊙)嗯
Thread-2 123
Thread-2 456
Thread-2 qweqwe
Thread-2 asdfaaa
Thread-2 (⊙o⊙)哦(⊙v⊙)嗯


去掉锁的输出
Thread-1 123
Thread-1 456
Thread-2 123
Thread-2 456
Thread-2 qweqweThread-1 qweqwe # 这里说明,线程2把数据写入了输出缓冲区,还没来得及输出呢,就被线程1给抢了,随着线程1一起输出了;
Thread-1 asdfaaa
Thread-1 (⊙o⊙)哦(⊙v⊙)嗯
# 这一个空行就是线程2为打印出来的,它回来接着这里打印
Thread-2 asdfaaa
Thread-2 (⊙o⊙)哦(⊙v⊙)嗯


之所以能被抢还是因为这是IO操作,释放了全局解释器锁
'''

四、信号量(semaphore)

1、什么是信号量?

同进程的一样,semaphore管理一个内置的计数器,每当调用acquire()时内置函数-1,每当调用release()时内置函数+1。

计数器不能为0,当计数器为0时acquire()将阻塞线程,直到其他线程调用release()。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import threading
import time

mysf = threading.Semaphore(5) # 创建信号量对象,(5表示这个锁同时支持的个数)


def func():
if mysf.acquire(): # 因为使用了信号量,下面的输出就会5个5个的同时输出
print(threading.currentThread().getName() + ' get semaphore')
time.sleep(1)
mysf.release()

if __name__ == '__main__':
for i in range(20):
t = threading.Thread(target=func)
t.start()

为什么在python中推荐使用多进程而不是多线程

每个CPU在同一时间只能执行一个线程

在单核CPU下的多线程其实都只是并发,不是并行,并发和并行从宏观上来讲都是同时处理多路请求的概念。但并发和并行又有区别,并行是指两个或者多个事件在同一时刻发生;而并发是指两个或多个事件在同一时间间隔内发生

在Python多线程下,每个线程的执行方式

1、获取GIL

2、执行代码直到sleep或者是python虚拟机将其挂起。

3、释放GIL

可见,某个线程想要执行,必须先拿到GIL,GIL会根据执行的字节码行数以及时间片释放,并且在遇到io操作的时候会主动释放。我们可以把GIL看作是“通行证”,并且在一个python进程中,GIL只有一个。拿不到通行证的线程,就不允许进入CPU执行。 而每次释放GIL锁,线程进行锁竞争、切换线程,会消耗资源。并且由于GIL锁存在,python里一个进程永远只能同时执行一个线程(拿到GIL的线程才能执行),这就是为什么在多核CPU上,python的多线程效率并不高。

那么是不是python的多线程就完全没用了呢?

在这里我们进行分类讨论:

1、CPU密集型代码(各种循环处理、计数等等),在这种情况下,由于计算工作多,ticks计数很快就会达到阈值,然后触发GIL的释放与再竞争(多个线程来回切换当然是需要消耗资源的),所以python下的多线程对CPU密集型代码并不友好。

2、IO密集型代码(文件处理、网络爬虫等),多线程能够有效提升效率(单线程下有IO操作会进行IO等待,造成不必要的时间浪费,而开启多线程能在线程A等待时,自动切换到线程B,可以不浪费CPU的资源,从而能提升程序执行效率)。所以python的多线程对IO密集型代码比较友好。

而在python3.x中,GIL不使用ticks计数,改为使用计时器(执行时间达到阈值后,当前线程释放GIL),这样对CPU密集型程序更加友好,但依然没有解决GIL导致的同一时间只能执行一个线程的问题,所以效率依然不尽如人意。

请注意:多核多线程比单核多线程更差,原因是单核下多线程,每次释放GIL,唤醒的那个线程都能获取到GIL锁,所以能够无缝执行,但多核下,CPU0释放GIL后,其他CPU上的线程都会进行竞争,但GIL可能会马上又被CPU0拿到,导致其他几个CPU上被唤醒后的线程会醒着等待到切换时间后又进入待调度状态,这样会造成线程颠簸(thrashing),导致效率更低

回到最开始的问题:经常我们会听到老手说:“python下想要充分利用多核CPU,就用多进程”,原因是什么呢?

原因是:每个进程有各自独立的GIL,互不干扰,这样就可以真正意义上的并行执行,所以在python中,多进程的执行效率优于多线程(仅仅针对多核CPU而言)。