安全地将内存块"lend"到 C 中的另一个线程,假设没有"concurrent access"



问题

我想在一个线程中分配内存,安全地将指针"借给"另一个线程,这样它就可以读取该内存。

我使用的是一种高级语言,翻译成C。高级语言有线程(未指定线程API,因为它是跨平台的——见下文),并支持标准的C多线程原语,如atomic-compare-exchange,但它并没有真正的文档(没有使用示例)。这种高级语言的限制是:

  • 每个线程执行一个事件处理无限循环
  • 每个线程都有自己的本地堆,由一些自定义分配器管理
  • 每个线程都有一个"输入"消息队列,其中可以包含来自任意数量的其他线程的消息
  • 消息传递队列包括:
    1. 对于固定类型的消息
    2. 使用复制

现在,这对于大型(不需要副本)或可变大小(我认为数组大小是类型的一部分)消息来说是不切实际的。我想发送这样的信息,下面是我想如何实现它的概述:

  • 消息(请求回复)可以内联存储"有效负载"(复制的、固定的总值大小限制),也可以是指向发送方堆中数据的指针
  • 消息内容(发送方堆中的数据)由发送线程拥有(分配和释放)
  • 接收线程在处理完消息内容后向发送线程发送ack
  • "正在发送"的线程在发送消息后,在接收到(ack)之前,不得修改消息内容
  • 在写入完成之前,不应该对正在写入的内存进行并发读取访问这应该由消息队列工作流来保证

我需要知道如何确保这在没有数据竞争的情况下工作我的理解是,我需要使用内存围栏,但我不完全确定哪一个(ATOMIC_RELEASE,…)以及循环中的位置(或者我是否需要任何围栏)。


便携性注意事项

因为我的高级语言需要跨平台,所以我需要解决以下问题:

  • Linux、MacOS,以及可选的Android和iOS
    • 使用pthreads原语锁定消息队列:pthread_mutex_initpthread_mutex_lock+pthread_mutex_unlock
  • Windows
    • 使用关键节对象锁定消息队列:InitializeCriticalSectionEnterCriticalSection+LeaveCriticalSection

如果有帮助,我假设以下架构:

  • 适用于Windows/Linux/MacOS的Intel/AMD PC体系结构(?)
  • 未知(ARM?),适用于iOS和Android

并使用以下编译器(您可以假设所有编译器的"最新"版本):

  • Windows上的MSVC
  • Linux上的clang
  • Xcode在MacOS/iOS上
  • CodeWorks for Android在Android上

到目前为止,我只在Windows上构建过,但当应用程序完成后,我想用最少的工作将其移植到其他平台。因此,我从一开始就努力确保跨平台兼容性。


尝试的解决方案

以下是我假设的工作流程:

  1. 从队列中读取所有消息,直到它为空(如果它完全为空,则仅阻塞)
  2. 在这里称之为"记忆围栏">
  3. 读取消息内容(消息中指针的目标),并处理消息。
    • 如果消息是"请求",则可以对其进行处理,并将新消息缓冲为"回复">
    • 如果消息是"回复",则可以释放原始"请求"的消息内容(隐式请求"ack")
    • 如果消息是"回复",并且它本身包含指向"回复内容"(而不是"内联回复")的指针,那么也必须发送"回复确认">
  4. 在这里称之为"记忆围栏">
  5. 将所有缓冲的消息发送到相应的消息队列中

真实代码太大,无法发布。以下是使用互斥(如消息队列)的简化伪代码(仅足以显示共享内存是如何访问的):

static pointer p = null
static mutex m = ...
static thread_A_buffer = malloc(...)
Thread-A:
do:
// Send pointer to data
int index = findFreeIndex(thread_A_buffer)
// Assume different value (not 42) every time
thread_A_buffer[index] = 42
// Call some "memory fence" here (after writing, before sending)?
lock(m)
p = &(thread_A_buffer[index])
signal()
unlock(m)
// wait for processing
// in reality, would wait for a second signal...
pointer p_a = null
do:
// sleep
lock(m)
p_a = p
unlock(m)
while (p_a != null)
// Free data
thread_A_buffer[index] = 0
freeIndex(thread_A_buffer, index)
while true
Thread-B:
while true:
// wait for data
pointer p_b = null
while (p_b == null)
lock(m)
wait()
p_b = p
unlock(m)
// Call some "memory fence" here (after receiving, before reading)?
// process data
print *p_b
// say we are done
lock(m)
p = null
// in reality, would send a second signal...
unlock(m)

这个解决方案有效吗?重新设置问题,Thread-B是否打印"42"?总是,在所有考虑的平台和操作系统(pthreads和Windows CS)上?或者我需要添加其他线程基元,例如内存栅栏吗


研究

我花了几个小时研究了许多相关的SO问题,并阅读了一些文章,但我仍然不完全确定。根据@Art的评论,我可能不需要做任何事情。我相信这是基于POSIX标准4.12内存同步:的这一声明

[…]使用同步线程执行以及同步内存与其他线程的函数。以下函数使内存与其他线程同步。

我的问题是,这句话没有明确说明它们的意思是"所有访问的内存",还是"只有在锁定和解锁之间访问的内存。"!

此外,这适用于pthreads,但我需要知道它如何应用于Windows线程。

我会根据标准文档或其他高度可靠来源的引用/链接选择任何答案,证明我不需要围栏,或者显示在上述平台配置下,我需要哪些围栏(至少适用于Windows/Linux/MacOS)。如果Windows线程在这种情况下表现得像pthreads,我也希望有一个链接/引号。

