C++并发编程实战——第三章 共享数据

本文讨论了C++中共享数据的问题,包括不变量、条件竞争、使用互斥量(如std::mutex和std::unique_lock)避免数据竞争,以及读写锁的原理和应用。还介绍了保护共享数据的初始化策略,如std::once_flag和懒汉模式。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

第三章 共享数据

3.1 共享数据的问题

  • 不变量(invariant):关于一个特定数据结构中总为true的语句,比如双向链表的两个相邻节点A、B,A的后指针一定指向B,B的前指针一定指向A。有时候程序会暂时破坏不变量,通常发生在更新复杂数据结构的时候,比如删除双向链表中的一个节点N,需要让N的前一个节点指向N的后一个节点,不变量破坏。
  • 条件竞争race condition):线程修改共享数据的时候,就会发生破坏不变量的情况,如果此时有其他线程访问修改,就可能导致数据结构永久破坏。
  • 数据竞争(data race):C++规定线程之间并发的修改单个对象为特定的一种条件竞争。会造成未定义的行为。

3.2 使用互斥量

3.2.1 互斥量
  • 访问共享数据前,将数据上锁,访问结束后,在解锁。其他线程必须等待数据解锁之后,才能访问。

  • C++通过实例化std::mutex创建互斥量实例,成员函数lock()\unlock()可对互斥量上锁\解锁。

  • 但是一般不直接调用这两个函数,而是使用std::lock_guard来自动处理加锁与解锁。它在构造的时候接受一个mutex并调用mutex.lock(),析构的自动调用mutex.unlock()

#include <iostream>
#include <mutex>

class A {
public:
    void lock() { std::cout << "lock" << std::endl; }
    void unlock() { std::cout << "unlock" << std::endl; }
};

int main() {
    A a;
    {
        std::lock_guard<A> l(a);  // lock
    }                           // unlock
}
  • 一般mutex和要保护的数据一起放在类中,定义为 private 数据成员,而非全局变量,这样能让代码更清晰。但如果某个成员函数返回指向数据成员的指针或引用,则通过这个指针的访问行为不会被mutex 限制,因此需要谨慎设置接口,确保mutex能锁住数据
#include <mutex>

class A {
 public:
  void f() {}
};

class B {
 public:
  A* get_data() {
    std::lock_guard<std::mutex> l(m_);
    return &data_;
  }

 private:
  std::mutex m_;
  A data_;
};

int main() {
  B b;
  A* p = b.get_data();
  p->f();  // 未锁定 mutex 的情况下访问数据
}
3.2.2 接口之间的条件竞争
  • 即便在很简单的接口中,也可能遇到 race condition
