C++中的 i++ 和 ++i 这两种自增运算不是原子操作

前言

C++中的 i++++i 这两种自增运算是不是原子操作,突然被这么一问竟有点不知所措,这么“简单”的操作应该是原子的吧,但是好像有读又有写应该不是原子操作,原子操作就是那种刷一下就能完成的操作,准确来描述就是一个操作不可再分,要完成都完成不能存在中间态,咦?怎么听起来和事务这么像?那么 i++++i 是不是原子操作我们看它是否满足不可再分就行了。

原子操作

怎么看是否可再分呢?想到一个办法,看一个操作是否可再分,直接看汇编是不是就行了,比如一个赋值语句:

1
2
3
4
5
int main()
{
int i = 110;
return 0;
}

使用 x86-64 gcc 13.1编译后生成的汇编:

1
2
3
4
5
6
7
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++:

1
2
3
4
5
6
int main()
{
int i = 110;
i++;
return 0;
}
1
2
3
4
5
6
7
8
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 生成的汇编如下:

1
2
3
4
5
6
7
8
9
10
11
12
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++自增操作,看最后的结果是否符合预期:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#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;
}

执行结果如下:

1
2
3
4
5
6
7
8
9
10
11
$ ./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); 即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#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;
}

再编译运行试试:

1
2
3
4
5
6
7
8
9
10
11
$ ./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)

1
2
3
4
5
6
7
_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 模板类的对象即可,逻辑代码几乎无需修改
==>> 反爬链接,请勿点击,原地爆炸,概不负责!<<==

我们常常把求之不得的东西称之为理想~

2023-7-4 09:51:32

Albert Shi wechat
欢迎您扫一扫上面的微信公众号,订阅我的博客