【问题标题】:C , C++ unsynchronized threads returning a strange resultC、C++ 非同步线程返回一个奇怪的结果
【发布时间】:2011-06-22 04:46:30
【问题描述】:

好的,我有一个关于线程的问题。

有两个不同步的线程同时运行并使用全局资源“int num” 第一个:

    void Thread()
{ 
    int i;
    for ( i=0 ; i < 100000000; i++ )
    {
        num++;
        num--;
    }
}

第二个:

    void Thread2()
{ 
    int j;
    for ( j=0 ; j < 100000000; j++ )
    {
        num++;
        num--;      
    }
}

问题说明:程序末尾的变量“num”的可能值是多少。 现在我会说 0 将是程序末尾的 num 的值,但是,尝试运行这段代码,你会发现结果是非常随机的, 我不明白为什么?

完整代码:

 #include <windows.h>
    #include <process.h>
    #include <stdio.h>

    int static num=0;

   void Thread()
    { 
        int i;
        for ( i=0 ; i < 100000000; i++ )
        {
            num++;
            num--;
        }
    }

   void Thread2()
    { 
        int j;
        for ( j=0 ; j < 100000000; j++ )
        {
            num++;
            num--;      
        }
    }

    int main()
    {
        long handle,handle2,code,code2;
        handle=_beginthread( Thread, 0, NULL );
        handle2=_beginthread( Thread2, 0, NULL );

        while( (GetExitCodeThread(handle,&code)||GetExitCodeThread(handle2,&code2))!=0 );

        TerminateThread(handle, code );
        TerminateThread(handle2, code2 );

        printf("%d ",num);
        system("pause"); 
    }

【问题讨论】:

  • @Marlon:不正确。请参阅@Thomas 的回答。

标签: c++ c multithreading thread-safety


【解决方案1】:

num++num-- 不必是原子操作。以num++为例,大概是这样实现的:

int tmp = num;
tmp = tmp + 1;
num = tmp;

tmp 保存在 CPU 寄存器中。

现在假设num == 0,两个线程都尝试执行num++,操作交错如下:

Thread A        Thread B
int tmp = num;
tmp = tmp + 1;
                int tmp = num;
                tmp = tmp + 1;
num = tmp;
                num = tmp;

最后的结果将是num == 1,即使它应该增加了两次。在这里,一个增量丢失;同样,减量也可能丢失。

在病态的情况下,一个线程的所有增量都可能丢失,导致num == -100000000,或者一个线程的所有减量可能丢失,导致num == +100000000。甚至可能还有更极端的情况潜伏在那里。

还有其他事情正在进行,因为 num 没有被声明为 volatile。因此,两个线程都会假设num 的值不会改变,除非它们是改变它的那个。这允许编译器优化整个for 循环,如果感觉如此倾向于!

【讨论】:

  • volatile NOT 在 C 和 C++ 中是否具有原子访问语义。
  • 当然不是。我是否暗示确实如此?我只是说volatile 的缺失使程序比现在更错误。
  • 仅仅因为它不太可能因 volatile 而失败,它并没有让事情变得更好。
  • @Axel 的论点是解决问题需要使用volatile 除了进行适当的同步(假设我们在此过程中关心i 的值而不仅仅是在最后,并假设我们关心有一个程序实际执行两个循环所描述的工作)。由于优化,正确同步它并省略volatile 可能仍然没有好处。 (当然,除非对同步原语的调用会干扰这些优化——我想它实际上会这样。)
  • @Karl:同步原语必须暗示编译器屏障,如果它们是正确的,那么volatile 是不必要的。
【解决方案2】:

num 的可能值包括所有可能的int 值,以及浮点值、字符串和鼻恶魔的 jpeg。一旦你调用了未定义的行为,所有的赌注都被取消了。

更具体地说,在没有同步的情况下从多个线程修改同一个对象会导致未定义的行为。在大多数现实世界的系统上,您看到的最坏影响可能是丢失或双倍递增或递减,但它可能会更糟(内存损坏、崩溃、文件损坏等)。所以不要这样做。

下一个即将推出的 C 和 C++ 标准将包括原子类型,可以从多个线程安全访问,而无需任何同步 API。

【讨论】:

  • 现有答案没有提到任何未定义的行为。
  • 从技术上讲,我不确定这是否符合 UB,因为(当前)C++ 标准甚至从未提及线程。但在“所有赌注都没有”部分,你肯定是对的。
  • 我不知道相关的 Windows 文档,但我想 MS 将其指定为 UB。当然,POSIX 将其指定为 UB。
  • 创建第二个线程的那一刻,就当前的 C++ 而言,您处于 UB 领域...
  • @bdonlan: 不,你处于实现定义的行为,或者更有用的是,higher-level-standard-defined 行为(即所有实现都声称符合的行为必须以更高级别的标准来定义)。在任何情况下,除非您假设某些特定的线程标准(例如 POSIX)或实现(例如 Windows),否则对有关线程的问题的回答是没有意义的。
【解决方案3】:

您所说的线程同时运行,如果您的系统中只有一个内核,实际上可能并非如此。假设您拥有不止一个。

