简述

很多人说python的list和dict对象是线程安全的。呵呵我不信。

首先我们把线程安全定义一下,省得抬杠。线程不安全有以下几种形态。如果发生其中之一,则肯定是线程不安全的。以下是充分非必要条件。

  1. 多个线程竞争访问时,发生内存访问异常。
  2. 多个线程竞争访问时,发生异常。
  3. 多个线程竞争访问时,数据某种程度上不一致。

其中三的争议比较大。到底什么是数据不一致,我们下面会给出细节解释。但是线程不安全的前提是读-写。如果是“纯读取”,则肯定不发生线程不安全。这点以下不再强调。

思路

Python的线程安全,其实是指GIL。GIL会在一个P指令的周期内,锁住解释器。除去少数代码,大部分P指令在执行时,均不会发生上下文切换。因此一般语言访问内部数据结构不一致所引发的内存访问异常,在Python内均不会发生。这是“Python的list和dict线程安全”这个断言的源头。

这个断言本身,其实并没有排除“组合不安全”的可能。例如a[k] += 1,其实是a[k] = a[k] + 1的缩写(两者在P代码层面有细微差别,但是不重要)。在多线程状态下,这个操作可能引起+1丢失问题。不过这个概率非常低,而且这是“预期中的不安全问题”。如下一节所演示的。下面希望阐述的,是每个调用都是原子调用,最终产生线程不安全的结果。

这类结果,最大可能是发生在keys/values/iterms调用上。这三个调用,在Python2的时代,返回的是容器类对象。虽然有复制开销,但是一劳永逸避免竞争问题。Python3大量改为iterator后,这里就隐藏了一个风险。

组合不安全问题的例子

代码。由于太难触发,因此加time.sleep来扩大问题。

import time, threading

def add(d):
	for _ in range(1000):
		i = d[1] + 1
		time.sleep(0.000001)
		d[1] = i

def main():
	d = {1: 0}
	ths = [threading.Thread(target=add, args=(d,)) for _ in range(2)]
	for th in ths:
		th.start()
	for th in ths:
		th.join()
	print(d[1])

结果:

  1. 注释掉time.sleep,确定的2000。
  2. 启用time.sleep,有时为1000,有时为1001。

key数量发生增减的形态

代码

import time
import threading

def daemon(d):
	time.sleep(0.2)
	for k, v in d.items():
		print(k, v)
		time.sleep(0.1)

def main():
	d = {}
	threading.Thread(target=daemon, args=(d,)).start()
	for i in range(10):
		d[i] = i
		time.sleep(0.1)
	print(d)

结果

0 0
Exception in thread Thread-1 (daemon):
Traceback (most recent call last):
  File "/usr/lib/python3.11/threading.py", line 1038, in _bootstrap_inner
	self.run()
  File "/usr/lib/python3.11/threading.py", line 975, in run
	self._target(*self._args, **self._kwargs)
  File "/tmp/t.py", line 15, in daemon
	for k, v in d.items():
RuntimeError: dictionary changed size during iteration
{0: 0, 1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 6: 6, 7: 7, 8: 8, 9: 9}

key内容发生改变的形态

由于需要变更key,因此代码里做了一个del和一个set操作。由于两者间没有刻意加入time.sleep,一般不停顿,因此不会引发上面的“key数量发生增减的形态”。代码

import time
import threading

def daemon(d):
	time.sleep(0.2)
	for k, v in d.items():
		print(k, v)
		time.sleep(0.1)

def main():
	d = {}
	threading.Thread(target=daemon, args=(d,)).start()
	for i in range(10):
		d[i] = i
	for i in range(10):
		del d[i]
		d[i+100] = i
		time.sleep(0.1)
	print(d)

结果