std::stack<int> s;
if (!s.empty()) {
  int n = s.top();  // 此时其他线程 pop 就会获取错误的 top
  s.pop();
}
  • 上述代码先检查非空再获取栈顶元素,在单线程中是安全的,但在多线程中,检查非空之后,如果其他线程先 pop,就会导致当前线程top出错。另一个潜在的竞争是,如果两个线程都未 pop,而是分别获取了 top,虽然不会产生未定义行为,但这种对同一值处理了两次的行为更为严重,因为看起来没有任何错误,很难定位 bug。

  • 这个错误,即使使用了互斥量,对栈内部数据进行保护也无法阻止条件竞争的发生,原因在于接口设计的问题,接口过多,在一个线程调用接口之间,可能有其他线程也进行了操作。

  • 为何不直接使用pop()返回元素,而需要拆成top()pop()?原因在于构造返回值的时候可能抛出异常,使元素弹出了,但是未返回造成数据丢失。

    • 假设有一个元素为vectorstack,并且pop()可以返回栈顶元素。
    • ”返回“这个操作肯定在函数的最后一步,这之前元素已经弹出,但是拷贝vector需要在堆上分配空间,如果系统负载严重导致内存空间不足无法分配,vector的构造函数就会抛出bad_alloc异常,导致返回失败,但是数据已经丢失
  • 下面思考如何将pop()top()合为一步。

    • 方法一:传入一个引用来获取结果值
      • 这样可以避免在获取之前弹出元素,保证可以成功获取元素之后,在弹出元素。
      • 但是缺点也很明显,需要提前构造一个栈中元素类型的实例,用于接收目标值,太麻烦,而且有时候构造还需要参数,而且还要是可以赋值的存储类型。
    std::vector<int> res;
    s.pop(res);
    
    • 方法二:无异常抛出的拷贝构造或者移动构造函数
      • 对于有返回值的pop()函数来说,只有“异常安全”方面的担忧(当返回值时可以抛出一个异常)。
      • 但这种方式过于局限,只支持拷贝或移动不抛异常的类型
    • 方法三:返回指向弹出值的指针
      • 指针可以自由拷贝,而且不会抛出异常。
      • 缺点就是返回指针需要对对象的内存分配进行管理,对于简单数据类型(比如:int),内存管理的开销要远大于直接返回值。
      • 对于这个方案,使用 std::shared_ptr 是个不错的选 择,不仅能避免内存泄露(因为当对象中指针销毁时,对象也会被销毁),而且标准库能够完全控制内存分配方案,就不需要new和delete操作。
    • 方法四:结合方法一和二 或者 结合方法一和三
    #include <iostream>
    #include <thread>
    #include <mutex>
    #include <stack>
    #include <memory>
    #include <exception>
    using namespace std;
    
    struct empty_stack : std::exception {
        const char* what() const throw() {
            return "empty stack";
        };
    };
    
    template<typename T>
    class threadsafe_stack {
    private:
        stack<T> data;
        mutable mutex m;
    public:
        threadsafe_stack() : data(stack<T>()) {}
        threadsafe_stack(const threadsafe_stack& other) {
            lock_guard<mutex> lock(other.m);
            data = other.data;
        }
        threadsafe_stack& operator=(const threadsafe_stack& other) = delete;
    
        void push(T new_value) {
            lock_guard<mutex> lock(m);
            data.push(new_value);
        }
    
        shared_ptr<T> pop() {
            lock_guard<mutex> lock(m);
            if (!data.empty()) {
                throw empty_stack();
            }
    
            shared_ptr<T> const res(make_shared<T>(data.top()));
            data.pop();
            return res;
        }
        void pop(T& value) {
            lock_guard<mutex> lock(m);
            if (!data.empty()) {
                throw empty_stack();
            }
            value = data.top();
            data.pop();
        }
    
        bool empty() const {
            lock_guard<mutex> lock(m);
            return data.empty();
        }
    };
    
    int main() {
    
    }
    
    • 削减接口,以保证最大的线程安全。
3.2.3 死锁
  • 死锁的四个必要条件:
    • 互斥
    • 不可抢占
    • 请求和保持
    • 循环等待
  • 线程对锁有竞争,当一个线程需要多个锁,对他们的互斥量进行操作时,如果另一个线程也有这样的需求,一人占有了一个锁,并且等待对方手中的锁,就会出现死锁,导致线程无法继续推进。
  • 避免死锁就是避免四个死锁的条件,通常建议,对两个及以上的mutex,都以相同的顺序进行上锁,但是这不是所有情况都适用;
    • 有多个互斥量保护同一个类的 独立实例时,一个操作对同一个类的两个不同实例进行数据的交换操作,为了保证数据交换操作的正确性, 就要避免并发修改数据,并确保每个实例上的互斥量都能锁住自己要保护的区域。不过,选择一个固定的顺 序(例如,实例提供的第一互斥量作为第一个参数,提供的第二个互斥量为第二个参数),可能会适得其反:在 参数交换了之后,两个线程试图在相同的两个实例间进行数据交换时,程序又死锁了。
  • 但是标准库中的lock()可以一次性锁住多个互斥量,并且没有副作用(死锁风险)。
#include <mutex>
#include <thread>
using namespace std;

struct A {
    explicit A(int n) : n_(n) {}
    mutex m_;
    int n_;
};

