ICode9

精准搜索请尝试: 精确搜索
首页 > 其他分享> 文章详细

dispatch_once 的秘密

2022-07-22 20:40:48  阅读:155  来源: 互联网

标签:predicate 调用 秘密 dispatch 线程 CPU once


原文: https://www.mikeash.com/pyblog/friday-qa-2014-06-06-secrets-of-dispatch_once.html

API

dispatch_once的行为就在名字中。它只做一次事情,而且只做一次。
它需要两个参数。第一个是跟踪 "一次 "的谓词。第二个是在第一次调用时要执行的块

    static dispatch_once_t predicate。
    dispatch_once(&predicate, ^{
        //一些一次性的任务
    });

这是一个很好的API,用于lazy init 共享状态,无论它是一个全局字典、一个单例、一个缓存,还是其他任何需要在第一时间进行一些设置的东西。
在一个单线程的世界里,这个调用有点无聊,可以用一个简单的if语句来代替。然而,我们生活在一个多线程的世界,dispatch_once是线程安全的。它可以保证多个线程同时调用dispatch_once时,只会执行一次该块,所有线程都会等待执行完成后再返回dispatch_once。即使这一点自己也不难做到,但dispatch_once的速度也非常快,这一点确实很难做到。

单线程的实现

让我们先看看这个函数的一个简单的单线程实现。这在实际意义上是无用的,但对语义有一个具体的观察是好的。注意,dispatch_once_t只是一个long的类型定义,初始化为0,其他值的含义由实现者决定。下面是这个函数:

    void SimpleOnce(dispatch_once_t *predicate, dispatch_block_t block) {
        if(!*predicate) {
            block()。
            *predicate = 1;
        }
    }

实现起来很简单:如果predicate包含0,就调用块并将其设置为1。随后的调用将看到这个1,并且不再调用该块。这正是我们想要的,除了在多线程环境下完全不安全之外。两个线程可能同时点击if语句,导致它们同时调用该块,这是很糟糕的。不幸的是,就像通常的情况一样,让这段代码成为线程安全的代码意味着对性能的巨大打击。

性能

在讨论dispatch_once的性能时,实际上有三种不同的情况需要考虑。

  1. 第一次调用带有给定谓词的dispatch_once,执行该块。
  2. 在第一次调用之后,但在区块执行完毕之前调用 dispatch_once。在这里,调用者必须等待区块完成后才能继续。
  3. 在第一次调用后,在块执行完毕后调用dispatch_once。不需要等待,他们可以立即进行。

场景1的性能在很大程度上并不重要,只要它不是慢得离谱就可以了。毕竟,它只发生一次。

场景2的性能也同样不重要。它可能会发生几次,但它只发生在块执行的时候。在大多数情况下,它根本就不会发生。如果它发生了,也可能只发生一次。即使是在很多线程同时调用dispatch_once,并且块需要很长时间才能执行的酷刑测试中,调用的数量仍然会被限制在数千次。这些调用无论如何都要等待区块,所以即使它们在执行时消耗了一些不必要的CPU时间也没什么大不了的。

场景3的性能是非常重要的。这种性质的调用在程序执行过程中可能会发生几百万甚至几十亿次。我们希望能够使用 dispatch_once 来保护一次性的计算,因为这些计算的结果会在所有地方被使用。理想情况下,在一个计算上使用 dispatch_once 应该不会比明确地提前进行计算并从某个全局变量中返回结果花费更多。换句话说,一旦你遇到第3种情况,我们真的希望这两块代码的性能是相同的。

  id gObject;

  void Compute(void) {
      gObject = ...;
  }

  id Fetch(void) {
      return gObject;
  }

  id DispatchFetch(void) {
      static id object;
      static dispatch_once_t predicate;
      dispatch_once(&predicate, ^{
          object = ...;
      });
      return object;
  }

当内联和优化时,SimpleOnce函数接近于实现这一目标。在我的测试中,在我的电脑上,它需要大约半纳秒的时间来执行。那么,这将是线程安全版本的黄金标准。

