文章目录
- 一、简介
- 二、原理
- 2.1 示例
- 2.2 call_once源码详解
- 2.3 once_flag源码详解
- 三、Linux内核中的 DO_ONCE 机制
一、简介
std::call_once 和 std::once_flag 是 C++11 中引入的线程安全的函数和类型,用于确保某个函数只被调用一次。
std::once_flag 是一个类型,用于标记一段代码是否已经被执行过。它必须通过引用传递给 std::call_once 函数,以确保在多线程环境下仅仅执行一次。
std::call_once 函数接受两个参数:一个可调用对象(可以是函数、lambda 表达式等)和一个 std::once_flag 对象的引用。该函数会检查 std::once_flag 对象是否被设置过,如果没有,就调用可调用对象,并设置 std::once_flag 对象为已设置状态。
使用 std::call_once 和 std::once_flag 可以避免在多线程环境下多次执行同一个函数,从而提高程序性能和正确性。
下面是一个简单的示例:
#include <iostream>
#include <thread>
#include <mutex>std::once_flag flag;void do_something()
{//call_once中的 lambda 表达式只执行一次std::call_once(flag, []() {std::cout << "do_something() called once" << std::endl;});std::cout << "Thread id" << std::this_thread::get_id() << std::endl;
}int main()
{std::thread t1(do_something);std::thread t2(do_something);t1.join();t2.join();return 0;
}
在这个例子中,我们定义了一个名为 do_something 的函数,并将其作为参数传递给 std::call_once 函数。 std::once_flag 对象被声明为全局变量,以便在多个线程之间共享。
当第一次调用 do_something 函数时,std::call_once 将检查 std::once_flag 是否已经被设置过。由于初始状态为未设置,因此 std::call_once 将执行提供的可调用对象——这里是一个 lambda 表达式,输出一条消息表示函数被调用了一次。
当第二次调用 do_something 函数时,std::call_once 将不再执行提供的可调用对象,因为 std::once_flag 已经被设置过。
通过这种方式,我们可以确保 do_something 函数中std::call_once 提供的可调用对象被调用一次,无论有多少个线程同时调用它。
/modern_c++$ ./a.out
do_something() called once
Thread id139891421738688
Thread id139891413345984
二、原理
2.1 示例
#include <iostream>
#include <thread>
#include <vector>
#include <mutex>class Singleton
{
public://使用了 std::call_once 函数,因此在多个线程同时调用时,只有一个线程会创建单例对象 instance_,即只有一个线程执行函数init()//其他线程会直接返回之前创建的单例对象 instance_,从而保证单例对象只被创建一次static Singleton& getInstance(){std::call_once(flag_, &Singleton::init);return *instance_;}Singleton(const Singleton&) = delete;Singleton& operator=(const Singleton&) = delete;private:Singleton() { std::cout << "Singleton instance created.\n"; }static void init(){instance_ = new Singleton();}//在类的定义中,一个静态成员变量必须由该类声明为static//并且通常还需要在类外初始化,这意味着在类的定义中仅指定其类型和名称static std::once_flag flag_;static Singleton* instance_;
};//在 class 外初始化 static 成员变量
std::once_flag Singleton::flag_;
Singleton* Singleton::instance_ = nullptr;void thread_func()
{//调用 Singleton::getInstance() 函数来获取单例对象的引用Singleton& singleton = Singleton::getInstance();std::cout << "Singleton instance address: " << &singleton << "\n";
}int main()
{std::vector<std::thread> threads;const int num_threads = 10;for (int i = 0; i < num_threads; ++i){//threads将 `thread_func` 函数作为线程函数,创建多个线程并启动它们:threads.emplace_back(thread_func);}for (auto& t : threads){t.join();}return 0;
}
modern_c++$ ./a.out
Singleton instance created.
Singleton instance address: 0x7f1310000b70
Singleton instance address: 0x7f1310000b70
Singleton instance address: 0x7f1310000b70
Singleton instance address: 0x7f1310000b70
Singleton instance address: 0x7f1310000b70
Singleton instance address: 0x7f1310000b70
Singleton instance address: 0x7f1310000b70
Singleton instance address: 0x7f1310000b70
Singleton instance address: 0x7f1310000b70
Singleton instance address: 0x7f1310000b70
在上面的代码中,我们使用 std::call_once 函数来保证单例模式在多线程环境中的正确性。当第一个线程调用 getInstance() 函数时,会执行 init() 函数来创建单例对象,同时将 flag_ 标志位设置为“已调用”。在后续的调用中,std::call_once 函数会检查 flag_ 标志位是否已经被设置,如果已经被设置,则直接返回之前创建的单例对象,不会再次执行 init() 函数,从而保证单例对象只被创建一次。
在 main() 函数中,我们创建了多个线程,并将 thread_func 函数作为线程函数,分别启动这些线程。在 thread_func 函数中,我们调用 Singleton::getInstance() 函数来获取单例对象的引用,并输出它的地址。由于 getInstance() 函数使用了 std::call_once 函数,因此在多个线程同时调用时,只有一个线程会创建单例对象,其他线程会直接返回之前创建的单例对象,从而保证单例对象只被创建一次。
使用 std::call_once 函数可以非常方便地实现线程安全的单例模式,通过在多个线程同时调用时只创建一个对象来避免资源竞争和数据不一致的问题。在多线程环境中使用单例模式时,可以将 getInstance() 函数作为线程函数,在多个线程中同时调用,以验证单例对象的创建情况。
示例代码中的析构函数只会执行一次,因为 Singleton::init函数只执行一次:
static void init(){instance_ = new Singleton();}
在这个例子中,Singleton 的构造函数只会执行一次,是因为在使用 std::call_once 函数时,该函数会使用一个 std::once_flag 类型的变量来标记是否已经执行过初始化函数,从而保证初始化函数只会被执行一次。
具体来说,当多个线程同时调用 Singleton::getInstance() 函数时,只有其中一个线程会执行 std::call_once 函数指定的初始化函数 &Singleton::init,其他线程会阻塞等待初始化函数执行完毕。初始化函数执行完毕之后,所有线程都会返回之前创建的单例对象 instance_ 的引用,从而保证单例对象只被创建一次。
在这个例子中,Singleton 的构造函数在初始化函数 &Singleton::init 中被调用,因此只会被执行一次。在其他线程中,由于 instance_ 已经被创建,因此不会再次调用构造函数。
备注:在C++中,类的静态成员变量是与类相关联的变量,而不是与对象相关联的。它们被视为该类的所有对象共享的变量,并且只有一个副本存在于内存中。静态成员变量通常用于跟踪某些信息,例如,表示所有实例之间共享的计数器或全局配置设置等。
在类的定义中,一个静态成员变量必须由该类声明为static,并且通常还需要在类外初始化,这意味着在类的定义中仅指定其类型和名称。
2.2 call_once源码详解
template<typename _Callable, typename... _Args>voidcall_once(once_flag& __once, _Callable&& __f, _Args&&... __args){// Closure type that runs the functionauto __callable = [&] {std::__invoke(std::forward<_Callable>(__f),std::forward<_Args>(__args)...);};once_flag::_Prepare_execution __exec(__callable);// XXX pthread_once does not reset the flag if an exception is thrown.if (int __e = __gthread_once(&__once._M_once, &__once_proxy))__throw_system_error(__e);}
std::call_once 函数是一个 C++ 标准库函数,它接受三个参数:
(1)std::once_flag& flag:一个标志位对象的引用,用于记录该函数是否已经被调用过。
(2)Callable&& func:一个可调用对象,即函数或函数对象,用于执行需要仅执行一次的代码。
(3)Args&&… args:可变模板参数包,用于传递给 func 函数的参数。
函数的实现分为以下步骤:
(1)创建一个 lambda 表达式 __callable,该表达式调用 std::__invoke 函数来执行 __f 函数并传递参数 __args…。
(2)创建一个 once_flag::_Prepare_execution 对象 __exec,该对象将在析构时执行 __callable。
(3)调用 __gthread_once 函数来执行一次性操作,如果操作已经被执行过,则不执行。如果在执行过程中发生异常,则不会重置 __once 标志位。
(4)如果 __gthread_once 函数返回一个非0 的值,则抛出一个系统错误异常。
下面是对代码实现的详细解释:
template<typename _Callable, typename... _Args>
void call_once(once_flag& __once, _Callable&& __f, _Args&&... __args)
{// 创建一个可调用对象 __callable,该对象调用 __f 函数并传递参数 __args...auto __callable = [&] {std::__invoke(std::forward<_Callable>(__f), std::forward<_Args>(__args)...);};// 创建一个 __exec 对象,并在其析构时调用 __callableonce_flag::_Prepare_execution __exec(__callable);// 调用 __gthread_once 函数执行一次性操作if (int __e = __gthread_once(&__once._M_once, &__once_proxy))__throw_system_error(__e);
}
在实现中,首先使用 lambda 表达式创建了一个可调用对象 __callable,该对象调用 std::__invoke 函数来执行传入的可调用对象 __f 并传递参数 __args…。这个可调用对象将在后续的线程安全的执行中使用。
接着,创建了一个 once_flag::_Prepare_execution 对象 __exec,该对象的构造函数接受一个可调用对象,并在其析构时调用该对象。这个对象的作用是确保在 std::call_once 函数执行结束后,可调用对象 __callable 被正确地执行。
然后,调用了 __gthread_once 函数来执行一次性操作。该函数接受两个参数:一个指向 __once._M_once 变量的指针,以及一个指向 __once_proxy 函数的指针。__once._M_once 是一个原子类型的变量,用于记录一次性操作是否已经被执行过。__once_proxy 函数是一个辅助函数,其作用是调用 __exec 对象的可调用对象。
如果 __gthread_once 函数返回一个非0 的值,则说明执行失败,此时会抛出一个系统错误异常。
需要注意的是,std::call_once 函数的实现依赖于操作系统和编译器提供的线程库。在不同的平台和编译器下,__gthread_once 函数的实现可能有所不同。但是,无论在哪个平台和编译器下,std::call_once 函数都会保证传入的可调用对象只会被执行一次。
2.3 once_flag源码详解
/// Flag type used by std::call_oncestruct once_flag{constexpr once_flag() noexcept = default;/// Deleted copy constructoronce_flag(const once_flag&) = delete;/// Deleted assignment operatoronce_flag& operator=(const once_flag&) = delete;private:// For gthreads targets a pthread_once_t is used with pthread_once, but// for most targets this doesn't work correctly for exceptional executions.__gthread_once_t _M_once = __GTHREAD_ONCE_INIT;struct _Prepare_execution;template<typename _Callable, typename... _Args>friend voidcall_once(once_flag& __once, _Callable&& __f, _Args&&... __args);};
once_flag 结构体通过提供同步机制来确保特定任务仅在首次执行时被执行一次,无论有多少个线程尝试执行它。它通过提供一个同步机制,允许线程等待特定任务被执行,并在任务被第一次执行后将其标记为已完成。
once_flag 结构体具有删除的拷贝构造函数和赋值运算符,这意味着它不能从另一个 once_flag 实例中拷贝或赋值。
在内部,once_flag 结构体包含一个 _M_once 成员变量,类型为 __gthread_once_t,它由底层线程库(在本例中为 gthreads)用于处理目标任务的同步和执行。
call_once 函数是 once_flag 的友元函数,它接受一个 once_flag 实例以及一个目标函数和其参数。它确保目标函数仅被执行一次,并对调用它的所有线程进行同步访问。
其中:
std::call_once 函数需要访问 std::once_flag 类的私有成员 _M_once,以确保可调用对象只被执行一次。但是,将 _M_once 成员声明为公共成员会破坏 std::once_flag 类的封装性,而将其声明为私有成员则无法从 std::call_once 函数中访问。
因此,为了解决这个问题,C++ 标准库将 std::call_once 函数声明为 std::once_flag 类的友元函数。这样,std::call_once 函数就可以访问 std::once_flag 类的私有成员 _M_once,而不会破坏 std::once_flag 类的封装性。
通过将 std::call_once 声明为 std::once_flag 类的友元函数,可以保证 std::call_once 函数与 std::once_flag 类紧密地结合在一起,形成一个可靠的只执行一次的函数机制。同时,它也使得使用 std::call_once 函数更加方便,用户只需要提供一个 std::once_flag 对象和一个可调用对象作为参数,即可实现只执行一次的函数调用。
C++ 中友元函数:
在 C++ 中,友元函数是一种特殊的函数,它可以访问类的私有成员和保护成员。友元函数可以作为类的非成员函数或其他类的成员函数来声明。在函数声明前加上 friend 关键字即可将其声明为友元函数。
友元函数对于实现一些特殊的功能非常有用,例如操作符重载、单例模式、只执行一次函数等等。友元函数可以访问类的私有成员和保护成员,这使得它们可以直接操作类的内部数据,而不需要通过类的公共接口来访问。这样可以提高程序的效率和灵活性,同时也可以保证类的封装性不被破坏。
需要注意的是,友元函数不是类的成员函数,因此它没有 this 指针,也不能直接访问类的成员变量和成员函数。友元函数可以通过类的对象、指针或引用来访问类的成员变量和成员函数,或者将类的成员变量和成员函数作为参数传递给友元函数。
总之,友元函数是一种特殊的函数,它可以访问类的私有成员和保护成员,但不是类的成员函数。友元函数可以通过类的对象、指针或引用来访问类的成员变量和成员函数,或者将类的成员变量和成员函数作为参数传递给友元函数。友元函数对于实现一些特殊的功能非常有用,但需要谨慎使用,以避免破坏类的封装性。
三、Linux内核中的 DO_ONCE 机制
在Linux 内核中也有对应的机制:DO_ONCE 宏。
DO_ONCE 宏是 Linux 内核中实现一次性代码执行的一种机制,可以保证多个线程同时调用宏时只有一个线程会执行代码,从而避免了重复执行的问题,并且可以确保代码的正确性和可靠性。
这里不过多介绍。