(C / C ++)为什么将单个阅读器和单个编写器与全局变量同步是有效的?

时间:2015-11-30 11:40:44

标签: c++ synchronization

假设有一个像std :: vector这样的数据结构和一个初始化为零的全局变量int syncToken。 另外,作为读/写器的两个线程,为什么以下(伪)代码(in)有效?

void reader_thread(){
    while(1){
        if(syncToken!=0){
            while(the_vector.length()>0){
                 // ... process the std::vector 
            }
            syncToken = 0;  // let the writer do it's work
        }
        sleep(1);
    }
}

void writer_thread(){
    while(1){
        std::string data = waitAndReadDataFromSomeResource(the_resource);
        if(syncToken==0){
            the_vector.push(data);
            syncToken = 1;  // would syncToken++; be a difference here?
        }
        // drop data in case we couldn't write to the vector
    }
}

虽然这段代码没有(时间)效率,但据我所知,代码是有效的,因为这两个线程只是以一种不会导致未定义行为的方式同步全局变量值。在同时使用向量时可能会出现唯一的问题,但这不应该发生,因为只在0和1之间切换作为同步值,对吧?

更新 由于我错误地提出了一个是/否的问题,我更新了我的问题,为什么希望得到一个非常具体的案例作为答案。 似乎问题本身根据答案得出了错误的图片,所以我将详细阐述我的问题/问题与上述代码的关系。

事先,我想指出我要求一个特定的用例/示例/证明/详细解释,以准确说明不同步的内容。即使是一个C示例代码,让一个示例计数器表现为非单调增加,只会回答是/否问题,但不是为什么! 我对这个原因感兴趣。所以,如果你提供一个证明它有问题的例子我对它的原因感兴趣。

通过(我的)定义上面的代码应该被命名为synchronized,当且仅当if语句中的代码(不包括if块底部的syncToken赋值)只能由这两个给定线程中的一个执行时给定的时间。

基于这个想法,我正在寻找一个可能基于汇编程序的例子,其中两个线程同时执行if块 - 意味着它们不同步或者不同步。

作为参考,让我们看一下gcc生成的汇编程序代码的相关部分:

; just the declaration of an integer global variable on a 64bit cpu initialized to zero
syncToken:
.zero   4
.text
.globl  main
.type   main, @function

; writer (Cpu/Thread B): if syncToken == 0, jump not equal to label .L1
movl    syncToken(%rip), %eax
testl   %eax, %eax
jne .L1

; reader (Cpu/Thread A): if syncToken != 0, jump to Label L2
movl    syncToken(%rip), %eax
testl   %eax, %eax
je  .L2

; set syncToken to be zero
movl    $0, syncToken(%rip)

现在我的问题是,我没有看到为什么这些说明可能会失去同步。

假设两个线程都在自己的CPU核心上运行,就像线程A在核心A上运行一样,线程B在核心B上运行。初始化是全局的,在两个线程开始执行之前完成,所以我们可以忽略初始化并假设两个线程都启动with syncToken = 0;

示例:

  1. Cpu A:movl syncToken(%rip),%eax
  2. Cpu A:上下文切换(保存所有寄存器)
  3. Cpu B:movl syncToken(%rip),%eax
  4. Cpu B:testl%eax,%eax
  5. Cpu B:jne .L1;这个是假的>执行writer if if block
  6. Cpu B:上下文切换
  7. Cpu A:上下文切换到线程(恢复所有寄存器)
  8. Cpu A:testl%eax,%eax
  9. Cpu A:je .L2;这是假=>不执行if block
  10. 老实说,我已经构建了一个运行良好的示例,但它表明我没有看到变量应该不同步的方式,以便两个线程同时执行if块。 我的观点是:虽然上下文切换会导致%eax与RAM中syncToken的实际值不一致,但代码应该做正确的事情,如果它不是允许运行的唯一线程,则不执行if块它

    更新2 可以假设syncToken仅在如图所示的代码中使用。没有其他函数(如waitAndReadDataFromSomeResource)允许以任何方式使用它

    更新3 让我们更进一步问一个稍微不同的问题:是否可以使用int syncToken同步两个线程,一个读取器,一个写入器,这样线程就不会通过执行而一直不同步if块同时?如果是的话 - 这非常有趣^^ 如果不是 - 为什么?