使线程安全的标准方法是用一个锁包围所有对共享数据的访问。这里有点棘手,因为在它所保护的数据旁边没有一个好地方可以放锁。 dispatch_once_t只是一个长条,没有空间放锁。
我们可以修改API,让它接受一个包含锁和标志的结构。但为了保留与dispatch_once相同的签名,并且因为这只是说明性的代码,我决定用一个全局锁来代替。这段代码使用一个静态的pthread_mutex_t来保护对谓词的所有访问。在一个真正的程序中,有许多不同的谓词,这将是一个糟糕的主意,因为不相关的谓词仍然会互相等待。对于一个快速的例子,我只用一个谓词进行测试,这很好。代码和之前的一样,只是周围有一个锁。

    void LockedOnce(dispatch_once_t *predicate, dispatch_block_t block) {
        static pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
        pthread_mutex_lock(&mutex)。
        if(!*predicate) {
            block()。
            *predicate = 1;
        }
        pthread_mutex_unlock(&mutex)。
    }

这段代码是线程安全的,但不幸的是,它的速度要慢很多。在我的电脑上,每次调用大约需要30纳秒,而以前的版本只需要半纳秒。锁定是相当快的,但当纳秒计数时就不快了。

自旋锁

自旋锁是一种实现锁的方式,试图将锁本身的开销降到最低。这个名字来自于自旋锁的实现在需要等待时如何在锁上 "旋转",反复地轮询它,看它是否被释放。一个正常的锁会与操作系统协调,让等待的线程休眠,并在锁被释放时唤醒它们。这可以节省CPU时间,但额外的协调并不是免费的。通过减少这种协调,自旋锁在锁没有被保持的情况下节省了时间,但代价是当多个线程试图同时取得锁时,效率会降低。
OS X通过OSSpinLock函数为自旋锁提供了一个方便的API。用OSSpinLock实现LockedOnce只是改变了几个名字而已。

    void SpinlockOnce(dispatch_once_t *predicate, dispatch_block_t block) {
        static OSSpinLock lock = OS_SPINLOCK_INIT;
        OSSpinLockLock(&lock)。
        if(!*predicate) {
            block()。
            *predicate = 1;
        }
        OSSpinLockUnlock(&lock)。
    }

这是一个相当大的改进,在我的电脑上每次调用的时间是6.5纳秒,而pthread_mutex版本每次调用是30纳秒。然而,它仍然比不安全版本的半纳秒时间短得多。

原子操作

原子操作是低级别的CPU操作,即使没有锁也是线程安全的。(从技术上讲,它们在硬件层面上使用锁,但这是一个实现细节。) 它们是你首先用来实现锁的东西。当锁的开销太大,直接使用原子操作可以提高性能。没有锁的线程编程是非常棘手的,所以除非你真的非常需要,否则这样做并不是一个好主意。在这种情况下,我们谈论的是一个可以得到大量使用的操作系统库,所以它可能符合条件。
原子操作的构建块是比较和交换。这是一个单一的操作,执行的是等价的。

    BOOL CompareAndSwap(long *ptr, long testValue, long newValue) {
        if(*ptr == testValue) {
            *ptr = newValue;
            return YES;
        }
        return NO;
    }

换句话说,它测试内存中的一个位置是否包含一个特定的值,如果是,就用一个新的值来替换它。它返回该值是否匹配。因为比较和交换是作为CPU的原子指令实现的,所以你可以保证,如果多个线程都试图对一个内存位置进行同样的比较和交换,只有一个会成功。
这个版本的函数的实现策略是给谓词分配三个值。0表示它从未被碰过。1表示该块目前正在执行,任何调用者应该等待它。2表示该区块已经完成,所有调用者可以返回。
一个比较和交换将被用来检查0,并在这种情况下原子地过渡到1。如果成功了,那么该线程就是第一个进行调用的线程,然后它将运行该块。在运行该块后,它将把谓词设置为2,以表明它已经完成。
如果比较和交换失败,那么它将直接进入一个循环,反复检查2,直到成功。这将导致它等待其他线程完成执行该块。
第一步是将谓词指针转换为易失性指针,以说服编译器,该值可能被其他线程在函数的中间改变。

    void AtomicBuiltinsOnce(dispatch_once_t *predicate, dispatch_block_t block) {
        volatile dispatch_once_t *volatilePredicate = predicate。

然后是比较和交换。Gcc和clang都提供了各种以__sync开头的内置函数来实现原子操作。还有一些以__atomic开头的新函数,但在这个实验中我坚持使用我所知道的。这个调用在谓词上做了一个原子比较和交换,测试0,如果匹配则设置为1。

        if(__sync_bool_compare_and_swap(volatilePredicate, 0, 1)) {

如果操作成功,该函数返回true。在这种情况下,这意味着谓词是0,而且这次调用是第一次。这意味着下一个任务是调用块。

            block()。

一旦区块完成,就该把谓词设置为2,以向任何等待的线程以及任何未来的调用者表明这一事实。然而,在此之前,我们需要一个内存屏障来确保每个人都能看到正确的读写顺序。稍后会有更多关于这个的内容。__sync_synchronize内置函数执行了一个内存屏障。

          __sync_synchronize()

然后可以安全地设置谓词。

            *volatilePredicate = 2;
        } else {

如果predicate不是0,那么进入一个循环,等待它变成2。如果它已经是2了,这个循环将立即终止。如果它是1,那么它将坐在循环上,不断地重新测试predicate的值,直到它变成2。

            while(*volatilePredicate != 2)
                ;

在返回之前,这里也需要有一个内存屏障,以配合上面的屏障。同样的,更多关于这个的内容将在稍后进行。

            __sync_synchronize()。
        }
    }

