【问题标题】:Relying on network I/O to provide cross-thread synchronization in C++C++中依靠网络I/O提供跨线程同步
【发布时间】:2018-08-08 15:10:16
【问题描述】:

可以依赖外部 I/O 作为跨线程同步的一种形式吗?

具体来说,考虑下面的伪代码,它假设存在网络/套接字函数:

int a;          // Globally accessible data.
socket s1, s2;  // Platform-specific.

int main() {
  // Set up + connect two sockets to (the same) remote machine.
  s1 = ...;
  s2 = ...;

  std::thread t1{thread1}, t2{thread2};
  t1.join();
  t2.join();
}

void thread1() {
  a = 42;
  send(s1, "foo");
}

void thread2() {
  recv(s2);     // Blocking receive (error handling omitted).
  f(a);         // Use a, should be 42.
}

我们假设远程机器仅在收到来自s1"foo" 时才向s2 发送数据。如果这个假设失败,那么肯定会导致未定义的行为。但如果它成立(并且没有发生其他外部故障,如网络数据损坏等),这个程序是否会产生定义的行为?

“从不”、“未指定(取决于实现)”、“取决于发送/接收实现提供的保证”是我期望的那种示例答案,最好有 C++ 标准(或其他相关标准,例如用于套接字/网络的 POSIX)。

如果“从不”,则将 a 更改为初始化为确定值(例如 0)的 std::atomic<int> 将避免未定义的行为,但该值保证在 thread2 中被读取为 42,或者可以读取过时的值? POSIX 套接字是否提供进一步的保证以确保不会读取过时的值?

如果“依赖”,POSIX 套接字是否提供相关保证以使其定义行为? (如果s1s2 是同一个套接字而不是两个独立的套接字呢?)

作为参考,标准 I/O 库有一个条款似乎在使用 iostreams 时提供了类似的保证(N4604 中的 27.2.3¶2):

如果一个线程调用库 a 将值写入流,结果,另一个线程通过库调用 b 从流中读取该值,这样不会导致数据争用,那么 a写与 b 的读同步。

那么底层网络库/函数是否提供了类似的保证?

实际上,对于sendrecv 函数,编译器似乎无法重新排序对全局a 的访问(因为它们原则上可以使用a)。但是,运行thread2 的线程仍然可以读取a 的陈旧值,除非send/recv 对本身提供某种内存屏障/同步保证。

【问题讨论】:

标签: c++ multithreading sockets network-programming memory-model


【解决方案1】:

简短回答:不,不能保证a 会被更新。我的建议是将a 的值与"foo" 一起发送 - 例如"foo, 42",或类似的东西。保证可以工作,并且可能不会有那么大的开销。 [当然可能还有其他原因导致效果不佳]

长篇大论并不能真正解决问题:

不保证全局数据在不进行进一步操作的情况下立即在多核处理器的不同内核中“可见”。是的,大多数现代处理器都是“连贯的”,但并非所有品牌的所有型号都保证这样做。所以如果thread2运行在一个已经缓存了a副本的处理器上,则不能保证调用fa的值是42。

C++标准保证在函数调用后加载全局变量,所以编译器是不允许这样做的:

 tmp = a;
 recv(...);
 f(tmp);

但正如我上面所说,可能需要缓存操作来保证所有处理器同时看到相同的值。如果sendrecv 的时间长或访问量足够大[没有直接的衡量标准可以说明多长时间或多长时间],您可能会在大多数甚至所有时间看到正确的值,但不能保证它们实际上是在最后写入值的线程之外更新的普通类型。

std::atomic 将有助于某些类型的处理器,但不能保证它在更改后的任何合理时间在第二个线程或第二个处理器内核中“可见”。

唯一实用的解决方案是使用某种“重复直到我看到它改变”类型代码 - 这可能需要一个值(例如)一个计数器,一个值是实际值 - 如果你愿意能够说“a 现在是 42。我又设置了 a,这次也是 42”。如果a 表示,例如缓冲区中可用的数据项的数量,则可能是“它改变了值”很重要,只需检查“这与上次是否相同”。 std::atomic 操作对排序有保证,这允许您使用它们来确保“如果我更新此字段,则保证其他字段同时或在此之前出现”。所以你可以用它来保证例如一对数据项被设置为“有一个新值”(例如一个计数器来指示当前数据的“版本号”)和“新值是 X” .