7 个答案:

答案 0 :(得分:13)

基本问题是你假设syncToken的更新是原子的,对矢量有更新,但他们并不是。

在多核CPU上无法保证这两个线程不会在不同的核心上运行。并且无法保证将内存更新写入主内存的顺序或从主内存刷新缓存。

因此,当在读取线程中将syncToken设置为零时,可能是编写器线程在之前看到更改它看到向量内存的更改。所以它可以开始推送到矢量的过时结束。

类似地,当您在编写器线程中设置令牌时,阅读器可能会开始访问该向量的旧版本内容。更有趣的是,根据向量的实现方式,读者可能会看到包含指向内存内容的旧指针的向量标题

void reader_thread(){
    while(1){
        if(syncToken!=0){
            while(the_vector.length()>0){
                 // ... process the std::vector 
            }
            syncToken = 0;  // let the writer do it's work
        }
        sleep(1);

这个sleep将导致内存刷新,因为它进入操作系统,但是不能保证内存刷新的顺序或编写器线程将看到它的顺序。

    }
}

void writer_thread(){
    while(1){
        std::string data = waitAndReadDataFromSomeResource(the_resource);

可能导致内存刷新。另一方面,它可能不会。

        if(syncToken==0){
            the_vector.push(data);
            syncToken = 1;  // would syncToken++; be a difference here?
        }
        // drop data in case we couldn't write to the vector
    }
}

使用syncToken++会(通常)没有帮助,因为它执行读取/修改/写入,所以如果另一端碰巧同时进行修改,您可以获得任何类型的结果出来的。

为了安全起见,您需要使用内存同步或锁定,以确保以正确的顺序读取/写入内存。

在此代码中,在编写之前,需要先使用读取同步屏障,然后再写入同步屏障。

使用写入同步可确保在之后的任何更新之前,所有到目前为止的内存更新都对主内存可见 - 以便syncTokenthe_vector设置为1之前得到适当更新。

在阅读syncToken之前使用读取同步将确保缓存中的内容与主内存一致。

通常情况下,这样做可能相当棘手,除非性能非常关键,否则最好使用互斥锁或信号量来确保同步。

如Anders所述,编译器仍然可以通过访问syncToken来重新排序对syncToken的访问权限(如果它可以确定这些函数的作用,那么the_vector可能会) - 添加内存屏障将停止这种重新排序。使std::vector volatile也会停止重新排序,但它不会解决多核系统上内存一致性问题,并且它不会让你安全地读取/修改/写入来自2个线程的相同变量。

答案 1 :(得分:11)

简短回答:,此示例未正确同步,也不会(始终)正常工作。

对于软件,人们普遍认为有时候工作但并不总是和破碎一样。现在,您可以问一些类似于"这是否适用于在优化级别-O0"在具有XYZ编译器的ACME品牌32位微控制器上将中断控制器与前台任务同步。答案肯定是肯定的。但在一般情况下,答案是否定的。事实上,这种在任何实际情况下工作的可能性很低,因为"的使用STL"和"简单的硬件和编译器才能正常工作"可能是空的。

正如其他评论/答案所述,它在技术上也是未定义的行为(UB)。真正的实现可以自由地使UB正常工作。所以只是因为它不是标准的"它可能仍然有效,但它不会严格符合或便携。它的工作原理取决于具体情况,主要依赖于处理器和编译器,也许还取决于操作系统。

什么有用

