依靠网络I / O在C ++中提供跨线程同步

时间:2018-02-28 16:47:54

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

可以依赖外部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.
}

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

“从不”,“未指定(取决于实现)”,“取决于send / recv实现提供的保证”是我期望的那种类型的示例答案,最好是从C ++标准的理由(或其他相关标准,如POSIX for socket / networking)。

如果“从不”,那么将s1更改为初始化为明确值(例如0)的a将避免未定义的行为,但是保证在{中保证读取为42的值{1}}或者可以读取陈旧的值吗? POSIX套接字是否提供了进一步的保证,确保不会读取过时值?

如果“依赖”,POSIX套接字是否提供相关保证以使其定义行为? (如果std::atomic<int>thread2是同一个套接字而不是两个独立的套接字,那该怎么办?)

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

  

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

因此,使用底层网络库/函数提供类似保证是一个问题吗?

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

2 个答案:

答案 0 :(得分:1)

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

漫无边际的事情并没有真正解决问题:

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

C ++标准保证在函数调用之后加载全局变量,因此不允许编译器执行:

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

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

std::atomic将对某些类型的处理器提供帮助,但无法保证在更改后的任何合理时间内,它在第二个线程或第二个处理器核心中“可见”。

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

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

答案 1 :(得分:1)

通常,不可以依赖外部I / O进行跨线程同步。

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

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

由于问题中概述的原因(调用外部函数send,在任一线程中都不需要编译器屏障,对于所有编译器都知道可能正在获取内部互斥锁以与之同步另一个调用recv)。

Hans Boehm的论文&#34; Threads Cannot be Implemented as a Library&#34;第4.3节中描述的那种类似的阴险问题。不应该担心,因为C ++编译器是线程感知的(特别是不透明函数sendrecv可以包含同步操作),因此在a之后向send引入写入的转换

这就留下了POSIX网络功能是否提供必要保证的悬而未决的问题。我非常怀疑它,因为在一些具有弱存储器模型的架构中,它们提供非常重要和/或昂贵(需要如前所述的全流程互斥或IPI)。特别是在x86上,几乎可以肯定的是,访问类似套接字的共享资源将需要thread1SFENCE(甚至是MFENCE - 前缀指令) ,这应该足够了,但这不太可能在任何地方都被标准化。编辑:事实上,我认为即使转换到内核模式的LOCK也需要耗尽存储缓冲区(我必须提供的最佳参考是forum post)。