整数读取需要进行临界区保护吗?

10

我遇到一些采用如下形式的C++03代码:

struct Foo {
    int a;
    int b;
    CRITICAL_SECTION cs;
}

// DoFoo::Foo foo_;

void DoFoo::Foolish()
{
    if( foo_.a == 4 )
    {
        PerformSomeTask();

        EnterCriticalSection(&foo_.cs);
        foo_.b = 7;
        LeaveCriticalSection(&foo_.cs);
    }
}

foo_.a的读取需要保护吗?例如:

void DoFoo::Foolish()
{
    EnterCriticalSection(&foo_.cs);
    int a = foo_.a;
    LeaveCriticalSection(&foo_.cs);

    if( a == 4 )
    {
        PerformSomeTask();

        EnterCriticalSection(&foo_.cs);
        foo_.b = 7;
        LeaveCriticalSection(&foo_.cs);
    }
}

如果是这样,为什么?

请假定整数是32位对齐的。平台是ARM。


3
注意,C++11规定任何涉及写操作的竞态条件都属于未定义行为。因此,如果您在另一个线程中写入foo_.a,那么是未定义行为。(§1.10/4和§1.10/21) C++03对并发没有任何规定。 - Mysticial
如果可以的话,请使用std::atomic,不必担心它。 - GManNickG
@AdrianMcCarthy:不幸的是,没有 InterlockedRead。据我所知,您可以使用 InterlockedCompareExchange(v, 0, 0),但会稍微降低性能,或者使用编译器的假设和扩展(如 _MemoryBarrier),编写自己的 InterlockedRead(并确保该函数的合同假定提供的整数已正确对齐,并确保类型使得 Windows 平台可以对该整数进行原子读取)。 - GManNickG
@PaulH:你的方法应该是模拟std::atomic在你的用例中的行为。 - GManNickG
1
可能是Reading interlocked variables的重复问题。 - Adrian McCarthy
显示剩余2条评论
5个回答

11
从技术上讲,是可以的,但在许多平台上不行。首先,让我们假设 int 是32位的(这是相当普遍的,但远非普及)。
如果 int 没有对齐,那么可能会分别读取或写入32位 int 的两个字(16位部分)。在某些系统上,它们将被分别读取。
想象一下,一个只能进行32位对齐的32位读写和16位对齐的16位读写的系统,以及跨越这种边界的 int。最初,int 是零(即 0x00000000)。
一个线程向 int 写入 0xBAADF00D,另一个 "同时" 读取它。
写入线程首先向 int 的高位写入 0xBAAD。然后读取线程读取整个 int(高位和低位),获得 0xBAAD0000——这是一个本来不应该出现的状态!
然后写入线程写入低字 0xF00D
如前所述,在某些平台上,所有的32位读/写都是原子性的,因此这不是一个问题。但还存在其他问题。
大多数锁定/解锁代码包括指令,以防止在锁定范围内重新排序。如果没有这种重新排序的预防措施,编译器可以随意重新排序,只要它在单线程上下文中表现得 "好像" 是那样工作的。因此,如果你在代码中读取 a 然后读取 b,编译器可以在读取 a 之前读取 b,只要它在该时间段内没有看到对 b 进行修改的机会。

可能你正在阅读的代码使用这些锁来确保变量的读取按照代码中编写的顺序进行。

下面的评论提出了其他问题,但我感觉自己没有能力解决: 缓存问题和可见性问题。


3
你忘了提到缓存效应。 - user405725
在所有当前主要的CPU架构(包括ARM)上,自然对齐的int和指针的读写是原子性的。请注意,由于编译器和CPU排序问题,仍需要在写入时使用临界区。 - Cameron
2
问题在于,如果将变量提升为寄存器,它就会崩溃。此外,现代处理器的松散内存模型可能会出现奇怪的问题,如果您不通过原子或关键部分强制执行内存屏障。 - Mysticial
@dmaij:这真的是个观点问题吗?除非在这种情况下正确性的定义是“我不需要这个准确或者明确定义”(有时可能是这样,但绝大多数情况下不是,也不适用于OP),否则你要么写出了能工作的代码,要么没有。 - GManNickG
3
仅仅具有原子性是不够的。你还需要关注可见性和编译器重新排序。 - Pete Becker
显示剩余7条评论