当然,如果您知道您的代码将在哪种处理器架构上运行,您就可以对行为将是什么做出更高级的猜测。例如,所有 x86 和许多 ARM 处理器都使用缓存接口来实现对变量的原子更新,因此通过在一个内核上进行原子更新,您可以知道“没有其他处理器会有这个过时的值”。但是有一些可用的处理器没有这个实现细节,并且在“未来某个时间,不确定何时”之前,即使使用原子指令,也不会在其他内核或其他线程上更新更新。

【讨论】:

  • 你认为将astd::atomic<int> 初始化为0 是否足以保证42 会被读取?我看不出使用原子如何提供足够的可见性保证(超出规定的“在合理的时间内可见”)。参考:N4604 中的 29.3¶12,Implementations should make atomic stores visible to atomic loads within a reasonable amount of time.
  • 不,您需要在某处将 std::atomic<int> 设置为 42。但是,是的,原子也不能保证,我会删除答案,因为它具有误导性。据我所知,没有办法保证你想要什么。
  • 随时编辑您的答案以包含此讨论。我认为即使它没有完全回答原始问题,它仍然很有用。 (我假设分配 a = 42 仍然存在。)
  • 我已经编辑了答案,但我不确定它是否真的那么好。这是一个很难解决的问题。
【解决方案2】:

一般来说,不,不能依赖外部 I/O 进行跨线程同步。

这个问题超出了 C++ 标准本身的范围,因为它涉及外部/OS 库函数的行为。因此程序是否为未定义行为取决于网络 I/O 功能提供的任何同步保证。在没有此类保证的情况下,这确实是未定义的行为。切换到(初始化的)原子以避免未定义的行为仍然不能保证将读取“正确”的最新值。为了确保在 C++ 标准的范围内需要某种锁定(例如自旋锁或互斥锁),即使由于情况的实时排序似乎不需要等待。

通常,C++ 标准不支持“实时”同步的概念(涉及可见性,而不仅仅是排序),以避免在 recv 返回后在加载 a 之前可能需要等待。然而,在较低级别,这个概念确实存在,并且通常通过inter-processor interrupts 来实现,例如FlushProcessWriteBuffers 在 Windows 上,或 sys_membarrier 在 x86 Linux 上。这将在 thread1 中的 send 之前的 a 存储之后插入。 thread2 中不需要同步或屏障。 (在 x86 上,由于其强大的内存模型,thread1 中的简单 SFENCE 似乎就足够了,至少在没有非临时加载/存储的情况下。)

由于问题中概述的原因(调用外部函数send,编译器知道这可能是获取内部互斥锁以与另一个调用同步) recv)。

Hans Boehm 的论文“Threads Cannot be Implemented as a Library”的第 4.3 节中描述的那种隐蔽问题不应该是一个问题,因为 C++ 编译器是线程感知的(特别是不透明函数 sendrecv 可能包含同步操作),因此在内存模型下不允许在thread1 中的send 之后引入写入a 的转换。

这留下了一个悬而未决的问题,即 POSIX 网络功能是否提供必要的保证。我非常怀疑它,因为在一些内存模型较弱的架构上,它们提供起来非常重要和/或昂贵(需要前面提到的进程范围的互斥锁或 IPI)。特别是在 x86 上,几乎可以肯定的是,访问像套接字这样的共享资源将需要一个 SFENCEMFENCE(甚至是 LOCK-prefixed 指令)沿线某处,这应该足够了,但这不太可能被奉为任何地方的标准。编辑:事实上,我认为即使是 INT 切换到内核模式也需要耗尽存储缓冲区(我必须提供的最佳参考是 forum post)。

【讨论】:

    猜你喜欢
    • 2020-01-03
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2012-10-17
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多