我听说i ++不是线程安全的语句,因为在汇编中,i ++减少了将原始值存储为temp的某个地方,然后递增它,然后替换它,这可能会被上下文切换中断。
但是,我想知道++ i。据我所知,这将减少为单个汇编指令,例如“ add r1,r1、1”,并且由于它只是一条指令,因此上下文切换将不会中断该指令。
谁能澄清?我假设正在使用x86平台。
我听说i ++不是线程安全的语句,因为在汇编中,i ++减少了将原始值存储为temp的某个地方,然后递增它,然后替换它,这可能会被上下文切换中断。
但是,我想知道++ i。据我所知,这将减少为单个汇编指令,例如“ add r1,r1、1”,并且由于它只是一条指令,因此上下文切换将不会中断该指令。
谁能澄清?我假设正在使用x86平台。
Answers:
你听错了。"i++"
对于特定的编译器和特定的处理器体系结构来说,这很可能是线程安全的,但是标准中并没有强制要求这样做。实际上,由于多线程不是ISO C或C ++标准的一部分(a),因此您无法根据自己的想法将所有内容视为线程安全的。
这是相当可行的++i
可以编译成一个任意序列,例如:
load r0,[i] ; load memory into reg 0
incr r0 ; increment reg 0
stor [i],r0 ; store reg 0 back to memory
这在没有内存增加指令的(虚构)CPU上不是线程安全的。或者它可能很聪明,可以将其编译为:
lock ; disable task switching (interrupts)
load r0,[i] ; load memory into reg 0
incr r0 ; increment reg 0
stor [i],r0 ; store reg 0 back to memory
unlock ; enable task switching (interrupts)
其中lock
禁用和unlock
启用中断。但是,即使那样,在具有多个这些CPU共享内存的体系结构中,这可能也不是线程安全的(lock
可能仅禁用一个CPU的中断)。
语言本身(或它的库,如果未在语言中构建)将提供线程安全的构造,因此您应使用这些构造,而不要依赖于对生成的机器代码的理解(或可能的误解)。
您需要研究(a)之类的东西,例如Java synchronized
和pthread_mutex_lock()
(在某些操作系统下可用于C / C ++ )。
(a)在C11和C ++ 11标准完成之前提出了这个问题。这些迭代现在将线程支持引入了语言规范,包括原子数据类型(尽管它们和线程通常是可选的,至少在C语言中是可选的)。
您无法对++ i或i ++作一番概括。为什么?考虑在32位系统上递增64位整数。除非底层机器具有四字“加载,递增,存储”指令,否则递增该值将需要多个指令,其中任何一条指令都可以被线程上下文切换中断。
此外,++i
并不总是“为值加一”。在像C这样的语言中,增加指针实际上会增加所指向对象的大小。也就是说,如果i
是指向32字节结构的指针,则++i
添加32字节。尽管几乎所有平台都具有原子的“在内存地址处增加值”指令,但并非所有平台都具有原子的“在内存地址处向值添加任意值”指令。
它们都是线程不安全的。
CPU无法直接使用内存进行数学运算。它是通过从内存中加载值并使用CPU寄存器进行数学运算来间接实现的。
我++
register int a1, a2;
a1 = *(&i) ; // One cpu instruction: LOAD from memory location identified by i;
a2 = a1;
a1 += 1;
*(&i) = a1;
return a2; // 4 cpu instructions
++ i
register int a1;
a1 = *(&i) ;
a1 += 1;
*(&i) = a1;
return a1; // 3 cpu instructions
对于这两种情况,都有一个竞争条件导致无法预测的i值。
例如,假设有两个并发的++ i线程,每个线程分别使用寄存器a1,b1。并且,执行上下文切换的方式如下:
register int a1, b1;
a1 = *(&i);
a1 += 1;
b1 = *(&i);
b1 += 1;
*(&i) = a1;
*(&i) = b1;
结果,我没有成为i + 2,而是成为i + 1,这是不正确的。
为了解决这个问题,在禁用上下文切换的间隔期间,现代CPU提供了某种LOCK,UNLOCK cpu指令。
在Win32上,使用InterlockedIncrement()为线程安全执行i ++。它比依靠互斥锁快得多。
如果要在多核环境中跨线程共享一个int,则需要适当的内存屏障。这可能意味着使用互锁指令(例如,请参见win32中的InterlockedIncrement),或使用做出某些线程安全保证的语言(或编译器)。对于CPU级指令重新排序和缓存以及其他问题,除非有这些保证,否则不要以为跨线程共享是安全的。
编辑:对于大多数体系结构,您可以假设的一件事是,如果要处理正确对齐的单个单词,则不会以包含两个被混在一起的值的组合的单个单词结尾。如果两次写操作相互重叠,则一次将获胜,而另一次将被丢弃。如果您小心一点,则可以利用此优势,并看到++ i或i ++在单写程序/多读程序的情况下是线程安全的。
如果要在C ++中实现原子增量,可以使用C ++ 0x库(std::atomic
数据类型)或类似TBB的东西。
曾经有一段时间,GNU编码准则说更新适合一个词的数据类型“通常是安全的”,但该建议对于SMP机器是错误的,对于某些体系结构是错误的,而在使用优化编译器时是错误的。
要澄清“更新单字数据类型”注释:
SMP计算机上的两个CPU可能在同一周期内写入同一内存位置,然后尝试将更改传播到其他CPU和缓存。即使只写入一个数据字,因此写入只需要一个周期即可完成,它们也会同时发生,因此您不能保证哪个写入成功。您不会得到部分更新的数据,但是一次写操作将消失,因为没有其他方法可以处理这种情况。
比较和交换可以在多个CPU之间正确地协调,但是没有理由相信一个字数据类型的每个变量分配都将使用比较和交换。
虽然一个优化编译器不会影响如何加载/存储编译,它可以改变时,加载/存储发生,如果你希望你的读取和写入在它们出现在源代码相同的顺序发生(,造成了严重的麻烦最著名的是经过双重检查的锁定在香草C ++中不起作用)。
注意 我最初的回答还说过,英特尔64位体系结构在处理64位数据时被破坏了。那是不对的,所以我编辑了答案,但是我的编辑声称PowerPC芯片坏了。 当将立即值(即常量)读入寄存器时,这是正确的(请参见清单2和清单4下名为“加载指针”的两个部分)。但是有一条指令可以在一个周期(lmw
)内从内存中加载数据,因此我删除了部分答案。
在C / C ++中的x86 / Windows上,您不应假定它是线程安全的。如果需要原子操作,则应使用InterlockedIncrement()和InterlockedDecrement()。
即使将其简化为单个汇编指令,直接在内存中增加该值,它仍然不是线程安全的。
当增加内存中的值时,硬件将执行“读取-修改-写入”操作:它从内存中读取值,将其递增,然后将其写回到内存中。x86硬件无法直接在内存上递增;RAM(和缓存)只能读取和存储值,而不能修改它们。
现在,假设您有两个单独的核心,要么位于单独的套接字上,要么共享单个套接字(带有或不带有共享缓存)。第一个处理器读取该值,然后在它可以写回更新的值之前,第二个处理器读取它。两个处理器回写该值之后,该值将仅增加一次,而不是两次。
有一种方法可以避免此问题。x86处理器(以及您将发现的大多数多核处理器)能够在硬件中检测到这种冲突并对其进行排序,从而使整个读取-修改-写入序列看起来都是原子的。但是,由于这样做非常昂贵,因此只有在代码请求时才通过x86在x86上通常通过LOCK
前缀来完成。其他架构可以通过其他方式执行此操作,效果相似。例如,负载链接/存储条件和原子比较和交换(最近的x86处理器也有最后一个)。
请注意,volatile
此处使用无济于事;它仅告诉编译器该变量可能已在外部进行了修改,并且对该变量的读取不得缓存在寄存器中或进行优化。它不会使编译器使用原子基元。
最好的方法是使用原子原语(如果您的编译器或库有原子原语),或直接在汇编中进行增量(使用正确的原子指令)。
永远不要假设增量会编译成原子操作。使用InterlockedIncrement或目标平台上存在的任何类似功能。
编辑:我只是查找了这个特定问题,在单处理器系统上X86上的增量是原子的,而在多处理器系统上则不是。使用锁前缀可以使其具有原子性,但是仅使用InterlockedIncrement更具可移植性。
根据x86 上的该汇编课,您可以在内存位置上自动添加寄存器,因此潜在地您的代码可以自动执行'++ i'或'i ++'。但是,正如另一篇文章所述,C ansi不会将原子性应用于'++'操作,因此您无法确定编译器将生成什么。
将我扔到线程本地存储中;它不是原子的,但是那么没关系。
AFAIK,根据C ++标准,对int
原子的读/写是原子的。
但是,所有这些操作都摆脱了与数据争用相关的不确定行为。
但是,如果两个线程都尝试增加,仍然会发生数据争用i
。
想象以下情况:
i = 0
首先让我们:
线程A从内存中读取值并将其存储在自己的缓存中。线程A将值增加1。
线程B从内存中读取值并将其存储在自己的缓存中。线程B将值增加1。
如果这仅是一个线程,那么您将进入i = 2
内存。
但是对于两个线程,每个线程都会写入其更改,因此线程A会写i = 1
回内存,而线程B会写到i = 1
内存。
它定义明确,没有部分破坏或构造,也没有任何形式的物体撕裂,但它仍然是一场数据竞赛。
为了原子地增加i
您可以使用:
std::atomic<int>::fetch_add(1, std::memory_order_relaxed)
可以使用宽松的排序,因为我们不在乎此操作在何处发生,我们只关心增量操作是原子操作。
对于计数器,我建议使用比较和交换惯用法,该惯用法既非锁定又是线程安全的。
在Java中:
public class IntCompareAndSwap {
private int value = 0;
public synchronized int get(){return value;}
public synchronized int compareAndSwap(int p_expectedValue, int p_newValue){
int oldValue = value;
if (oldValue == p_expectedValue)
value = p_newValue;
return oldValue;
}
}
public class IntCASCounter {
public IntCASCounter(){
m_value = new IntCompareAndSwap();
}
private IntCompareAndSwap m_value;
public int getValue(){return m_value.get();}
public void increment(){
int temp;
do {
temp = m_value.get();
} while (temp != m_value.compareAndSwap(temp, temp + 1));
}
public void decrement(){
int temp;
do {
temp = m_value.get();
} while (temp > 0 && temp != m_value.compareAndSwap(temp, temp - 1));
}
}