Python 利用 Lock 防止多个线程争数据
Python 语言的标准实现叫作 CPython,它分两步来运行 Python 程序。首先解析源代码文本,并将其编译成字节码(bytecode)。字节码是一种底层代码,可以把程序表示成 8 位的指令(从 Python 3.6 开始,这种底层代码实际上已经变成 16 位了,所以应该叫作 wordcode 才对,但基本原理依然相同)。然后,CPython 采用基于栈的解释器来运行字节码。这种字节码解释器在执行 Python 程序的过程中,必须确保相关的状态不受干扰,所以 CPython 会用一种叫作全局解释器锁(global interpreter lock,GIL)的机制来保证这一点。
GIL实际上就是一种互斥锁(mutual-exclusion lock,mutex),用来防止 CPython 的状态在抢占式的多线程环境(preemptive multithreading)之中受到干扰,因为在这种环境下,一条线程有可能突然打断另一条线程抢占程序的控制权。如果这种抢占行为来得不是时候,那么解释器的状态(例如为垃圾回收工作而设立的引用计数等)就会遭到破坏。所以,CPython 要通过 GIL 阻止这样的动作,以确保它自身以及它的那些C扩展模块能够正确地执行每一条字节码指令。
但是,GIL会产生一个很不好的影响。尽管 Python 也支持多线程,但这些线程受 GIL 约束,所以每次或许只能有一条线程向前推进,而无法实现多头并进。一般多线程的程序在标准的 CPython 解释器之中会受 GIL 牵制(例如 CPython 要通过 GIL 防止这些线程争抢全局锁,而且要花一些时间来协调)。
若要 CPython 把多个核心充分利用起来,还是有一些办法的,但那些办法都不采用标准的 Thread 类,而且实现起来也需要大量的精力。既然有这么多限制,那 Python 还支持多线程干什么?这其实有两个原因。
首先,这种机制让我们很容易就能实现出一种效果,也就是令人感觉程序似乎能在同一时间做许多件事。这样的效果采用手工方式很难编写,而通过线程来实现,则可以让 Python 自动把这些问题处理好,让多项任务能够并发地执行。由于 GIL 机制,虽然每次还是只能有一个线程向前执行,但 CPython 会确保这些 Python 线程之间能够公平地轮换执行。
其次,可以通过 Python 的多线程机制处理阻塞式的 I/O 任务,因为线程在执行某些系统调用的过程中会发生阻塞,假如只支持一条线程,那么整个程序就会卡在这里不动。Python 程序需要通过系统调用与外部环境交互,其中有一些调用属于阻塞式的 I/O 操作,例如读取文件、写入文件、联网以及与显示器等设备交互。多线程机制可以让程序中的其他线程继续执行各自的工作,只有发起调用请求的那条线程才需要卡在那里等待操作系统给出结果。
GIL 只不过是让 Python 内部的代码无法平行推进而已,至于系统调用,则不会受到影响,因为 Python 线程在即将执行系统调用时,会释放 GIL,待完成调用之后,才会重新获取它。
了解到全局解释器锁(GIL)的效果之后,许多 Python 新手可能觉得没必要继续在代码里使用互斥锁(mutual-exclusion lock,mutex)了。既然 GIL 让 Python 线程没办法平行地运行在多个 CPU 核心上,那是不是就意味着它同时还会自动保护程序里面的数据结构,不需要再加锁了?在列表与字典等结构上面测试过之后,有些人可能真的以为是这样的。
其实并非如此。GIL 起不到这样的保护作用。虽说同一时刻只能有一条 Python 线程在运行,但这条线程所操纵的数据结构还是有可能遭到破坏,因为它在执行完当前这条字节码指令之后,可能会被 Python 系统切换走,等它稍后切换回来继续执行下一条字节码指令时,当前的数据或许已经与实际值脱节了,因为中途切换进来的其他线程可能更新过这个值。所以,多个线程同时访问同一个对象是很危险的。每条线程在操作这份数据时,都有可能遭到其他线程打扰,因此数据之中的固定关系或许已经被别的线程破坏了,这会令程序陷入混乱状态。
例如,要编写一个程序,让它平行地采集数据。如果要采集传感器网络中的每个传感器所给出的亮度,那么就需要用到这种程序。首先需要定义下面这样一个新类,用来记录采集到的样本总数。
class Counter:
def __init__(self):
self.count = 0
def increment(self, offset):
self.count += offset
然后,假设获取传感器读数的操作是一种阻塞式的 I/O 操作,这样的话,就需要针对每个传感器都开启一条工作线程专门读取它所负责的这个传感器。每采集到一份样本,线程就会给表示样本总数的那个量加1,直到采集完应采集样本为止。
def worker(sensor_index, how_many, counter):
for _ in range(how_many):
# Read from the sensor
...
counter.increment(1)
现在,给每个传感器建立各自的工作线程,让这些线程平行地采样,最后等待所有线程完成各自采样工作。
from threading import Thread
how_many = 10**5
counter = Counter()
threads = []
for i in range(5):
thread = Thread(target=worker, args=(i, how_many, counter))
threads.append(thread)
thread.start()
for thread in threads:
thread.join()
expected = how_many * 5
found = counter.count
print(f'Counter should be {expected}, got {found}')
# >>>
# Counter should be 500000, got 246760
这个程序似乎相当简单,所以运行结果应该是 500000 才对。但实际上却差得很远,这是为什么呢?这样简单的程序竟然会出错,而且 Python 解释器最多只允许一条线程在运行,那为什么还统计不出正确结果呢?
其实,Python 解释器需要保证这些线程可以公平地获得执行机会,或者说,保证每条线程所分配到的执行时间大致相等。为了实现这种效果,它会及时暂停某条线程,并且把另一条线程切换过来执行。然而问题是,我们并不清楚它具体会在什么时候暂停线程,万一这条线程正在执行的是一项本来不应该中断的原子操作(atomic operation),那会如何呢?上面的例子遇到的正是这种情况。
Counter 对象的 increment 方法看上去很简单,工作线程在调用这个方法时,相当于是在执行下面这样一条语句:counter.count += 1
然而,在对象的属性上面执行 += 操作,实际上需要分成三个小的步骤。也就是说,Python 系统会这样看待这次操作:
value = getattr(counter, 'count')
result = value + 1
setattr(counter, 'count', result)
这三个步骤本来应该一次执行完才对,但是 Python 系统有可能在任意两步之间,把当前这条线程切换走,这就导致这条线程在切换回来后,看到的是个已经过时的 value 值,它把这个过时的值通过 setattr 赋给 Counter 对象的 count 属性,从而使统计出来的样本总数偏小。下面就来模拟线程受到干扰时的样子:
# Running in Thread A
value_a = getattr(counter, 'count')
# Context switch to Thread B
value_b = getattr(counter, 'count')
result_b = value_b + 1
setattr(counter, 'count', result_b)
# Context switch back to Thread A
result_a = value_a + 1
setattr(counter, 'count', result_a)
线程 A 在执行了第一步之后,还没来得及执行第二步,就被线程 B 打断了。等到线程 B 把它的三个步骤执行完毕后,线程 A 才重新获得执行机会。这时,它并不知道 count 已经被线程 B 更新过了,它仍然以为自己在第一步里读取到的那个 value_a 是正确的,于是线程 A 就给 value_a 加 1 并将结果(也就是 result_a)赋给 count 属性。这实际上把线程 B 刚刚执行的那一次递增操作覆盖掉了。上面的传感器采样总数之所以出错,也正是这个原因所致。
除了这个例子,其他形式的数据结构也会遇到类似问题。为了避免数据争用,Python 在内置的 threading 模块里提供了一套健壮的工具。其中最简单也最有用的是一个叫作 Lock 的类,它相当于互斥锁(mutex)。
通过这样的锁,可以确保多条线程有秩序地访问 Counter 类的 count 属性,使得该属性不会遭到破坏,因为线程必须先获取到这把锁,然后才能操纵 count,而每次最多只能有一条线程获得该锁。下面,用 with 语句来实现加锁与解锁,这种写法使读者很容易就能看出受到保护的究竟是哪一段代码。
from threading import Lock
class LockingCounter:
def __init__(self):
self.lock = Lock()
self.count = 0
def increment(self, offset):
with self.lock:
self.count += offset
现在,就可以确保这些工作线程能够正确地递增 count 属性了。只不过这次的 Counter 对象要改用刚才写的 LockingCounter 类来制作。
counter = LockingCounter()
for i in range(5):
thread = Thread(target=worker, args=(i, how_many, counter))
threads.append(thread)
thread.start()
for thread in threads:
thread.join()
expected = how_many * 5
found = counter.count
print(f'Counter should be {expected}, got {found}')
# >>>
# Count should be 500000, got 500000
这样的结果才正是我们想要看到的。这说明 Lock 确实能够解决数据争用问题。所以虽然 Python 有全局解释器锁,但开发者还是得设法避免线程之间发生数据争用。