C++ std::call_once

软件发布|下载排行|最新软件

当前位置:首页IT学院IT技术

C++ std::call_once

Codemaxi   2022-05-28 我要评论

在多线程的环境下,有些时候我们不需要某个函数被调用多次或者某些变量被初始化多次,它们仅仅只需要被调用一次或者初始化一次即可。很多时候我们为了初始化某些数据会写出如下代码,这些代码在单线程中是没有任何问题的,但是在多线程中就会出现不可预知的问题。

bool initialized = false;
void foo() {
    if (!initialized) {
        do_initialize ();  //1
        initialized = true;
    }
}

为了解决上述多线程中出现的资源竞争导致的数据不一致问题,我们大多数的处理方法就是使用互斥锁来处理。只要上面①处进行保护,这样共享数据对于并发访问就是安全的。如下:

bool initialized = false;
std::mutex resource_mutex;

void foo() {
    std::unique_lock<std::mutex> lk(resource_mutex);  // 所有线程在此序列化 
    if(!initialized) {
        do_initialize ();  // 只有初始化过程需要保护 
    }
    initialized = true;
    lk.unlock();
    // do other;
}

但是,为了确保数据源已经初始化,每个线程都必须等待互斥量。为此,还有人想到使用“双重检查锁模式”的办法来提高效率,如下:

bool initialized = false;
std::mutex resource_mutex;

void foo() {
    if(!initialized) {  // 1
        std::unique_lock<std::mutex> lk(resource_mutex);  // 2 所有线程在此序列化 
        if(!initialized) {
            do_initialize ();  // 3 只有初始化过程需要保护 
        }
        initialized = true;
    }
    // do other;  // 4
}

第一次读取变量initialized时不需要获取锁①,并且只有在initialized为false时才需要获取锁。然后,当获取锁之后,会再检查一次initialized变量② (这就是双重检查的部分),避免另一线程在第一次检查后再做初始化,并且让当前线程获取锁。

但是上面这种情况也存在一定的风险,具体可以查阅著名的《C++和双重检查锁定模式(DCLP)的风险》。

对此,C++标准委员会也认为条件竞争的处理很重要,所以C++标准库提供了更好的处理方法:使用std::call_once函数来处理,其定义在头文件#include<mutex>中。std::call_once函数配合std::once_flag可以实现:多个线程同时调用某个函数,它可以保证多个线程对该函数只调用一次。它的定义如下:

struct once_flag
{
    constexpr once_flag() noexcept;
    once_flag(const once_flag&) = delete;
    once_flag& operator=(const once_flag&) = delete;
};

template<class Callable, class ...Args>
void call_once(once_flag& flag, Callable&& func, Args&&... args);

他接受的第一个参数类型为std::once_flag,它只用默认构造函数构造,不能拷贝不能移动,表示函数的一种内在状态。后面两个参数很好理解,第一个传入的是一个Callable。Callable简单来说就是可调用的东西,大家熟悉的有函数、函数对象(重载了operator()的类)、std::function和函数指针,C++11新标准中还有std::bindlambda(可以查看我的上一篇文章)。最后一个参数就是你要传入的参数。 在使用的时候我们只需要定义一个non-local的std::once_flag(非函数局部作用域内的),在调用时传入参数即可,如下所示:

#include <iostream>
#include <thread>
#include <mutex>
 
std::once_flag flag1;
void simple_do_once() {
    std::call_once(flag1, [](){ std::cout << "Simple example: called once\n"; });
}
 
int main() {
    std::thread st1(simple_do_once);
    std::thread st2(simple_do_once);
    std::thread st3(simple_do_once);
    std::thread st4(simple_do_once);
    st1.join();
    st2.join();
    st3.join();
    st4.join();
}

call_once保证函数func只被执行一次,如果有多个线程同时执行函数func调用,则只有一个活动线程(active call)会执行函数,其他的线程在这个线程执行返回之前会处于”passive execution”(被动执行状态)——不会直接返回,直到活动线程对func调用结束才返回。对于所有调用函数func的并发线程,数据可见性都是同步的(一致的)。

