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

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

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 = ;
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 = ;
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

c++多线程并发学习笔记(1)的更多相关文章

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

    多进程并发:将应用程序分为多个独立的进程,它们在同一时刻运行.如图所示,独立的进程可以通过进程间常规的通信渠道传递讯息(信号.套接字..文件.管道等等). 优点:1.操作系统在进程间提供附附加的保护操 ...

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

    等待一个时间或其他条件 在一个线程等待完成任务时,会有很多选择: 1. 它可以持续的检查共享数据标志(用于做保护工作的互斥量),直到另一个线程完成工作时对这个标志进行重设.缺点:资源浪费,开销大 2. ...

  3. 多线程编程学习笔记——使用异步IO(一)

    接上文 多线程编程学习笔记——使用并发集合(一) 接上文 多线程编程学习笔记——使用并发集合(二) 接上文 多线程编程学习笔记——使用并发集合(三) 假设以下场景,如果在客户端运行程序,最的事情之一是 ...

  4. 多线程编程学习笔记——使用异步IO

    接上文 多线程编程学习笔记——使用并发集合(一) 接上文 多线程编程学习笔记——使用并发集合(二) 接上文 多线程编程学习笔记——使用并发集合(三) 假设以下场景,如果在客户端运行程序,最的事情之一是 ...

  5. Java多线程技术学习笔记(二)

    目录: 线程间的通信示例 等待唤醒机制 等待唤醒机制的优化 线程间通信经典问题:多生产者多消费者问题 多生产多消费问题的解决 JDK1.5之后的新加锁方式 多生产多消费问题的新解决办法 sleep和w ...

  6. 多线程编程学习笔记——async和await(一)

    接上文 多线程编程学习笔记——任务并行库(一) 接上文 多线程编程学习笔记——任务并行库(二) 接上文 多线程编程学习笔记——任务并行库(三) 接上文 多线程编程学习笔记——任务并行库(四) 通过前面 ...

  7. 多线程编程学习笔记——async和await(二)

    接上文 多线程编程学习笔记——async和await(一) 三.   对连续的异步任务使用await操作符 本示例学习如何阅读有多个await方法方法时,程序的实际流程是怎么样的,理解await的异步 ...

  8. 多线程编程学习笔记——async和await(三)

    接上文 多线程编程学习笔记——async和await(一) 接上文 多线程编程学习笔记——async和await(二) 五.   处理异步操作中的异常 本示例学习如何在异步函数中处理异常,学习如何对多 ...

  9. 多线程编程学习笔记——编写一个异步的HTTP服务器和客户端

    接上文 多线程编程学习笔记——使用异步IO 二.   编写一个异步的HTTP服务器和客户端 本节展示了如何编写一个简单的异步HTTP服务器. 1.程序代码如下. using System; using ...

随机推荐

  1. 【NOIP2016提高A组模拟8.17】(雅礼联考day1)总结

    考的还ok,暴力分很多,但有点意外的错误. 第一题找规律的题目,推了好久.100分 第二题dp,没想到. 第三题树状数组.比赛上打了个分段,准备拿60分,因为时间不够,没有对拍,其中有分段的20分莫名 ...

  2. 【leetcode】1214.Two Sum BSTs

    题目如下: Given two binary search trees, return True if and only if there is a node in the first tree an ...

  3. fiddler界面工具栏介绍(二)

    工具栏介绍 1.Winconfig,Windows 使用了一种称为“AppContainer”的隔离技术,使得一些进程的流量无法捕获,打开WinConfig后可设置解除隔离. 2.气泡按钮,给sess ...

  4. 2019春Python程序设计作业1(0319-0325)

    判断题 1-1 在Python 3.x中可以使用中文作为变量名. (2分) T         F Python变量使用前必须先声明,并且一旦声明就不能再当前作用域内改变其类型.(2分) T     ...

  5. Tomcat的安装、配置常见问题

    (1)服务里面没有Tomcat怎么办? ——运行:cmd=>再到Tomcat 8.0/bin目录下运行: service install  即可: ——然后用: net start   Tomc ...

  6. 湖南省第十二届省赛:Parenthesis

    Description Bobo has a balanced parenthesis sequence P=p1 p2…pn of length n and q questions. The i-t ...

  7. python build-in function

    目录(?)[-] absx alliterable anyiterable basestring binx boolx callableobject chri classmethodfunction ...

  8. MySQL_DML操作

    DML(Data Manipulation Laguage)指对数据库数据的增(Create)删(Delete)改(Update)操作 1.增加操作 (1)先创建一个表,如图所示: 语法:Insert ...

  9. logstash之Filter插件

    Logstash之所以强悍的主要原因是filter插件:通过过滤器的各种组合可以得到我们想要的结构化数据 1:grok正则表达式 grok**正则表达式是logstash非常重要的一个环节**:可以通 ...

  10. SpringMvc中@ModelAttribute的运用

    /** * 1. 有 @ModelAttribute 标记的方法, 会在每个目标方法执行之前被 SpringMVC 调用! * 2. @ModelAttribute 注解也可以来修饰目标方法 POJO ...