void _swap(A& a, A& b) {
    if (&a == &b) {
        return;  // 防止对同一对象重复加锁
    }
    std::lock(a.m_, b.m_);  // 同时上锁防止死锁
    // 下面按固定顺序加锁,看似不会有死锁的问题
    // 但如果没有 std::lock 同时上锁,另一线程中执行 f(b, a, n)
    // 两个锁的顺序就反了过来,从而可能导致死锁
    std::lock_guard<std::mutex> lock1(a.m_, std::adopt_lock);
    std::lock_guard<std::mutex> lock2(b.m_, std::adopt_lock);

    // 等价实现,先不上锁,后同时上锁
    //   std::unique_lock<std::mutex> lock1(a.m_, std::defer_lock);
    //   std::unique_lock<std::mutex> lock2(b.m_, std::defer_lock);
    //   std::lock(lock1, lock2);

    int temp = a.n_;
    a.n_ = b.n_;
    b.n_ += temp;
}


int main() {
    A x{ 70 };
    A y{ 30 };

    thread t1(_swap, std::ref(x), std::ref(y));
    thread t2(_swap, std::ref(y), std::ref(x));

    t1.join();
    t2.join();
}
  • 避免死锁的建议
    • 避免嵌套锁:一个线程已经获取一个锁时就不要获取第二个。如果每个线程只有一个锁,锁上就不会产生死锁(但除了互斥锁,其他方面也可能造成死锁,比如即使无锁,线程间相互等待也可能造成死锁)
    • 避免在持有锁的时候调用外部函数:因为代码是外部提供的,所以没有办法确定外部要做什么。外部程序可能做任何事 情,包括获取锁。
    • 使用固定顺序获取锁:当硬性要求获取两个或两个以上的锁,并且不能使用 std::lock 单独操作来获取它们时,最好在每个线程上, 用固定的顺序获取它们(锁)。
    • 使用层级锁
      • 层级锁(Hierarchical Locks)是一种多线程同步机制,旨在解决多线程编程中的死锁问题。它的核心思想是引入锁的层级结构,并规定线程只能按照一定的顺序获取锁,从而避免死锁的发生。
      • 层级锁引入了锁的层级结构,每个锁都被分配到一个特定的层级。线程只能按照升序或降序的顺序获取锁,即从低层级锁到高层级锁或反之。这确保了线程不会形成循环等待。
3.2.4 灵活的锁——std::unique_lock
  • std::unique_lock 是C++标准库中的一个类,用于管理互斥量(std::mutex)的锁定和解锁操作。它提供了比 std::lock_guard 更灵活的锁定机制,允许您在需要时手动锁定和解锁互斥量。
  1. 构造函数
    • std::unique_lock<std::mutex> lock(mutex);:创建一个 std::unique_lock 对象 lock 并立即锁定互斥量 mutex
    • std::unique_lock<std::mutex> lock(mutex, std::defer_lock);:创建一个 std::unique_lock 对象 lock,但不会立即锁定互斥量 mutex,而是延迟锁定
    • std::unique_lock<std::mutex> lock(mutex, std::try_to_lock);:尝试锁定互斥量 mutex,如果锁定成功则创建 lock否则创建一个未锁定的 lock 对象。
    • std::unique_lock<std::mutex> lock(mutex, std::adopt_lock);:假设当前线程已经锁定了互斥量 mutex,然后创建一个 lock 对象来接管已有的锁。
  2. 成员函数
    • lock():手动锁定互斥量。如果 std::unique_lock 对象是延迟锁定的,可以使用此方法来锁定互斥量。
    • unlock():手动解锁互斥量。可以使用此方法来显式释放互斥量的锁定。
    • try_lock():尝试锁定互斥量,如果互斥量已被其他线程锁定,则不会阻塞当前线程,而是返回一个指示锁定状态的布尔值。
  3. 自动析构解锁std::unique_lock 对象的析构函数会自动解锁互斥量,这意味着当 std::unique_lock 对象超出其作用域时,互斥量会被自动解锁,确保不会忘记解锁。
  4. 锁定和解锁的灵活性:与 std::lock_guard 不同,std::unique_lock 允许您手动控制锁定和解锁的时机,使其更适用于复杂的多线程场景。例如,您可以在需要时释放锁定,并在稍后重新锁定互斥量,以允许其他线程执行某些操作。
  5. 条件变量的配合std::unique_lock 常常与条件变量(std::condition_variable)一起使用,用于等待某些条件的发生,然后在条件满足时唤醒。条件变量需要与 std::unique_lock 一起使用,以确保在等待期间互斥量处于解锁状态。
  • 总之,std::unique_lock 提供了更多的灵活性和控制,适用于需要手动控制互斥量锁定和解锁时机的多线程编程场景。它是实现复杂多线程同步的重要工具之一。
