num ++可以是'int num'的primefaces吗?

通常,对于int numnum++ (或++num )作为读取 – 修改 – 写入操作是不primefaces的 。 但是我经常看到编译器,比如GCC ,为它生成下面的代码( 试试这里 ):

在这里输入图像说明

由于对应于num++第5行是一条指令,所以在这种情况下,我们可以得出结论: num++ 是primefaces吗?

如果是这样, 这是否意味着这样生成的num++可以在并发(multithreading)的情况下使用,没有任何数据竞争的危险 (即,我们不需要,例如, std::atomic<int>并强加相关的成本,因为它primefaces无论如何)?

UPDATE

请注意,这个问题并不是增量是否primefaces的(这不是,而且是问题的开场白线)。 这是否可以在特定情况下,即在某些情况下是否可以利用单指令性质来避免lock前缀的开销。 而且,正如接受的答案中提到的关于单处理机的部分,以及这个答案 ,在它的评论和其他人的谈话中解释, 它可以 (但不是用C或C ++)。

这绝对是C ++定义为导致未定义行为的数据竞争的原因,即使一个编译器碰巧生成的代码在某些目标机器上执行了您所期望的操作。 您需要使用std::atomic来获得可靠的结果,但是如果您不关心重新sorting,则可以将它与memory_order_relaxed使用。 使用fetch_add查看下面的代码和asm输出fetch_add


但首先,汇编语言的部分问题是:

由于num ++是一条指令( add dword [num], 1 ),所以在这种情况下,我们可以得出结论num ++是primefaces吗?

内存目标指令(纯粹的存储除外)是在多个内部步骤中发生的读取 – 修改 – 写入操作 。 没有架构寄存器被修改,但CPU必须在内部保存数据,而通过其ALU发送。 即使是最简单的CPU,实际的寄存器文件也只是数据存储器的一小部分,锁存器保持一级输出作为另一级的input等。

来自其他CPU的内存操作可以在加载和存储之间全局可见。 也就是说两个线程运行add dword [num], 1一个循环中的每个线程都会add dword [num], 1对方的存储。 (见@玛格丽特的一个很好的图解答案 )。 在两个线程中每个线程增加40k后,计数器在真正的多核x86硬件上可能只会增加约60k(而不是80k)。


“希腊语”中的“primefaces”意味着没有观察者可以看到这个操作是分开的。 同时发生的所有位的物理/电力瞬间发生只是一个方法来实现这一点的负载或存储,但这甚至不可能的ALU操作。在x86上的Atomicity的答案中进入了更多关于纯粹加载和纯粹存储的细节,而这个答案的重点是读取 – 修改 – 写入。

lock前缀可应用于许多读取 – 修改 – 写入(内存目标)指令,以使整个操作相对于系统中的所有可能的观察者(其他核心和DMA设备,而不是连接到CPU引脚的示波器) 。 这就是它存在的原因。 (另见本问答 )。

所以lock add dword [num], 1 primefaces的 。 运行该指令的CPU内核会在加载从caching中读取数据之前,将caching行保持在Modified状态,保留在私有L1caching中,直到存储将结果提交回caching。 这样可以防止系统中的任何其他高速caching根据MESI高速caching一致性协议的规则(或者多核心AMD / MESI所使用的MOESI / MESIF版本)在从加载到存储的任何点上具有高速caching行的副本,英特尔CPU)。 因此,其他内核的操作似乎在之前或之后发生,而不是在此期间。

如果没有lock前缀,另一个内核可以获得caching行的所有权,并在我们加载之后但在我们的商店之前对其进行修改,以使其他商店在我们的加载和存储之间成为全局可见的。 其他几个答案会导致这个错误,并声称如果没有lock你会得到相同caching行的冲突副本。 这在连贯的caching系统中不会发生。

(如果一个lock编辑指令在跨越两条caching线的内存上运行,那么当传播给所有的观察者时,要确保对象的这两个部分的改变保持primefaces状态需要更多的工作,所以观察者不会看到撕裂。可能必须locking整个内存总线,直到数据到达内存。不要错位你的primefacesvariables!)

请注意, lock前缀还会将指令转换为完全内存屏障(如MFENCE ),停止所有运行时重新sorting,从而实现顺序一致性。 (见Jeff Preshing出色的博客文章 ,他的其他文章也很出色,清楚地解释了许多关于无锁编程的优点,从x86和其他硬件细节到C ++规则。)


