进行延迟初始化——std::call_once

有时我们有一些特定的代码段,可以在多个线程间并行执行,不过其中有一些功能需要在进行执行前,完成一次初始化的过程。一个很简单的方式,就是在程序进入并行前,执行已存在的准备函数。

这种方法有如下几个缺点:

  • 当并行线程来自于一个库,使用者肯定会忘记调用准备函数。这样会让库函数不是那么容易的让人使用。

  • 当准备函数特别复杂,并且在某些条件下我们要通过条件来判断,是否要执行这个准备函数。

本节中,我们将来了解一下std::call_once,其能帮助使用简单且优雅的方式解决上面提到的问题。

How to do it...

我们将完成一个程序,我们使用多线程对同一段代码进行执行。虽然这里执行的是相同的代码,但是我们的准备函数只需要运行一次:

  1. 包含必要的头文件,并声明所使用的命名空间:

    #include <iostream>
    #include <thread>
    #include <mutex>
    #include <vector>
    
    using namespace std;
  2. 我们将使用std::call_once。为了对其进行使用,需要对once_flag进行实例化。在对指定函数使用call_once时,需要对所有线程进行同步:

    once_flag callflag;
  3. 现在来定义一个只需要执行一次的函数,就让这个函数打印一个感叹号吧:

    static void once_print()
    {
        cout << '!';
    }
  4. 再来定义所有线程都会运行的函数。首先,要通过std::call_once调用once_printcall_once需要我们之前定义的变量callflag。其会被用来对线程进行安排:

    static void print(size_t x)
    {
        std::call_once(callflag, once_print);
        cout << x;
    }
  5. OK,让我们启动10个线程,并且让他们使用print函数进行执行:

    int main()
    {
        vector<thread> v;
    
        for (size_t i {0}; i < 10; ++i) {
            v.emplace_back(print, i);
        }
    
        for (auto &t : v) { t.join(); }
        cout << '\n';
    }
  6. 编译并运行程序,我们就会得到如下的输出。首先,我们可以看到由once_print函数打印出的感叹号。然后,我么可以看到线程对应的ID号。另外,其会对所有线程进行同步,所以不会有ID在once_print函数执行前被打印:

    $ ./call_once
    !1239406758

How it works...

std::call_once工作原理和栅栏类似。其能对一个函数(或是一个可调用的对象)进行访问。第一个线程达到call_once的线程会执行对应的函数。直到函数执行结束,其他线程才能不被call_once所阻塞。当第一个线程从准备函数中返回后,其他线程也就都结束了阻塞。

我们可以对这个过程进行安排,当有一个变量决定其他线程的运行时,线程则必须对这个变量进行等待,直到这个变量准备好了,所有变量才能运行。这个变量就是once_flag callflag;。每一个call_once都需要一个once_flag实例作为参数,来表明预处理函数是否运行了一次。

另一个细节是:如果call_once执行失败了(因为准备函数抛出了异常),那么下一个线程则会再去尝试执行(这种情况发生在下一次执行不抛出异常的时候)。

Last updated