正如您的(代码)注释所暗示的那样,数据很可能会被丢弃,因此这被认为是有意的。这个例子的性能很差,因为只有向量需要被锁定"就是在添加,删除或测试数据时。但是,reader_thread()拥有向量,直到完成测试,删除处理所有项目。这比预期的要长,因此它更有可能丢弃数据,而不是原本需要的数据。

但是,只要变量访问是同步的,并且语句出现在" naive"程序顺序,逻辑似乎是正确的。 writer_thread()不会访问该向量,直到它拥有"它(syncToken == 0)。类似地,reader_thread()在拥有它之前不会访问该向量(syncToken == 1)。即使没有原子写入/读取(比如这是一个16位机器,而syncToken是32位),这仍然是 "工作"

注1:if(flag){... flag = x}的模式是非原子测试和设置。通常这将是一场竞争条件。但在这个非常具体的案例中,这场比赛是侧面的。一般来说(例如不止一个读者或作者)也会出现问题。

注2:syncToken ++ less 可能比syncToken = 1更具原子性。通常这将是另一个不良行为的领头羊,因为它涉及读 - 修改 - 写。在这种特殊情况下,它应该没有区别。

出了什么问题

  1. 如果对syncToken的写入与其他线程不同步怎么办?如果写入syncToken是寄存器而不是存储器怎么办?在这种情况下,可能性是reader_thread()根本不会执行,因为它不会看到syncToken集。尽管syncToken是一个普通的全局变量,但只有在调用waitAndReadDataFromSomeResource()时才会将其写回内存,或者当寄存器压力恰好足够高时,它只能随机写回内存。但是由于writer_thread()函数是一个无限的while循环并且永不退出,所以它完全有可能永远不会发生。要解决这个问题,必须将syncToken声明为volatile,强制每次写入和读取都进入内存。

    正如其他评论/答案所提到的,缓存的可能性可能是一个问题。但对于正常系统内存中的大多数架构,它不会。硬件将通过缓存一致性协议(如MESI)确保所有处理器上的所有缓存都保持一致性。如果将syncToken写入处理器P1上的L1高速缓存,则当P2尝试访问同一位置时,硬件会确保在P2加载之前刷新来自P1的脏高速缓存行。因此,对于正常的缓存一致系统内存,这可能是"好的"。

    但是,如果写入设备或IO内存,而缓存和缓冲区未自动同步,则这种情况并非完全无法实现。例如,PowerPC EIEIO instruction需要同步外部总线内存,PCI发布的写入可以由桥缓冲,并且必须以编程方式刷新。如果vector或syncToken没有存储在普通的高速缓存一致系统内存中,这也可能导致同步问题。

  2. 更现实地说,如果同步不是问题,那么编译器的优化器将重新排序。优化程序可以决定由于the_vector.push(data)syncToken = 1没有依赖关系,因此可以首先移动syncToken = 1。显然,通过允许reader_thread()在与writer_thread()同时处理向量来解决问题。

    简单地将syncToken声明为volatile也是不够的。仅保证针对其他易失性访问对易失性访问进行排序,而不是在易失性和非易失性访问之间进行排序。因此,除非矢量也是易变的,否则这仍然是个问题。由于vector可能是一个STL类,因此声明它不稳定甚至不起作用也不明显。

  3. 现在假定同步问题和编译器优化器已被打成提交。您查看汇编代码并清楚地看到现在所有内容都以正确的顺序显示。最后一个问题是现代CPU习惯于无序执行和退出指令。由于在the_vector.push(data)汇编到syncToken = 1的任何内容中的最后一条指令之间没有依赖关系,因此处理器可以决定在movl $0x1, syncToken(%rip)之前的其他指令之前执行the_vector.push(data)已完成,例如,保存新的长度字段。这与汇编语言操作码的顺序无关。

    通常,CPU知道指令#3取决于指令#1的结果,因此它知道#3必须在#1之后完成。也许指令#2不依赖于任何一个,可以在它们之前或之后。此调度在运行时根据当前可用的任何CPU资源动态发生。

    出现问题的是访问the_vector的指令与访问syncToken的指令之间没有明确的依赖关系。然而,该程序仍然隐含地要求它们被订购以便正确操作。 CPU无法知道这一点。

    防止重新排序的唯一方法是使用特定于特定CPU的内存栅栏,屏障或其他同步指令。例如,可以在触摸the_vector和syncToken之间插入英特尔mfence指令或PPC sync。只需要指示哪一条指令或一系列指令,以及它们需要放置的位置,就非常特定于CPU模型和情况。

  4. 在一天结束时,使用"正确的"会更容易。同步原语。同步库调用还处理在正确的位置放置编译器和CPU障碍。此外,如果你做了类似下面的事情,它会表现得更好而且不需要丢弃数据(虽然sleep(1)仍然很笨拙 - 最好使用条件变量或信号量):

    void reader_thread(){
        while(1){
            MUTEX_LOCK()
            if(the_vector.length()>0){
                std::string data = the_vector.pop();
                MUTEX_UNLOCK();
    
                // ... process the data
            } else {
                MUTEX_UNLOCK();
            }
            sleep(1);
        }
    }
    
    void writer_thread(){
        while(1){
            std::string data = waitAndReadDataFromSomeResource(the_resource);
            MUTEX_LOCK();
            the_vector.push(data);
            MUTEX_UNLOCK();
        }
    }
    

