前言
C++中的 i++
和 ++i
这两种自增运算是不是原子操作,突然被这么一问竟有点不知所措,这么“简单”的操作应该是原子的吧,但是好像有读又有写应该不是原子操作,原子操作就是那种刷一下就能完成的操作,准确来描述就是一个操作不可再分,要完成都完成不能存在中间态,咦?怎么听起来和事务这么像?那么 i++
和 ++i
是不是原子操作我们看它是否满足不可再分就行了。
原子操作
怎么看是否可再分呢?想到一个办法,看一个操作是否可再分,直接看汇编是不是就行了,比如一个赋值语句:
int main()
{
int i = 110;
return 0;
}
使用 x86-64 gcc 13.1编译后生成的汇编:
main:
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-4], 110
mov eax, 0
pop rbp
ret
int i = 110;
被汇编成了 mov DWORD PTR [rbp-4], 110
看起来是一句,没啥问题,再看 i++
:
int main()
{
int i = 110;
i++;
return 0;
}
main:
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-4], 110
add DWORD PTR [rbp-4], 1
mov eax, 0
pop rbp
ret
i++
也被汇编成了一句 add DWORD PTR [rbp-4], 1
,居然也是一句,那么这是原子的吗?我们换个编译器看看,使用 x64 msvc 19.35
生成的汇编如下:
i$ = 0
main PROC
$LN3:
sub rsp, 24
mov DWORD PTR i$[rsp], 110 ; 0000006eH
mov eax, DWORD PTR i$[rsp]
inc eax
mov DWORD PTR i$[rsp], eax
xor eax, eax
add rsp, 24
ret 0
main ENDP
看吧,这里被翻译成了3句,这肯定不是原子操作了,那返回来看在gcc编译时生成 add DWORD PTR [rbp-4], 1
的这一句,就是原子操作吗?
准确来表述是这样的 add DWORD PTR [rbp-4], 1
这条汇编指令本身是原子的,但是在多线程环境中,对于变量的自增操作需要使用适当的同步机制(如互斥锁、原子类型等)来确保原子性和线程安全性。
如果在单核机器上,上述不加锁不会有问题,但到了多核机器上,这个不加锁同样会带来意外后果,两个CPU可以同时执行这条指令,但是两个执行以后,却可能出现只自加了一次
证明++i不是原子操作的例子
写个简单的例子,两个线程同时执行i++自增操作,看最后的结果是否符合预期:
#include <iostream>
#include <thread>
int val = 0;
void f1(int n)
{
for (int i = 0; i < n; ++i) ++val;
}
int main(int argc, char* argv[])
{
int n = 100000000;
if (argc > 1) n = atoi(argv[1]);
std::thread t1(f1, n);
std::thread t2(f1, n);
t1.join();
t2.join();
std::cout << "The final value is [" << val << "] for 2 threads running [" << n << "] times." << std::endl;
return 0;
}
执行结果如下:
$ ./iplusplus 1000
The final value is [2000] for 2 threads running [1000] times.
$ ./iplusplus 10000
The final value is [20000] for 2 threads running [10000] times.
$ ./iplusplus 100000
The final value is [117784] for 2 threads running [100000] times.
$ ./iplusplus 1000000
The final value is [1271769] for 2 threads running [1000000] times.
从运行结果得知,起初1000次和10000次还没出现竞态条件问题,当次数扩大到100000次时,2个线程最终自增的结果只有117784
保证原子操作
还是上面的例子,怎样改成原子操作呢?这时可以利用 std::atomic
模板类,只需将上述例子中的 val
变量修改成 std::atomic<int> val(0);
即可:
#include <iostream>
#include <thread>
#include <atomic>
std::atomic<int> val(0);
void f1(int n)
{
for (int i = 0; i < n; ++i) ++val;
}
int main(int argc, char* argv[])
{
int n = 100000000;
if (argc > 1) n = atoi(argv[1]);
std::thread t1(f1, n);
std::thread t2(f1, n);
t1.join();
t2.join();
std::cout << "The final value is [" << val << "] for 2 threads running [" << n << "] times." << std::endl;
return 0;
}
再编译运行试试:
$ ./iplusplus 100
The final value is [200] for 2 threads running [100] times.
$ ./iplusplus 10000
The final value is [20000] for 2 threads running [10000] times.
$ ./iplusplus 1000000
The final value is [2000000] for 2 threads running [1000000] times.
$ ./iplusplus 100000000
The final value is [200000000] for 2 threads running [100000000] times.
这样就解决了i++不是原子操作的问题,这里还可以将 ++val
写成 val.fetch_add(1)
表示原子加,其实 std::atomic
类实现了 operator++
调用的就是 fetch_add(1)
:
_GLIBCXX_ALWAYS_INLINE value_type
operator++(int) const noexcept
{ return fetch_add(1); }
value_type
operator++() const noexcept
{ return __atomic_impl::__add_fetch(_M_ptr, value_type(1)); }
总结
i++
和++i
不是原子操作,执行命令时包含内存读取,变量递增,回写内存三步,所以存在data race
- 即使被汇编成一句
add DWORD PTR [rbp-4], 1
一句代码在多核CPU上也会导致结果的不确定性或错误 - 想要
i++
变成原子操作只需要定义成std::atomic
模板类的对象即可,逻辑代码几乎无需修改
我们常常把求之不得的东西称之为理想~