如何声明和使用"one writer, many readers, one process, simple type"变量?

how to declare and use "one writer, many readers, one process, simple type" variable?

本文关键字:one process readers many simple type 变量 何声明 writer 声明      更新时间:2023-10-16

我有一个非常简单的问题。我有一个简单的类型变量(比如int)。我有一个进程,一个编写器线程,几个"只读"线程。我应该如何声明变量?

  • volatile int
  • std::atomic<int>
  • int

我希望当"编写器"线程修改值时,所有"读取器"线程都应该尽快看到新的值。

同时读取和写入变量是可以的,但我希望读者获得旧值或新值,而不是一些"中间"值。

我使用的是单CPU Xeon E5 v3机器。我不需要是可移植的,我只在这个服务器上运行代码,我用-march=native -mtune=native编译。性能非常重要,所以除非绝对需要,否则我不想添加"同步开销"。


如果我只使用int,并且一个线程写入值,那么在另一个线程中,我有可能在一段时间内看不到"新鲜"值吗?

只需使用std::atomic

不要使用volatile,也不要按原样使用;这并不能提供必要的同步。在一个线程中修改它并在不同步的情况下从另一个线程访问它将产生未定义的行为。

如果您对一个变量的访问不同步,并且您有一个或多个写入程序,那么您的程序具有未定义的行为。一些你必须保证在写的时候不会发生其他的写或读。这称为同步。如何实现此同步取决于应用程序。

对于这样的情况,我们有一个作者和几个读者,并且使用TriviallyCopyable数据类型,那么std::atomic<>就可以了。原子变量将确保只有一个线程可以同时访问该变量。

如果您没有TriviallyCopyable类型或不想使用std::atomic,您也可以使用传统的std::mutexstd::lock_guard来控制访问

{ // enter locking scope
    std::lock_guard lock(mutx); // create lock guard which locks the mutex
    some_variable = some_value; // do work
} // end scope lock is destroyed and mutx is released

使用这种方法需要记住的一件重要事情是,您希望// do work段尽可能短,因为在互斥锁被锁定时,没有其他线程可以进入该段。

另一种选择是使用std::shared_timed_mutex(C++14)或std::shared_mutex(C++17),这将允许多个读取器共享互斥体,但当您需要写入时,您仍然可以查看互斥体并写入数据。

您不想使用volatile来控制同步,因为在这个答案中是jalf状态:

对于线程安全访问共享数据,我们需要保证:

  • 读/写实际上发生了(编译器不会只是将值存储在寄存器中,并将更新主内存推迟到很久以后)
  • 不会发生重新排序。假设我们使用volatile变量作为标志来指示某些数据是否准备好阅读在我们的代码中,我们只需在准备好数据后设置标志,因此一切看起来都很好。但是,如果指令被重新排序,那么标志是先设置的吗

volatile确实保证了第一点。它还保证在不同的CCD_ 18读/写之间发生重新排序。全部的volatile内存访问将按照指定。这就是volatile的全部用途:操作I/O寄存器或内存映射硬件,但它不会在volatile对象经常所在的多线程代码中帮助我们仅用于同步对非易失性数据的访问。这些访问仍然可以相对于CCD_ 22进行重新排序。

和往常一样,如果你测量了性能,但性能不足,那么你可以尝试不同的解决方案,但一定要在更改后重新测量和比较。

最后,Herb Sutter在C++和Beyond 2012上做了一个出色的演讲,名为原子武器:

这是一个由两部分组成的演讲,涵盖C++内存模型,锁、原子和围栏如何交互和映射到硬件,等等。尽管我们谈论的是C++,但这在很大程度上也适用于Java和.NET,它们具有类似的内存模型,但并不是C++的所有功能(如宽松原子)。

我将完成前面的一些答案。

如前所述,由于各种原因(即使有英特尔处理器的内存顺序限制),仅仅使用int或最终的volatile int是不够的

所以,是的,你应该使用原子类型,但你需要额外的考虑:原子类型保证一致访问,但如果你有可见性问题,你需要指定内存屏障(内存顺序)

屏障将强制线程之间的可见性和一致性,在英特尔和大多数现代体系结构上,它将强制缓存同步,以便每个内核都可以看到更新。问题是,如果你不够小心,它可能会很贵。

可能的内存顺序为:

  • 放松:没有特殊的屏障,只执行连贯的读/写
  • 连续性一致性:最强可能约束(默认)
  • 获取:强制当前加载之后的任何加载之前都不会重新排序,并添加所需的屏障以确保已发布的存储可见
  • consump:获取的简化版本,主要只约束重新排序
  • release:强制之前的所有存储都在当前存储之前完成,并且内存写入已经完成,并且对执行获取屏障的负载可见

因此,如果你想确保变量的更新对读者可见,你需要用一个(至少)发布内存顺序来标记你的存储,而在读者端,你需要一个获取内存顺序(至少也是如此)。否则,读者可能看不到整数的实际版本(它至少会看到一个连贯的版本,即旧版本或新版本,但不会看到两者的丑陋混合。)

