std::call_once 解决多线程数据共享
在多线程环境中,我们经常会遇到一个需求:确保某段代码(通常是初始化代码)在整个程序的生命周期中,无论有多少个线程并发调用它,都只被执行一次。一个经典的例子就是线程安全的懒汉式单例模式 (Lazy-Initialized Singleton)。
1 |
|
getInstance()
方法,且 instance 为 nullptr。 2. 线程 A
先进入 if 语句,发现 instance 为
nullptr,于是它开始创建 Singleton 的实例。 3.
线程 B 也进入 if 语句,发现 instance 为
nullptr,于是它也开始创建 Singleton 的实例。
4. 由于线程 A 和线程 B 都在创建 Singleton
的实例,所以就会发生竞态条件,导致 instance 被创建了两次。
5. 当线程 A 或线程 B 尝试访问 instance
时,就会发生访问冲突,导致程序崩溃。
最终的结果是单例模式被破坏(创建了多个实例),并且造成了内存泄漏(第一个实例的指针被覆盖丢失)。
虽然可以使用 std::mutex 来解决这个问题(这种模式被称为“双重检查锁定”,Double-Checked Locking),但手动实现起来复杂且容易出错。为了以一种更简单、更高效、更安全的方式解决这类问题,C++11 提供了 std::call_once。
std::call_once
std::call_once 是一个函数模板,它配合一个 std::once_flag 对象,能够保证一个函数或可调用对象在多线程环境下只被成功调用一次。其核心部分如下:
std::once_flag:这是一个特殊的标记对象, 你可以把它想象成一个一次性的门锁或一次性的门票。它用于 std::call_once 来同步各个线程,并记录目标函数是否已经被调用过。std::once_flag 对象不可复制,也不可移动,通常被定义为 static、全局或类的成员变量,以便在多个线程调用点之间共享。
**std::call_once(std::once_flag& flag, Callable&& f, Args&&… args):这是执行调用的函数。
- flag:上面定义的 once_flag 对象的引用。
- f:你希望只被执行一次的可调用对象(如函数指针、Lambda表达式、函数对象等)。
- args…:传递给可调用对象 f 的参数。
另外注意的是, std::call_once 和 std::once_flag 都定义在头文件
使用 std::call_once 解决懒汉式单例模式的代码如下:
1 |
|
在此期间,如果其他线程也调用了 getInstance() 并到达 std::call_once,它们会阻塞等待,直到第一个线程的 Lambda 表达式执行完毕。
当第一个线程成功执行完 Lambda 后,std::call_once 会将 flag 永久地标记为“已完成”。所有之前阻塞等待的线程会被唤醒,并从 std::call_once 返回。它们不会再次执行 Lambda。
此后任何线程再调用 std::call_once 并传入同一个 flag 对象,都会发现 flag 已被标记,于是会立即返回,不做任何事。
它的优势是线程安全:由标准库保证其实现的线程安全性,无需手动加锁; 高效:相比于每次调用都加锁的互斥量方案,std::call_once 在初始化完成后,后续的调用开销极低(通常只是一次无锁的内存读取), 且代码简洁,意图明确:清晰地表达了“此代码只执行一次”的意图。
主要应用于上述提到的线程安全的懒汉式单例模式和一次性全局初始化(程序中某些模块或资源只需要被初始化一次。例如首次使用时才加载配置文件, 首次需要时才初始化日志系统, 首次访问时才建立一个全局的数据库连接池等)
下面也提到了, 单例模式的实现可以通过 Magic statics来实现, 但是如果你有更通用的、不局限于静态变量初始化的“执行一次”的需求,std::call_once 依然是那个最合适的、强大的工具。
单例模式(Singleton Pattern)
单例模式(Singleton Pattern)是一种在软件设计中被广泛使用的创建型设计模式, 核心思想是确保一个类在任何情况下只有一个实例,并为该实例提供一个全局唯一的访问点。
想象一下,系统中有一些组件是“全局唯一”的,比如: - 配置管理器:整个应用程序共享同一份配置信息。 - 日志记录器:所有模块都应该将日志写入同一个日志文件。 - 数据库连接池:管理一组数据库连接,避免频繁创建和销毁连接的开销。 - 线程池:统一管理和调度一组工作线程。
在这些场景下,如果创建多个实例,可能会导致程序行为异常(如配置不一致)、资源过度使用(如过多的数据库连接)或结果不可预测。单例模式正是为了解决这类问题而生的。
单例模式的实现要点
要实现一个标准的单例模式,通常需要满足以下几个关键条件:
私有化构造函数 (Private Constructor): - 为了防止外部代码通过 new 关键字随意创建类的实例。这是保证类实例唯一性的基础。 - 如果构造函数是 public 的,那么任何地方都可以自由地创建该类的对象,就无法实现“单例”的目标。
私有静态实例变量 (Private Static Instance) - 在类的内部持有那个唯一的实例。 - 使用 static 关键字可以确保这个实例变量属于类本身,而不是类的某个对象,因此它在内存中只有一份。将其设为 private 是为了防止外部直接访问和修改它。
公有静态访问方法 (Public Static Access Method) - 提供一个全局唯一的、可供外部访问该实例的入口。这个方法通常被命名为 getInstance() 或类似名称。 - 这是外部世界获取单例实例的唯一途径。该方法会检查实例是否已经被创建:如果尚未创建,则创建它;如果已经存在,则直接返回。
单例模式主要有两种经典的实现方式:饿汉式(Eager Initialization) 和 懒汉式(Lazy Initialization)。
饿汉式(Eager Initialization)
饿汉式在类加载的时候就立即创建实例,因此它是线程安全的。
优点是实现简单; 且在类加载时就完成了实例化,避免了多线程同步问题,是天然线程安全的。
缺点是如果这个实例从未使用过,会造成内存浪费。因为它不是在需要时才创建,而是一开始就创建了。
1 |
|
懒汉式单例
懒汉式在第一次被调用 getInstance() 方法时才创建实例。这种方式延迟了对象的创建时间。
优点是实现了延迟加载(Lazy Loading),只有在实际需要时才创建实例,节约了资源。
缺点是在多线程环境下,如果不进行同步处理,可能会创建出多个实例,从而破坏单例模式。因此,必须处理线程安全问题。
我们当然可以像上述代码一样使用std::call_once来实现单例模式, 然而在 C++11 及以后,最推荐的懒汉式实现是使用静态局部变量(Meyers’ Singleton),因为它既简洁又线程安全, 并且避免了手动管理裸指针。编译器和标准库在底层为你实现了与 std::call_once 类似的安全保障。 >
1 |
|
- 线程安全:C++11 标准明确规定,这种静态局部变量的初始化过程必须是原子性的,即线程安全的。编译器和运行时库会处理好多线程同时首次调用 getInstance() 的同步问题。
- 自动内存管理:instance 的生命周期与程序相同,程序结束时它会自动被销毁,无需像饿汉式那样手动管理内存。
显然, 如果实例的创建成本不高,且在程序启动后肯定会被用到,饿汉式是更简单、更可靠的选择; 如果实例的创建非常耗时或占用大量资源,并且不确定是否会用到它,懒汉式(特别是双重检查锁定版本)是更好的选择。