感觉现有的回答都不全面。
cpp原子库有些实现是调用操作系统提供的接口,所以这个实现可能跟操作系统有关。同时,又因为操作系统的实现是依赖于硬件的,所以具体的锁的实现要取决于硬件的支持情况。
题主有一个误区,因为原子操作一定需要关调度,关中断,这个理解是错的。锁的本质是同步数据,理论上说,只要保证特定的数据不被修改即可。在硬件层面上看,就是特定的物理内存,特定的cache line,不被修改,所以,并不是所有原子操作,都一定要关调度,关中断。
大多数主流的CPU,都会提供硬件指令,原子修改某个内存,对于Intel的CPU,手册system programming guide的8.1.2.2 Software Controlled Bus Locking里有详细的描述:
The bit test and modify instructions (BTS, BTR, and BTC).
The exchange instructions (XADD, CMPXCHG, and CMPXCHG8B).
The LOCK prefix is automatically assumed for XCHG instruction.
The following single-operand arithmetic and logical instructions: INC, DEC, NOT, and NEG.
The following two-operand arithmetic and logical instructions: ADD, ADC, SUB, SBB, AND, OR, and XOR.
如果操作系统使用的是硬件指令实现原子操作,那么用这些指令就可以了,这种实现不需要锁,不需要关中断或者调度。
对于ARM/PPC/RISCV也有类似的指令。
但是,也有例外情况。
比如ARM32/PPC32/RISCV32上不支持对8B数据的原子操作,只有Intel在32位环境中提供了CMPXCHG8B的指令(甚至于,Intel还提供了CMPXCHG16B的指令),这种情况就比较麻烦了。
atomic库里是提供了各种长度的数据的,如果硬件本身不支持,那么就需要通过软件实现。atomic库里有std::atomic_is_lock_free来告诉应用程序,这个操作是不是lock free的,如果不是,那么底层就是用锁来实现的。具体可以参考这个:
如果不是lock free的,那么其实它的实现跟用户自己用锁来实现是差不多的,甚至性能还不如用户自己实现。通过软件实现的原子操作是需要关中断,关调度,使用mem fence等动作保证数据不被改变,如果是用户自己的代码,确认中断不会更改关键数据的话,那么可以不用关中断。
某些开源库并没有考虑到不同硬件上的原子操作差异,所以在某些平台上,使用了原子操作的开源库实际上是会有风险的(比如openmp的kmp库在PPC/RISCV上)。
所以,原子操作可能是虚假的(软件模拟),也可能是真实的(硬件指令),在X86平台上,基本上都是真实的,在非X86平台,有可能是虚假的。
这个问题要仔细说明白的话,不简单。但广泛给个四海而皆准的回答就是:std::atomic确实是都用“锁”的——区别无非是哪种“锁”而已。
扩展一点说:
首先,std::atomic可以特化为不同类型,平台上如果直接提供了原子指令的类型,会使用源自指令来实现(这在很多人眼中,这就是“无锁”了)。没有直接提供的类型,会直接用锁来实现。
然后,问题就退化为:原子指令到底是不是真的“无锁”。实际上在逻辑上,原子指令本身在逻辑上,也是一种“锁”的实现。
接着,简单解释一下原子指令里的“锁”:
早期的双U/双核年代,原子指令是需要在多CPU之间额外支持的。它的基本原理是当进入原子指令时,会锁住整个总线,使之进入“原子状态”(没记错应该还会关掉硬中断)。
现在核心多了,再动辄锁总线就性能损耗太大了,于是就变为锁cache。对应内存的cache块的标记为独占(加锁),等到操作结束才会解除(解锁)。而这时候,抢锁的操作实际上是由电路实现的(类似于抢答器之类的电路)。
最后,原子指令在理论上已经达到了最优解了,虽然肯定还是无法避免性能损失(抢不到锁的脏页要放弃后重新加载),但肯定比在软件层做得要更好了。
是真正的“原子”。只不过,你严重误解了原子这个词。
一个变量是原子的,意思并不是整个程序对这个变量的八万次读写是一个整体。没这回事,不要和数据库事务的原子性混淆。
变量是原子的,意思是每次操作这个变量,包括读取其值、更改其值,这次操作中执行的唯一一条指令是原子的(对于面向对象语言,也可以是一次接口调用是原子的,这个后面会讲到)。
正因此,很多CPU才不得不专门提供一条Test and Exchange指令,把“检测内容,等于xx则修改其值”这样必须两三条指令才能完成的操作整合进一条指令,且在执行时锁定总线。
如果只靠总线锁定前缀的话,这个test and exchange操作先后执行的几条指令,虽然每一条都是原子的,但加起来就不是了。也就是这里必须提供一个方法,用来把几条指令绑起来(的确有CPU允许你在连续执行多条指令期间锁定总线,从而灵活组合一堆指令、让它们加起来是原子的。但这类操作往往需要很高特权级,不是用户态应用可以用的)。
注意这里有个思想:锁操作代价高昂,因此锁的粒度一定要小。最好小到只有一条指令,那么锁操作的代价就完全可控了。
注意,一条指令也是需要取指、译码、执行、写回结果等许多步骤的。
普通指令,这些步骤可以打断,可以相互穿插。比如,字节不对齐时,一条指令可能需要两次以上访存才能把待操作的数字载入寄存器,如果两次访存之间被打断、且另一条指令改写了被操作数字时,这里就会出现脏读(脏写也类似)。
CPU可以智能的分析指令执行涉及到的东西,从而避免几条指令穿插影响。但这个智能并不太高。比如,操纵和CPU字长相等的、字节对齐的int时,很多CPU可以保证原子性。
没错,不用锁总线,天然支持,只要你保证它的地址对齐满足CPU要求。但你要自己查阅资料来确认这一点,同时还要确认你的编译器生成的代码能够保证地址对齐。
那么这时候,cpp的atomic实际上等于什么都没做。
当然了,经常的,仅仅一条甚至七八条指令的原子性也是不够用的。比如,电商开发中经常遇到的,用户购买流程,需要先锁库存,下订单,等待付款,付款完成再把商品标记为出库:这一整套成千上万条指令都必须是原子的。
CPP的atomic不能也不该来保证这一点。
还记得吗?锁的粒度要尽量小。所以CPU努力把它做到了一条指令。
但是,电商这种情况该怎么办?
很简单,用一个atomic变量来保护数据。
注意了,CPU只能保护这个atomic变量不脏读脏写,它可保护不了你要保护的一大片数据在千百条指令执行过程中的原子性。
那该怎么办?
简单,想象一条虚拟的总线,当函数a执行时,锁住这条虚拟总线;执行完了,再给这条虚拟总线解锁。
比如,我们可以把数据库连接看作这条总线,然后把一个atomic变量等于1看作锁定状态,等于0则是free状态:那么,只要每个人都确认这个锁的从0到1是自己做的(也就是持有这个锁),那么就可以去修改数据库了。注意改完了要把锁恢复到0哦。
类似的,只要通过接口或者别的什么(甚至是文档中的约定)确保一切访问之前都要举行一个“锁定虚拟总线”的仪式,假装自己真的是通过这条总线访问的,那么当然也能达到原子访问的目的。
这就是为什么很多库会告诉你“随便用!我保证线程安全”的原因:很简单,它把必要的仪式都写死在接口实现代码里了。
注意,你的确得到了原子性/线程安全,执行时但并没有真的锁物理总线(我在脑中锁虚拟总线,关你物理总线什么事)。
只除了那条至关重要的test and exchange执行时。
仔细阅读、提炼这段描述,你才会理解为什么test and Exchange是必要的、为什么没有它就没办法正确实现锁。
当然,锁数据库链接粒度实在太大,这等于把数据库变成串行的了。实践中,数据库内部替你维护了表(级)锁、行锁等更小粒度的锁,甚至还区分读写锁。。。
类似的,电商购物,你也不要真的从用户下订单到支付完成都锁定数据库表或者表中的一行。不合适的使用大粒度锁是专业水平欠佳的表现。
相反,你要审慎思考:占用库存究竟是”锁住数据库中的一行(从而独占的修改它)”呢,还是“正确的把数据库余额减一(这个操作需要原子性),然后记住这个临时扣除状态,在用户付款后把状态改为永久、或者在用户付款后把余额再加一”:前者带来极长时间的数据库锁定,而后者只需要修改余额这个动作本身是原子性的。
这就是锁的优化。
前些年很是流行过一阵“无锁编程”,其核心思想就是不用任何大粒度锁,而是把一切同步操作都压缩到一条CPU指令。
换句话说,无锁编程并不是真正的无锁,而是锁粒度的极致优化,优化到只剩一条test and exchange指令。
换句话说:一切复杂操作的原子性、最终都可以归结为“一个guard变量”的原子性。
再换句话说,每次访问之前,都先围绕着guard举行一套仪式,就可以保证另外一堆复杂数据的原子性。这套仪式的关键,最终都可以归结到一条test and exchange指令。
能否认识到这一点,是你能否理解你的问题的必要前提(也是能否看出那群“一提12306,就说锁库存很可怕”的人的成色的前提)。
换句话说,真的理解了这里提到的东西,你才会明白自己的问题错在哪里。
似乎很少看到在讨论 atomic 的时候同时讨论 interrupts。我认为这两者虽然都直接与 CPU 有关,但是是可以分开讨论的,因为,虽然你每敲一下键盘、移动一下鼠标 CPU 都会被中断,但中断处理例程(Interrupt Service Routine,IRS)一般位于 driver 里[1],并且 CPU 从中断恢复之后,用户态程序的环境跟之前是一样的,而且用户态程序也无法直接操作 IF 来禁止被中断[2],只有 OS 核心才行。
关于是否会使用锁机制的问题,虽然我不是硬件方面的专家不过据我了解,比如 atomic 在 x86 上最终可能以类似 lock cmpxchg8b
这样的指令来实现。这个 lock 前缀意味着锁,目的是保证在其修饰的指令执行时对 shared memory 的独占,它可能会锁住 BUS,但如果这块内存在 CPU 里面已经 cache,那么只需要锁 cache 就可以了。
Beginning with the P6 family processors, when the LOCK
prefix is prefixed to an instruction and the memory area being accessed is cached internally in the processor, the LOCK# signal is generally not asserted. Instead, only the processor’s cache is locked. Here, the processor’s cache coherency mechanism ensures that the operation is carried out atomically with regards to memory.
atomic 是 CPU 实现的,所以,是的,在不同的 CPU 上实现肯定有差别。在讨论 atomic 的时候,我们更多关注是内存对齐,内存一致性,内存读写顺序的问题。
是真正的原子,直接对应到编译器的原子intrinsics,对应到CPU的相应指令。
都不是。你对原子的理解有偏差。
atomic是否用锁:可以通过atomic::is_lock_free和is_always_lock_free来判断。一般对于一些基本类型来说都不会用互斥锁,你传一个比较大的自定义类型进去大概率就得用锁了
atomic的实现:对于不用的cpu体系结构来说确实是不同的,比如你说的原子自增操作,对x86来说一个lock前缀就搞定了,对于其他体系结构可能需要在循环中使用ll/sc指令对或者cas操作来做一些争抢。
无论是lock前缀还是循环式的争抢,都只是指令层面的动作,与操作系统无关,不会禁用操作系统对其他线程的调度,一般只是锁住总线来做一个原子的读取和更新(在不使用ring bus而使用其他互联网络的系统中会更复杂一些),其他核上的线程还是在正常运行,只是可能因为总线被占用而某条指令卡住一小会。在使用ring bus的处理器中,实际上即使你不用atomic,只是单纯做一个对齐的写操作,总线也会被你占住一段时间, 这种占住总线的行为正是缓存一致性的serialization特性的要求
本站所有内容均为互联网搜索引擎提供的公开搜索信息,本站不存储任何数据与内容,任何内容与数据均与本站无关,如有需要请联系相关搜索引擎包括但不限于百度,google,bing,sogou 等
© 2025 tinynews.org All Rights Reserved. 百科问答小站 版权所有