尝试在Python的不同变体中重现竞争条件。以下是一个例子:
import threading, time
count = 0
def counter():
global count
c = count # 100 потоков одновременно изменили переменную c, присвоив ей значение count + 1, то есть 0+1 = 1
time.sleep(0.1) # потоки заснули
count = c + 1 # Потоки проснулись и просто присвоили переменной count значение 0+1 100 раз.
print(count) # печать count после повторного присвоения единицы.
for t in range(100):
thr = threading.Thread(target=counter)
thr.start()
(修改)
这里100个线程进入函数,给变量counter赋值,然后睡眠0.1秒。在睡眠状态下,线程会释放GIL,这就是它们随后尝试同时增加的原因。然而,问题不在于线程捕获相同的状态。事实上,在给定的例子中,变量 count 不能以任何方式改变,因为变量始终等于 1,并且每个线程进入第 135 行,只是将2 分配给该变量,这是始终的事实的结果。这里没有竞争条件——GIL 成功阻止多个线程获取锁。我们得到以下输出:countccount = c + 1count = 0ccountcount = 2
#...
# 1
# 1
# 1
# 1
# 1
# 1
# 1
# 1
然后我尝试稍微改变一下这个功能:
import threading, time
count = 0
def counter():
global count
time.sleep(0.1)
count = count + 1
print(count)
for t in range(100):
thr = threading.Thread(target=counter)
thr.start()
与前一种情况相比,这里唯一的区别是,我们不是创建中间变量,而是count用其自身增加它。除此之外,一切都一样——100 个线程进入该函数,休眠 0.1 秒,这会导致GIL被释放,并且变量会增加count = count + 1。但是,竞争条件并没有发生!结论:
#...
# 94
# 95
# 96
# 97
# 98
# 99
# 100
变量count等于100,为什么会出现这种情况?看起来增量操作 += 是原子的。也就是说,可以假设在这个例子中,由于字节码级别操作的原子性,GIL 不允许多个线程同时工作。
代码
count = count + 1以原子方式执行。代码count += 1也是。但这件作品已不复存在:该语言本身不提供任何代码原子性的保证。但是在您的 CPython 版本中,有些选项是原子执行的。这取决于解释器及其版本。例如,在 CPython 3.9 中没有原子性。并且在 3.10 和 3.11 她出现了。我有 CPython 3.11,我会在上面展示它。
注意:不要依赖原子性。它没有任何保证并且可能随时被违反。他们会在解释器中发现一个错误,修复它,然后原子性就会消失。没必要冒险。
Python 将源代码编译为字节码,然后进行解释。
count = count + 1被编译成以下的指令序列(这是一个反汇编程序,代码本身当然是二进制的,易于解释):堆栈用于处理数据。操作顺序:
将一个值放入堆栈
count,将一个值放在顶部,
调用加法指令,
将结果保存在中
count。解释器
_PyEval_EvalFrameDefault循环读取指令并执行它们。处理说明
LOAD_CONST:这里有很多宏,但其含义并没有丢失:我们从一些常量存储中获取一个值并将其放在堆栈上。请注意挑战
DISPATCH();。该宏隐藏了goto将处理下一条指令的标签。假设一个进程有两个线程正在运行,那么 Python 会从其中一个线程执行一些代码,从另一个线程执行一些代码。线程永远不会并行执行,GIL负责这一点。在 Python 中无法中断线程的执行。相反,解释器会定期检查是否有其他线程正在等待访问处理器。如果处于待处理状态,解释器将停止当前线程中的解释,释放 GIL,并将当前线程排队等待执行。
切换过于频繁会浪费时间,Python 每 5 毫秒切换线程不会超过一次。可以通过调用 进行调整
sys.setswitchinterval。也就是说,当一个线程获得控制权时,它会定期检查标志
eval_breaker。五毫秒后该标志将被升起,解释器将注意到这一点并放弃控制。控制总是在指令之间进行。翻译员多久检查一次
eval_breaker?不经常。标志检查通过调用完成CHECK_EVAL_BREAKER();。再看一下处理代码LOAD_CONST。那里不存在这样的挑战。解释器将无条件地继续执行下一条指令。此后,LOAD_CONST电流流动就无法中断。如果您还检查
LOAD_GLOBAL,BINARY_OP和STORE_GLOBAL,那么所有这些都无法验证。这段代码作为单个原子指令执行。那么为什么带有函数调用的代码会中断呢?
因为它包含一个函数调用:
CALL执行标志检查:您的第一个代码中断不是因为您调用了
time.sleep(...),而是因为您在读取全局变量的值并将其写回之间调用了某个函数。数据竞赛的秘诀:
PS练习:想出一个代码,从所有其他线程中夺走控制权至少一秒钟。
对于为什么没有成功,答案如下:
如果您采取许多短流,其中大多数将根本不活跃。此外,在大多数情况下,如果您不插入
time.sleep(),则前一个线程将在下一个线程开始之前结束。需要更加谨慎地为“种族”的出现创造条件,例如:
很容易看出,对于传统的 CPython(带有 GIL),竞争条件很可能出现在以下形式的语句中
counter += Fib(7):然而,即使在一些具有 GIL 的版本上,也相对容易看出该操作符也是
counter += 1非原子的:嗯,特别是对于自由线程 CPython(没有 GIL),它们很可能同时发生在
counter += Fib(7)和上counter += 1:有关详细信息,您应该阅读PEP 583 – Python 的并发内存模型,因为 CPython 2.7 实现似乎严格保证不间断性
counter += 1。在 3.x 中,没有这样的保证,调度程序有时会使过程复杂化。附言
不值得一提的是,在字节码中,这些甚至是几种操作,而不是一种特殊的操作,因此在任何情况下,都会有数据发生不可预测的变化。也许在调试器中,也许在使用共享内存时
multiprocessing.shared_memory,也许在其他情况下。唯一的问题是这种事件发生的概率。