我听说i ++不是一个线程安全的语句,因为在汇编时它减少了将原始值存储为某个地方的temp,递增它,然后替换它,这可能被上下文切换中断。
但是,我想知道++ i。据我所知,这将减少为单个汇编指令,例如'add r1,r1,1',因为它只有一条指令,所以它不会被上下文切换中断。
任何人都可以澄清吗?我假设正在使用x86平台。
答案 0 :(得分:154)
你听错了。很可能"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中有多个CPU共享内存的架构中,这可能不是线程安全的(lock
可能只会禁用一个CPU的中断)。
语言本身(或者它的库,如果它没有内置到语言中)将提供线程安全的结构,你应该使用它们而不是依赖于你对将生成什么机器代码的理解(或可能是误解)。
诸如Java synchronized
和pthread_mutex_lock()
(在某些操作系统下可用于C / C ++)之类的内容是您需要查看(a)。
(a)在C11和C ++ 11标准完成之前询问了这个问题。这些迭代现在已经在语言规范中引入了线程支持,包括原子数据类型(尽管它们和一般的线程,可选,至少在C中)。
答案 1 :(得分:42)
你不能对++ i或i ++做一个全面的陈述。为什么?考虑在32位系统上递增64位整数。除非底层机器具有四字“load,increment,store”指令,否则递增该值将需要多个指令,其中任何指令都可以被线程上下文切换中断。
此外,++i
并非始终“在值中添加一个”。在像C这样的语言中,递增指针实际上会增加指向的东西的大小。也就是说,如果i
是指向32字节结构的指针,++i
会增加32个字节。几乎所有平台都有一个“内存地址递增值”指令,这是一个原子,并非所有平台都有一个原子“在内存地址处添加任意值”指令。
答案 2 :(得分:14)
它们都是线程不安全的。
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 ++。它比依赖互斥锁要快得多。
答案 3 :(得分:11)
如果您在多核环境中跨线程共享一个int,则需要适当的内存屏障。这可能意味着使用互锁指令(例如,请参阅win32中的InterlockedIncrement),或者使用可以提供某些线程安全保证的语言(或编译器)。使用CPU级别指令重新排序和缓存以及其他问题,除非您有这些保证,否则不要假设跨线程共享任何内容都是安全的。
编辑:对于大多数体系结构,您可以假设的一件事是,如果您正在处理正确对齐的单个单词,那么您最终不会得到包含两个被混合在一起的值的组合的单个单词。如果两个写入发生在彼此之上,则一个将获胜,另一个将被丢弃。如果你小心,你可以利用这一点,并看到++ i或i ++在单一编写器/多读卡器情况下是线程安全的。
答案 4 :(得分:8)
如果你想在C ++中使用原子增量,你可以使用C ++ 0x库(std::atomic
数据类型)或TBB之类的东西。
曾经有一段时间GNU编码指南说更新适合一个单词的数据类型“通常是安全的”但是对于SMP机器来说这个建议是错误的,某些架构的错误,和错误时使用优化编译器。
澄清“更新单字数据类型”评论:
SMP计算机上的两个CPU可以在同一周期中写入相同的内存位置,然后尝试将更改传播到其他CPU和缓存。即使只写入一个数据字,因此写入只需要一个周期完成,它们也会同时发生,因此您无法保证哪个写入成功。您不会获得部分更新的数据,但是一次写入将消失,因为没有其他方法可以处理这种情况。
比较并交换多个CPU之间的正确坐标,但没有理由相信单字数据类型的每个变量赋值都将使用比较和交换。
虽然优化编译器不会影响如何编译加载/存储,但是当加载/存储发生时它可以更改,如果您期望会导致严重的问题您的读取和写入的顺序与它们在源代码中出现的顺序相同(最着名的是双重检查锁定在vanilla C ++中不起作用)。
注意我的原始答案还说英特尔64位架构在处理64位数据时被打破。这不是真的,所以我编辑了答案,但我的编辑声称PowerPC芯片坏了。 That is true when reading immediate values (i.e., constants) into registers(参见清单2和清单4中名为“加载指针”的两个部分)。但是有一个指令用于在一个周期内加载来自内存的数据(lmw
),所以我删除了那部分答案。
答案 5 :(得分:4)
在C / C ++中的x86 / Windows上,你不应该认为它是线程安全的。如果需要原子操作,则应使用InterlockedIncrement()和InterlockedDecrement()。
答案 6 :(得分:3)
如果您的编程语言没有提及线程,但在多线程平台上运行,任何语言构造如何是线程安全的?
正如其他人所指出的那样:您需要通过特定于平台的调用来保护对变量的任何多线程访问。
有些库可以抽象出平台特异性,即将推出的C ++标准已经调整了它的内存模型以应对线程(从而可以保证线程安全)。
答案 7 :(得分:3)
即使它被简化为单个汇编指令,直接在内存中递增值,它仍然不是线程安全的。
当递增内存中的值时,硬件执行“读 - 修改 - 写”操作:它从内存中读取值,递增它,然后将其写回内存。 x86硬件无法直接在内存上递增; RAM(和缓存)只能读取和存储值,而不能修改它们。
现在假设您有两个独立的内核,可以在不同的套接字上,也可以共享一个套接字(有或没有共享缓存)。第一个处理器读取该值,在它可以回写更新的值之前,第二个处理器读取它。两个处理器都将值写回后,它只会增加一次,而不是两次。
有一种方法可以避免这个问题; x86处理器(以及你会发现的大多数多核处理器)能够在硬件中检测到这种冲突并对其进行排序,因此整个读 - 修改 - 写序列看起来是原子的。但是,由于这非常昂贵,因此只有在代码请求时才会执行,通常通过LOCK
前缀在x86上完成。其他架构可以通过其他方式实现此目的,结果相似;例如,load-linked / store-conditional和atomic compare-and-swap(最近的x86处理器也有最后一个)。
请注意,使用volatile
对此无效;它只告诉编译器该变量可能已在外部修改,并且读取该变量不得缓存在寄存器中或优化出来。它不会使编译器使用原子基元。
最好的方法是使用原子基元(如果你的编译器或库有它们),或直接在汇编中使用增量(使用正确的原子指令)。
答案 8 :(得分:2)
永远不要假设增量将编译为原子操作。使用InterlockedIncrement或目标平台上存在的任何类似功能。
编辑:我只是查找了这个特定的问题,X86上的增量在单处理器系统上是原子的,但在多处理器系统上却没有。使用锁定前缀可以使其成为原子,但只是使用InterlockedIncrement它更便携。
答案 9 :(得分:1)
1998 C ++标准对线程无话可说,尽管下一个标准(今年或下一年到期)确实如此。因此,如果不参考实现,就不能说明操作的线程安全性。它不仅仅是使用的处理器,而是编译器,操作系统和线程模型的组合。
如果没有相反的文档,我不会认为任何操作都是线程安全的,特别是对于多核处理器(或多处理器系统)。我也不相信测试,因为线程同步问题可能只是偶然出现。
除非您的文档说明它适用于您正在使用的特定系统,否则什么都不是线程安全的。
答案 10 :(得分:1)
根据x86上的assembly lesson,您可以以原子方式将寄存器添加到内存位置,因此您的代码可能会以原子方式执行'++ i'ou'i ++'。 但正如另一篇文章所述,C ansi并没有将原子性应用于'++'操作,因此您无法确定编译器将生成什么。
答案 11 :(得分:1)
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)
可以使用轻松的排序,因为我们不在乎此操作在哪里发生,我们只关心增量操作是原子操作。
答案 12 :(得分:0)
我认为如果表达式“i ++”是声明中唯一的,它等同于“++ i”,编译器足够聪明,不能保持时间值等等。所以如果你可以互换使用它们(否则你不会问哪一个使用),无论你使用哪个都没关系,因为它们几乎相同(除了美学)。
无论如何,即使增量运算符是原子的,如果你不使用正确的锁,也不能保证其余的计算是一致的。
如果你想自己试验,写一个程序,其中N个线程同时增加共享变量M次...如果该值小于N * M,则覆盖一些增量。尝试使用preincrement和postincrement并告诉我们; - )
答案 13 :(得分:0)
对于计数器,我建议使用比较和交换习惯用法,它既是非锁定又是线程安全的。
这是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));
}
}
答案 14 :(得分:0)
把我扔进线程本地存储;它不是原子的,但它并不重要。
答案 15 :(得分:-1)
你说“它只是一条指令,它不会被上下文切换中断。” - 对于单个CPU而言,这一切都很好,但是双核CPU呢?然后你可以让两个线程同时访问同一个变量而不需要任何上下文切换。
在不知道语言的情况下,答案就是测试它。