我听说i ++不是线程安全的,++ i是线程安全的吗?


90

我听说i ++不是线程安全的语句,因为在汇编中,i ++减少了将原始值存储为temp的某个地方,然后递增它,然后替换它,这可能会被上下文切换中断。

但是,我想知道++ i。据我所知,这将减少为单个汇编指令,例如“ add r1,r1、1”,并且由于它只是一条指令,因此上下文切换将不会中断该指令。

谁能澄清?我假设正在使用x86平台。


就一个问题。两个(或多个)线程访问这样的变量将需要哪种情况?我是在诚实地问这里,而不是批评。就在这个时候,我的脑子想不起来。
OscarRyz

5
C ++类中的类变量是否维护对象计数?
paxdiablo

1
关于我今天刚刚观看的
精彩

1
重新标记为C / C ++; 这里不考虑使用Java,C#是类似的,但是缺少这种严格定义的内存语义。
Tim Williscroft 09年

1
@Oscar Reyes假设您有两个都使用i变量的线程。如果一个线程仅在某个点时增加线程,而另一个仅在另一点时减少线程,则您必须担心线程安全。
萨摩斯,2009年

Answers:


157

你听错了。"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 synchronizedpthread_mutex_lock()(在某些操作系统下可用于C / C ++


(a)在C11和C ++ 11标准完成之前提出这个问题。这些迭代现在将线程支持引入了语言规范,包括原子数据类型(尽管它们和线程通常是可选的,至少在C语言中是可选的)。


8
+1强调这不是特定于平台的问题,更不用说明确的答案了……
RBerteig

2
祝贺您的C银徽章:)
Johannes Schaub-litb

我想你应该确切,没有现代操作系统授权用户模式程序把中断关闭,并pthread_mutex_lock()的不是C的一部分
巴斯蒂安莱昂纳尔·

@Bastien,没有内存增量指令的CPU不能运行现代操作系统:-)但是,您的意思是关于C的
。– paxdiablo

5
@巴斯蒂安:公牛。RISC处理器通常没有内存增加指令。加载/添加/存储三元组就是您在PowerPC上执行此操作的方式。
derobert

42

您无法对++ i或i ++作一番概括。为什么?考虑在32位系统上递增64位整数。除非底层机器具有四字“加载,递增,存储”指令,否则递增该值将需要多个指令,其中任何一条指令都可以被线程上下文切换中断。

此外,++i并不总是“为值加一”。在像C这样的语言中,增加指针实际上会增加所指向对象的大小。也就是说,如果i是指向32字节结构的指针,则++i添加32字节。尽管几乎所有平台都具有原子的“在内存地址处增加值”指令,但并非所有平台都具有原子的“在内存地址处向值添加任意值”指令。


35
当然,如果您不局限于无聊的32位整数,就可以使用C ++之类的语言,++ i实际上可以是对更新数据库中值的Web服务的调用。

16

它们都是线程不安全的。

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 ++。它比依靠互斥锁快得多。


6
“ CPU无法直接使用内存进行数学运算”-这是不正确的。有CPU,您可以在其中“直接”对存储元素进行数学运算,而无需先将其加载到寄存器中。例如。MC68000
darklon 2011年

1
LOCK和UNLOCK CPU指令与上下文切换无关。他们锁定高速缓存行。
David Schwartz

11

如果要在多核环境中跨线程共享一个int,则需要适当的内存屏障。这可能意味着使用互锁指令(例如,请参见win32中的InterlockedIncrement),或使用做出某些线程安全保证的语言(或编译器)。对于CPU级指令重新排序和缓存以及其他问题,除非有这些保证,否则不要以为跨线程共享是安全的。

编辑:对于大多数体系结构,您可以假设的一件事是,如果要处理正确对齐的单个单词,则不会以包含两个被混在一起的值的组合的单个单词结尾。如果两次写操作相互重叠,则一次将获胜,而另一次将被丢弃。如果您小心一点,则可以利用此优势,并看到++ i或i ++在单写程序/多读程序的情况下是线程安全的。


