C++多线程(二)

简介: C++多线程

二、互斥量库mutex

2.1 mutex的种类

在C++11中,mutex类中总共包了四种互斥量


std::muetx


mutex锁是C++11提供的最基本的互斥量,mutex对象之间不能进行拷贝,也不能进行移动


常用成员函数如下:

32e4492ef9f44db8ba85c569b12011bd.png



线程函数调用lock时:


若该互斥量当前没有被其他线程获取,则调用线程获取互斥量,直到调用unlock()之前,该线程一直拥有该锁

若该互斥量已经被其他线程获取,则当前的调用线程会被阻塞,直至其他线程将互斥量释放

若当前线程已获取该互斥量,却又调用lock(),则会产生死锁(deadlock)

线程调用try_lock时:


若该互斥量当前没有被其他线程获取,则调用线程获取该互斥量,直到调用unlock()之前,该线程一直拥有该锁

若该互斥量已经被其他线程获取,则try_lock()调用返回false,调用线程不会被阻塞

若当前线程已获取该互斥量,却又调用try_lock(),则会产生死锁(deadlock)

std:recursive_mutex


recursive_mutex被称为递归互斥锁,该锁专门用于递归函数中的加锁操作


若在递归函数中使用mutex互斥锁进行加锁,那么在线程进行递归调用时,可能会重复申请已经申请到但自己还未释放的锁,进而导致死锁问题

而recursive_mutex允许同一个线程对互斥量多次上锁(即递归上锁),来获得互斥量对象的多层所有权,但是释放互斥量时需要调用与该锁层次深度相同次数的unlock

recursive_mutex也提供了lock、try_lock和unlock成员函数,其的特性与mutex大致相同


std::timed_mutex


timed_mutex中提供了以下两个成员函数:


try_lock_for:接受一个时间范围,表示在这一段时间范围之内线程若没有获得锁则被阻塞住,若在此期间其他线程释放了锁,则该线程可以获得对互斥量的锁,若超时(即在指定时间之内还是没有获得锁),则返回false

try_lock_untill:接受一个时间点作为参数,在指定时间点未到来之前线程若没有获得锁则被阻塞,若在此期间其他线程释放了锁,则该线程可以获得对互斥量的锁,若超时(即在指定时间点到来时还是没有获得锁),则返回false

timed_mutex也提供了lock、try_lock和unlock成员函数,其的特性与mutex相同


std::recursive_timed_mutex


recursive_timed_mutex就是recursive_mutex和timed_mutex的结合,recursive_timed_mutex既支持在递归函数中进行加锁操作,也支持定时尝试申请锁


2.2 lock_guard与unique_lock

使用互斥锁时可能出现的问题


使用互斥锁时,若在加锁区域抛出异常,则后续的解锁代码则不会执行,导致此后所有申请该锁的线程都被阻塞。或者加锁的范围太大,那么极有可能在中途返回时忘记了解锁


因此C++11采用RAII的方式对锁进行了封装,于是就有了lock_guard和unique_lock


lock_guard


lock_guard是C++11中的一个模板类

template <class Mutex>
class lock_guard;

lock_guard类模板主要是通过RAII的方式,对其管理的互斥锁进行了封装


在需要加锁的地方,用互斥锁实例化一个lock_guard对象,在lock_guard的构造函数中会调用lock()进行加锁

当lock_guard对象出作用域前会自动调用析构函数,而在lock_guard的析构函数中调用了unlock()自动解锁

从lock_guard对象定义到该对象析构,这段区域的代码都属于互斥锁的保护范围。若只想保护某一段代码,可以通过定义匿名的局部域来控制lock_guard对象的生命周期

#include <iostream>
#include <mutex>
using namespace std;
mutex mtx;
void func()
{
  //匿名局部域
  {
    lock_guard<mutex> lg(mtx); //调用构造函数加锁
    if (true) {
      return; //调用析构函数解锁
    }
  } //调用析构函数解锁
}
int main()
{
  func();
  return 0;
}


模拟实现lock_guard


lock_guard类中包含一个锁成员变量(引用类型),这个锁就是lock_guard对象管理的互斥锁

调用lock_guard的构造函数时需要传入一个被管理互斥锁,用该互斥锁来初始化锁成员变量后,调用互斥锁的lock函数进行加锁

lock_guard的析构函数中调用互斥锁的unlock进行解锁