但是,如果活动线程在执行func时抛出异常,则会从处于”passive execution”状态的线程中挑一个线程成为活动线程继续执行func,依此类推。一旦活动线程返回,所有”passive execution”状态的线程也返回,不会成为活动线程。(实际上once_flag相当于一个锁,使用它的线程都会在上面等待,只有一个线程允许执行。如果该线程抛出异常,那么从等待中的线程中选择一个,重复上面的流程)。

std::call_once在签名设计时也很好地考虑到了参数传递的开销问题,可以看到,不管是Callable还是Args,都使用了&&作为形参。他使用了一个template中的reference fold(我前面的文章也有介绍过),简单分析:

  • 如果传入的是一个右值,那么Args将会被推断为Args
  • 如果传入的是一个const左值,那么Args将会被推断为const Args&
  • 如果传入的是一个non-const的左值,那么Args将会被推断为Args&

也就是说,不管你传入的参数是什么,最终到达std::call_once内部时,都会是参数的引用(右值引用或者左值引用),所以说是零拷贝的。那么还有一步呢,我们还得把参数传到可调用对象里面执行我们要执行的函数,这一步同样做到了零拷贝,这里用到了另一个标准库的技术std::forward(我前面的文章也有介绍过)。

如下,如果在函数执行中抛出了异常,那么会有另一个在once_flag上等待的线程会执行。

#include <iostream>
#include <thread>
#include <mutex>
 
std::once_flag flag;
inline void may_throw_function(bool do_throw) {
    // only one instance of this function can be run simultaneously
    if (do_throw) {
        std::cout << "throw\n"; // this message may be printed from 0 to 3 times
        // if function exits via exception, another function selected
        throw std::exception();
    }

    std::cout << "once\n"; // printed exactly once, it's guaranteed that
    // there are no messages after it
}
 
inline void do_once(bool do_throw) {
    try {
        std::call_once(flag, may_throw_function, do_throw);
    } catch (...) {
    }
}
 
int main() {
    std::thread t1(do_once, true);
    std::thread t2(do_once, true);
    std::thread t3(do_once, false);
    std::thread t4(do_once, true);
 
    t1.join();
    t2.join();
    t3.join();
    t4.join();
}

std::call_once 也可以用在类中:

#include <iostream>
#include <mutex>
#include <thread>

class A {
 public:
  void f() {
    std::call_once(flag_, &A::print, this);
    std::cout << 2;
  }

 private:
  void print() { std::cout << 1; }

 private:
  std::once_flag flag_;
};

int main() {
  A a;
  std::thread t1{&A::f, &a};
  std::thread t2{&A::f, &a};
  t1.join();
  t2.join();
}  // 122

还有一种初始化过程中潜存着条件竞争:static 局部变量在声明后就完成了初始化,这存在潜在的 race condition,如果多线程的控制流同时到达 static 局部变量的声明处,即使变量已在一个线程中初始化,其他线程并不知晓,仍会对其尝试初始化。很多在不支持C++11标准的编译器上,在实践过程中,这样的条件竞争是确实存在的,为此,C++11 规定,如果 static 局部变量正在初始化,线程到达此处时,将等待其完成,从而避免了 race condition,只有一个全局实例时,对于C++11,可以直接用 static 而不需要 std::call_once,也就是说,在只需要一个全局实例情况下,可以成为std::call_once的替代方案,典型的就是单例模式了:

template <typename T>
class Singleton {
 public:
  static T& Instance();
  Singleton(const Singleton&) = delete;
  Singleton& operator=(const Singleton&) = delete;

 private:
  Singleton() = default;
  ~Singleton() = default;
};

template <typename T>
T& Singleton<T>::Instance() {
  static T instance;
  return instance;
}

今天的内容就到这里了。

参考:

std::call_once - C++中文 - API参考文档 (apiref.com)

Copyright 2022 版权所有 软件发布 访问手机版

声明:所有软件和文章来自软件开发商或者作者 如有异议 请与本站联系 联系我们