在int访问(读/写)是原子的环境中,实际上是错误的。即使缺少内存障碍,也可能有一些算法可以在这种环境下工作,这可能意味着您有时正在处理过时的数据。
MSalters

2
我只是说原子性不能保证线程安全性。如果您足够聪明,可以设计无锁数据结构或算法,请继续。但是您仍然需要知道编译器将为您提供的保证。

10

如果要在C ++中实现原子增量,可以使用C ++ 0x库(std::atomic数据类型)或类似TBB的东西。

曾经有一段时间,GNU编码准则说更新适合一个词的数据类型“通常是安全的”,但该建议对于SMP机器是错误的,对于某些体系结构是错误的而在使用优化编译器时是错误的。


要澄清“更新单字数据类型”注释:

SMP计算机上的两个CPU可能在同一周期内写入同一内​​存位置,然后尝试将更改传播到其他CPU和缓存。即使只写入一个数据字,因此写入只需要一个周期即可完成,它们也会同时发生,因此您不能保证哪个写入成功。您不会得到部分更新的数据,但是一次写操作将消失,因为没有其他方法可以处理这种情况。

比较和交换可以在多个CPU之间正确地协调,但是没有理由相信一个字数据类型的每个变量分配都将使用比较和交换。

虽然一个优化编译器不会影响如何加载/存储编译,它可以改变时,加载/存储发生,如果你希望你的读取和写入在它们出现在源代码相同的顺序发生(,造成了严重的麻烦最著名的是经过双重检查的锁定在香草C ++中不起作用)。

注意 我最初的回答还说过,英特尔64位体系结构在处理64位数据时被破坏了。那是不对的,所以我编辑了答案,但是我的编辑声称PowerPC芯片坏了。 当将立即值(即常量)读入寄存器时,这是正确的(请参见清单2和清单4下名为“加载指针”的两个部分)。但是有一条指令可以在一个周期(lmw)内从内存中加载数据,因此我删除了部分答案。


如果您的数据自然对齐且大小正确(即使使用SMP和优化编译器),则在大多数现代CPU上读写都是原子的。但是,有很多警告,尤其是对于64位计算机,因此确保您的数据满足每台计算机上的要求可能很麻烦。
丹·奥尔森,2009年

感谢您的更新。正确,读写是原子的,因为您说它们不能半途而废,但是您的评论强调了我们在实践中如何处理这一事实。与内存屏障相同,它们不会影响操作的原子性质,但会影响我们在实践中的处理方式。
丹·奥尔森,2009年


4

如果您的编程语言对线程一无所知,但却在多线程平台上运行,那么任何一种语言构造如何都能保证线程安全?

正如其他人指出的那样:您需要通过平台特定的调用来保护对变量的任何多线程访问。

那里有一些库可以抽象出平台的特定性,而即将到来的C ++标准已经对其内存模型进行了修改以适应线程(因此可以保证线程安全)。


4

即使将其简化为单个汇编指令,直接在内存中增加该值,它仍然不是线程安全的。

当增加内存中的值时,硬件将执行“读取-修改-写入”操作:它从内存中读取值,将其递增,然后将其写回到内存中。x86硬件无法直接在内存上递增;RAM(和缓存)只能读取和存储值,而不能修改它们。

现在,假设您有两个单独的核心,要么位于单独的套接字上,要么共享单个套接字(带有或不带有共享缓存)。第一个处理器读取该值,然后在它可以写回更新的值之前,第二个处理器读取它。两个处理器回写该值之后,该值将仅增加一次,而不是两次。

有一种方法可以避免此问题。x86处理器(以及您将发现的大多数多核处理器)能够在硬件中检测到这种冲突并对其进行排序,从而使整个读取-修改-写入序列看起来都是原子的。但是,由于这样做非常昂贵,因此只有在代码请求时才通过x86在x86上通常通过LOCK前缀来完成。其他架构可以通过其他方式执行此操作,效果相似。例如,负载链接/存储条件和原子比较和交换(最近的x86处理器也有最后一个)。

