c++多线程并发学习笔记(1)

共享数据带来的问题:条件竞争

避免恶性条件竞争的方法:

1. 对数据结构采用某种保护机制,确保只有进行修改的线程才能看到修改时的中间状态。从其他访问线程的角度来看,修改不是已经完成了,就是还没开始。

2. 对数据结构的设计进行修改,修改完的结构必须能完成一系列不可分割的变化,也就是保证每个不变量保持稳定的状态,这就是所谓的无锁编程。

3. 使用事务的方式去处理数据结构的更新(这里的"处理"就如同对数据库进行更新一样)。所需的一些数据和读取都存储在事务日志中,然后将之前的操作合为一步,再进行提交。当数据结构被另一个线程修       改后,或处理已经重启的情况下,提交就会无法进行,这称作为“软件事务内存”(software transactional memory (STM))。

 

使用互斥量来保护共享数据

主要实现方法:当访问共享数据前,将数据锁住,在访问结束后,再将数据解锁。线程库需要保证,当一个线程使用特定互斥量锁住共享数据时,其他的线程想要访问锁住的数据,都必须等到之前那个线程对数据进行解锁后,才能进行访问。这就保证了所有线程都能看到共享数据,并而不破坏不变量。

互斥量不是万能的,在使用时要注意以下问题:

1. 需要编排代码来保护数据的正确性

2. 避免接口间的竞争条件

3. 避免死锁

4. 对数据的保护太多或太少

 

c++中的互斥量

通过实例化 std::mutex 来创建互斥量实例,需要包含头文件<mutex>

使用方法:通过成员函数lock() 和unlock()来实现上锁和解锁

#include <iostream>
#include <thread>
#include <mutex>

using namespace std;

class Test
{
    std::mutex m;
public:
    void add(int& num)
    {
        m.lock();//上锁
        ++num;
        cout << num << endl;
        m.unlock();//解锁
    }
};

int main()
{
    int num = 1;
    Test test;
    thread t1(&Test::add, &test, std::ref(num));
    thread t2(&Test::add, &test, std::ref(num));
    t1.join();
    t2.join();
}

但是,实践中不推荐直接去调用成员函数,调用成员函数就意味着,必须在每个函数出口都要去调用unlock(),也包括异常的情况。C++标准库为互斥量提供了一个RAII语法的模板类std::lock_guard,在构造时就能提供已锁的互斥量,并在析构的时候进行解锁,从而保证了一个已锁互斥量能被正确解锁。

对于上个例子而言,可以改造为:

#include <iostream>
#include <thread>
#include <mutex>

using namespace std;

class Test
{
    std::mutex m;
public:
    void add(int& num)
    {
        lock_guard<std::mutex> guard(m); //在构造时lock
        ++num;
        cout << num << endl;
    }//在析构时unlock
};

int main()
{
    int num = 1;
    Test test;
    thread t1(&Test::add, &test, std::ref(num));
    thread t2(&Test::add, &test, std::ref(num));
    t1.join();
    t2.join();
}

另外要注意:在使用互斥量来保护数据时,要注意检查指针和引用。切勿将受保护数据的指针或引用传递到互斥锁作用域之外,无论是函数返回值,还是存储在外部可见内存,亦或是以参数的形式传递到用户提供的函数中去。只要没有成员函数通过返回值或者输出参数的形式,向其调用者返回指向受保护数据的指针或引用,数据就是安全的。

 

接口间的竞争

考虑一个std::stack,它有top(), pop(), empty()等方法。即使我们在每个方法调用内部使用互斥量std::mutex 进行保护,由于接口之间的依赖关系,还是会存在竞争。例如:在调用empty()和调用top()之间,可能有来自另一个线程的pop()调用并删除了最后一个元素。这是一个经典的条件竞争,使用互斥量对栈内部数据进行保护,但依旧不能阻止条件竞争的发生,这就是接口固有的问题。

 

死锁

线程有对锁的竞争:一对线程需要对他们所有的互斥量做一些操作,其中每个线程都有一个互斥量,且等待另一个解锁。这样没有线程能工作,因为他们都在等待对方释放互斥量。这种情况就是死锁,它的最大问题就是由两个或两个以上的互斥量来锁定一个操作。

避免死锁的一般方法:就是让两个互斥量总以相同的顺序上锁:总在互斥量B之前锁住互斥量A,就永远不会死锁。

std::lock——可以一次性锁住多个(两个以上)的互斥量,并且没有副作用(死锁风险)。因为std::lock要么将两个锁都锁住,要不一个都不锁。

 

// 这里的std::lock()需要包含<mutex>头文件
class some_big_object;
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::adopt_lock作用是声明互斥量已在本线程锁定,std::lock_guard只是保证互斥量在作用域结束时被释放
    std::lock_guard<std::mutex> lock_b(rhs.m,std::adopt_lock);
    swap(lhs.some_detail,rhs.some_detail);
  }
};

 

 

避免死锁的一些方法

1. 避免嵌套锁

