文章

保护共享数据的方式

保护共享数据,初始化时用 std::call_once 保证线程安全高效;读多写少场景用 std::shared_mutex 实现读写锁;递归锁(std::recursive_mutex)支持同线程重复加锁,对应 Java 的可重入锁。

保护共享数据的方式

保护共享数据的方式

互斥量是一种通用的保护共享数据机制,但不是唯一方式。特定情况下,可以用更合适的手段保障共享数据安全。

一种极端情况是共享数据只读且需要保护初始化过程。这时对数据初始化的保护是必要的,但初始化后加锁访问会带来性能开销。

C++标准为保护共享数据初始化提供了专门机制。

保护共享数据的初始化过程

假设有共享资源,初始化开销很大(如打开数据库连接、分配大量内存)。

延迟初始化(Lazy Initialization)在单线程中常见:

1
2
3
4
5
6
7
8
9
std::shared_ptr<some_resource> resource_ptr;

void foo() {
  if(!resource_ptr) {
    // 检查是否初始化
    resource_ptr.reset(new some_resource);  // 初始化
  }
  resource_ptr->do_something();
}

多线程中,为避免数据竞争,需要对初始化部分加锁:

1
2
3
4
5
6
7
8
9
10
11
std::shared_ptr<some_resource> resource_ptr;
std::mutex resource_mutex;

void foo() {
  std::unique_lock<std::mutex> lk(resource_mutex);  // 所有线程在此序列化
  if(!resource_ptr) {
    resource_ptr.reset(new some_resource);  // 初始化过程保护
  }
  lk.unlock();
  resource_ptr->do_something();
}

这种写法虽然正确,但每个线程都要等待锁,造成不必要的性能损失。

很多人尝试用“双重检查锁定模式”(Double-Checked Locking Pattern,DCLP):

1
2
3
4
5
6
7
8
9
10
11
void undefined_behaviour_with_double_checked_locking() {
  if(!resource_ptr) {
    // 1. 第一次检查,无锁,可能有竞争
    std::lock_guard<std::mutex> lk(resource_mutex);
    if(!resource_ptr) {
      // 2. 第二次检查,有锁保护
      resource_ptr.reset(new some_resource);  // 3. 初始化
    }
  }
  resource_ptr->do_something();  // 4. 使用
}

该模式有严重缺陷:

  • 第一次无锁读取①存在条件竞争,可能看到未完全初始化的指针。
  • 导致未定义行为,因为写入和读取没有同步。
  • 甚至对象的构造过程都可能未被正确看到,导致调用 do_something() 时错误。

此问题详见内存模型与数据竞争相关讨论,也有经典文章指出风险。

推荐做法:std::call_oncestd::once_flag

C++ 标准提供 std::call_oncestd::once_flag 来安全、高效地保护只初始化一次的资源。

1
2
3
4
5
6
7
8
9
10
11
std::shared_ptr<some_resource> resource_ptr;
std::once_flag resource_flag;

void init_resource() {
  resource_ptr.reset(new some_resource);
}

void foo() {
  std::call_once(resource_flag, init_resource);  // 确保只初始化一次
  resource_ptr->do_something();
}

std::call_once 保证 init_resource 只被调用一次,且线程安全。后续调用无需上锁,性能更优。

std::call_once 作为类成员使用示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class X {
private:
  // 连接信息,用于打开连接时的配置参数
  connection_info connection_details;

  // 连接句柄,表示已打开的连接
  connection_handle connection;

  // 用于保证连接初始化函数只被调用一次的标志
  std::once_flag connection_init_flag;

  // 私有函数:打开连接,调用连接管理器打开连接并赋值给 connection
  void open_connection() {
    connection = connection_manager.open(connection_details);
  }

public:
  // 构造函数,初始化连接信息
  X(connection_info const& connection_details_)
    : connection_details(connection_details_) {}

  // 发送数据
  void send_data(data_packet const& data) {
    // 确保连接只被初始化一次(线程安全)
    std::call_once(connection_init_flag, &X::open_connection, this);
    // 通过连接发送数据
    connection.send_data(data);
  }

  // 接收数据
  data_packet receive_data() {
    // 确保连接只被初始化一次(线程安全)
    std::call_once(connection_init_flag, &X::open_connection, this);
    // 通过连接接收数据并返回
    return connection.receive_data();
  }
};
  • std::once_flag 和初始化数据成员不可复制或移动,需显式定义类成员函数处理。
  • std::call_once 支持传递成员函数指针和 this 指针。
  • 这样第一次调用 send_data()receive_data() 的线程完成初始化。