2 2
3 3
5 5
6 6
7 7
8 8
9 9
100 0
{100: 0, 101: 1, 102: 2, 103: 3, 104: 4, 105: 5, 106: 6, 107: 7, 108: 8, 109: 9}
101 1
102 2
Exception in thread Thread-1 (daemon):
Traceback (most recent call last):
  File "/usr/lib/python3.11/threading.py", line 1038, in _bootstrap_inner
	self.run()
  File "/usr/lib/python3.11/threading.py", line 975, in run
	self._target(*self._args, **self._kwargs)
  File "/tmp/t.py", line 14, in daemon
	for k, v in d.items():
RuntimeError: dictionary keys changed during iteration

这个数据其实很有意思。可以结合最下面的源码想想为什么。

key不发生改变的形态

代码

import time
import threading

def daemon(d):
	time.sleep(0.2)
	for k, v in d.items():
		print(k, v)
		time.sleep(0.05)

def main():
	d = {}
	threading.Thread(target=daemon, args=(d,)).start()
	for i in range(10):
		d[i] = i
	for i in range(10):
		d[i] = i+1
		time.sleep(0.1)
	print(d)

结果

0 1
1 2
2 3
3 4
4 5
5 5
6 6
7 7
8 8
9 9
{0: 1, 1: 2, 2: 3, 3: 4, 4: 5, 5: 6, 6: 7, 7: 8, 8: 9, 9: 10}

注意4之前,d[k] = k+1。5之后,d[k] = k。这个结果非常有意思,属于典型的情况三。如果你认为每个数都是独立的,那么这个例子线程安全。但如果你认为这些数之间有内在关联性,例如你需要用他们的总和来进行后续计算。那么这个例子线程不安全。

其他有趣形态

这个例子是“key内容发生改变的形态”的一种变形。代码

import time
import threading

def daemon(d):
	time.sleep(0.2)
	for k, v in reversed(d.items()):
		print(k, v)
		time.sleep(0.1)

def main():
	d = {}
	threading.Thread(target=daemon, args=(d,)).start()
	for i in range(10):
		d[i] = i
	for i in range(10):
		del d[i]
		d[i+100] = i
		time.sleep(0.1)
	print(d)

结果

101 1
100 0
9 9
8 8
7 7
{100: 0, 101: 1, 102: 2, 103: 3, 104: 4, 105: 5, 106: 6, 107: 7, 108: 8, 109: 9}

这同样也是线程非安全的,而且不安全的非常有趣。没有任何报错,但取得的数据集既不是新的,也不是旧的。

源码

注意dictiterobject这个对象,其中包含的是PyDictObject *di_dict指针。这也预示着多个线程将真实引用同一个对象。如果我们学Python2一般,让每个线程获得一个独立象,则确定不会出现问题。

退化成Python2的形态

代码

import time
import threading

def daemon(d):
	time.sleep(0.2)
	for k, v in list(d.items()):
		print(k, v)
		time.sleep(0.1)

def main():
	d = {}
	threading.Thread(target=daemon, args=(d,)).start()
	for i in range(10):
		d[i] = i
		time.sleep(0.1)
	print(d)

结果

0 0
1 1
{0: 0, 1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 6: 6, 7: 7, 8: 8, 9: 9}

只要在d.items调用后,list调用前,d.items不发生改变,这个结果就是稳定的,不出错的。一般来说,这个情况出现的概率比较小。但是严格的编程中,也应当避免这种形态。d.copy().items()则没有这个问题。

当然,内存和CPU的消耗是另一码事。

结论

即便不考虑“key不发生改变的形态”到底算不算线程不安全。由于有其他几个情形的存在,因此多线程中混用keys/values/items和其他修改操作,一般来说结果将是不可预测和危险的。甚至,如果更进一步考虑__get____set__之类元函数的存在,直接使用dict和list来保证线程安全性,几乎是不可行的。整个设计难度相当大,且所有使用者均需要遵守使用原则。(严禁重载元函数,所有的key必须在排他的情况下进行修改,且结果总体不具一致性,或干脆放弃使用keys/values/items)鉴于此,Python中使用多线程访问dict和list的最佳实践应上锁。