需要删除lock_guard类的拷贝构造和拷贝赋值,lock_guard类本身也是不支持拷贝的

template<class Mutex>
class lock_guard
{
public:
  lock_guard(Mutex& mtx) :_mtx(mtx) {
    mtx.lock(); //加锁
  }
  ~lock_guard() {
    mtx.unlock(); //解锁
  } 
private:
  lock_guard(const lock_guard&) = delete;
  lock_guard& operator=(const lock_guard&) = delete;
private:
  Mutex& _mtx;
};

unique_lock


由于lock_guard太单一,用户没有办法对锁进行控制,因此C++11又提供了unique_lock


unique_lock与lock_guard类似,unique_lock类模板也是采用RAII的方式对锁进行了封装。在创建unique_lock对象调用构造函数时也会调用lock进行加锁,在unique_lock对象销毁调用析构函数时也会调用unlock进行解锁


但lock_guard不同的是,unique_lock更加的灵活,提供了更多的成员函数:


加锁/解锁操作:lock()、try_lock()、try_lock_for()、try_lock_until()与unlock()

修改操作:移动赋值、swap、release(返回它所管理的互斥量对象的指针,并释放所有权)

获取属性:owns_lock(返回当前对象是否上了锁)、operator bool(与owns_lock的功能相同)、mutex(返回当前unique_lock所管理的互斥量的指针)

如下场景就适合使用unique_lock:


要用互斥锁保护func1()的大部分代码,但是中间有一小块代码调用了func2(),而func2()不需要用func1()中的互斥锁进行保护,func2()内部的代码由其他互斥锁进行保护

因此在调用func2()之前需要对当前互斥锁进行解锁,当func()调用返回后再进行加锁,这样当调用func2()时其他线程调用func1()就能够获取到这个锁

db23086b4a984223af248684ab1f9cbb.png


三、原子性操作库atomic

线程安全问题


多线程最主要的问题是共享数据带来的问题(即线程安全)。若共享数据都是只读的,那么没问题,因为只读操作不会影响到数据,更不会涉及对数据的修改,所以所有线程都会获得同样的数据。但是,当一个或多个线程要修改共享数据时,就会产生很多潜在的麻烦


2bfae2ba93994017bb809c9440bb70bb.png


上述代码中分别让两个线程对同一个变量num进行了100000次++操作,理论上最终num的值应该是200000,但最终打印出n的值却是小于200000的


根本原因就是++操作并不是一个原子操作,该操作分为三步:


load:将共享变量n从内存加载到寄存器中

update:更新寄存器里面的值,执行+1操作

store:将新值从寄存器写回共享变量num的内存地址

++操作对应的汇编代码如下:

6bfe234800284694a8e4c68365c8de4f.png



因此可能当线程1刚将num的值加载到寄存器中就被切走了,也就是只完成了++操作的第一步,而线程2可能顺利完成了一次完整的++操作才被切走,而这时线程1继续用之前加载到寄存器中的值完成剩余的两步操作,最终就会导致两个线程分别对共享变量num进行了一次++操作,但最终num的值却只被++了一次


加锁解决线程安全问题

#include <iostream>
#include <thread>
#include <mutex>
using namespace std;
void func(int& num, int count, mutex& mtx)
{
  mtx.lock();
  for (int i = 0; i < count; i++) 
  {
    //mtx.lock();
    ++num;
    //mtx.unlock();
  }
  mtx.unlock();
}
int main()
{
  int num = 0;
  int count = 100000; //每个线程对num++的次数
  mutex mtx;
  thread t1(func, ref(num), count, ref(mtx));
  thread t2(func, ref(num), count, ref(mtx));
  t1.join();
  t2.join();
  cout << num << endl;
  return 0;
}

可以选择在for循环体里面进行加锁解锁,也可以选择在for循环体外进行加锁解锁。但效果终究是不尽人意的,在for循环体里面进行加锁解锁会导致线程的频繁进行加锁解锁操作,在for循环体外面进行加锁解锁会导致两个线程的执行逻辑变为串行


原子类解决线程安全问题

95abdfee98f84d19806e136b35ca7664.png



注意: 需要用大括号对原子类型的变量进行初始化