当然,默认行为(完全一致性)也会为您提供正确的行为,但代价是大量的同步。简言之,每次添加屏障时,它都会强制缓存同步,这几乎与几次缓存未命中(从而在主内存中读取/写入)一样昂贵

因此,简而言之,您应该将int声明为原子,并使用以下代码进行存储和加载:

// Your variable
std::atomic<int> v;
// Read
x = v.load(std::memory_order_acquire);
// Write
v.store(x, std::memory_order_release);

只是为了完成,有时(通常是你认为的)你并不真正需要顺序一致性(甚至是部分发布/获取一致性),因为更新的可见性是相对的。在处理并发操作时,更新不是在执行写入时发生的,而是在其他人看到更改时发生的。读取旧值可能不是问题

我强烈建议阅读与相对论编程和RCU相关的文章,这里有一些有趣的链接:

  • 相对论编程wiki:http://wiki.cs.pdx.edu/rp/
  • 结构化延迟:通过延迟进行同步:https://queue.acm.org/detail.cfm?id=2488549
  • RCU概念简介:http://www.rdrop.com/~paulmck/RCU/RCU.LinuxCon.2013.10.22a.pdf

让我们从int的int开始。一般来说,当在单处理器、单核机器上使用时,假设int大小等于或小于CPU字(比如32位CPU上的32位int),这就足够了。在这种情况下,假设地址字地址正确对齐(高级语言默认情况下应该确保这一点),写/读操作应该是原子操作。如[1]所述,这是由英特尔保证的。然而,在C++规范中,从不同线程同时读取和写入是未定义的行为。

1.10美元/年

6如果两个表达式求值中的一个修改存储器位置(1.7),而另一个访问或修改相同的存储器位置,则两个表达式赋值冲突。

现在是volatile。此关键字几乎禁用所有优化。这就是它被使用的原因。例如,有时当优化编译器时,您只在一个线程中读取的变量是常量,只需将其替换为初始值即可。这就解决了这些问题。但是,它不允许访问变量atomic。此外,在大多数情况下,这是完全没有必要的,因为使用适当的多线程工具,如互斥或内存屏障,将单独实现与volatile相同的效果,例如在[2]中所述

虽然这对于大多数用途来说可能已经足够了,但还有其他操作不能保证是原子操作。就像增量是一个。这是std::atomic出现的时候。它定义了这些操作,就像[3]中提到的增量一样。当从不同的线程进行读写时,它也得到了很好的定义[4]。

此外,正如[5]中的回答所述,还有许多其他因素可能会对操作的原子性产生(负面)影响。从失去多个内核之间的缓存一致性到一些硬件细节,都是可能改变操作执行方式的因素。

总之,创建std::atomic是为了支持来自不同线程的访问,强烈建议在多线程时使用它。

[1]http://www.intel.com/Assets/PDF/manual/253668.pdf参见第8.1.1节。

[2]https://www.kernel.org/doc/Documentation/volatile-considered-harmful.txt

[3]http://en.cppreference.com/w/cpp/atomic/atomic/operator_arith

[4]http://en.cppreference.com/w/cpp/atomic/atomic

[5] C++是对int原子的读取和写入吗?

当可移植性很重要时,其他答案是正确的,即使用atomic而不是volatile。如果你问这个问题,这是一个很好的问题,这对你来说是一个实际的答案,而不是,"但是,如果标准库不提供,你可以自己实现一个无锁、无等待的数据结构!"然而,如果标准图书馆不提供,只要只有一个编写器,您就可以自己实现一个在特定编译器和特定体系结构上工作的无锁数据结构。(另外,必须有人在标准库中实现那些原子基元。)如果我错了,我相信有人会善意地通知我。

如果你绝对需要一个保证在所有平台上都是无锁的算法,你可以用atomic_flag构建一个。如果这还不够,并且您需要滚动自己的数据结构,那么您可以这样做。

由于只有一个编写器线程,所以即使您只是使用普通访问而不是锁,甚至是比较和交换,您的CPU也可以保证对数据的某些操作仍能以原子方式工作。根据语言标准,这是不安全的,因为C++必须在不安全的体系结构上工作,但如果您保证要更新的变量适合一个不与其他任何东西共享的缓存行,并且您可能能够通过__attribute__ (( aligned (x) ))等非标准扩展来确保这一点,则它是安全的,例如,在x86 CPU上的

类似地,你的编译器可能会提供一些保证:g++特别保证编译器不会假设volatile*引用的内存没有改变,除非当前线程已经改变了它。实际上,每次你取消引用变量时,它都会从内存中重新读取它。这决不足以确保线程安全,但是如果另一个线程正在更新该变量,那么它会很方便。