这就可以了,而且应该是安全的。(无锁线程代码很棘手,我不想在没有更多考虑的情况下直接宣布。但这是一个相当简单的方案,而且无论如何,我们并不是要在这里建立一个值得生产的东西。)
性能如何?结果是,不是那么好。在我的电脑上,每次调用需要20纳秒,比自旋锁版本要高得多。
早期保释
有一个明显的优化可以应用于这段代码。常见的情况是当谓词包含2时,但代码首先测试0。通过首先测试2并提前跳出,常见的情况可以变得更快。这方面的代码很简单:在函数的顶部增加一个2的检测,如果成功了,在一个内存屏障后返回。

    void EarlyBailoutAtomicBuiltinsOnce(dispatch_once_t *predicate, dispatch_block_t block) {
        if(*predicate == 2) {
            __sync_synchronize();
            return;
        }

        volatile dispatch_once_t *volatilePredicate = predicate;

        if(__sync_bool_compare_and_swap(volatilePredicate, 0, 1)) {
            block();
            __sync_synchronize();
            *volatilePredicate = 2;
        } else {
            while(*volatilePredicate != 2)
                ;
            __sync_synchronize();
        }
    }

与第一版代码相比,这是一个不错的改进,每次调用约为11.5纳秒。然而,这仍然比半纳秒的目标慢得多,甚至比自旋锁代码还慢。
内存障碍并不是免费的,这就解释了为什么这段代码比目标慢了这么多。至于为什么它比自旋锁慢,有不同种类的内存障碍可用。__sync_synchronize产生了一个mfence指令,这是最偏执的指令,可以处理诸如SSE4流式读写这样的异类,而OSSpinLock则坚持使用适合普通代码的便宜的指令。我们可以摆弄一下这段代码中使用的精确屏障,以获得更好的性能,但很明显,成本仍然会比我们想要的高,所以我将跳过这一点。

不安全的早期版本

让我们再看一个版本的代码。它与前一个版本相同,只是它摆脱了内存障碍。

    void UnsafeEarlyBailoutAtomicBuiltinsOnce(dispatch_once_t *predicate, dispatch_block_t block) {
     if(*predicate == 2)
         return;

     volatile dispatch_once_t *volatilePredicate = predicate;

     if(__sync_bool_compare_and_swap(volatilePredicate, 0, 1)) {
         block();
         *volatilePredicate = 2;
     } else {
         while(*volatilePredicate != 2)
             ;
     }
 }

不出所料,这和SimpleOnce的执行速度一样快,每次调用大约半纳秒。由于*predicate == 2是迄今为止最常见的情况,几乎每次调用都只是执行该检查并返回。在块已经被调用的情况下,这与SimpleOnce执行的工作量相同。
然而,正如它的名字所表明的,缺乏内存障碍使它不安全。为什么呢?

分支预测、失序执行