#include <iostream>
#include <thread>
#include <atomic>
using namespace std;
void func(atomic_int& num, int count)
{
  for (int i = 0; i < count; i++) {
    ++num;
  }
}
int main()
{
  atomic_int num = { 0 };
  int count = 100000; //每个线程对n++的次数
  thread t1(func, ref(num), count);
  thread t2(func, ref(num), count);
  t1.join();
  t2.join();
  cout << num << endl; //打印n的值
  return 0;
}


原子类型通常属于"资源类型"数据,多个线程只能访问单个原子类型的拷贝,因此在C++11中,原子类型只能从其模板参数中进行构造,不允许原子类型进行拷贝构造、移动构造以及operator=等

为了防止意外,标准库已经将atomic模板类中的拷贝构造、移动构造、operator=默认删除

原子类型不仅仅支持原子的++操作,还支持原子的--、+=、-=、与、或、异或操作

原子操作原理CAS


在对变量进行计算之前(如 ++ 操作),首先读取原变量值,称为 旧的预期值 A

然后在更新之前再获取当前内存中的值,称为 当前内存值 V

如果 A==V 则说明变量从未被其他线程修改过,此时将会写入新值 B

如果 A!=V 则说明变量已经被其他线程修改过,当前线程应当重新++

四、条件变量库condition_variable

wait系列成员函数


wait系列成员函数的作用就是让调用线程进行阻塞等待,包括wait()、wait_for()、wait_until()


下面以wait为例,wait()函数提供了两个不同的版本:

void wait(unique_lock<mutex>& lck);
template<class Predicate>
void wait(unique_lock<mutex>& lck, Predicate pred);

调用第一个版本的wait函数时只需要传入一个互斥锁,线程调用wait后会立即被阻塞,直到被唤醒

调用第二个版本的wait函数时除了需要传入一个互斥锁,还需要传入一个返回值类型为bool的可调用对象,与第一个版本的wait不同的是,调用传入的可调用对象若返回值为false才会阻塞;被唤醒后也会调用该对象,若可调用对象的返回值为false,那么该线程还需要继续被阻塞

while (!pred()) wait(lck); == wait()版本二

为什么调用wait系列函数时需要传入一个互斥锁?


因为wait系列函数一般是在临界区中调用的,为了让当前线程调用wait()阻塞时其他线程能够获取到锁,因此调用wait系列函数时需要传入一个互斥锁,当线程被阻塞时这个互斥锁会被自动解锁,而当这个线程被唤醒时,又会自动获得这个互斥锁

因此wait系列函数实际上有两个功能,一个是让线程在条件不满足时进行阻塞等待,另一个是让线程将对应的互斥锁进行解锁

wait_for和wait_until函数的使用方式与wait函数类似:


wait_for函数也提供了两个版本的接口,只不过这两个版本的接口都比wait函数对应的接口多了一个参数,这个参数是一个时间段,表示让线程在该时间段内进行阻塞等待,如果超过这个时间段则线程被自动唤醒

wait_until函数也提供了两个版本的接口,只不过这两个版本的接口都比wait函数对应的接口多了一个参数,这个参数是一个具体的时间点,表示让线程在该时间点之前进行阻塞等待,若超过这个时间点则线程被自动唤醒

线程调用wait_for或wait_until函数在阻塞等待期间,其他线程调用notify系列函数也可以将其唤醒。此外,若调用的是wait_for或wait_until函数的第二个版本的接口,那么当线程被唤醒后还需要调用传入的可调用对象,若可调用对象的返回值为false,那么当前线程还需要继续被阻塞

注意: 调用wait系列函数时,传入互斥锁的类型必须是unique_lock


notify系列成员函数


notify系列成员函数的作用就是唤醒等待的线程,包括notify_one和notify_all


notify_one:唤醒等待队列中的首个线程,若等待队列为空则什么也不做

notify_all:唤醒等待队列中的所有线程,若等待队列为空则什么也不做

注意: 条件变量下可能会有多个线程在进行阻塞等待,其会被放到一个等待队列中进行排队


五、实现两个线程交替打印1-100

尝试用两个线程交替打印1-100的数字,要求一个线程打印奇数,另一个线程打印偶数,并且打印数字从小到大依次递增


该题目主要考察的就是线程的同步和互斥


互斥:两个线程都在向控制台打印数据,为了保证两个线程的打印数据不会相互影响,因此需要对线程的打印过程进行加锁保护

同步:两个线程必须交替进行打印,因此需要用到条件变量让两个线程进行同步,当一个线程打印完再唤醒另一个线程进行打印