答案 2 :(得分:5)

大约20年前,该计划可以正常运作。那些日子过去和完成,很可能不会很快回来。人们购买速度快,功耗低的处理器。他们不会购买能让程序员更轻松地编写这样代码的代码。

现代处理器设计是处理延迟的练习。长镜头最严重的延迟问题是内存的速度。典型的RAM访问时间(经济实惠的类型)徘徊在100纳秒左右。现代核心可以在那段时间轻松执行指令。处理器充满了处理巨大差异的技巧。

电源是一个问题,他们不能再使处理器更快。实际时钟速度最高可达~3.5千兆赫。走得更快需要更多的电力,而且除了耗尽电池太快之外,你可以有效处理多少热量的上限。具有缩略图尺寸的硅片产生一百瓦特就是它停止变得实用的地方。只有处理器设计人员才能使处理器更强大的其他事情是增加更多的执行核心。根据理论,您将知道如何编写代码以有效地使用它们。这需要使用线程。

通过提供处理器缓存来解决内存延迟问题。内存中数据的本地副本。物理上靠近执行单元,因此具有较少的延迟。现代核心具有64 KB的L1缓存,最小,因此最接近,因此最快。更大更慢的L2缓存,通常为256 KB。还有一个更大更慢的L3缓存,芯片上所有核心共享的4 MB典型值。

如果缓存中没有存储在程序所需的内存位置的数据副本,缓存仍然会蹲下。因此处理器有一个 prefetcher ,一个在指令流中向前看的逻辑电路,并猜测将需要哪个位置。换句话说,它会在程序使用它之前读取内存

另一个电路处理写入,存储缓冲区。它接受来自执行核心的写入指令,因此它不必等待物理写入完成。换句话说,它会在程序更新之后写入内存

也许你开始看到熊陷阱,当你的程序读取syncToken变量值然后它得到一个陈旧的值,这个值很容易与逻辑值不匹配。另一个核心可以提前几纳秒更新它,但你的程序不会意识到这一点。在代码中产生逻辑错误。很难调试,因为它严重依赖于时间,纳秒。

避免这种难以辨认的令人讨厌的错误需要使用 fences ,这是确保内存访问同步的特殊指令。它们价格昂贵,导致处理器停滞不前。它们用std :: atomic包装在C ++中。

然而,它们只能解决部分问题,请注意代码的另一个不良特征。只要您无法获得syncToken,您的代码就会在while循环中旋转。燃烧100%的核心,而不是完成工作。如果另一个线程没有持续太长时间,这没关系。它开始需要几微秒时就不行了。然后你需要让操作系统参与其中,它需要将线程置于保持状态,以便另一个程序的另一个线程可以完成一些有用的工作。由std :: mutex和朋友包裹。

