[英]I've heard i++ isn't thread safe, is ++i thread-safe?
我听说 i++ 不是线程安全的语句,因为在汇编中它减少了将原始值存储为某个地方的临时值,递增它,然后替换它,这可能会被上下文切换中断。
但是,我想知道 ++i。 据我所知,这将减少为单个汇编指令,例如“add r1, r1, 1”,并且由于它只是一个指令,因此它不会被上下文切换中断。
谁能澄清一下? 我假设正在使用 x86 平台。
你听错了。 很可能"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 共享 memory 的架构中,这可能不是线程安全的( lock
可能只禁用一个 CPU 的中断)。
语言本身(或者它的库,如果它没有内置到语言中)将提供线程安全的结构,你应该使用它们而不是依赖于你对将生成什么机器代码的理解(或者可能是误解)。
诸如 Java synchronized
和pthread_mutex_lock()
(某些操作系统下的 C/C++ 可用)之类的东西是您需要研究的 (a) 。
(a)这个问题是在C11和C++11标准完成之前问的。 这些迭代现在已经在语言规范中引入了线程支持,包括原子数据类型(尽管它们和一般的线程是可选的,至少在 C 语言中是这样)。
您不能对 ++i 或 i++ 做出笼统的陈述。 为什么? 考虑在 32 位系统上递增 64 位 integer。 除非底层机器有一个四字“加载、递增、存储”指令,否则递增该值将需要多条指令,其中任何一条指令都可以被线程上下文切换中断。
此外, ++i
并不总是“将值加一”。 在像 C 这样的语言中,递增指针实际上会增加所指向事物的大小。 也就是说,如果i
是一个指向 32 字节结构的指针,则++i
添加 32 个字节。 尽管几乎所有平台都有原子性的“memory 地址处的增量值”指令,但并非所有平台都有原子性的“将任意值添加到 memory 地址处的值”指令。
它们都是线程不安全的。
CPU 不能直接对 memory 进行数学运算。它通过从 memory 加载值并使用 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
++我
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 没有变成 i+2,而是变成了 i+1,这是不正确的。
为了解决这个问题,现代 CPU 在上下文切换被禁用的时间间隔内提供某种 LOCK、UNLOCK cpu 指令。
在 Win32 上,使用 InterlockedIncrement() 执行 i++ 以确保线程安全。 它比依赖互斥锁快得多。
如果您在多核环境中跨线程共享一个 int,则需要适当的 memory 屏障。 这可能意味着使用互锁指令(例如,请参阅 win32 中的 InterlockedIncrement),或使用可提供某些线程安全保证的语言(或编译器)。 对于 CPU 级指令重新排序和缓存以及其他问题,除非您有这些保证,否则不要假设跨线程共享的任何内容都是安全的。
编辑:对于大多数架构,您可以假设的一件事是,如果您处理的是正确对齐的单个单词,那么您最终不会得到一个包含两个值的组合的单词,这些值被混合在一起。 如果两次写入发生在彼此之上,一个将获胜,另一个将被丢弃。 如果你小心,你可以利用这一点,并看到 ++i 或 i++ 在单写入器/多读取器情况下是线程安全的。
如果你想要 C++ 中的原子增量,你可以使用 C++0x 库( std::atomic
数据类型)或类似 TBB 的东西。
曾经有一段时间,GNU 编码指南说更新适合一个词的数据类型“通常是安全的”,但该建议对于 SMP 机器是错误的,
对于某些体系结构是错误的,
并且在使用优化编译器时也是错误的。
澄清“更新单字数据类型”注释:
SMP 机器上的两个 CPU 可能会在同一周期内写入相同的 memory 位置,然后尝试将更改传播到其他 CPU 和缓存。 即使只有一个字的数据被写入,因此写入只需要一个周期即可完成,它们也会同时发生,因此您无法保证哪个写入成功。 您不会获得部分更新的数据,但一次写入将消失,因为没有其他方法可以处理这种情况。
比较和交换在多个 CPU 之间适当协调,但没有理由相信单字数据类型的每个变量赋值都会使用比较和交换。
虽然优化编译器不会影响加载/存储的编译方式,但它可能会在加载/存储发生时发生变化,如果您希望读取和写入按照它们在源代码中出现的相同顺序发生,则会造成严重的麻烦(最著名的是双重检查锁定在普通 C++ 中不起作用)。
注意我原来的回答还说英特尔 64 位架构在处理 64 位数据时被破坏了。 那不是真的,所以我编辑了答案,但我的编辑声称 PowerPC 芯片坏了。 将立即值(即常量)读入寄存器时也是如此(参见清单 2 和清单 4 下名为“加载指针”的两节)。 但是有一条在一个周期 ( lmw
) 中从 memory 加载数据的说明,所以我删除了那部分答案。
即使缩减为单条汇编指令,直接在memory中自增,依然不是线程安全的。
当memory的值递增时,硬件会做一个“读-修改-写”的操作:从memory读取值,递增,写回memory。x86硬件没有办法直接在memory递增; RAM(和缓存)只能读取和存储值,不能修改它们。
现在假设您有两个独立的核心,或者在单独的 sockets 上,或者共享一个套接字(有或没有共享缓存)。 第一个处理器读取该值,在它可以写回更新的值之前,第二个处理器读取它。 在两个处理器写回值后,它只会增加一次,而不是两次。
有一种方法可以避免这个问题; x86 处理器(以及您会发现的大多数多核处理器)能够在硬件中检测到这种冲突并对其进行排序,因此整个读取-修改-写入序列看起来是原子的。 然而,由于这是非常昂贵的,所以只有在代码请求时才会这样做,在 x86 上通常通过LOCK
前缀。 其他架构可以通过其他方式做到这一点,并得到类似的结果; 例如,加载链接/存储条件和原子比较和交换(最近的 x86 处理器也有最后一个)。
请注意,使用volatile
在这里没有帮助; 它只告诉编译器该变量可能已被外部修改,并且对该变量的读取不得缓存在寄存器中或进行优化。 它不会使编译器使用原子原语。
最好的方法是使用原子原语(如果您的编译器或库有它们),或者直接在汇编中执行增量(使用正确的原子指令)。
在使用 C/C++ 的 x86/Windows 上,您不应假定它是线程安全的。 如果需要原子操作,则应使用InterlockedIncrement()和InterlockedDecrement() 。
如果您的编程语言对线程只字未提,却在多线程平台上运行,那么任何语言构造怎么可能是线程安全的呢?
正如其他人指出的那样:您需要通过特定于平台的调用来保护对变量的任何多线程访问。
有一些库可以抽象掉平台的特殊性,即将到来的 C++ 标准已经调整了它的 memory model 来处理线程(因此可以保证线程安全)。
根据 x86 上的汇编课程,您可以自动将寄存器添加到 memory 位置,因此您的代码可能会自动执行 '++i' 或 'i++'。 但正如在另一篇文章中所说,C ansi 不会将原子性应用于“++”操作,因此您无法确定您的编译器将生成什么。
永远不要假设增量将编译为原子操作。 使用 InterlockedIncrement 或目标平台上存在的任何类似函数。
编辑:我刚刚查看了这个特定问题,X86 的增量在单处理器系统上是原子的,但在多处理器系统上不是。 使用 lock 前缀可以使其成为原子的,但仅使用 InterlockedIncrement 就可移植得多。
将 i 放入线程本地存储; 它不是原子的,但是没关系。
AFAIK,根据 C++ 标准,对int
的读/写是原子的。
但是,这所做的只是摆脱与数据竞争相关的未定义行为。
但是如果两个线程都试图增加i
,仍然会出现数据竞争。
想象一下以下场景:
让i = 0
初始:
线程 A 从 memory 中读取值并存储在自己的缓存中。 线程 A 将值递增 1。
线程 B 从 memory 中读取值并存储在自己的缓存中。 线程 B 将值递增 1。
如果这都是单线程,您将在 memory 中得到i = 2
。
但是对于两个线程,每个线程都会写入它的更改,因此线程 A 将i = 1
写回 memory,而线程 B 将i = 1
写回 memory。
它定义明确,没有部分破坏或构造或任何类型的 object 撕裂,但它仍然是一场数据竞赛。
为了自动递增i
你可以使用:
std::atomic<int>::fetch_add(1, std::memory_order_relaxed)
可以使用宽松的顺序,因为我们不关心这个操作发生在哪里,我们只关心增量操作是原子的。
1998 年的 C++ 标准没有提到线程,尽管下一个标准(今年或明年到期)有。 因此,如果不参考实现,就不能说任何关于操作的线程安全性的智能信息。 不仅仅是正在使用的处理器,而是编译器、操作系统和线程 model 的组合。
在没有相反文档的情况下,我不会假设任何操作都是线程安全的,尤其是对于多核处理器(或多处理器系统)。 我也不相信测试,因为线程同步问题很可能只是偶然出现的。
除非您有说明它适用于您正在使用的特定系统的文档,否则没有什么是线程安全的。
你说“这只是一条指令,它不会被上下文切换中断。” - 这对单 CPU 来说很好,但是双核 CPU 呢? 然后你真的可以让两个线程同时访问同一个变量而无需任何上下文切换。
在不知道该语言的情况下,答案是对其进行测试。
我认为如果表达式“i++”在一条语句中是唯一的,它等同于“++i”,编译器足够聪明,不会保留时间值等。所以如果你可以互换使用它们(否则你赢了不要问使用哪个),无论您使用哪个都没有关系,因为它们几乎相同(美学除外)。
无论如何,即使增量运算符是原子的,如果您不使用正确的锁,也不能保证计算的 rest 是一致的。
如果您想自己试验,请编写一个程序,其中 N 个线程同时递增一个共享变量 M 次,每个...如果该值小于 N*M,则某些递增被覆盖。 试试预增量和后增量,然后告诉我们;-)
对于计数器,我建议使用非锁定和线程安全的比较和交换习惯用法。
这是在 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));
}
}
声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.