在单处理器机器上,或者在单线程的过程中 ,单个RMW指令实际上primefaces的,没有lock前缀。 其他代码访问共享variables的唯一方法是CPU执行上下文切换,这在指令中间不会发生。 所以一个简单的dec dword [num]可以在单线程程序和它的信号处理程序之间同步,或者在单核机器上运行的multithreading程序中同步。 关于另一个问题 ,请参阅我的答案的下半部分 ,以及下面的评论,我将在这里更详细地解释这个问题。


回到C ++:

使用num++完全是假的,而不告诉编译器你需要编译成单个读 – 修改 – 写实现:

 ;; Valid compiler output for num++ mov eax, [num] inc eax mov [num], eax 

如果以后使用num的值,这很可能:编译器会在增量后将其保存在寄存器中。 所以,即使你检查num++如何编译的,改变周围的代码也会影响它。

(如果以后不需要该值,则首选inc dword [num] ;现代x86 CPU将运行内存目标RMW指令的效率至less与使用三个单独的指令一样高效有趣的事实: gcc -O3 -m32 -mtune=i586实际上会发射这个 ,因为(Pentium)P5的超标量stream水线并没有将复杂的指令解码为P6和后来的微架构的多种简单的微操作,更多信息请参考Agner Fog的指令表/微体系结构指南以及x86标签wiki提供了许多有用的链接(包括英特尔的x86 ISA手册,这些手册可以作为PDF免费获得))。


不要将目标内存模型(x86)与C ++内存模型混淆

编译时重新sorting是允许的 。 你用std :: atomic得到的另一部分是对编译时重新sorting的控制,以确保你的num++只有在其他操作之后才能成为全局可见的。

经典示例:将一些数据存储到另一个线程的缓冲区中,然后设置一个标志。 尽pipex86可以免费获取加载/释放存储,但是您仍然必须告诉编译器不要使用flag.store(1, std::memory_order_release);重新sortingflag.store(1, std::memory_order_release);

您可能期望这段代码将与其他线程同步:

 // flag is just a plain int global, not std::atomic<int>. flag--; // This isn't a real lock, but pretend it's somehow meaningful. modify_a_data_structure(&foo); // doesn't look at flag, and the compilers knows this. (Assume it can see the function def). Otherwise the usual don't-break-single-threaded-code rules come into play! flag++; 

但它不会。 编译器可以自由地将flag++移动到函数调用中(如果内联函数或知道它不看flag )。 那么它可以完全优化掉修改,因为flag volatile 。 (不,C ++ volatile不是std :: atomic的有用替代品,std :: atomic确实使编译器假定内存中的值可以asynchronous修改,类似于volatile ,但是还有更多的东西。 volatile std::atomic<int> foovolatile std::atomic<int> foo不一样,正如@Richard Hodges所讨论的那样。)

将非primefacesvariables上的数据竞争定义为未定义行为是让编译器能够将加载和汇入存储器循环加载,以及对多个线程可能引用的内存进行的许多其他优化。 (有关UB如何实现编译器优化的更多信息,请参阅LLVM博客 。)


正如我所提到的, x86 lock前缀是一个完整的内存屏障,所以使用num.fetch_add(1, std::memory_order_relaxed); 在x86上生成与num++ (默认为顺序一致性)相同的代码,但是在其他体系结构(如ARM)上可以更加高效。 即使在x86上,放宽也允许更多的编译时间重新sorting。

这是GCC在x86上实际执行的一些function,它们在std::atomic全局variables上运行。

在Godbolt编译器资源pipe理器中查看格式很好的source +汇编语言代码。 您可以select其他目标体系结构,包括ARM,MIPS和PowerPC,以查看从这些目标的atomics中获得的汇编语言代码的types。

 #include <atomic> std::atomic<int> num; void inc_relaxed() { num.fetch_add(1, std::memory_order_relaxed); } int load_num() { return num; } // Even seq_cst loads are free on x86 void store_num(int val){ num = val; } void store_num_release(int val){ num.store(val, std::memory_order_release); } // Can the compiler collapse multiple atomic operations into one? No, it can't. 
 # g++ 6.2 -O3, targeting x86-64 System V calling convention. (First argument in edi/rdi) inc_relaxed(): lock add DWORD PTR num[rip], 1 #### Even relaxed RMWs need a lock. There's no way to request just a single-instruction RMW with no lock, for synchronizing between a program and signal handler for example. :/ There is atomic_signal_fence for ordering, but nothing for RMW. ret inc_seq_cst(): lock add DWORD PTR num[rip], 1 ret load_num(): mov eax, DWORD PTR num[rip] ret store_num(int): mov DWORD PTR num[rip], edi mfence ##### seq_cst stores need an mfence ret store_num_release(int): mov DWORD PTR num[rip], edi ret ##### Release and weaker doesn't. store_num_relaxed(int): mov DWORD PTR num[rip], edi ret 