以下是我读到的一些(最好的)相关问题/链接,但相互矛盾的信息让我怀疑自己的理解。

  • pthread_mutex_lock是否包含内存围栏指令
  • 记忆围栏-需要帮助才能理解
  • pThread同步问题
  • 通过pthread库的内存可见性
  • pthread互斥所涉及的全内存屏障的澄清
  • pthreads中的内存模型规范
  • http://www.hpl.hp.com/techreports/2005/HPL-2005-217R1.html
  • http://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap04.html#tag_04_11
  • https://msdn.microsoft.com/en-us/library/windows/desktop/ms684208(v=vs.85).aspx

我对C++11的文档以及C11:n1570.pdf中的类似措辞的审查使我有了以下理解。

如果在线程之间执行某种形式的协作同步,那么数据在线程之间是安全可用的。如果有一个队列,它在互斥体中从队列中读取一个项目,如果在互斥体被持有的同时,项目被添加到队列中,那么在第二个线程中可读的内存将是在第一个线程中写入的内存。

这是因为编译器和底层CPU基础设施不允许组织通过排序的副作用。

来自n1570

如果A与B同步,则评估A线程间发生在评估B之前是在B之前排序的依赖项,或者,对于某些评估X:

--A与X同步,X在B之前排序,

--A在X之前排序,X线程间发生在B或之前

--一个线程间发生在X之前,X个线程间出现在B 之前

因此,为了确保新线程中可见的内存是一致的,以下内容将保证结果。

  • 互斥访问锁
  • 生产者的互锁写入+消费者的互锁读取

互锁写入会导致线程A上的所有先前操作被排序,并在线程B看到读取之前刷新缓存。

在数据被写入队列进行"其他线程处理"后,第一个线程无法安全地(解锁)修改或读取对象中的任何内存,直到它知道(通过某种机制)其他线程不再访问数据。只有通过某种同步机制才能看到正确的结果。

C++和C标准都旨在将编译器和CPU的现有行为形式化。因此,尽管pthreads和C99标准的使用没有那么正式的保证,但这些标准应该是一致的。

从你的例子来看

线程A

int index = findFreeIndex(thread_A_buffer)

这一行有问题,因为它没有显示任何同步原语。如果findFreeIndex的机制只依赖于线程A编写的内存,那么这将起作用。如果线程B或任何其他线程修改了内存,则需要进一步锁定。

lock(m)
p = &(thread_A_buffer[index])
signal()
unlock(m)

这包含在…中。。。。

15如果,则评估A在评估B之前是依赖排序的

--A对原子对象M执行释放操作,在另一个线程中,B对M执行消耗操作,并读取由A或引导的释放序列中的任何副作用写入的值

--对于某些求值X,A是在X之前排序的依赖项,X带有依赖B.

18如果A在B或A线程间之前排序,则评估A发生在评估B之前发生在B.之前

同步之前的操作"发生在"同步之前,并保证在同步之后在其他线程中可见。

锁定(获取)和解锁(释放),确保线程a中的信息有严格的顺序,并且对B可见。

thread_A_buffer[index] = 42;      // happens before 

目前,内存线程_A_buffer在A上可见,但在B上读取它会导致未定义的行为。

lock(m);  // acquire

尽管发布需要,但我看不到任何结果。

p = &thread_A_buffer[index];
unlock(m);

A的所有指令流现在对B可见(由于它与m同步)。

thread_A_buffer[index] = 42;  << This happens before and ...
p = &thread_A_buffer[index];  << carries a dependency into p
unlock(m);

A中的所有东西现在都对B可见,因为

评估A线程间发生在评估B之前,如果A与B同步,A在B之前是依赖关系排序的,或者,对于某些评估X

--A与X同步,X在B之前排序,

--A在X之前排序,X线程间发生在B或之前

--线程间在X之前发生,X线程间在B之前发生。

pointer p_a = null
do:
// sleep
lock(m)
p_a = p
unlock(m)
while (p_a != null)

此代码是完全安全的,读取到p_a中的值将与其他线程一起排序,并且在同步写入线程b后不会为空。同样,锁定/解锁会导致严格的排序,以确保读取值将是写入值。

线程B的所有交互都在一个锁内,因此也是完全安全的。

如果A在将对象交给B之后修改该对象,那么它将无法工作,除非有进一步的同步。

如果你想拥有平台独立性,那么你需要使用多个操作系统和c:

  1. 使用互斥锁和解锁进行同步
  2. 使用条件变量向其他线程发送信号
  3. 当分配给其他线程时,使用具有保持增量的堆内存,并在访问结束后将其递减。这将避免无效释放

我也将Nim用于个人项目。Nim有一个垃圾收集器,您必须避免它,因为您的线程的内存处理例程使用它的C调用:

https://nim-lang.org/docs/backends.html

在Linux中,malloc使用内部互斥来避免并发访问造成的损坏。我认为Windows也是这样。您可以自由使用内存,但需要避免多次"释放"或访问冲突(您必须保证只有一个线程在使用内存,并且可以"释放"内存)。

您提到使用自定义堆实现。这个堆可能可以从其他线程访问,但您必须检查这个库是否不会对另一个线程正在处理的指针进行"释放"。如果这个自定义堆实现是Nim的垃圾收集器,那么您必须不惜一切代价避免它,并执行内存访问的自定义C实现,并使用Nim的C调用来获取内存malloc和free。

相关内容

最新更新