如果有多个设备以 CPU 或总线主控或 DMA 的形式访问主存储器,它们必须同步。这由锁定前缀处理(对于指令 xchg 是隐含的)。它访问系统总线上的物理线路,该线路实质上向所有存在的设备发出信号以使其远离。例如,它是 Win32 函数 EnterCriticalSection 的一部分。

因此,如果同一芯片上的两个内核访问同一位置,结果将是未定义的,考虑到应该发生一些同步,这可能看起来很奇怪,因为它们共享相同的 L3 缓存(如果有的话)。似乎合乎逻辑,但它不那样工作。为什么?因为当您将两个内核放在不同的芯片上时会发生类似的情况(即没有共享的 L3 缓存)。你不能指望它们是同步的。好吧,您可以考虑所有其他可以访问主存储器的设备。如果您计划在两个 CPU 芯片之间进行同步,则不能止步于此 - 您必须执行全面同步,阻止所有具有访问权限的设备,并确保成功同步所有其他设备需要时间来识别同步已完成被请求并且需要很长时间,特别是如果设备已被授予访问权限并且正在执行必须允许完成的总线主控操作。 PCI 总线将每 0.125 us (8 MHz) 执行一次操作,考虑到您的 CPU 以 400 次运行,您会看到很多等待状态。然后考虑可能需要几个 PCI 时钟周期。

您可能会争辩说应该存在中等类型(仅限内存总线)锁,但这意味着每个处理器上都有一个额外的引脚,每个芯片组中都有一个额外的逻辑,只是为了处理程序员真正误解的情况。所以没有实现。

总结一下:可以处理您的情况的通用同步会使您的 PC 无用,因为它总是必须等待最后一个设备签入并确定同步。让它成为可选的并且仅在开发人员确定绝对必要时才插入等待状态是一个更好的解决方案。


这太有趣了,我玩了一下示例代码并添加了自旋锁来看看会发生什么。自旋锁组件是

// prototypes

char spinlock_failed (spinlock *);
void spinlock_leave (spinlock *);

// application code

while (spinlock_failed (&sl)) ++n;
++num;
spinlock_leave (&sl);

while (spinlock_failed (&sl)) ++n;
--num;
spinlock_leave (&sl);

spinlock_failed 是围绕“xchg mem,eax”指令构建的。一旦失败(没有设置自旋锁 成功设置它) spinlock_leave 将分配给它“mov mem,0”。 “++n”计算重试的总次数。

我将循环更改为 250 万个(因为每个循环有两个线程和两个自旋锁,我得到了 1000 万个自旋锁,很好且易于舍入)并在双核 Athlon II 上使用“rdtsc”计数对序列进行计时M300 @ 2GHz,这就是我发现的

  • 运行一个线程而不计时 (除了主循环)和锁 (如在原始示例中)33748884 16.9 毫秒 => 13.5 个周期/循环。
  • 运行一个线程,即没有其他内核 尝试了 210917969 个周期 105.5 ms => 84,4 个周期/循环 0.042 us/循环。自旋锁需要 112581340 个周期 22.5 个周期/ 自旋锁序列。尽管如此, 需要最慢的自旋锁 1334208 周期:那是 667 我们或只有 1500 每秒。

因此,不受其他 CPU 影响的自旋锁的添加使总执行时间增加了百分之几。 num 的最终值为 0。

  • 在没有自旋锁的情况下运行两个线程 花了 171157957 个周期 85.6 ms => 68.5 个循环/循环。数字包含 10176。
  • 两个带自旋锁的线程占用了 4099370103 2049 毫秒 => 1640 循环/循环 0.82 微秒/循环。这 需要自旋锁 3930091465 个周期 => 每个自旋锁序列 786 个周期。最慢的自旋锁 需要 27038623 个周期:即 13.52 ms 或每秒只有 74 个。编号 包含 0 个。

顺便说一句,两个没有自旋锁的线程的 171157957 个周期与两个有自旋锁的线程相比,其中自旋锁时间已被删除:4099370103-3930091465 = 169278638 个周期。

对于我的序列,自旋锁竞争导致每个线程重试 21-29 百万次,结果是每个自旋锁重试 4.2-5.8 次或每个自旋锁重试 5.2-6.8 次。自旋锁的添加导致 1927% (1500/74-1) 的执行时间损失。最慢的自旋锁需要 5-8% 的尝试。

【讨论】:

  • 您的锁是在xchg 上旋转,还是在尝试xchg 之前有负载?这是正常的建议,但我不确定在这么短的时间内持有锁是否会有很大的不同,并且只有两个线程在争夺它。
【解决方案4】:

正如 Thomas 所说,结果是不可预测的,因为您的增量和减量是非原子的。您可以使用 InterlockedIncrement 和 InterlockedDecrement(它们是原子的)来查看可预测的结果。

【讨论】:

    猜你喜欢
    • 2021-03-25
    • 1970-01-01
    • 1970-01-01
    • 2012-05-28
    • 2014-10-02
    • 1970-01-01
    • 2012-08-08
    • 2012-12-09
    • 1970-01-01
    相关资源
    最近更新 更多