C++标准线程库之共享资源

多线程下难免会需要资源共享,这样难免会发生异常情况。

#include 
#include 

class X
{
    int* _p;

public:
    X(int* p = nullptr) : _p(p) {}
    ~X() { if(_p) delete _p; }

    void read()
    {
        if(_p) std::cout << "value: " << *_p << std::endl; 
    }

    void reset()
    {
        if(_p) {
            delete _p;			// 2
            _p = nullptr;		// 3
        }
    }
};

int main()
{
    X x(new int(77));

    std::thread t1(&X::read, &x), t2(&X::reset, &x);
    t1.join();
    t2.join();

    return 0;
}

C++标准线程库之共享资源_第1张图片

编译运行这个例子可以发现有时代码正常运行输出,而有时却发生了段错误。原因在于指针_p被两个线程共享,会有三种情况:

  • t1 线程已执行过访问_p,t2线程还未执行delete _p,那么就正常输出value: 77
  • t1线程执行完判断还未访问_p就被挂起,t2线程调度成功执行完delete _p,然后t2线程也被挂起,t1线程唤醒继续执行访问 _p造成段错误
  • t2线程执行完了_p = nullptr,t1线程判断 _p,那么就没输出,不过概率不高
  • 还要种情况就留给读者自己思考了,概率也不是很高,主要还是知道会发生异常就行了

所以需要一种方法使得t1线程访问_p时,t2线程不得对 _p就行操作;同理,t2操作时,t1不得读取。

1. 使用互斥量

C++11 提供了互斥量来保护共享数据,思路是线程A在操作共享数据前上锁互斥量,其他线程执行到上锁的互斥量时就会进入阻塞,只有线程A解锁互斥量时, 其他某个线程才能继续上锁,然后那个线程继续执行后续代码。

#include 

// 互斥量类,不可移动也不可否则
class std::mutex;

void std::mutex::lock();
// 对互斥量进行上锁, 一次只能有线程成功调用此方法并返回,其他线程执行此方法就会进入阻塞状态, 直到unlock方法调用

void std::mutex::unlock();
// 对lock的互斥量继续解锁,以便其他线程可以解除阻塞而返回

bool std::mutex::try_lock();
// 尝试进行上锁操作。若无其他线程上锁,那么上锁互斥量,返回true;若互斥量也被上锁,那么上锁失败返回false

std::mutex::native_handle_type std::mutex::native_handle();
// 返回互斥量的底层标识符, 可用于扩展,但不建议这样用
#include 

class X
{
    int* _p;
    std::mutex mutex;

public:
    X(int* p = nullptr) : _p(p) {}
    ~X() { if(_p) delete _p; }

    void read()
    {
        mutex.lock();
        if(_p) std::cout << "value: " << *_p << std::endl;
        mutex.unlock();
    }

    void reset()
    {
        mutex.lock();
        if(_p) {
            delete _p;
            _p = nullptr;
        }
        mutex.unlock();
    }
};

修复后的类X,在运行就没段错误了,保证了只有同时只有一个线程来操作_p,其他线程必须等待那个线程操作完后才能继续操作。

需要注意的是,本例子的效率来说多线程比单线程还低,这是因为包含了整个函数,没有代码处于临界区之外,也就是没有一条并行的代码。因此,mutex包含的代码区域越小越好。

2. 更加智能的锁

每次都要上锁解锁太烦了,C++11,14和17陆续加入了一些智能的锁。

2.1 最简单的自动锁lock_guard

这个锁是在构造函数中帮我们上锁,析构函数解锁,无其他额外的方法,功能简单实用,缺点是不够灵活,想解锁时只能等它销毁才能解锁。

#include 

template<typename _Mutex>
class std::lock_guard;
class X
{
    int* _p;
    std::mutex mutex;

public:
    X(int* p = nullptr) : _p(p) {}
    ~X() { if(_p) delete _p; }

