线程安全
2022/7/13 23:20:05
本文主要是介绍线程安全,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!
关键词:线程安全、GIL、原子操作(atomic operation)、存储数据类型(List、Queue.Queue、collections.deque)
当多个线程同时进行,且共同修改同一个资源时,我们必须保证修改不会发生冲突,数据修改不会发生错误,也就是说,我们必须保证线程安全。
同时我们知道,python中由于GIL的存在,即使开了多线程,同一个时间也只有一个线程在执行。
那么这是否就说明python中多个线程执行时,不会发生冲突呢?答案是否定的。
GIL下的线程不安全
来看下面这段代码
import threading zero = 0 def change_zero(): global zero for i in range(3000000): zero += 1 zero -= 1 th1 = threading.Thread(target = change_zero) th2 = threading.Thread(target = change_zero) th1.start() th2.start() th1.join() th2.join() print(zero)
两个线程共同修改zero变量,每次对变量的操作都是先加1再减1,按理说执行3000000次,zero结果应该还是0,但是运行过这段代码发现,结果经常不是0,而且每次运行结果都不一样,这就是数据修改之间发生冲突的结果。
其根本原因在于,zero += 1这一步操作,并不是简单的一步,而可以看做两步的结合,如下
x = zero + 1 zero = x
所以可能在一个线程执行时,两步只执行了一步x = zero + 1(即还没来得及对zero进行修改),GIL锁就给了另一个线程(不熟悉锁的概念以及GIL的可以先看这篇文章),等到GIL锁回到第一个线程,zero已经发生了改变,这时再执行接下来的zero = x,结果就不是对zero加1了。一次出错的完整的模拟过程如下
初始:zero = 0 th1: x1 = zero + 1 # x1 = 1 th2: x2 = zero + 1 # x2 = 1 th2: zero = x2 # zero = 1 th1: zero = x1 # zero = 1 问题出在这里,两次赋值,本来应该加2变成了加1 th1: x1 = zero - 1 # x1 = 0 th1: zero = x1 # zero = 0 th2: x2 = zero - 1 # x2 = -1 th2: zero = x2 # zero = -1 结果:zero = -1
为了更好地说明python在GIL下仍存在线程不安全的原因,这里需要引入一个概念:原子操作(atomic operation)
原子操作
原子操作,指不会被线程调度机制打断的操作,这种操作一旦开始,就一直运行到结束,中间不会切换到其他线程。
像zero += 1这种一步可以被拆成多步的程序,就不是一个原子操作。不是原子操作的直接后果就是它没有完全执行结束,就有可能切换到其他线程,此时如果其他线程修改的是同一个变量,就有可能发生资源修改冲突。
一个解决办法是通过加锁(Lock),将上面change_zero函数的定义改为
from threading import Lock lock = Lock() zero = 0 def change_zero(): global zero for i in range(1000000): with lock: zero += 1 zero -= 1 change_zero() print(zero)
加锁后,锁内部的程序要么不执行,要执行就会执行结束才会切换到其他线程,这其实相当于实现了一种“人工原子操作”,一整块代码当成一个整体运行,不会被打断。这样做就可以防止资源修改的冲突。读者可以试着加锁后重新运行程序,会发现结果zero变量始终输出为0。
下面我们来考虑一个问题:如果程序本身就是原子操作,是不是就自动实现了线程安全,就不需要加锁了呢?答案是肯定的。
举一个例子,现在我们要维护一个队列,开启多个线程,一部分线程负责向队列中添加元素,另一部分线程负责提取元素进行后续处理。这时,这个队列就是在多个线程之间共享的一个对象,我们必须保证在添加和提取的过程中,不会发生冲突,也就是说要保证线程安全。如果操作过程比较复杂,我们可以通过加锁来使多个操作中间不会中断。但是如果这个程序本身就是原子操作,则不需要添加额外的保护措施。
比如我们用queue模块中的Queue对象来维护队列,通过Queue.put填入元素,通过Queue.get提取元素,因为Queue.put和Queue.get都是原子操作,所以要么执行,要么不执行,不会存在被中断的问题,所以这里就不需要添加多余的保护措施。
那么这里自然就会产生一个问题:我们怎么知道哪些操作是原子操作,哪些不是呢?官网上列了一个表
下面这些都是原子操作
L.append(x) L1.extend(L2) x = L[i] x = L.pop() L1[i:j] = L2 L.sort() x = y x.field = y D[x] = y D1.update(D2) D.keys()
下面这些则不是原子操作
i = i+1 L.append(L[-1]) L[i] = L[j] D[x] = D[x] + 1
这里要注意的一点是,我们有时会听到说python中的list对象不是线程安全的,这个说法是不严谨的,因为线程是否安全,针对的不是对象,而是操作。如果我们指这样的操作L[0] = L[0] + 1,它当然不是一个原子操作,不加以保护就会导致线程不安全,而L.append(i)这样的操作则是线程安全的。
因此列表是可以用作多线程中的存储对象的。但是我们一般不用列表,而使用queue.Queue,是因为后者内部实现了Condition锁的通信机制,详情请看这篇文章
下面我们回到原子操作,虽然官网上列出了一些常见的操作,但是有时我们还需要自己能够判断的方法,可以使用dis模块的dis函数,举例如下所示
from dis import dis a = 0 def fun(): global a a = a + 1 dis(fun)
结果如下
5 0 LOAD_GLOBAL 0 (a) 2 LOAD_CONST 1 (1) 4 BINARY_ADD 6 STORE_GLOBAL 0 (a) 8 LOAD_CONST 0 (None) 10 RETURN_VALUE
我们只要关注每一行即可,每一行表示执行这个fun函数的过程,可以被拆分成这些步骤,导入全局变量—>导入常数—>执行加法—>存储变量……,这里每一个步骤都是指令字节码,可以看做原子操作。这里列出的是fun函数执行的过程,而我们要关心的是a = a + 1这个过程包含了几个指令,可以看到它包含了两个,即BINARY_ADD和STORE_GLOBAL,如果在前者(运算加和)执行后,后者(赋值)还没开始时,切换了线程,就会出现我们上文例子中的修改资源冲突。
下面我们来看看L.append(i)过程的字节码
from dis import dis l = [] def fun(): global l l.append(1) dis(fun)
得到结果
5 0 LOAD_GLOBAL 0 (l) 2 LOAD_METHOD 1 (append) 4 LOAD_CONST 1 (1) 6 CALL_METHOD 1 8 POP_TOP 10 LOAD_CONST 0 (None) 12 RETURN_VALUE
可以看到append其实只有POP_TOP这一步,要么执行,要么不执行,不会出现被中断的问题。
原子操作我们就讲到这里,接下来,对比一下python中常用的队列数据结构
python中常见队列对比
List VS Queue.Queue VS collections.deque]
首先,我们需要将Queue.Queue和其他两者区分开来,因为它的出现主要用于线程之间的通信,而其他二者主要用作存储数据的工具。当我们需要实现Condition锁时,就要用Queue.Queue,单纯存储数据则用后两者。
collections.deque与list的区别主要在于数据的插入与提取上。如果要将数据插入列表头部,或者从头部提取数据,则前者的效率远远高于后者,这是前者的双向队列特性,优势毋庸置疑。如果对提取的顺序无所谓,则没有必要一定要用collections.deque
这篇关于线程安全的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!
- 2024-12-27OpenFeign服务间调用学习入门
- 2024-12-27OpenFeign服务间调用学习入门
- 2024-12-27OpenFeign学习入门:轻松掌握微服务通信
- 2024-12-27OpenFeign学习入门:轻松掌握微服务间的HTTP请求
- 2024-12-27JDK17新特性学习入门:简洁教程带你轻松上手
- 2024-12-27JMeter传递token学习入门教程
- 2024-12-27JMeter压测学习入门指南
- 2024-12-27JWT单点登录学习入门指南
- 2024-12-27JWT单点登录原理学习入门
- 2024-12-27JWT单点登录原理学习入门