答案 3 :(得分:2)

他们说,这些c ++代码不是线程安全的原因是:

  1. 编译器可能会重新排序说明。 (事实并非如此,正如您在汇编程序中所证明的那样,但是使用不同的编译器设置可能会发生重新排序。为防止重新排序,请使syncToken成为易失性。
  2. 处理器的缓存不同步。读者的线程CPU看到新的syncToken,但旧的矢量。
  3. 处理器硬件可能会重新排序指令。加上装配说明可能不是原子的。但在内部它们可能是一堆微码,而这些微码又可以重新排序。也就是说,您看到的程序集可能与cpu执行的实际微代码不同。因此,syncToken updaaaatiiing和vector updaaaatiiing可以混合使用。
  4. 可以阻止所有这些以下线程安全模式。

    在特定的CPU或特定供应商上,使用特定的编译器,您的代码可能正常工作。它甚至可以在您定位的所有平台上运行。但它不便携。

答案 4 :(得分:1)

鉴于

  • syncToken的类型为int
  • 您使用syncToken!=0syncToken==0作为同步条件(以您的条款说出来)和
  • 复制作业syncToken = 1syncToken = 0以更新同步条件

结论是

  • 不,它无效

,因为

  • syncToken!=0syncToken==0syncToken = 1syncToken = 0 非原子

如果你运行足够的测试,你可能会遇到一些不同步的效果。

C ++提供了STL库中的工具来处理线程互斥任务等。我建议您阅读这些内容。您可能会在互联网上找到简单的例子。

在你的情况下(我认为相似)你可以参考这个答案:https://stackoverflow.com/a/9291792/1566187

答案 5 :(得分:0)

这种类型的同步不是正确的方法。 例如: 要测试这个条件,“syncToken == 0”cpu可能会执行多个串行语言指令,

MOV DX,@ syncToken CMP DX,00;将DX值与零进行比较 JE L7;如果是,则跳转到标签L7

同样,要更改syncToken变量cpu的值,可能会串行执行多个汇编语言指令。

在多线程的情况下,操作系统可能会在执行时先占(Context switch)线程。

现在让我们考虑一下, 线程A正在执行此条件“syncToken == 0”,并且OS切换上下文,如下所示

assembly lang instr 1 assembly lang instr 2 上下文切换到线程B. assembly lang instr 3 assembly lang instr 4

和线程B正在执行此条件“syncToken = 1”并且OS切换上下文,如下所示, assembly lang instr 1 assembly lang instr 2 assembly lang instr 3 上下文切换到线程A. assembly lang instr 4

在这种情况下,变量syncToken中的值可能会重叠。这将导致问题。

即使你将syncToken变量设为原子并继续使用,这对最佳性能也不利。

因此,我建议使用互斥锁进行同步。或者根据用途你可以去读写锁。

答案 6 :(得分:0)

您假设即使您更改或读取它,也会将SyncToken的值写入内存并从内存中读取。它不是。它缓存在CPU中,可能无法写入内存。

如果考虑到这一点,编写器线程会认为SyncToken为1(因为他设置了这种方式)并且读者线程会认为SyncToken为0(因为他设置了这种方式)并且没有人会工作直到刷新CPU缓存。 (可能需要永远,谁知道)。

将其定义为volatile / atomic / interlocked会阻止此缓存效果并导致代码以您希望的方式运行。

编辑:

您应该考虑的另一件事是您的代码在无序执行时会发生什么。我可以自己写一下,但这个答案涵盖了它:Handling out of order execution

因此,陷阱1是线程可能在某个时刻停止工作,陷阱2是无序执行可能导致SyncToken过早更新。

我建议使用boost lockfree队列来执行此类任务。