    void read()
    {
        // 小技巧, 使用代码块,避免锁的范围大到整个函数, 出代码块时锁就会销毁
        {
            std::lock_guard<std::mutex> lock(mutex);
            if(_p) std::cout << "value: " << *_p << std::endl;
        }
    }

    void reset()
    {
        {
            std::lock_guard<std::mutex> lock(mutex);
            if(_p) {
                delete _p;
                _p = nullptr;
            }
        }
    }
};

2.2 灵活的锁unique_lock

为了解决上个锁的缺点于是诞生了灵活的锁,同样的在构造函数中上锁,但是其提供了一些方法来保证其灵活性,代价就是空间的增大。这个锁是可移动不可复制的。

#include 

template<typename _Mutex>
class std::unique_lock;
// 构造函数先不介绍, 因为灵活所以构造函数有点多,这章只需要知道传入互斥量就行了

template<typename _Mutex>
void std::unique_lock::lock();
// 对互斥量上锁

template<typename _Mutex>
void std::unique_lock::unlock();
// 对互斥量解锁

template<typename _Mutex>
void std::unique_lock::try_lock();
// 对互斥量尝试上锁

template<typename _Mutex>
template<typename _Clock, typename _Duration>
bool std::unique_lock::try_lock_until(const chrono::time_point<_Clock, _Duration>& __atime);
// 直到某个时间点就行尝试上锁,时间点到之前上锁成功返回true,否则返回false

template<typename _Mutex>
template<typename _Rep, typename _Period>
bool std::unique_lock::try_lock_for(const chrono::duration<_Rep, _Period>& __rtime);
// 就行一段时间的尝试
	// 省去一段无改动的代码
	void read()
    {
        std::unique_lock<std::mutex> lock(mutex);
        if(_p) std::cout << "value: " << *_p << std::endl;
    }

    void reset()
    {
        std::unique_lock<std::mutex> lock(mutex);
        if(_p) {
            delete _p;
            _p = nullptr;
        }
    }

3. 死锁问题

引入了线程互斥量后,成功解决了共享资源的问题,但是互斥量不是银弹,带来了新的问题。看下面的代码:

std::mutex mutex_1, mutex_2;

void do_something_1()
{
    mutex_1.lock();   	// 1
    mutex_2.lock();		// 2
    // ...
    mutex_2.unlock();
    mutex_1.unlock();
}

void do_something_2()
{
    mutex_2.lock();		// 3
    mutex_1.lock();		// 4
    // ...
    mutex_1.unlock();
    mutex_2.unlock();
}

两个任务由两个线程来分担,当线程A执行完1时mutex_1被上锁,线程B这是也执行过了3了mutex_2也被上锁,线程A执行2处代码发现已经上锁只能进入阻塞状态等待解锁,而线程B执行4处代码发现也是上锁的也进入阻塞状态等待解锁,就这样两个线程全在等待解锁,而又无法解锁,这样就称为线程死锁。

3.1 死锁的条件

过于理论,这里就简单提一下,不是系列的关注点

  • 互斥条件

  • 请求和保持条件

  • 不剥夺条件

  • 环路等待条件

操作系统原理都会谈到,有兴趣可以自己去学下

3.2 解决方法

C++11提供了一种方法,C++17专门提供了防死锁的智能锁来避免这个问题。

#include 

template<typename _L1, typename _L2, typename... _L3>
void std::lock(_L1& __l1, _L2& __l2, _L3&... __l3);
// 传入多个互斥量,提供了避免死锁的算法来防止死锁。
// 如果有已经上锁的互斥量,那么先对所有存入的互斥量解锁,然后抛出异常
(1) 配合智能锁使用
// 这里需要先介绍下lock_gaurd和unique_lock都有的一个构造函数
lock_guard(mutex_type& __m, adopt_lock_t);
unique_lock(mutex_type& __m, adopt_lock_t);
// 第一个参数都是互斥量,不用介绍了,主要介绍一下第二个参数adopt_lock_t
// 这是一个指定锁定策略,有一下几个常量值
// std::defer_lock 对传入的互斥量不上锁
// std::try_to_lock 尝试对互斥量上锁
// std::adopt_lock 假设互斥量已经上锁
// 这里我也有点搞不明白,哪位大佬详细谈下这几个的区别,感觉都差不多
std::mutex mutex_1, mutex_2;

