安全"借出"内存块到C中的另一个线程,假设没有"并发访问"

时间:2017-11-03 07:41:09

标签: c multithreading thread-safety nim

问题

我想在一个线程中分配内存,安全地"借出"指向另一个线程的指针,以便它可以读取该内存。

我使用的高级语言转换为C。高级语言有线程(未指定的线程API,因为它的跨平台 - 见下文)并支持标准的C多线程原语,如原子比较交换,但它并不是真的记录(没有用例)。 这种高级语言的限制是:

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

现在,对于大型(不想要副本)或可变大小(我认为数组大小是类型的一部分)消息,这是不切实际的。我想发送这样的消息,这里是我想要实现它的大纲:

  • 消息(请求回复)可以存储"有效负载"内联(复制,固定限制总值大小),或指向发件人堆中数据的指针
  • 邮件内容(发件人堆中的数据)由发送线程拥有(分配并免费)
  • 接收线程在完成消息内容
  • 后向发送线程发送确认
  • "发送"线程在发送之后不得修改消息内容,直到收到(确认)。
  • 在写入完成之前,永远应该是写入内存的并发读取访问权限。 这应该由消息队列work-flow保证。

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

可移植性考虑因素

因为我的高级语言需要跨平台,所以我需要得到答案:

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

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

  • 适用于Windows / Linux / MacOS的英特尔/ AMD PC架构(?)。
  • 未知(ARM?)适用于iOS和Android

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

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

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

尝试解决方案

这是我假设的工作流程:

  1. 读取队列中的所有消息,直到它为空(仅当它完全为空时才阻止)。
  2. 打电话给一些"记忆围栏"这里吗?
  3. 读取消息内容(消息中指针的目标),并处理消息。
    • 如果邮件是"请求",则可以对其进行处理,并将新邮件缓存为"回复"。
    • 如果邮件是"回复",原始"请求的邮件内容"可以释放(隐式请求" ack")。
    • 如果邮件是"回复",它本身包含指向"回复内容的指针" (而不是"内联回复"),然后是"回复-ack"也必须发送。
  4. 打电话给一些"记忆围栏"这里吗?
  5. 将所有缓冲的消息发送到相应的消息队列中。
  6. 真实代码太大而无法发布。这里简化了(足以显示如何访问共享内存)使用互斥锁的伪代码(如消息队列):

    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)
    

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

    研究

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

      

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

    我的问题是,这句话并没有明确说明它们是否意味着"所有被访问的内存",或者只有在锁定和解锁之间访问的内存。"我读过人们为这两种情况辩护,甚至有些人暗示它是故意写的,是为了让编译器实现者在实现中有更多的余地!

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

    我会根据标准文档中的引号/链接或其他一些高度可靠的来源选择任何答案,证明我不需要围栏在上述平台配置下显示我需要的 ,适用于Windows / Linux / MacOS案例的至少。如果在这种情况下Windows线程的行为类似于pthreads,我也喜欢这样的链接/引用。

    以下是我读过的一些(最好的)相关问题/链接,但是存在冲突信息会让我怀疑我的理解。

3 个答案:

答案 0 :(得分:1)

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

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

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

从n1570起

  

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

     

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

     

- 在X和X之间的线程发生在B之前或

之前对A进行排序      

- 在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携带a之前是依赖排序的   对B的依赖。

  

18评估A在评估B之前发生,如果A在B或A之间进行排序   发生在B之前。

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

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

thread_A_buffer[index] = 42;      // happens before 

目前内存thread_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之前是依赖顺序的,或者对于某些评估X,则在评估B之前发生线程间的交互。

     

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

     

- 在X和X之间的线程发生在B之前或

之前对A进行排序      

- 在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之后修改了对象,那么它将不起作用,除非有进一步的同步。

答案 1 :(得分:0)

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

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

在Linux中,malloc使用内部互斥锁来避免并发访问的损坏。我认为Windows也是如此。您可以自由使用内存,但需要避免多个“免费”内容。或访问冲突(您必须保证只有一个线程正在使用内存,并且可以“免费”)。

您提到您使用自定义堆实现。可能可以从其他线程访问此堆,但您必须检查此库是否不会执行“免费”操作。用于由另一个线程处理的指针。如果这个自定义堆实现是Nim的垃圾收集器,那么你必须不惜一切代价避免使用它并执行内存访问的自定义C实现,并使用Nim的C调用内存malloc和free。

答案 2 :(得分:0)

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

  1. 使用互斥锁和解锁进行同步。
  2. 将condtional变量用于其他线程的信号。
  3. 使用堆内存并在分配给其他线程时保持增量,并在访问结束后将其减少。这样可以避免无效的自由。