【问题标题】:Is synchronization for variable change cheaper then for something else?变量更改的同步是否比其他的更便宜?
【发布时间】:2019-06-24 23:43:02
【问题描述】:

在多线程环境下,是不是每次对RAM的操作都必须是synchronized

假设我有一个变量,它是指向另一个内存地址的指针:

foo     12345678

现在,如果一个线程将该变量设置为另一个内存地址(比如说89ABCDEF),同时第一个线程读取该变量,难道不是第一个线程从变量中读取完全垃圾,如果访问不会?不是synchronized(在某些系统级别上)?

foo     12345678  (before)
        89ABCDEF  (new data)
        •••••     (writing thread progress)
        89ABC678  (memory content)

由于我从未见过这些事情发生,我假设在编写变量时存在一些系统级同步。我认为,这就是为什么它被称为“原子”操作。正如我发现的here,这个问题实际上是一个话题,并不是我完全虚构的。

另一方面,我到处读到同步对性能有重大影响。 (除了必须等待的线程。它们不能进入锁;我的意思是锁定和解锁的动作。)喜欢here

synchronized 给方法 […] 增加了大量开销。这些操作非常昂贵 […] 它对程序性能有极大的影响。 […] 昂贵的同步操作导致代码非常慢。

这如何结合在一起?为什么更改变量的锁定速度不明显,而其他任何东西的锁定都如此昂贵?或者,它是否同样昂贵,并且在使用时应该有一个很大的警告标志——比如说——longdouble,因为它们总是隐含地需要同步?

【问题讨论】:

    标签: multithreading memory-management


    【解决方案1】:

    不是说RAM上的每一个操作都必须同步吗?

    没有。大多数“RAM 上的操作”将针对仅由一个线程使用的内存位置。例如,在大多数编程语言中,线程的函数参数或局部变量都不会与其他线程共享;通常,一个线程会使用它不与任何其他线程共享的堆对象。

    当两个或多个线程通过共享变量相互通信时,您需要同步。它有两个部分:

    互斥

    您可能需要防止“竞争条件”。如果某个线程 T 更新了一个数据结构,它可能不得不在更新完成之前将该结构置于一个临时的、无效的状态。您可以使用互斥(即互斥锁/信号量/锁/临界区)来确保当数据结构处于临时无效状态时,没有其他线程 U 可以看到数据结构。

    缓存一致性

    在具有多个 CPU 的计算机上,每个处理器通常都有自己的内存缓存。因此,当在两个不同处理器上运行的两个不同线程都访问相同的数据时,它们可能都在查看自己的、单独缓存的副本。因此,当线程 T 更新该共享数据结构时,重要的是要确保它更新的所有变量在允许线程 U 看到它们之前进入线程 U 的缓存中。

    如果一个处理器的每次写入都使其他处理器的缓存失效,这将完全破坏单独缓存的目的,因此通常只有在需要时才会有特殊的硬件指令来执行此操作,并且典型的互斥锁/锁实现会在进入或离开受保护的代码块。

    【讨论】:

      【解决方案2】:

      关于您的第一点,当处理器将一些数据写入内存时,这些数据总是被正确写入,并且不会被线程进程、操作系统等的其他写入“丢弃”。这不是同步问题,只是需要以确保正确的硬件行为。

      同步是一个需要硬件支持的软件概念。假设您只想获取锁。它应该在 0 时免费,而在 1 时锁定。

      做到这一点的基本方法是

      got_the_lock=0
      while(!got_the_lock)
        fetch lock value from memory  
        set lock value in memory to 1
        got_the_lock = (fetched value from memory ==  0)
      done
      print  "I got the lock!!"  
      

      问题是如果其他线程同时做同样的事情并且在锁值被设置为1之前读取它,几个线程可能会认为他们得到了锁。

      为了避免这种情况,需要原子内存访问。原子访问通常是对内存中数据的读-修改-写循环,不能中断,并且在完成之前禁止访问该信息。因此,并非所有访问都是原子的,只有特定的读取-修改-写入操作,并且由于 tp 特定的处理器支持而实现(例如,参见 test-and-setfetch-and-add 指令)。大多数访问不需要它,可以是常规访问。原子访问主要用于同步线程以确保只有一个线程处于临界区。

      那么为什么原子访问很昂贵?有几个原因。

      1. 第一个是必须确保指令的正确顺序。您可能知道指令顺序可能与指令程序顺序不同,前提是程序的语义得到尊重。这被大量利用以提高性能:编译器重新排序指令,处理器乱序执行它们,回写高速缓存以任何顺序将数据写入内存,内存写缓冲区做同样的事情。这种重新排序可能会导致不当行为。
      1 while (x--) ;         // random and silly loop
      2 f(y);
      3 while(test_and_set(important_lock)) ; //spinlock to get a lock
      4 g(z);
      

      显然指令 1 没有约束,并且可以在之前执行 2(并且可能 1 将被优化编译器删除)。但是如果 4 在 3 之前执行,行为将不会像预期的那样。

      为避免这种情况,原子访问会刷新需要数十个周期的指令和内存缓冲区(请参阅memory barrier)。

      1. 如果没有管道,您将支付操作的全部延迟:从内存中读取数据,对其进行修改并将其写回。这种延迟总是会发生,但对于常规内存访问,您可以在此期间进行其他工作,从而在很大程度上隐藏延迟。

      在现代处理器上,原子访问至少需要 100-200 个周期,因此非常昂贵。

      这如何结合在一起?为什么更改变量的锁定速度不明显,而其他任何东西的锁定都如此昂贵?或者,它是否同样昂贵,并且在使用 long 和 double 时应该有一个很大的警告标志,因为它们总是隐含地需要同步?

      常规内存访问不是原子的。只有特定的同步指令是昂贵的。

      【讨论】:

        【解决方案3】:

        同步总是有成本的。并且由于线程唤醒、争夺锁而导致的竞争成本增加,只有一个获得它,其余的进入睡眠,导致大量上下文切换。

        但是,通过在更精细的级别上使用同步,例如 CPU 的 CAS(比较和交换)操作或读取 volatile 变量的内存屏障,可以将这种争用保持在最低限度。更好的选择是在不影响安全性的情况下完全避免同步。

        考虑以下代码:

        synchronized(this) {
            // a DB call
        }
        

        此代码块在执行 IO 时将需要几秒钟的时间才能执行,因此很有可能在其他想要执行同一块的线程之间产生争用。持续时间足以在繁忙的系统中建立大量等待线程队列。

        这就是 Treiber Stack Michael Scott 这样的非阻塞算法存在的原因。他们以最少的同步量完成他们的任务(否则我们会使用更大的同步块来完成)。

        【讨论】:

          猜你喜欢
          • 2012-11-21
          • 1970-01-01
          • 2015-03-27
          • 2021-02-06
          • 1970-01-01
          • 1970-01-01
          • 2013-09-21
          • 2019-01-29
          • 2010-10-29
          相关资源
          最近更新 更多