请注意,volatile此处使用无济于事;它仅告诉编译器该变量可能已在外部进行了修改,并且对该变量的读取不得缓存在寄存器中或进行优化。它不会使编译器使用原子基元。

最好的方法是使用原子原语(如果您的编译器或库有原子原语),或直接在汇编中进行增量(使用正确的原子指令)。


2

永远不要假设增量会编译成原子操作。使用InterlockedIncrement或目标平台上存在的任何类似功能。

编辑:我只是查找了这个特定问题,在单处理器系统上X86上的增量是原子的,而在多处理器系统上则不是。使用锁前缀可以使其具有原子性,但是仅使用InterlockedIncrement更具可移植性。


1
InterlockedIncrement()是Windows函数;我所有的Linux机器和现代OS X机器都基于x64,因此说InterlockedIncrement()比x86代码“更具可移植性”是很虚假的。
皮特·柯坎

与C比组装比C的可移植性更高,从某种意义上讲,它具有更大的可移植性。这里的目标是使您自己避免依赖特定处理器生成的特定程序集。如果您需要其他操作系统,则InterlockedIncrement很容易包装。
丹·奥尔森,2009年

2

根据x86 上的该汇编课,您可以在内存位置上自动添加寄存器,因此潜在地您的代码可以自动执行'++ i'或'i ++'。但是,正如另一篇文章所述,C ansi不会将原子性应用于'++'操作,因此您无法确定编译器将生成什么。


1

1998年的C ++标准没有关于线程的任何意见,尽管下一个标准(今年或下一年)可以做到。因此,在不参考实现的情况下,您不能说出任何关于操作的线程安全的知识。不仅是所使用的处理器,还包括编译器,操作系统和线程模型的组合。

在没有相反的文档的情况下,我不会假设任何操作都是线程安全的,尤其是对于多核处理器(或多处理器系统)而言。我也不信任测试,因为线程同步问题很可能只是偶然发生的。

除非您有说明适用于您所使用的特定系统的文档,否则没有什么是线程安全的。


1

将我扔到线程本地存储中;它不是原子的,但是那么没关系。


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)

可以使用宽松的排序,因为我们不在乎此操作在何处发生,我们只关心增量操作是原子操作。


0

您说“这只是一条指令,上下文切换不会中断它”。-对于单个CPU来说一切都很好,但是对于双核CPU呢?这样,您实际上可以让两个线程同时访问同一变量,而无需任何上下文切换。

在不知道语言的情况下,答案是测试语言。


4
您无法通过测试来确定某事物是否是线程安全的-线程问题可能是百万分之一。您可以在文档中查找它。如果您的文档不能保证它是线程安全的,则不能。

2
在这里同意@Josh。如果可以通过对基础代码的分析进行数学证明,则只有线程安全。没有大量的测试可以开始接近这一点。
Rex M

直到最后一句话,这是一个很好的答案。
罗布K 2009年

0

我认为,如果表达式“ i ++”是语句中唯一的表达式,则它等效于“ ++ i”,编译器足够聪明,不会保留时间值,依此类推。因此,如果可以互换使用它们(否则,您会获胜。不必问要使用哪一个),无论您使用哪个都无所谓,因为它们几乎相同(美学除外)。

无论如何,即使增量运算符是原子运算符,也不能保证如果不使用正确的锁,则其余计算将保持一致。

如果您想自己进行实验,请编写一个程序,其中N个线程同时增加一个共享变量,每个线程M倍……如果该值小于N * M,则某些增量被覆盖。尝试增加前和增加后,然后告诉我们;-)


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));

    }
}

似乎类似于test_and_set函数。
samoz

1
您写了“非锁定”,但是“同步”不是锁定吗?
Corey Trager
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.