但只有同步和互斥是无法满足题目要求的,无法保证哪一个线程会先进行打印,不能说先创建的线程就一定先打印,后创建的线程先打印也是有可能的

#include <iostream>
#include <thread>
#include <atomic>
#include <condition_variable>
using namespace std;
int main()
{
  int i = 1;
  int n = 100;
  mutex mtx;
  condition_variable cv;
  size_t flag = 1;//1 or 2 代表哪个线程可以打印
  //奇数
  thread t1([&]() {
    while (i < n)//最大99
    {
      unique_lock<mutex> lock(mtx);
      cv.wait(lock, [&flag]()->bool { return flag == 1; });
      cout << this_thread::get_id() << ":" << i << endl;
      i += 1;
      flag = 2;
      cv.notify_one();
    }
  });
  //偶数
  thread t2([&]() {
    while (i <= n)//最大100
    {
      unique_lock<mutex> lock(mtx);
      cv.wait(lock, [&flag]()->bool { return flag == 2; });
      cout << this_thread::get_id() << ":" << i << endl;
      i += 1;
      flag = 1;
      cv.notify_one();
    }
  });
  this_thread::sleep_for(chrono::seconds(1));
  cout << "t1:" << t1.get_id() << endl;
  cout << "t2:" << t2.get_id() << endl;
  t1.join();
  t2.join();
  return 0;
}
目录
相关文章
|
16天前
|
缓存 安全 C++
C++无锁队列:解锁多线程编程新境界
【10月更文挑战第27天】
30 7
|
16天前
|
消息中间件 存储 安全
|
1月前
|
存储 消息中间件 资源调度
C++ 多线程之初识多线程
这篇文章介绍了C++多线程的基本概念,包括进程和线程的定义、并发的实现方式,以及如何在C++中创建和管理线程,包括使用`std::thread`库、线程的join和detach方法,并通过示例代码展示了如何创建和使用多线程。
43 1
C++ 多线程之初识多线程
|
22天前
|
存储 并行计算 安全
C++多线程应用
【10月更文挑战第29天】C++ 中的多线程应用广泛,常见场景包括并行计算、网络编程中的并发服务器和图形用户界面(GUI)应用。通过多线程可以显著提升计算速度和响应能力。示例代码展示了如何使用 `pthread` 库创建和管理线程。注意事项包括数据同步与互斥、线程间通信和线程安全的类设计,以确保程序的正确性和稳定性。
|
1月前
|
存储 前端开发 C++
C++ 多线程之带返回值的线程处理函数
这篇文章介绍了在C++中使用`async`函数、`packaged_task`和`promise`三种方法来创建带返回值的线程处理函数。
45 6
|
1月前
|
缓存 负载均衡 Java
c++写高性能的任务流线程池(万字详解!)
本文介绍了一种高性能的任务流线程池设计,涵盖多种优化机制。首先介绍了Work Steal机制,通过任务偷窃提高资源利用率。接着讨论了优先级任务,使不同优先级的任务得到合理调度。然后提出了缓存机制,通过环形缓存队列提升程序负载能力。Local Thread机制则通过预先创建线程减少创建和销毁线程的开销。Lock Free机制进一步减少了锁的竞争。容量动态调整机制根据任务负载动态调整线程数量。批量处理机制提高了任务处理效率。此外,还介绍了负载均衡、避免等待、预测优化、减少复制等策略。最后,任务组的设计便于管理和复用多任务。整体设计旨在提升线程池的性能和稳定性。
77 5
|
1月前
|
C++
C++ 多线程之线程管理函数
这篇文章介绍了C++中多线程编程的几个关键函数,包括获取线程ID的`get_id()`,延时函数`sleep_for()`,线程让步函数`yield()`,以及阻塞线程直到指定时间的`sleep_until()`。
23 0
C++ 多线程之线程管理函数
|
1月前
|
资源调度 Linux 调度
Linux C/C++之线程基础
这篇文章详细介绍了Linux下C/C++线程的基本概念、创建和管理线程的方法,以及线程同步的各种机制,并通过实例代码展示了线程同步技术的应用。
29 0
Linux C/C++之线程基础
|
3月前
|
Java 调度
基于C++11的线程池
基于C++11的线程池
|
4月前
|
算法 编译器 C++
开发与运维线程问题之在C++的原子操作中memory_order如何解决
开发与运维线程问题之在C++的原子操作中memory_order如何解决
43 2