本文首发于 知乎
本文从线程同步的起因开始讲起,深入到各种同步机制,主要包括如下内容
- 线程锁(线程同步、互斥锁)
- GIL锁
- 死锁
- RLock(递归锁、可重入锁)
线程锁(线程同步、互斥锁)
在多进程中,每一个进程都拷贝了一份数据,而多线程的各个线程则共享相同的数据。这使多线程占用的资源更少,但是资源混用会导致一些错误,我们来看下面这个例子
import threading
import time
zero = 0
def change_zero():
global zero
for i in range(1000000):
zero = zero + 1
zero = zero - 1
th1 = threading.Thread(target = change_zero)
th2 = threading.Thread(target = change_zero)
th1.start()
th2.start()
th1.join()
th2.join()
print(zero)
change_zero
函数会将
zero
变量加1再减1,按理说无论运行多少次,
zero
变量都应该是0,但是上面代码运行多次,总会出现不是0的情况(如果循环改为运行10000000次,则很难出现结果是0的情况了),说明不同线程一起修改
zero
变量出现了错乱,下面我们来看一下错乱的起因是什么样的。(参考
这里
)
zero = zero + 1
在python中会先产生一个中间变量,比如
x1 = zero + 1
,然后再
zero = x1
。
在不使用多线程时,运行两次
change_zero
函数是这样的
初始:zero = 0
th1: x1 = zero + 1 # x1 = 1
th1: zero = x1 # zero = 1
th1: x1 = zero - 1 # x1 = 0
th1: zero = x1 # zero = 0
th2: x2 = zero + 1 # x2 = 1
th2: zero = x2 # zero = 1
th2: x2 = zero - 1 # x2 = 0
th2: zero = x2 # zero = 0
结果:zero = 0
使用多线程,可能出现这样的交叉影响
初始: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
当循环次数非常多的时候就难免出现这样的乱象,从而导致结果的错误。threading模块提供了解决方法:线程锁。
创建出一个锁,在
change_zero
函数中首先要获得锁才能继续运行,最后释放锁。同一个锁同一时间只能被一个线程使用,所以当一个线程使用锁时,其他线程只能等着,等到锁被释放才能获得锁进行计算。代码改为
import threading
import time
zero = 0
lock = threading.Lock()
def change_zero():
global zero
for i in range(1000000):
lock.acquire()
zero = zero + 1
zero = zero - 1
lock.release()
th1 = threading.Thread(target = change_zero)
th2 = threading.Thread(target = change_zero)
th1.start()
th2.start()
th1.join()
th2.join()
print(zero)
这样做返回的结果每次都是0
注意几点:
-
acquire
和release
时可以使用try finally
模式,保证锁一定被释放,否则可能有一些线程一直等着锁却等不到 - 使用线程锁虽然能解决变量混用造成的错误,但是也降低了运行效率,因为一个线程使用锁运行时其他线程无法一起运行
-
一般不要用
acquire release
包含整个运行函数部分,而是只包含可能导致错误的那一步即可,否则就和使用单线程没有区别了 - 使用多个锁时可能两个线程各持有一个锁,并试图获得对方的锁,这会造成死锁,所有线程都耗在这里了,需要人为终止
- 这个现象出现的情况:比如用多个线程读写同一份文件,要在读写整个过程前后加一个锁保证读写过程不被影响
另外,lock是有上下文管理形式的,上面的代码可以改写为
import threading
import time
zero = 0
lock = threading.Lock()
def change_zero():
global zero
for i in range(1000000):
with lock:
zero = zero + 1
zero = zero - 1
th1 = threading.Thread(target = change_zero)
th2 = threading.Thread(target = change_zero)
th1.start()
th2.start()
th1.join()
th2.join()
print(zero)
最后解释两个常见的概念(来自百度百科)
- 线程同步:这里的同步指按预定的先后次序进行运行,“同”字应是指协同、协助、互相配合。所谓同步,就是在发出一个功能调用时,在没有得到结果之前,该调用就不返回,同时其它线程也不能调用这个方法。与异步相对。
- 互斥锁:防止两条线程同时对同一公共资源(比如全局变量)进行读写的机制
GIL锁
GIL锁全称为全局解释锁(Global Interpreter Lock),任何python线程在执行之前都需要先获得GIL锁,然后每执行一部分代码,解释器就会自动释放GIL锁,其他线程就可以竞争这个锁,只有得到才能执行程序。
GIL锁是很多人说python多线程鸡肋的原因,但这其实是CPython解释器存在的问题(换个解释器可能就没有GIL锁了,不过当前绝大多数python程序都是用CPython解释器)。
首先要声明,GIL锁只影响CPU密集型程序的运行效率。对于IO密集型或者网页请求这种程序,多线程的效率还是很高的,因为它们主要消耗的时间在于等待。
对于CPU密集型程序,GIL锁的影响在于,有了它的存在,开启多线程无法利用多核优势,也就是只能用到一个核CPU来运行代码,要想用到多个核只能开启多进程(或者使用不带有GIL锁的解释器)。锁的存在使得一个时间只有一个线程在进行计算,所以即使开启多线程,也无法同时运算,而是线性地运算。因此有时开启多线程运行CPU密集型程序,反而会降低运行效率,因为多出了线程之间的切换与争夺锁所耗的时间。
死锁
锁利用不当可能造成死锁,我们来看下面一个例子
import threading
import time
lock1 = threading.Lock()
lock2 = threading.Lock()
class MyThread(threading.Thread):
def print1(self):
lock1.acquire() # 获得第一个锁
print('print1 first ' + threading.current_thread().name)
time.sleep(1)
lock2.acquire() # 未释放第一个锁就请求第二个锁