请注意,在顺序一致性存储之后,需要MFENCE(完全屏障)。 一般而言,x86强烈sorting,但是StoreLoad重新sorting是允许的。 具有存储缓冲区对于stream水线无序CPU的良好性能至关重要。 Jeff Preshing的内存重新sorting显示了使用MFENCE的后果,真正的代码显示了在真实硬件上发生的重新sorting。


Re:关于@Richard Hodges关于编译器合并std :: atomic num++; num-=2;的回答的讨论num++; num-=2; num++; num-=2; 操作合并成一个num--; 指令

目前的编译器实际上并没有这样做(但),但不是因为它们不被允许。 C ++ WG21 / P0062R1:编译器何时优化primefaces? 讨论了许多程序员期望编译器不会做出“令人惊讶”的优化,以及标准可以为程序员提供什么样的控制。 N4455讨论了许多可以优化的事例,包括这一个。 它指出,内联和常量传播可以引入像fetch_or(0)这样的东西,即使当原始源没有任何明显的变化时,它也可能变成只是一个load() (但仍然具有获取和释放语义)多余的primefaces操作。

编译器不这样做的真正原因是:(1)没有人编写复杂的代码,使编译器能够安全地执行这个操作(没有发生错误);(2)它可能违反了最小原则惊喜 。 无锁代码很难正确写入。 所以,不要随意使用primefaces武器:它们不便宜,不会优化太多。 尽pipe避免使用std::shared_ptr<T>进行多余的primefaces操作并不总是那么容易,因为它没有非primefaces版本(尽pipe这里的一个答案提供了一个简单的方法来为gcc定义一个shared_ptr_unsynchronized<T> )。


回到num++; num-=2; num++; num-=2; 编译就好像是num-- :编译器可以这样做,除非numvolatile std::atomic<int> 。 如果可以进行重新sorting,as-if规则允许编译器在编译时决定它总是以这种方式发生。 没有什么能保证观察者可以看到中间值( num++结果)。

也就是说,如果在这些操作之间没有东西变得全局可见的顺序与源的顺序要求(根据抽象机器的C ++规则而不是目标架构)兼容,编译器可以发出单个lock dec dword [num]而不是lock inc dword [num] / lock sub dword [num], 2

num++; num-- num++; num--不能消失,因为它与其他看起来是num线程仍然有Synchronize With关系,并且它是一个获取加载和释放存储,它们不允许在这个线程中重新sorting其他操作。 对于x86,这可能会编译为MFENCE,而不是lock add dword [num], 0 (即num += 0 )。

正如在PR0062中所讨论的那样 ,在编译时更加积极地合并非相邻的primefaces操作可能是不好的(例如,进度计数器只在最后一次更新一次,而不是每次迭代),但是它也可以帮助性能而没有缺点(例如跳过如果编译器可以certificate另一个shared_ptr对象存在于临时的整个生命周期中,则ref的primefacesinc / dec计算何时创build和销毁shared_ptr的副本。)

即使是num++; num-- 当一个线程解锁并重新locking时,合并可能会影响locking实现的公平性。 如果它从来没有真正被释放,甚至硬件仲裁机制也不会让另一个线程有机会在那个时候获得locking。