局部 static 变量的线程安全初始化

1
2
3
4
5
6
class my_class;

my_class& get_my_class_instance() {
  static my_class instance;  // 这里初始化只会被执行一次,且线程安全
  return instance;
}

在 C++11 标准之前,函数内的局部 static 变量初始化可能存在线程安全问题:如果多个线程同时调用该函数,可能会出现多个线程同时初始化这个变量,导致竞态条件(race condition)或未定义行为。

而从 C++11开始,标准保证

函数内的局部 static 变量的初始化是线程安全的,即使多个线程同时第一次调用该函数,也只会有一个线程执行初始化操作,其他线程会等待初始化完成。

保护不常更新的数据结构

以 DNS 缓存为例,缓存条目很少更新,多线程读多写少,如何高效保护?

普通 std::mutex 独占锁在无写时阻塞所有读操作,性能不佳。

读者-作者锁(共享锁)

C++17 提供 std::shared_mutexstd::shared_timed_mutex 支持读写锁机制:

  • 多个线程可以同时获取共享锁(读锁),实现并发读取。
  • 写线程获取独占锁,阻塞其他读写线程,保证数据一致性。

C++14 仅有 std::shared_timed_mutex,C++11 无内置支持,可使用 Boost 库。

使用示例:DNS缓存类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <map>
#include <string>
#include <mutex>
#include <shared_mutex>

class dns_entry;  // 假设定义了dns_entry类

class dns_cache {
  // DNS缓存,存储域名到dns_entry的映射
  std::map<std::string, dns_entry> entries;

  // 读写锁,支持多线程环境下读写操作的同步
  mutable std::shared_mutex entry_mutex;

public:
  // 查找域名对应的DNS条目,线程安全
  dns_entry find_entry(std::string const& domain) const {
    // 共享锁,允许多个线程同时读取,不阻塞彼此
    std::shared_lock<std::shared_mutex> lk(entry_mutex);
    auto it = entries.find(domain);
    // 如果没找到,返回默认dns_entry对象,否则返回对应条目
    return (it == entries.end()) ? dns_entry() : it->second;
  }

  // 更新已有条目或添加新条目,线程安全
  void update_or_add_entry(std::string const& domain, dns_entry const& dns_details) {
    // 独占锁,保证写操作互斥,防止数据竞争
    std::lock_guard<std::shared_mutex> lk(entry_mutex);
    entries[domain] = dns_details;
  }
};
  • find_entry() 使用 std::shared_lock,多线程可同时调用。
  • update_or_add_entry() 使用独占锁,更新时阻塞其他读写。

嵌套锁(递归锁)

尝试在同一线程对同一 std::mutex 多次加锁会导致未定义行为。

有时类的成员函数互相调用且都上锁,需要避免死锁,C++提供 std::recursive_mutex 支持:

  • 同一线程可对同一递归锁多次加锁。
  • 必须匹配次数调用 unlock() 释放锁。

使用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <mutex>

std::recursive_mutex rec_mutex;

void func1() {
  std::lock_guard<std::recursive_mutex> lock(rec_mutex);
  // ...
  func2();
}

void func2() {
  std::lock_guard<std::recursive_mutex> lock(rec_mutex);
  // ...
}

使用递归锁需谨慎

  • 设计上通常不推荐递归锁。
  • 递归锁可能掩盖设计缺陷,如类不变量可能被破坏。
  • 更好的设计是:
    • 抽取私有不加锁的成员函数。
    • 由外层函数加锁,内部调用私有函数。
    • 保证锁定期间数据状态正确。

嵌套锁(递归锁)在概念上就是Java中的可重入锁(Reentrant Lock)

具体来说:

  • 递归锁(Recursive Lock)的定义是:同一个线程可以多次获得同一把锁而不会死锁,只有当锁被同样次数释放后,其他线程才有机会获得该锁。
  • Java 中的可重入锁(ReentrantLock)就是一种递归锁实现。它允许线程多次进入同步代码块(锁),只要是同一个线程再次请求同一把锁时,不会阻塞。
  • Java 中 synchronized 关键字的内部实现也是基于可重入锁机制,所以一个线程可以在同步方法或同步块中调用另外的同步方法而不会导致死锁。

总结

  • 保护初始化:用 std::call_once + std::once_flag,比双重检查锁更安全且高效。
  • 读多写少的数据结构:用 std::shared_mutex 实现读写锁,提升并发读性能。
  • 递归锁std::recursive_mutex 支持同一线程多次上锁,但要谨慎使用,避免设计问题。
本文由作者按照 CC BY 4.0 进行授权