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