使用当前的gcc6.2和clang3.9,即使在最明显优化的情况下使用memory_order_relaxed,您仍然可以获得单独的lock操作。 ( Godbolt编译器资源pipe理器,所以你可以看到最新版本是不同的。)

 void multiple_ops_relaxed(std::atomic<unsigned int>& num) { num.fetch_add( 1, std::memory_order_relaxed); num.fetch_add(-1, std::memory_order_relaxed); num.fetch_add( 6, std::memory_order_relaxed); num.fetch_add(-5, std::memory_order_relaxed); //num.fetch_add(-1, std::memory_order_relaxed); } multiple_ops_relaxed(std::atomic<unsigned int>&): lock add DWORD PTR [rdi], 1 lock sub DWORD PTR [rdi], 1 lock add DWORD PTR [rdi], 6 lock sub DWORD PTR [rdi], 5 ret 

…现在让我们启用优化:

 f(): rep ret 

好吧,让我们给它一个机会:

 void f(int& num) { num = 0; num++; --num; num += 6; num -=5; --num; } 

结果:

 f(int&): mov DWORD PTR [rdi], 0 ret 

另一个观察线程(即使忽略高速caching同步延迟)也没有机会观察到个别的变化。

相比于:

 #include <atomic> void f(std::atomic<int>& num) { num = 0; num++; --num; num += 6; num -=5; --num; } 

其结果是:

 f(std::atomic<int>&): mov DWORD PTR [rdi], 0 mfence lock add DWORD PTR [rdi], 1 lock sub DWORD PTR [rdi], 1 lock add DWORD PTR [rdi], 6 lock sub DWORD PTR [rdi], 5 lock sub DWORD PTR [rdi], 1 ret 

现在,每一个修改是:

  1. 在另一个线程中可观察到,并且
  2. 尊重其他线程中发生的类似修改。

primefaces性不仅仅处于指令级别,它涉及到从处理器到caching到内存和后端的整个stream水线。

更多信息

关于std::atomic atomics更新的优化效果。

c ++标准有'as if'规则,通过这个规则,编译器可以对代码进行重新sorting,甚至可以重写代码,前提是结果具有完全相同的可观察效果(包括副作用),就好像它只是简单地执行码。

as-if规则是保守的,尤其涉及primefaces。

考虑:

 void incdec(int& num) { ++num; --num; } 

因为没有互斥锁,primefaces或影响线程间测序的任何其他构造,所以我认为编译器可以自由地将这个函数重写为NOP,例如:

 void incdec(int&) { // nada } 

这是因为在c ++内存模型中,没有另一个线程观察增量结果的可能性。 如果numvolatile (可能会影响硬件行为),这当然是不同的。 但是在这种情况下,这个函数将是修改这个内存的唯一函数(否则程序是不合格的)。

但是,这是一个不同的球赛:

 void incdec(std::atomic<int>& num) { ++num; --num; } 

num是一个primefaces。 对其进行的更改必须可以被观察到的其他线程观察到。 改变这些线程本身的作用(例如在递增和递减之间将值设置为100)将对num的最终值产生非常深远的影响。

这里是一个演示:

 #include <thread> #include <atomic> int main() { for (int iter = 0 ; iter < 20 ; ++iter) { std::atomic<int> num = { 0 }; std::thread t1([&] { for (int i = 0 ; i < 10000000 ; ++i) { ++num; --num; } }); std::thread t2([&] { for (int i = 0 ; i < 10000000 ; ++i) { num = 100; } }); t2.join(); t1.join(); std::cout << num << std::endl; } } 

样本输出:

 99 99 99 99 99 100 99 99 100 100 100 100 99 99 100 99 99 100 100 99 

没有太多复杂的指令,像add DWORD PTR [rbp-4], 1是非常CISC风格的。

它执行三个操作:从内存中加载操作数,增加它,将操作数存回内存。
在这些操作过程中,CPU获取和释放总线两次,在任何其他代理之间也可以获得它,这违反了primefaces性。

 AGENT 1 AGENT 2 load X inc C load X inc C store X store X 

X只增加一次。

添加指令不是primefaces的。 它引用内存,并且两个处理器内核可能具有不同的内存本地caching。

IIRC add指令的primefacesvariables称为lockingxadd

因为对应于num ++的第5行是一条指令,所以在这种情况下,我们可以得出结论:num ++是primefaces吗?

根据“逆向工程”生成的组件得出结论是危险的。 例如,你似乎编译了禁用了优化的代码,否则编译器会抛出该variables或直接加载1而不调用operator++ 。 由于生成的程序集可能会根据优化标志,目标CPU等而发生显着变化,因此您的结论基于沙子。

另外,一个汇编指令意味着一个操作是primefaces的想法也是错误的。 即使在x86架构上,这个add在多CP​​U系统上也不会是primefaces的。

即使你的编译器总是把它作为一个primefaces操作来发送,但是根据C ++ 11和C ++ 14标准,从其他任何线程并发地访问num将构成一个数据竞争,并且程序会有不确定的行为。

但比这更糟糕。 首先,如前所述,编译器在增加variables时生成的指令可能取决于优化级别。 其次,如果num不是primefaces的,编译器可能会重新sorting其他内存访问,例如

 int main() { std::unique_ptr<std::vector<int>> vec; int ready = 0; std::thread t{[&] { while (!ready); // use "vec" here }); vec.reset(new std::vector<int>()); ++ready; t.join(); } 

即使我们乐观地认为++ready是“primefaces”的,并且编译器会根据需要生成检查循环(正如我所说的那样,它是UB,因此编译器可以自由删除它,用无限循环replace它。 ),编译器可能仍然会移动指针分配,甚至更糟糕的是,在增量操作之后, vector的初始化会导致混乱。 在实践中,如果优化编译器彻底删除了readyvariables和检查循环,我不会感到惊讶,因为这不影响语言规则下的可观察行为(与您的私人希望相反)。

事实上,在去年的C ++会议上,我听到两位编译器开发人员的发言,他们非常乐意实现优化,只要语言规则允许,即使只是略有改善在正确的书面程序。

最后,即使你不关心可移植性,而且你的编译器神奇的好,你使用的CPU很可能是一个超标量的CISCtypes,并将指令分解成微操作,重新sorting和/或推测性地执行它们,在一定程度上受限于同步基元(如英特尔)的LOCK前缀或内存屏蔽,以便最大化每秒的操作。

长话短说,线程安全编程的自然职责是:

  1. 您的职责是编写在语言规则下(特别是语言标准内存模型)具有明确定义的行为的代码。
  2. 编译器的职责是生成在目标架构的内存模型下具有相同的良好定义(可观察)行为的机器代码。
  3. 您的CPU的职责是执行此代码,以便观察到的行为与其自身架构的内存模型兼容。

如果你想以自己的方式做,它可能只是在某些情况下工作,但要明白,保修是无效的,你将全权负责任何不想要的结果。 🙂

PS:正确书写的例子:

 int main() { std::unique_ptr<std::vector<int>> vec; std::atomic<int> ready{0}; // NOTE the use of the std::atomic template std::thread t{[&] { while (!ready); // use "vec" here }); vec.reset(new std::vector<int>()); ++ready; t.join(); } 

这是安全的,因为:

  1. ready检查不能根据语言规则进行优化。
  2. ++ready – 在检查++ready 之前不会发生零,其他操作不能在这些操作周围进行重新sorting。 这是因为++ready和检查是顺序一致的 ,这是C ++内存模型中描述的另一个术语,它禁止这个特定的重新sorting。 因此,编译器不得重新sorting指令,并且还必须告诉CPU它不能例如在ready之后延迟对vec的写入。 顺序一致是语言标准中关于primefaces的最强保证。 次要的(理论上便宜的)保证是可用的,例如通过std::atomic<T>其他方法,但是这些对于专家来说是绝对的,编译器开发人员可能不会优化太多,因为它们很less被使用。

On a single-core x86 machine, an "add" instruction with a word-aligned memory destination will generally be atomic; an interrupt can't split a single instruction down the middle. Out-of-order execution is required to preserve the illusion of instructions executing one at a time in order within a single core, so any instruction running on the same CPU will either happen completely before or completely after the add.

Modern x86 systems are multi-core, so the uniprocessor special case doesn't apply.

If one is targeting a small embedded PC and has no plans to move the code to anything else, the atomic nature of the "add" instruction could be exploited. On the other hand, platforms where operations are inherently atomic are becoming more and more scarce.

Back in the day when x86 computers had one CPU, the use of a single instruction ensured that interrupts would not split the read/modify/write and if the memory would not be used as a DMA buffer too, it was atomic in fact (and C++ did not mention threads in the standard so this wasn't addresses).

When it was rare to have a dual core (Pentium Pro) on a customer desktop, I effectively used this to avoid the LOCK prefix on a single core machine and improve performance.

Today, it would only help against multiple threads that were all set to the same CPU affinity, so the threads you are worried about would only come into play via time slice expiring and running the other thread on the same CPU (core). That is not realistic.

With modern x86/x64 processors, the single instruction is broken up into several micro ops and furthermore the memory reading and writing is buffered. So different threads running on different CPUs will not only see this as non-atomic but may see inconsistent results concerning what it reads from memory and what it assumes other threads have read to that point in time: you need to add memory fenses to restore sane behavior.

No. https://www.youtube.com/watch?v=31g0YE61PLQ (That's just a link to the "No" scene from "The Office")

Do you agree that this would be a possible output for the program:

sample output:

 100 100 100 100 100 100 100 100 100 100 100 100 100 100 100 100 100 100 100 100 

If so, then the compiler is free to make that the only possible output for the program, in whichever way the compiler wants. ie a main() that just puts out 100s.

This is the "as-if" rule.

And regardless of output, you can think of thread synchronization the same way – if thread A does num++; num--; and thread B reads num repeatedly, then a possible valid interleaving is that thread B never reads between num++ and num-- . Since that interleaving is valid, the compiler is free to make that the only possible interleaving. And just remove the incr/decr entirely.

There are some interesting implications here:

 while (working()) progress++; // atomic, global 

(ie imagine some other thread updates a progress bar UI based on progress )

Can the compiler turn this into:

 int local = 0; while (working()) local++; progress += local; 

probably that is valid. But probably not what the programmer was hoping for 🙁

The committee is still working on this stuff. Currently it "works" because compilers don't optimize atomics much. But that is changing.

And even if progress was also volatile, this would still be valid:

 int local = 0; while (working()) local++; while (local--) progress++; 

: – /

Yes, but…

Atomic is not what you meant to say. You're probably asking the wrong thing.

The increment is certainly atomic . Unless the storage is misaligned (and since you left alignment to the compiler, it is not), it is necessarily aligned within a single cache line. Short of special non-caching streaming instructions, each and every write goes through the cache. Complete cache lines are being atomically read and written, never anything different.
Smaller-than-cacheline data is, of course, also written atomically (since the surrounding cache line is).

Is it thread-safe?

This is a different question, and there are at least two good reasons to answer with a definite "No!"

First, there is the possibility that another core might have a copy of that cache line in L1 (L2 and upwards is usually shared, but L1 is normally per-core!), and concurrently modifies that value. Of course that happens atomically, too, but now you have two "correct" (correctly, atomically, modified) values — which one is the truly correct one now?
The CPU will sort it out somehow, of course. But the result may not be what you expect.

Second, there is memory ordering, or worded differently happens-before guarantees. The most important thing about atomic instructions is not so much that they are atomic . It's ordering.

You have the possibility of enforcing a guarantee that everything that happens memory-wise is realized in some guaranteed, well-defined order where you have a "happened before" guarantee. This ordering may be as "relaxed" (read as: none at all) or as strict as you need.

For example, you can set a pointer to some block of data (say, the results of some calculation) and then atomically release the "data is ready" flag. Now, whoever acquires this flag will be led into thinking that the pointer is valid. And indeed, it will always be a valid pointer, never anything different. That's because the write to the pointer happened-before the atomic operation.

That a single compiler's output, on a specific CPU architecture, with optimizations disabled (since gcc doesn't even compile ++ to add when optimizing in a quick&dirty example ), seems to imply incrementing this way is atomic doesn't mean this is standard-compliant (you would cause undefined behavior when trying to access num in a thread), and is wrong anyways, because add is not atomic in x86.

Note that atomics (using the lock instruction prefix) are relatively heavy on x86 ( see this relevant answer ), but still remarkably less than a mutex, which isn't very appropriate in this use-case.

Following results are taken from clang++ 3.8 when compiling with -Os .

Incrementing an int by reference, the "regular" way :

 void inc(int& x) { ++x; } 

This compiles into :

 inc(int&): incl (%rdi) retq 

Incrementing an int passed by reference, the atomic way :

 #include <atomic> void inc(std::atomic<int>& x) { ++x; } 

This example, which is not much more complex than the regular way, just gets the lock prefix added to the incl instruction – but caution, as previously stated this is not cheap. Just because assembly looks short doesn't mean it's fast.

 inc(std::atomic<int>&): lock incl (%rdi) retq 

When your compiler uses only a single instruction for the increment and your machine is single-threaded, your code is safe. ^^

Try compiling the same code on a non-x86 machine, and you'll quickly see very different assembly results.

The reason num++ appears to be atomic is because on x86 machines, incrementing a 32-bit integer is, in fact, atomic (assuming no memory retrieval takes place). But this is neither guaranteed by the c++ standard, nor is it likely to be the case on a machine that doesn't use the x86 instruction set. So this code is not cross-platform safe from race conditions.

You also don't have a strong guarantee that this code is safe from Race Conditions even on an x86 architecture, because x86 doesn't set up loads and stores to memory unless specifically instructed to do so. So if multiple threads tried to update this variable simultaneously, they may end up incrementing cached (outdated) values

The reason, then, that we have std::atomic<int> and so on is so that when you're working with an architecture where the atomicity of basic computations is not guaranteed, you have a mechanism that will force the compiler to generate atomic code.