在 C++ 中递增和递减全局变量时的竞争条件
Race condition when incrementing and decrementing global variable in C++
我在 linux 中找到了一个可以在 g++
下重现的竞争条件示例。我不明白的是操作顺序在此示例中的重要性。
int va = 0;
void fa() {
for (int i = 0; i < 10000; ++i)
++va;
}
void fb() {
for (int i = 0; i < 10000; ++i)
--va;
}
int main() {
std::thread a(fa);
std::thread b(fb);
a.join();
b.join();
std::cout << va;
}
我可以理解,如果我使用了 va = va + 1;
,顺序很重要,因为 RHS va
可能会在返回指定的 LHS va
之前发生变化。有人可以澄清一下吗?
标准说(引用最新草案):
[intro.races]
Two expression evaluations conflict if one of them modifies a memory location ([intro.memory]) and the other one reads or modifies the same memory location.
The execution of a program contains a data race if it contains two potentially concurrent conflicting actions, at least one of which is not atomic, and neither happens before the other, except for the special case for signal handlers described below.
Any such data race results in undefined behavior.
您的示例程序存在数据竞争,程序的行为未定义。
What I don't understand is how the order of operations matter in this example.
操作顺序很重要,因为操作不是原子操作,它们读取和修改相同的内存位置。
can undertand that the order matters if I had used va = va + 1; because then RHS va could have changed before getting back to assigned LHS va
自增运算符也是如此。抽象机将:
- 从内存中读取一个值
- 增加值
- 将值写回内存
那里有多个步骤可以与另一个线程中的操作交错。
即使每个线程只有一个操作,也无法保证定义明确的行为,除非这些操作是原子的。
C++ 范围之外的注意事项:CPU 可能只有一条指令用于递增内存中的整数。比如x86就有这样的指令。它可以以原子方式和非原子方式调用。编译器使用原子指令是一种浪费,除非你在 C++ 中明确使用原子操作。
这里的重要思想是,当编译 c++ 时,它是 "translated" 汇编语言。 ++va
或 --va
的翻译将导致汇编代码将 va
的值移动到寄存器,然后将向该寄存器加 1 的结果存储回 va
在单独的说明中。这样一来,就和va = va + 1;
完全一样了。这也意味着操作 va++
不一定是 atomic.
有关这些指令的汇编代码的解释,请参阅 here。
为了进行原子操作,变量可以使用锁定机制。您可以通过声明一个原子变量(它将为您处理线程同步)来做到这一点:
std::atomic<int> va;
首先,这是未定义的行为,因为两个线程对同一个非原子变量的读取和写入 va
可能是并发的,并且不会先于另一个发生。
话虽如此,如果您想了解当此程序为 运行 时您的计算机实际在做什么,假设 ++va
与 va = va + 1
相同可能会有所帮助].事实上,标准说它们是相同的,编译器可能会以相同的方式编译它们。由于您的程序包含 UB,因此不需要编译器执行任何明智的操作,例如使用原子增量指令。如果你想要一个原子增量指令,你应该使 va
原子化。同样,--va
与 va = va - 1
相同。所以在实践中,各种结果都是可能的。
我在 linux 中找到了一个可以在 g++
下重现的竞争条件示例。我不明白的是操作顺序在此示例中的重要性。
int va = 0;
void fa() {
for (int i = 0; i < 10000; ++i)
++va;
}
void fb() {
for (int i = 0; i < 10000; ++i)
--va;
}
int main() {
std::thread a(fa);
std::thread b(fb);
a.join();
b.join();
std::cout << va;
}
我可以理解,如果我使用了 va = va + 1;
,顺序很重要,因为 RHS va
可能会在返回指定的 LHS va
之前发生变化。有人可以澄清一下吗?
标准说(引用最新草案):
[intro.races]
Two expression evaluations conflict if one of them modifies a memory location ([intro.memory]) and the other one reads or modifies the same memory location.
The execution of a program contains a data race if it contains two potentially concurrent conflicting actions, at least one of which is not atomic, and neither happens before the other, except for the special case for signal handlers described below. Any such data race results in undefined behavior.
您的示例程序存在数据竞争,程序的行为未定义。
What I don't understand is how the order of operations matter in this example.
操作顺序很重要,因为操作不是原子操作,它们读取和修改相同的内存位置。
can undertand that the order matters if I had used va = va + 1; because then RHS va could have changed before getting back to assigned LHS va
自增运算符也是如此。抽象机将:
- 从内存中读取一个值
- 增加值
- 将值写回内存
那里有多个步骤可以与另一个线程中的操作交错。
即使每个线程只有一个操作,也无法保证定义明确的行为,除非这些操作是原子的。
C++ 范围之外的注意事项:CPU 可能只有一条指令用于递增内存中的整数。比如x86就有这样的指令。它可以以原子方式和非原子方式调用。编译器使用原子指令是一种浪费,除非你在 C++ 中明确使用原子操作。
这里的重要思想是,当编译 c++ 时,它是 "translated" 汇编语言。 ++va
或 --va
的翻译将导致汇编代码将 va
的值移动到寄存器,然后将向该寄存器加 1 的结果存储回 va
在单独的说明中。这样一来,就和va = va + 1;
完全一样了。这也意味着操作 va++
不一定是 atomic.
有关这些指令的汇编代码的解释,请参阅 here。
为了进行原子操作,变量可以使用锁定机制。您可以通过声明一个原子变量(它将为您处理线程同步)来做到这一点:
std::atomic<int> va;
首先,这是未定义的行为,因为两个线程对同一个非原子变量的读取和写入 va
可能是并发的,并且不会先于另一个发生。
话虽如此,如果您想了解当此程序为 运行 时您的计算机实际在做什么,假设 ++va
与 va = va + 1
相同可能会有所帮助].事实上,标准说它们是相同的,编译器可能会以相同的方式编译它们。由于您的程序包含 UB,因此不需要编译器执行任何明智的操作,例如使用原子增量指令。如果你想要一个原子增量指令,你应该使 va
原子化。同样,--va
与 va = va - 1
相同。所以在实践中,各种结果都是可能的。