(三)C++ 并发编程 3:共享数据的管理


1. 线程间的数据共享

从整体来看,线程之间共享数据的读写问题,都是修改数据导致的,只读数据不会导致线程间的竞争。

在并发性中,竞争条件指决定两个或多个线程操作执行的相对顺序的一切事务。最简单的处理有问题的竞争条件的方法是使用保护机制封装数据结构,确保只有实际执行修改的线程能够访问,其他线程只能看到该数据结构的未修改和修改完成的状态。


2. 用互斥元保护共享数据

2.1 在 C++ 中使用互斥元保护共享数据

互斥元是 C++ 中最常用的数据保护机制,通过构造 std::mutex 实例来创建互斥元,调用成员函数 lock 来锁定它、unlock 来解锁它。

通常,不推荐直接使用 lock 和 unlock。因为调用 lock 后必须在结束线程前的每条代码路径上均调用 unlock,否则可能导致其他线程无法访问该共享资源。C++ 提供的 std::lock_guard 类模板优化了互斥元的加锁与解锁。如使用 std::lock_guard 保护列表:

#include <list>
#include <mutex>
#include <algorithm>


std::list<int> some_list;
std::mutex some_mutex;	// 使用some_mutex保护some_list

void add_to_list(int new_value)
{
    std::lock_guard<std::mutex> guard(some_mutex);
    some_list.push_back(new_value);
}

bool list_contains(int value_to_find)
{
    std::lock_guard<std::mutex> guard(some_mutex);
    return std::find(some_list.begin(), some_list.end(), value_to_find) 
        != some_list.end();
}

2.2 处理接口中固有的竞争条件

栈是常用的数据结构,通常用到 push(入栈)、pop(出栈)、top(取栈顶元素)、size(栈大小)、empty(栈是否为空)等接口。

stack<int> s;
if (!s.emtpy())	// [1]
{
	int const value = s.top();	// [2]
	s.pop();	// [3]					
	do_something(value);
}

上述代码仅在单线程中是安全的,对于共享的栈对象,这个调用序列存在隐患。如在执行语句 [1] 和 [2] 之间可能有来自其他线程的 pop 调用,删除了栈内的最后一个元素。同理,[2] 和 [3] 之间也会出现类似的情况。重新设计接口,并使用互斥元保护栈内数据。

解决办法:传入引用。事先构造一个栈对象,将出栈值的引用作为参数传递给 pop 调用。不引发异常的拷贝构造函数和移动构造函数返回指向出栈值得指针。指针可以被自由复制而不会引发异常,但同时需要管理分配给对象的内存。

综合上述解决办法,通过 lock_guard 构造一个没有条件竞争的栈:

#include <exception>
#include <mutex>
#include <stack>
#include <memory>


struct empty_stack : std::exception
{
    const char* what() const throw();
};


template<typename T>
class threadsafe_stack
{
private:
    std::stack<T> data;
    mutable std::mutex m;
public:
    threadsafe_stack();
    threadsafe_stack(const threadsafe_stack& other)
    {
        std::lock_guard<std::mutex> lock(other.m);
        data = other.data;
    }

    threadsafe_stack& operator=(const threadsafe_stack&) = delete;

    void push(T new_value)
    {
        std::lock_guard<std::mutex> lock(m);
        data.push(new_value);
    }

    std::shared_ptr<T> pop()
    {
        std::lock_guard<std::mutex> lock(m);
        if (data.empty())
        {
            throw empty_stack();
        }
        std::shared_ptr<T> const res(std::make_shared<T>(data.top()));
        data.pop();
        return res;
    }

    void pop(T& value)
    {
        std::lock_guard<std::mutex> lock(m);
        if (data.empty())
        {
            throw empty_stack();
        }
        value = data.top();
        data.pop();
    }

    bool empty() const
    {
        std::lock_guard<std::mutex> lock(m);
        return data.empty();
    }
};

2.3 死锁

死锁是指两个或两个以上的进程(线程)在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞现象,若无外力作用,它们都将无法推进下去。

为了避免死锁,建议是始终使用相同的顺序锁定互斥元。使用 std::lock 和 std::lock_guard 完成简单的交换操作:

#include <mutex>


void swap(some_big_object& lhs, some_big_object& rhs);


class X
{
private:
    some_big_object some_detail;
    std::mutex m;
public:
    X(some_big_object const& sd) : some_detail(sd) {}

    friend void swap(X& lhs, X& rhs)
    {
        // 检查参数以确保不是同一实例
        if (&lhs == &rhs)
        {
            return;
        }
        // 锁定互斥元
        std::lock(lhs.m, rhs.m);
        // 每个实例对应一个互斥元
        std::lock_guard<std::mutex> lock_a(lhs.m, std::adopt_lock);
        std::lock_guard<std::mutex> lock_b(rhs.m, std::adopt_lock);
        swap(lhs.some_detail, rhs.some_detail);
    }
};