3.2.5 读写锁
  • 读写锁Read-Write Lock),也称为共享-独占锁,是一种多线程同步机制,用于管理对共享资源的并发访问。与标准互斥量不同,读写锁允许多个线程同时读取共享资源,但在写入共享资源时只能有一个线程。这种机制旨在提高多线程程序的性能,因为通常情况下,读取操作不会修改共享资源,所以允许多个线程同时读取不会引发竞争条件。

1. 读写锁的类型

在C++中,读写锁通常有两种类型:读锁Read Lock)和写锁Write Lock)。

  • 读锁:允许多个线程同时持有读锁,以允许并发读取操作,但阻止写入操作。只有在没有写锁的情况下才能获取读锁。
  • 写锁:只允许一个线程持有写锁,以确保写入操作的独占性。在有写锁或读锁的情况下,无法获取写锁。

2. 使用场景

读写锁适用于以下场景:

  • 当共享资源在读取时被多个线程频繁访问,但在写入时只有一个线程修改时,使用读写锁可以提高并发性。
  • 读写锁特别适用于对共享资源的读取操作比写入操作频繁的情况,因为它允许多个线程同时读取。

3. 读写锁的优点

  • 提高性能:允许多个线程同时读取,提高了并发性,因为读取操作通常不会修改数据。
  • 写入保护:写锁提供了对写入操作的互斥保护,确保只有一个线程可以修改共享资源。
#include <iostream>
#include <shared_mutex>
#include <thread>

using namespace std;

shared_mutex rwLock;
int shared_int = 10;

void reader() {
    shared_lock<shared_mutex> lock(rwLock);
    cout << "Read: " << shared_int << endl;
}

void writer() {
    unique_lock<shared_mutex> lock(rwLock);
    shared_int += 10;
    cout << "Write: " << shared_int << endl;
}

int main() {
    thread t_1(reader);
    thread t_2(reader);
    thread t_3(writer);

    t_1.join();
    t_2.join();
    t_3.join();
}

3.3 保护共享数据的方式

3.3.1 保护共享数据的初始化
  • 延迟初始化(Lazy initialization)在单线程代码很常见——每一个操作都需要先对源进行检查,为了了解数据是 否被初始化,然后在其使用前决定,数据是否需要初始化。
  • 只有①处需要保护,这样共享数据对于并发访问就是安全的。但是下面天真的转换会使 得线程资源产生不必要的序列化,为了确定数据源已经初始化,每个线程必须等待互斥量。
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();
}

  • C++11 提供了 std::once_flag std::call_once 来保证对某个操作只执行一次
#include <memory>
#include <mutex>
#include <thread>
using namespace std;

class A {
public:
    void fun() {}
};

shared_ptr<A> p;
once_flag once_flag_t;

void init() {
    call_once(once_flag_t, [] {p = make_shared<A>();});
    p->fun();
}

int main() {
    thread t_1(init);
    thread t_2(init);

    t_1.join();
    t_2.join();
}
  • std::once_flag std::call_once 也可以用在类中,表示这个实例化对象只调用一次
#include <memory>
#include <mutex>
#include <iostream>
#include <thread>
using namespace std;

class A {
public:
    void fun() {
        call_once(m_once, &A::print, this);
    }
private:
    once_flag m_once;
    void print() const {
        cout << 1 << endl;
    }
};

int main() {
    A a;
    thread t_1(&A::fun, &a);
    thread t_2(&A::fun, &a);

    t_1.join();
    t_2.join();
}
  • 在 C++11 及以后的标准中,静态局部变量的初始化是线程安全的。所以懒汉模式的初始化可以使用static 局部变量
class my_class {
public:
    static my_class& get_instance() {
        static my_class instance; // 在程序启动时创建实例
        return instance;
    }

    void do_something() {
        // 实例方法的实现
    }

private:
    my_class() {
        // 私有构造函数,防止外部实例化
    }

    // 阻止复制构造函数和赋值操作符
    my_class(const my_class&) = delete;
    my_class& operator=(const my_class&) = delete;
};
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值