我们想象,我们的CPU是简单的机器。我们告诉它们做一件事,它们就去做。然后我们告诉它们做下一件事,它们也会做。这样重复下去,直到我们感到厌烦或停电。
很久以前,这确实是事实。老式的CPU真的是简单的机器,其工作方式正是如此。它们取来一条指令,然后执行它。然后他们获取下一条指令,并执行它。
不幸的是,虽然这种方法简单、便宜、容易,但它也不是非常快。摩尔定律保佑我们有越来越多的晶体管可以堆积到CPU中。8086是由大约29,000个晶体管构成的。一个英特尔哈斯韦尔架构的CPU包含远远超过10亿个晶体管。
市场希望有更好的电脑游戏(以及一些人希望更快速地执行各种不值得一提的无聊商业任务),要求CPU制造商利用这些进步来获得更好的性能。现代CPU是几十年来将更多的晶体管转化为更快的计算机的工作成果。

这就快多了! 有了足够的晶体管,事情可以变得更加复杂,许多指令同时并行执行。
甚至超越这个范围,如果看起来能加快速度,指令可以完全不按顺序执行。与上面的简化例子不同,现实世界的指令往往需要更多的步骤,而且在步骤的多少上也有很大的差异。例如,对主内存的读写可能需要大量的时间。通过执行指令流中后来的其他工作,CPU可以从事生产性工作,而不是闲置。正因为如此,CPU可能发现不按指令流中相应指令的顺序进行内存读写是有利的。
所有这些东西的最终结果是,你的代码并不总是按照它看起来的顺序运行的。如果你写道

    x = 1;
    y = 2;

你的CPU可以先执行对y的写入。在某些情况下,编译器也可以对这样的语句重新排序,但即使你消除了这一点,CPU仍然可以做到。如果你的系统中有一个以上的CPU(现在我们几乎总是这样),那么其他的CPU会看到这些失序的写入。即使写入是按顺序进行的,其他CPU也可以进行不按顺序的读取。把这一切放在一起,另一个正在读取x和y的线程可以看到y=2,同时仍然看到x的旧值。
有时你绝对需要这些值以正确的顺序写入和读取,这就是内存屏障的作用。在上面的代码中添加一个内存屏障,可以确保x先被写入。

    x = 1;
    memory_barrier()。
    y = 2;

同样地,在读取时加入一个障碍,可以确保以适当的顺序进行读取。

    use(x);
    memory_barrier()。
    use(y)。

然而,由于内存屏障的全部目的是挫败CPU的加速尝试,所以有一个固有的性能缺陷。
这一点在dispatch_once和懒惰初始化中起作用,因为有多个读和写是按顺序进行的,而它们的顺序是非常重要的。例如,典型的懒惰对象初始化模式看起来像。

    static SomeClass *obj;
    static dispatch_once_t predicate;
    dispatch_once(&predicate, ^{ obj = [[SomeClass alloc] init]; }) 。
    [obj doStuff]。

如果obj是在predicate之前被读取的,那么这段代码就有可能在它仍然包含nil的时候读取它,就在另一个线程将最终值写入变量并设置predicate之前。然后,这段代码可以将predicate理解为表示工作已经完成,从而继续使用未初始化的nil。甚至可以想象,这段代码可以为obj读取正确的值,但从为该对象分配的内存中读取未初始化的值,在试图发送doStuff时导致崩溃。
因此,dispatch_once需要一个内存屏障。但正如我们所看到的,内存屏障的速度相对较慢,如果我们能避免的话,我们不想在普通情况下付出这个代价。

分支预测和投机执行

对于线性指令序列来说,流水线式的失序模型非常好用,但对于条件性分支来说,问题就来了。CPU不知道从哪里开始获取更多的指令,直到分支条件可以被评估,这通常取决于紧接着的指令。CPU必须停下来,等待前面的工作完成,然后评估分支条件并继续执行。这被称为流水线停滞,并可能导致显著的性能损失。
为了弥补这一缺陷,CPU进行了推测性执行。当它们看到一个条件性分支时,它们会猜测它们认为该分支会向哪个方向发展。现代CPU拥有复杂的分支预测硬件,通常可以在90%以上的时间内猜对。它们从猜测的分支开始执行指令,而不是仅仅等待分支条件被评估。如果结果证明猜测是正确的,那么它就继续执行。如果猜测是错误的,它就扔掉所有的推测执行,在另一个分支上重新开始。
具体来说,这种情况在dispatch_once的读取端起作用,也就是我们想要尽可能快地完成的那一端。有一个关于predicate值的条件性分支。CPU应该预测到该分支将采取普通路径,即绕过运行块并立即返回。在这个分支的猜测执行过程中,CPU可能会在另一个线程初始化之前从内存中加载后续值。如果这个猜测最终是正确的,那么它就会提交使用未初始化值的推测性执行。

