在什么情况下Python线程可以安全地操作共享状态?

5 投票
2 回答
2867 浏览
提问于 2025-04-15 22:10

在另一个问题的讨论中,我受到启发,想更好地理解在多线程的Python程序中,什么时候需要使用锁。

根据这篇关于Python线程的文章,我有几个很好的、可以测试的例子,展示了当多个线程访问共享状态时可能出现的问题。这里提供的例子是一个竞争条件,涉及多个线程在读取和操作一个存储在字典中的共享变量时发生的竞争。我认为这个竞争的情况非常明显,而且很容易测试。

不过,我一直无法通过一些原子操作,比如向列表添加元素或变量自增,来引发竞争条件。这个测试试图全面展示这样的竞争:

from threading import Thread, Lock
import operator

def contains_all_ints(l, n):
    l.sort()
    for i in xrange(0, n):
        if l[i] != i:
            return False
    return True

def test(ntests):
    results = []
    threads = []
    def lockless_append(i):
        results.append(i)
    for i in xrange(0, ntests):
        threads.append(Thread(target=lockless_append, args=(i,)))
        threads[i].start()
    for i in xrange(0, ntests):
        threads[i].join()
    if len(results) != ntests or not contains_all_ints(results, ntests):
        return False
    else:
        return True

for i in range(0,100):
    if test(100000):
        print "OK", i
    else:
        print "appending to a list without locks *is* unsafe"
        exit()

我已经运行了上面的测试,没有出现失败(100次,每次10万次多线程添加)。有没有人能让它失败?有没有其他类型的对象可以通过线程的原子性增量修改而出现问题?

这些隐含的“原子”语义是否适用于Python中的其他操作?这和全局解释器锁(GIL)有直接关系吗?

2 个回答

1

在CPython中,线程切换是在执行sys.getcheckinterval()的字节码时进行的。所以在执行单个字节码的过程中,线程是不会切换的。也就是说,被编码为单个字节码的操作本身是原子性的,也就是线程安全的,除非这个字节码执行了其他的Python代码或者调用了释放全局解释器锁(GIL)的C代码。大多数内置集合类型(比如字典、列表等)的操作都属于“本身就是线程安全”的范畴。

不过,这只是Python的C实现中的一个细节,不能完全依赖它。其他版本的Python(比如Jython、IronPython、PyPy等)可能不会以相同的方式工作。而且也没有保证未来的CPython版本会保持这种行为。

7

往列表里添加东西是线程安全的,没错。你只能在持有全局解释器锁(GIL)的情况下往列表里添加东西,而且在执行append操作时,列表会确保不释放这个锁(毕竟,这个操作相对简单)。不同线程的添加操作顺序是随机的,但由于在添加时GIL不会被释放,所以所有的操作都是严格按顺序进行的。

不过,其他操作就不一定安全了。Python中很多操作可能会执行任意的Python代码,这样就可能导致GIL被释放。例如,i += 1其实是三个不同的操作:“获取i”、“加1”和“把结果存回i”。“加1”这个步骤在这里会变成it.__iadd__(1),这时候它可以去做任何事情。

Python对象本身会保护自己的内部状态——比如字典(dict)不会因为两个不同的线程同时往里面添加东西而被搞坏。但是,如果字典里的数据需要保持一致性,字典和GIL并不会特别保护这一点,通常情况下只是让事情变得不太可能但仍然有可能出现和你预想的不一样的结果。

撰写回答