3

看起来,根据这个链接的内容,ARM有相当宽松的内存模型,因此需要一种形式的内存屏障来确保一个线程中的写操作在另一个线程中被正确地读取。所以,您需要在您的平台上使用std::atomic或者采取其他措施。如果不考虑这一点,就可能会导致不同线程中的更新顺序错乱,从而破坏您的示例。


2

我认为您可以使用C++11来确保整数读取是原子性的,例如使用std::atomic<int>


2
C++标准规定,如果一个线程在另一个线程读取该变量的同时写入该变量,或者两个线程同时写入同一变量,则存在数据竞争。进一步地,它指出数据竞争会产生未定义的行为。因此,正式地说,您必须同步这些读写操作。
当一个线程读取另一个线程写入的数据时,有三个单独的问题。首先是撕裂:如果写入需要多个总线周期,那么在操作中途可能会发生线程切换,并且另一个线程可能会看到半写入的值;如果读取需要多个总线周期,则存在类似的问题。其次是可见性:每个处理器都有自己的本地数据副本,它最近正在处理的数据,并且对一个处理器的缓存的写入不一定会更新另一个处理器的缓存。第三,编译器优化会以在单个线程中可以接受的方式重新排序读取和写入,但会破坏多线程代码。线程安全的代码必须解决所有三个问题。这就是同步原语(互斥锁、条件变量和原子操作)的工作。

0

尽管整数的读写操作确实很可能是原子的,但如果不正确处理,编译器优化和处理器缓存仍然会给您带来问题。

简单来说,编译器通常会假定代码是单线程的,并进行许多依赖于此的优化。例如,它可能会更改指令的顺序。或者,如果它看到变量只被写入而从未被读取,它可能会完全优化掉它。

CPU也会缓存该整数,因此如果一个线程写入它,另一个线程可能要等很久才能看到它。

有两件事情可以做。一种方法是像原始代码中那样将其包装在关键部分中。另一种方法是将变量标记为volatile。这将向编译器发出信号,表明该变量将被多个线程访问,并禁用一系列优化,以及在对变量进行访问时放置特殊的缓存同步指令(也称为“内存屏障”)(或者我理解的是这样)。显然这是错误的。

添加: 此外,正如另一个答案所指出的那样,Windows具有可用于避免非volatile变量的这些问题的Interlocked API。


根据许多人的说法,将变量标记为“volatile”并不是真正用于可能在另一个线程上被修改的变量,尽管Microsoft对volatile的文档表明它是这样的,并且该问题被标记为WinAPI。 - Adrian McCarthy
@AdrianMcCarthy - 嗯,我不是专家。 :) 我只知道这是我能想到的volatile的唯一用途。 - Vilx-
1
很抱歉,但我们不需要再传播“volatile”与多线程有任何关系的谎言了。如果是这样的话,C++11就不会添加“std::atomic”了。放弃你认为volatile对于多线程有帮助的任何想法。(免责声明:一些编译器,特别是MSVC,现在已经废弃了volatile的扩展,这使得我之前基于语言的说法是错误的;这被认为是事后的错误。) - GManNickG
3
这句话错误的更具体表述是:“这将向编译器发出信号,表明该变量将被多个线程访问”。它仅仅表示“对该变量进行读写是可观察行为(一个技术术语),因此确保你这样做”。它并未涉及顺序、原子性或其他你需要正确处理多线程代码所需的任何保证。 - GManNickG
@GManNickG - 好的,谢谢你纠正我。你能给我指出任何文章(或者自己解释)为什么是错误的,然后volatile到底是做什么的吗? - Vilx-
@Vilx-: 没问题。一个好的开始在这里(http://software.intel.com/en-us/blogs/2007/11/30/volatile-almost-useless-for-multi-threaded-programming/),还有一份PDF文件(我就不贴链接了),名字是“C++和双重检查锁定的危害”。 - GManNickG

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接