锁层次定义锁定顺序,来检查在运行时是否遵循了约定。其思路是将应用程序分层,当代码视图锁定一个互斥元时,如果它在较低层已经持有锁定,那么不允许它锁定该互斥元。通过给每个互斥元分配层号,并记录每个线程都锁定了哪些互斥元,就可以在运行时检查是否符合规定。两个线程使用锁层次避免死锁:

#include <mutex>


class hierarchical_mutex 
{
private:
    unsigned long const hierarchy_value;
public:
    hierarchical_mutex(unsigned long const hv) : hierarchy_value(hv) {}
};


hierarchical_mutex high_level_mutex(10000);
hierarchical_mutex low_level_mutex(5000);

int do_low_level_stuff();

int low_level_func()
{
    std::lock_guard<hierarchical_mutex> lk(low_level_mutex);
    return do_low_level_stuff();
}

void high_level_stuff(int some_param);

void high_level_func()
{
    std::lock_guard<hierarchical_mutex> lk(high_level_mutex);
    high_level_stuff(low_level_func());
}

// 高层次值的互斥元调用较低层次值的互斥元
void thread_a()
{
    high_level_func();
}

hierarchical_mutex other_mutex(100);

void do_other_stuff();

void other_stuff()
{
    high_level_func();
    do_other_stuff();
}

// 低层次值的互斥元调用较高层次值的互斥元,程序终止
void thread_b()
{
    std::lock_guard<hierarchical_mutex> lk(other_mutex);
    other_stuff();
}

2.4 使用 std::unique_lock

std::unique_lock 是个类模板,比 std::lock_guard 灵活,但效率差一点且内存占用较大。第二个参数 adopt_lock 表示这个互斥元已经加锁;而 defer_lock 的前提是不能先加锁 ,而是初始化一个没有加锁的互斥元。

#include <mutex>


void swap(some_big_object& lhs, some_big_object& rhs);


class X
{
private:
    some_big_object some_details;
    std::mutex m;
public:
    X(some_big_object const& sd) : some_details(sd) {}

    friend void swap(X& lhs, X& rhs)
    {
        if (&lhs == &rhs)
        {
            return;
        }
        std::unique_lock<std::mutex> lock_a(lhs.m, std::defer_lock);
        std::unique_lock<std::mutex> lock_b(rhs.m, std::defer_lock);
        std::lock(lock_a, lock_b);
        swap(lhs.some_details, rhs.some_details);
    }
};

2.5 使用恰当粒度的锁定

锁粒度用来描述单个锁所保护的数据量。细粒度锁保护着少量的数量,粗粒度锁保护着大量的数据。使用最大的保护所有数据的锁,此时程序串行执行。选择合适粒度的锁,既能发挥并发的优势,也能保证共享数据的安全访问。


3. 用于共享数据保护的替代工具

虽然互斥元是最通用的机制,但是对于保护共享数据,它并不是唯一的选择。

3.1 在初始化时保护共享资源

如果共享资源的构建非常昂贵,也许它会打开一个数据库连接或分配大量内存。这样,延迟初始化是一种较佳的选择——对于共享资源的操作首先检查它是否已经初始化,如果没有就在使用前初始之。对于共享资源的并发访问,需要保护的部分为资源的初始化。使用互斥元进行线程安全的延迟初始化:

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();
}

3.2 保护很少更新的数据结构

假设有一个用于存储 DNS(Domain Name Server) 条目缓存的表,它用来将域名解析为相应的 IP 地址。通常,一个给定的 DNS 条目在很长一段时间内不会发生改变。

class dns_cache
{
    std::map<std::string, dns_entry> entries;
    mutable boost::shared_mutex entry_mutex;
public:
    dns_entry find_entry(std::string const& domain) const 
    {
    	// 只读、共享
        boost::shared_lock<boost::shared_mutex> lk(entry_mutex);
        std::map<std::string, dns_entry>::const_iterator const it = 
            entries.find(domain);
        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<boost::shared_mutex> lk(entry_mutex);
        entries[domain] = dns_details;
    }
};

3.3 递归锁

在使用 std::mutex 时,一个线程视图锁定其已拥有的互斥元是错误的,并且视图这么做将导致未定义行为。然而,在某些情况下线程多次重新获取同一互斥元是可取的。


4. 总结

  1. 线程间的竞争发生在多个线程访问可读的共享数据。
  2. 在 C++ 中,通常使用互斥元保护数据。
  3. 除使用互斥元,其他包括延迟初始化、递归锁等手段可用于共享数据的保护。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值