Q: Python里面的list、dict是线程安全的吗?
A: 是。
Q:就这么简单?
A: 对。
相信大多数从别的语言过来的程序员面对这种简单的答案会表示不满意,线程安全问题怎么可能这么简单,这可是折磨他们无数个日夜的问题啊。可事实是,对于Python来说,我们确实可以认定这样一个简单的答案。
当然,我们还是希望一探究竟,考虑这个看起来不可思议的答案背后到底是什么原因。
什么是线程安全
线程安全是指一段代码在多线程环境下仍然能够正常运行,比如这样一段代码:
call_count = 0
def foo():
call_count += 1
return call_count
其中call_count存的是foo函数被调用的次数,当在多线程环境下运行该代码时,我们能得到正确的调用次数吗?显然,有些线程的调用会被 “忽略”。
如何分析一个类的线程安全性
严格来说,线程安全是针对操作的,不能笼统的说一个类十分线程安全。所以所有问一个类的线程安全问题,实际是要分析这个类的一些具体的操作在多线程下是否安全。所有我们必须要深入源码层面去考虑,一个类的某个具体操作在多线程环境下是否会有问题。
回到开头的问题,Python中的list和dict是如何实现的呢?
list源码片段
int
PyList_Append(PyObject *op, PyObject *newitem)
{
if (PyList_Check(op) && (newitem != NULL))
return app1((PyListObject *)op, newitem);
PyErr_BadInternalCall();
return -1;
}
static int
app1(PyListObject *self, PyObject *v)
{
Py_ssize_t n = PyList_GET_SIZE(self);
assert (v != NULL);
if (n == PY_SSIZE_T_MAX) {
PyErr_SetString(PyExc_OverflowError,
"cannot add more objects to list");
return -1;
}
if (list_resize(self, n+1) < 0)
return -1;
Py_INCREF(v);
PyList_SET_ITEM(self, n, v);
return 0;
}
static int
list_resize(PyListObject *self, Py_ssize_t newsize)
{
PyObject **items;
size_t new_allocated, num_allocated_bytes;
Py_ssize_t allocated = self->allocated;
/* Bypass realloc() when a previous overallocation is large enough
to accommodate the newsize. If the newsize falls lower than half
the allocated size, then proceed with the realloc() to shrink the list.
*/
if (allocated >= newsize && newsize >= (allocated >> 1)) {
assert(self->ob_item != NULL || newsize == 0);
Py_SIZE(self) = newsize;
return 0;
}
/* This over-allocates proportional to the list size, making room
* for additional growth. The over-allocation is mild, but is
* enough to give linear-time amortized behavior over a long
* sequence of appends() in the presence of a poorly-performing
* system realloc().
* The growth pattern is: 0, 4, 8, 16, 25, 35, 46, 58, 72, 88, ...
* Note: new_allocated won't overflow because the largest possible value
* is PY_SSIZE_T_MAX * (9 / 8) + 6 which always fits in a size_t.
*/
new_allocated = (size_t)newsize + (newsize >> 3) + (newsize < 9 ? 3 : 6);
if (new_allocated > (size_t)PY_SSIZE_T_MAX / sizeof(PyObject *)) {
PyErr_NoMemory();
return -1;
}
if (newsize == 0)
new_allocated = 0;
num_allocated_bytes = new_allocated * sizeof(PyObject *);
items = (PyObject **)PyMem_Realloc(self->ob_item, num_allocated_bytes);
if (items == NULL) {
PyErr_NoMemory();
return -1;
}
self->ob_item = items;
Py_SIZE(self) = newsize;
self->allocated = new_allocated;
return 0;
}
从中可以看到,list实现过程的主要流程
获取数组a的长度n
-
检测长度是否足以容纳新元素,
2.1 如果不足,则resize
..2.1.1 分配新空间
..2.1.2 将旧数组的所有元素拷贝到新的数组
..2.1.3 将a指向新的数组
..2.1.4 a[n] = new_elem2.2 否则,a[n] = new_elem
这么复杂的流程,当出现多个线程一起调用的时候会出现什么情况呢?
我们简化一下,如果操作不涉及resize,会确定安全吗?其实还是不行,比如一个线程刚走完获取数组大小的那一步,然后发生切换,另一个线程执行了所有的操作,此时:a[n] = new_elem2了,再切换回来的时候,a[n] = new_elem1,相当于覆盖了前面的更新,数组的大小也不对,等于有一个线程的更新操作丢失了。
说了这么多,那岂不是开头那个问题的答案错掉了?其实也不是,这就涉及到Python的另一个问题了----全局解释器锁。
Python 的多线程与全局解释器锁
全局解释器锁是Python的多线程效率被吐槽的元凶,它使得多个线程只有获取的了解释器锁才能执行。但对于内置容器而言,全局解释器锁却带来了一个额外的好处,那就是内置的C实现的模块能保证不发生切换(解释器所有权切换只能发生在字节码之间),所以之前分析的Python中list的实现那一连串的过程都可以认为是原子的,也就是线程安全的。
总结
目前也存在不使用全局解释器锁的Python实现(未能成为主流),对于这类,内置容器的线程安全问题可能就需要重新考虑一下了。