不平衡的屏障

写入障碍通常需要对称。写入端有一个,以确保写的顺序正确,读取端也有一个,以确保读的顺序正确。然而,在这种特殊情况下,我们的性能需求是完全不对称的。我们可以容忍在写方面的巨大减速,但我们希望在读方面的速度尽可能快。
诀窍是击败导致该问题的投机执行。当分支预测不正确时,投机执行的结果会被丢弃,这意味着丢弃内存中潜在的未初始化的值。如果dispatch_once能在初始化值对所有CPU可见后强制进行分支错误预测,那么问题就可以避免了。
在谓词条件性分支后最早可能的投机性内存读取和条件性分支实际被评估之间有一个关键的时间间隔。这个时间间隔的确切长度取决于特定CPU的设计,但一般来说最多只有几十个CPU周期。
如果在写出初始化值和写出最终值给predicate之间,写的一方至少要等待这么多时间,那么一切都很好。不过,要确保这一点变得有点棘手,因为所有那些疯狂的失序执行又一次出现了。
在英特尔CPU上,dispatch_once滥用了cpuid指令来达到这个目的。cpuid指令的存在是为了获得关于CPU的身份和能力的信息,但它也强行将指令流序列化,并需要相当长的时间来执行,在一些CPU型号上需要数百个周期,这足以完成这项工作了。
如果你看一下dispatch_once的实现,你会发现在读取方面没有任何障碍。

    DISPATCH_INLINE DISPATCH_ALWAYS_INLINE DISPATCH_NONNULL_ALL DISPATCH_NOTHROW
    void
    _dispatch_once(dispatch_once_t *predicate, dispatch_block_t block)
    {
        if (DISPATCH_EXPECT(*predicate, ~0l) != ~0l) {
            dispatch_once(predicate, block);
        }
    }
    #define dispatch_once _dispatch_once

这是在头文件中,并且总是内联到调用者中。DISPATCH_EXPECT是一个宏,它告诉编译器发出代码,告诉CPU *predicate是~0l的分支是更可能的路径。这可以提高分支预测的成功率,从而提高性能。最终,这只是一个普通的if语句,没有任何形式的障碍。性能测试证明了这一点:对真正的dispatch_once的调用符合0.5ns的目标。
写入方面的问题可以在实现中找到。在调用块之后,在做其他事情之前,dispatch_once使用这个宏。

    dispatch_atomic_maximally_synchronizing_barrier()。

在英特尔上,该宏生成了一条cpuid指令,当针对其他CPU架构时,它生成了适当的汇编。

总结

多线程是一个奇怪而复杂的地方,由于现代CPU的设计可以在你背后做很多事情,所以变得更加复杂。内存屏障允许你在你真正需要事情以某种顺序发生时通知硬件,但它们是有代价的。dispatch_once的独特要求允许它以一种非常规的方式解决这个问题。通过在相关的内存写入之间等待足够长的时间,它可以确保读者总是看到一个一致的画面,而不需要在每次访问时支付内存屏障的开销。

标签:predicate,调用,秘密,dispatch,线程,CPU,once
来源: https://www.cnblogs.com/pencilCool/p/16507879.html

本站声明: 1. iCode9 技术分享网(下文简称本站)提供的所有内容,仅供技术学习、探讨和分享;
2. 关于本站的所有留言、评论、转载及引用,纯属内容发起人的个人观点,与本站观点和立场无关;
3. 关于本站的所有言论和文字,纯属内容发起人的个人观点,与本站观点和立场无关;
4. 本站文章均是网友提供,不完全保证技术分享内容的完整性、准确性、时效性、风险性和版权归属;如您发现该文章侵犯了您的权益,可联系我们第一时间进行删除;
5. 本站为非盈利性的个人网站,所有内容不会用来进行牟利,也不会利用任何形式的广告来间接获益,纯粹是为了广大技术爱好者提供技术内容和技术思想的分享性交流网站。

专注分享技术,共同学习,共同进步。侵权联系[81616952@qq.com]

Copyright (C)ICode9.com, All Rights Reserved.

ICode9版权所有