//	lock_guard写法
void do_something_1()
{
    std::lock(mutex_1, mutex_2);
    std::lock_guard<std::mutex> lock(mutex_1, std::adopt_lock);
    std::lock_guard<std::mutex> lock(mutex_2, std::adopt_lock);
    // ...
}

// unique_lock写法
void do_something_2()
{  
    std::unique_lock<std::mutex> lock(mutex_1, std::defer_lock);
    std::unique_lock<std::mutex> lock(mutex_2, std::defer_lock);
    std::lock(mutex_1, mutex_2);
    // ...
}

两种都可以避免死锁的发生,主要看需求选择合适的写法

(2) C++17的专门的智能锁

#include 

template<class ...MutexTypes>
class scoped_lock;
// 和lock_gaurd很像,无其他的成员函数,避免死锁的RAll封装
void do_something()
{
    // 就一行搞定问题
    std::scoped_lock lock(mutex_1, mutex_2);
    // ...
}

4. 共享互斥量

有时有这样的需求:

  • 当data被线程A读取时,其他线程仍可继续读取却不能写入
  • 当data被线程A写入时,其他线程不可读取

这也就是读写锁

#include 

// C++17标准
class std::shared_mutex;
// 可以被多个线程共享, 但是只有一个线程可以占有的互斥量
// 有互斥量的几个方法,保证独有性
// 下面几个是共享性的方法
void std::shared_mutex::lock_shared()bool std::shared_mutex::try_lock_shared()void std::shared_mutex::unlock_shared()template<typename _Mutex>
class std::shared_lock;
// 灵活的锁,和unique_lock行为一致
class dns_cache
{
    std::map<std::string, dns_entry> entries;
    mutable std::shared_mutex mutex;

public:
    dns_entry find_entry(const std::string& domain) const
    {
        // 可以被多个线程读取DNS信息
        std::shared_lock<std::shared_mutex> lk(mutex);
        const std::map<std::string, dns_entry>::const_iterator it = entries.find(domain);
        return (it == entries.end()) ? dns_entry() : it->second;
    }

    void update_or_add_entry(const std::string& domain, const dns_entry& dns_tetails)
    {
        // 更新时不可读
        std::lock_guard<std::shared_mutex> lk(mutex);
        entries[domain] = dns_tetails;
    }
};

  • 当有线程在查找dns信息时,其他线程也可以查找,但是更新线程却会阻塞,只有无线程读取时,更新线程才会解除阻塞
  • 当线程更新时,其他线程无法读取

5. 特殊的互斥量

5.1 可多次上锁的互斥量

#include 

class std::recursive_mutex;
// 成员方法和mutex一样
// 特点是: 不同线程行为和mutex一致,但是同一线程中能进行多次lock,而mutex不行
// 进行多少次lock,就需要多少次unlock,否则其他线程永远处于阻塞

5.2 定时互斥量

#include 

class std::timed_mutex;
// 除了mutex方法外,又提供了2个定时方法,前面见过很多次了,不说了

template <class _Rep, class _Period>
bool std::timed_mutex::try_lock_for(const chrono::duration<_Rep, _Period>& __rtime);

template <class _Clock, class _Duration>
bool std::timed_mutex::try_lock_until(const chrono::time_point<_Clock, _Duration>& __atime);

5.3 递归定时互斥量

#include 

class std::recursive_timed_mutex;
// std::recursive_mutex 和 std::timed_mutex 的合体版本

5.4 共享定时互斥量

#include 

class std::shared_timed_mutex;
// 见这名字应该懂了吧

6. 相关系列

  • C++标准线程库之入门
  • C++标准线程库之当前线程管理

你可能感兴趣的:(C++标准库)