当前位置: 首页 > 科技观察

对int变量的赋值是原子的吗?为什么?_0

时间:2023-03-23 11:19:18 科技观察

前言这个问题是面试的时候遇到的,当时没有回答。回到家后,我翻看了一下,整理了一下。原题:什么指令集支持原子操作?它的原理是什么?如果考虑所有的指令集,问题就太大了,这里简化一下。以X86和ARM为例。原子操作是不可分割的操作,完成时不会被任何事件中断。在单处理器系统(UniProcessor,简称UP)中,可以在单条指令中完成的操作可以认为是原子操作,因为中断只能发生在指令之间。例如,如果C语言代码没有经过优化,就有可能生成如下程序集:这样当多个进程执行这段代码时,可能会出现并发问题:这样就会出问题。在单处理器上,这个问题的解决方案是将count++语句翻译成单指令操作。X86指令集支持inc运算,这样一指就能完成计数运算。进程的上下文切换总是在执行一条指令后完成,所以不会出现上述的并发问题。对于单个处理器,处理器指令是一个原子操作。同样,ARM中的SWP和X86中的XCHG都是单处理器的原子操作。但是,在多处理器系统(SymmetricMulti-Processor,简称SMP)中情况就不同了。由于系统中的多个处理器是独立运行的,即使是可以在一条指令中完成的操作也可能受到干扰。因为此时并发的主体不再是进程,而是处理器。X86架构IntelX86指令集提供指令前缀锁,锁定前端串行总线FSB,保证指令在执行过程中不会受到其他处理器的干扰。例如:使用lock指令前缀后,禁止在处理过程中对count内存的并发访问(Read/Write),从而保证了指令的原子性。如图所示:X86LOCK其原理在Intel开发手册中有如下说明:说明导致处理器的LOCK#信号在执行伴随指令期间被断言(将指令变成原子指令)。在多处理器环境中,LOCK#信号确保处理器在该信号有效时独占使用任何共享内存。LOCK前缀只能添加到后续指令之前,并且只能添加到目标操作数为内存操作数:ADD、ADC、AND、BTC、BTR、BTS、CMPXCHG、CMPXCH8B、CMPXCHG16B、DEC、INC、NEG、NOT、OR、SBB、SUB、XOR、XADD和XCHG。如果LOCK前缀与这些指令之一一起使用并且源操作数是内存操作数,则可能会生成未定义的操作码异常(#UD)。如果LOCK前缀与不在上述列表中的任何指令一起使用,也会生成未定义的操作码异常。XCHG指令总是断言LOCK#信号而不管是否存在LOCK前缀。LOCK前缀通常与BTS指令一起使用,以在共享内存环境中对内存位置执行读取-修改-写入操作。LOCK前缀的完整性不受内存字段对齐的影响。对于任意未对齐的字段,会观察到内存锁定。处理器的LOCK#信号在伴随指令的执行期间被断言(将指令变成原子指令)。在多处理器环境中,LOCK#信号确保处理器在信号有效时独占使用任何共享内存。LOCK前缀只能附加在以下指令之前,并且仅适用于那些目标操作数是内存操作数的指令格式:ADD、ADC、AND、BTC、BTR、BTS、CMPXCHG、CMPXCH8B、CMPXCHG16B、DEC、INC、NEG、NOT、OR、SBB、SUB、XOR、XADD和XCHG。如果LOCK前缀与这些指令之一一起使用并且源操作数是内存操作数,则可能会产生未定义的操作码异常(#UD)。如果LOCK前缀与任何不在上述列表中的指令一起使用,也会引发未定义的操作码异常。无论是否存在LOCK前缀,XCHG指令始终断言LOCK#信号。LOCK前缀经常与BTS指令一起使用,在共享内存环境中对内存位置执行读-修改-写操作。LOCK前缀的完整性不受内存字段对齐的影响。内存锁定适用于任何未对齐的字段。在操作系统中的实现在Linux源码中,原子自增定义如下:LOCK_PREFIX的定义如下:可见:在对称多处理器架构的情况下,LOCK_PREFIX被解释为指令前缀锁。对于单处理器架构,LOCK_PREFIX不包含任何内容。另外,对于CAS,还有cmpxchg指令来操作。代码如下:static__always_inlineintatomic_cmpxchg(atomic_t*v,intold,intnew){returncmpxchg(&v->counter,old,new);}#definecmpxchg(ptr,old,new)\__cmpxchg(ptr,old,新的,sizeof(*(ptr)))#define__cmpxchg(ptr,old,new,size)\__raw_cmpxchg((ptr),(old),(new),(size),LOCK_PREFIX)#define__raw_cmpxchg(ptr,old,新的,大小,锁)\({\__typeof__(*(ptr))__ret;\__typeof__(*(ptr))__old=(old);\__typeof__(*(ptr))__new=(new);\switch(大小){\case__X86_CASE_B:\{\volatileu8*__ptr=(volatileu8*)(ptr);\asmvolatile(lock"cmpxchgb%2,%1"\:"=a"(__ret),"+m"(*__ptr)\:"q"(__new),"0"(__old)\:"内存");\break;\}\case__X86_CASE_W:\{\volatileu16*__ptr=(volatileu16*)(ptr);\asmvolatile(lock"cmpxchgw%2,%1"\:"=a"(__ret),"+m"(*__ptr)\:"r"(__new),"0"(__old)\:"内存");\休息;\}\case__X86_CASE_L:\{\volatileu32*__ptr=(volatileu32*)(ptr);\asmvolatile(lock"cmpxchgl%2,%1"\:"=a"(__ret),"+m"(*__ptr)\:"r"(__new),"0"(__old)\:"内存");\休息;\}\case__X86_CASE_Q:\{\volatileu64*__ptr=(volatileu64*)(ptr);\asmvolatile(lock"cmpxchgq%2,%1"\:"=a"(__ret),"+m"(*__ptr)\:"r"(__new),"0"(__old)\:"内存");\休息;\}\默认值:\__cmpxchg_wrong_size();\}\__ret;\})ARM架构ARM架构下,没有LOCK#指令,其具体实现如下:##ARMv6之前的早期ARM架构不支持SMP,这些单核架构的CPU通过轮流实现原子操作关闭CPU中断。ARM架构下的Linux代码:如下:这是一组很多操作共享的代码。对于cmpxchg:可见对v->counter的操作是临界区,指令的执行不能中断,内存访问也需要保持不受干扰。以前版本的ARMv6通过关闭本地中断来保护这个临界区。看起来很简单,但神秘的是以前版本的ARMv6并不支持SMP。比如经典的read-modify-write问题,其本质是维护一个内存读写访问的原子问题,也就是说内存读写访问不能中断。可以通过硬件、软件或软硬件结合的方式来解决这个问题。早期的ARMCPU给出的解决方案是依赖硬件:SWP汇编指令执行内存读取操作和内存写入操作,但从程序员的角度来看,SWP指令是原子的,两者之间不会有间隙读写。被任何异步事件中断。具体底层硬件是怎么做的呢?这时硬件会提供一个锁定信号,在内存操作过程中设置锁定信号,告诉总线这是一次不间断的内存访问,直到SWP需要的两次内存访问完成后才清除锁定信号。多说一点SWP和SWPB。这两条指令用于同步,不用于执行原子操作。在独占访问被引入ARM架构之前,SWP和SWPB指令通常用于同步。限制是,如果在触发交换操作时触发中断,则处理器必须在执行中断之前完成指令的加载和存储部分,从而增加中断延迟。由于独立加载和独占存储是单独的指令,因此在使用新的同步原语时会减少这种影响。但在多核系统中,阻止所有处理器在指令交换期间访问主内存会降低系统性能。在处理器以不同频率运行但共享相同主内存的多核系统中尤其如此。因此,在ARMv6及之后的版本中,放弃了SWP,ARMv6架构引入了独占访问内存的概念,提供更灵活的原子内存更新。ARMv6架构以Load-Exclusive和Store-Exclusive同步原语LDREX和STREX的形式引入了LoadLink和StoreConditional指令。从ARMv6T2开始,这些指令在ARM和Thumb指令集中可用。独立加载和专用存储提供灵活且可扩展的同步,取代已弃用的SWP和SWPB指令。后来使用了LDREX和STREX指令,armv7之后又使用了ldrex和strex:内存访问指令LDREX/STREX不同于普通的LDR/STR内存访问指令,它是一条“独占”的内存访问指令。这对指令内存访问由一个称为“独占监视器”的组件监视,用于独占访问。