现实世界中的一个例子可能是:编写器线程维护某种指针(在其自己的缓存线上),该指针指向数据结构的一致视图,该视图将在未来的所有更新中保持有效。它使用RCU模式更新其数据,确保在更新其数据副本后和使指向该数据的指针全局可见之前使用释放操作(以特定于体系结构的方式实现),以便确保看到更新指针的任何其他线程也能看到更新的数据。然后,读取器对指针的当前值进行本地复制(而不是volatile),从而获得数据的视图,即使在编写器线程再次更新后,该视图也将保持有效,并使用该视图。你想在通知读者更新的单个变量上使用volatile,这样即使编译器"知道"你的线程不可能更改它,他们也可以看到这些更新。在这个框架中,共享数据只需要是恒定的,读者将使用RCU模式。这是我认为volatile在现实世界中有用的两种方式之一(另一种是当你不想优化你的定时循环时)。

在这个方案中,还需要有某种方法让程序知道何时没有人再使用数据结构的旧视图。如果这是读卡器的计数,则在读取指针的同时,需要在单个操作中原子性地修改该计数(因此获取数据结构的当前视图涉及原子CAS)。或者,这可能是一个周期性的滴答声,当所有线程都保证用他们现在处理的数据完成时。它可能是一种世代数据结构,其中写入程序通过预先分配的缓冲区进行轮换。

还要注意,程序可能会做的很多事情都会隐式地序列化线程:那些原子硬件指令会锁定处理器总线并迫使其他CPU等待,那些内存围栏可能会使线程停滞,或者线程可能正在排队等待从堆中分配内存。

不幸的是,这取决于具体情况。

在多个线程中读取和写入变量时,可能会出现2次故障。

1) 撕裂。其中一半数据为变更前数据,一半数据为变动后数据。

2) 陈旧的数据。其中读取的数据具有一些较旧的值。

int、volatile int和std:amic都不会撕裂。

过时的数据是另一个问题。然而,所有的值都已经存在,可以被视为正确的。

挥发性。这告诉编译器既不要缓存数据,也不要对数据周围的操作进行重新排序。这通过确保线程中的所有操作都在变量之前、变量处或之后来提高线程之间的一致性。

这意味着

volatile int x;
int y;
y =5;
x = 7;

x=7的指令将在y=5之后写入;

不幸的是,CPU也能够重新排序操作。这可能意味着另一个线程在y=5 之前看到x==7

std::原子x;将允许保证在看到x==7之后,另一个线程将看到y==5。(假设其他线程没有修改y)

因此,intvolatile intstd::atomic<int>的所有读取都将显示x的先前有效值。使用volatileatomic增加值的顺序。

请参阅kernel.org barriers

我有一个简单的类型变量(比如int)。我有一个进程,一个编写器线程,几个"只读"线程。怎样我应该声明变量吗?

易失性intstd::原子int

使用std::atomic和memory_order_relaxed进行存储和加载

它很快,而且从你对问题的描述来看,是安全的。例如

void func_fast()
{
    std::atomic<int> a; 
    a.store(1, std::memory_order_relaxed);
}

编译为:

func_fast():
    movl    $1, -24(%rsp)
    ret

这假设您不需要保证在更新整数之前看到任何其他数据被写入,因此没有必要进行更慢、更复杂的同步。

如果你像这样天真地使用原子:

void func_slow()
{
    std::atomic<int> b;
    b = 1; 
}

您得到的MFENCE指令没有memory_order*规范,速度慢得多(比裸MOV多100个周期,而只有1或2个周期)。

func_slow():
    movl    $1, -24(%rsp)
    mfence
    ret

请参阅http://goo.gl/svPpUa

(有趣的是,在英特尔上,此代码使用memory_order_release和_acquire会产生相同的汇编语言。英特尔保证在使用标准MOV指令时按顺序进行写入和读取)。

以下是我的赏金尝试:-a.上面已经给出的一般答案是"使用原子论"。这是正确的答案。挥发性是不够的。-a。如果你不喜欢这个答案,并且你在英特尔,并且你已经正确地对齐了int,并且你喜欢不可移植的解决方案,你可以使用英特尔强大的内存排序保证来取消简单的volatile。

TL;DR:如果您多次读取,请使用std::atomic<int>并在其周围使用互斥对象。

这取决于你想要多有力的保证。

第一个volatile是一个编译器提示,您不应该指望它做一些有用的事情。

如果使用int,可能会出现内存混叠。假设你有类似的东西

struct {
  int x;
  bool q;
}

根据这在内存中的对齐方式以及CPU和内存总线的确切实现,当页面从CPU缓存复制回ram时,写入q实际上可能会覆盖x。因此,除非你知道在int周围分配多少,否则不能保证你的编写器能够在不被其他线程覆盖的情况下进行编写。此外,即使你写了,你也依赖于处理器将数据重新加载到其他核心的缓存中,所以不能保证你的其他线程会看到一个新的值。

std::atomic<int>基本上保证您总是分配足够的内存,正确对齐,这样您就不会出现混叠。根据请求的内存顺序,您还将禁用一系列优化,如缓存,因此所有操作的运行速度都会稍慢。

这仍然不能保证,如果你多次读取var,你就会得到值。做到这一点的唯一方法是在它周围放置一个互斥锁,以阻止写入程序更改它

最好找到一个已经解决了你的问题的库,并且它已经过其他人的测试,以确保它运行良好。