一个线程已获得一个锁时,再别去获取第二个。因为每个线程只持有一个锁,锁上就不会产生死锁。当你需要获取多个锁,使用一个std::lock来做这件事(对获取锁的操作上锁),避免产生死锁。

2. 避免在持有锁时调用用户提供的代码

因为代码是用户提供的,你没有办法确定用户要做什么;用户程序可能做任何事情,包括获取锁。

 

3. 使用固定顺序获取锁

当硬性条件要求你获取两个或两个以上的锁,并且不能使用std::lock单独操作来获取它们;那么最好在每个线程上,用固定的顺序获取它们(锁)。

4. 使用锁的层次结构

 

std::unique_lock——灵活的锁

 

互斥锁保证了线程间的同步,但是却将并行操作变成了串行操作,这对性能有很大的影响,所以我们要尽可能的减小锁定的区域,也就是使用细粒度锁。这一点lock_guard做的不好,不够灵活,lock_guard只能保证在析构的时候执行解锁操作,lock_guard本身并没有提供加锁和解锁的接口,但是有些时候会有这种需求。

class LogFile {
    std::mutex _mu;
    ofstream f;
public:
    LogFile() {
        f.open("log.txt");
    }
    ~LogFile() {
        f.close();
    }
    void shared_print(string msg, int id) {
        {
            std::lock_guard<std::mutex> guard(_mu);
            //do something 1
        }
        //do something 2
        {
            std::lock_guard<std::mutex> guard(_mu);
            // do something 3
            f << msg << id << endl;
            cout << msg << id << endl;
        }
    }

};

上述代码因为有两段代码需要上锁保护,所以使用lock_guard只能用两个局部变量来上锁和解锁,使用一个也可以,但锁的粒度太大,影响效率,这个时候就可以用unique_lock。

unique_lock它提供了lock()unlock()接口,能记录现在处于上锁还是没上锁状态,在析构的时候,会根据当前状态来决定是否要进行解锁(lock_guard就一定会解锁)。

上面的代码使用unique_lock可以修改为:

class LogFile {
    std::mutex _mu;
    ofstream f;
public:
    LogFile() {
        f.open("log.txt");
    }
    ~LogFile() {
        f.close();
    }
    void shared_print(string msg, int id) {

        std::unique_lock<std::mutex> guard(_mu);
        //do something 1
        guard.unlock(); //临时解锁

        //do something 2

        guard.lock(); //继续上锁
        // do something 3
        f << msg << id << endl;
        cout << msg << id << endl;
        // 结束时析构guard会临时解锁
        // 这句话可要可不要,不写,析构的时候也会自动执行
        // guard.ulock();
    }

};

另外,还可以使用std::defer_lock设置初始化的时候不进行默认的上锁操作:

std::unique_lock<std::mutex> guard(_mu, std::defer_lock);

unique_lock的灵活是有代价的,因为它内部需要维护锁的状态,所以效率要比lock_guard低一点,在lock_guard能解决问题的时候,就是用lock_guard,反之,使用unique_lock

另外,unique_locklock_guard都不能复制,lock_guard不能移动,但是unique_lock可以。

 

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

某些场景下,我们需要代码只被执行一次,比如单例类的初始化,考虑到多线程安全,需要进行加锁控制。C++11中提供的call_once可以很好的满足这种需求。

#include<mutex>

template <class Fn, class... Args>
void call_once (once_flag& flag, Fn&& fn, Args&&...args);

第一个参数是std::once_flag的对象(once_flag是不允许修改的,其拷贝构造函数和operator=函数都声明为delete),第二个参数可调用实体,即要求只执行一次的代码,后面可变参数是其参数列表。

call_once保证函数fn只被执行一次,如果有多个线程同时执行函数fn调用,则只有一个活动线程(active call)会执行函数,其他的线程在这个线程执行返回之前会处于”passive execution”(被动执行状态不会直接返回,直到活动线程对fn调用结束才返回。对于所有调用函数fn的并发线程,数据可见性都是同步的(一致的)。还有一个要注意的地方是 once_flag的生命周期,它必须要比使用它的线程的生命周期要长。所以通常定义成全局变量比较好。

 

一些其他的互斥锁

嵌套锁:std::recursive_mutex

除了可以对同一线程的单个实例上获取多个锁,其他功能与std::mutex相同。互斥量锁住其他线程前,必须释放拥有的所有锁,所以当调用lock()三次后,也必须调用unlock()三次。

shared_mutex(c++17)/std::shared_timed_mutex(C++ 14)

shared_mutex的适用场景比较特殊:一个或多个读线程同时读取共享资源,且只有一个写线程来修改这个资源,这种情况下才能从shared_mutex获取性能优势。对于不需要去修改数据结构的线程,

可以使用std::shared_lock<std::shared_mutex>获取访问权。

 

参考资料:

https://chenxiaowei.gitbook.io/c-concurrency-in-action-second-edition-2019/3.0-chinese/3.2-chinese

https://www.jianshu.com/p/34d219380d90

https://blog.csdn.net/xijiacun/article/details/71023777

 

posted @ 2019-08-30 16:35  断_水_流  阅读(670)  评论(0编辑  收藏  举报