C++多线程编程(下)

简介: C++多线程编程

C++多线程编程(上):https://developer.aliyun.com/article/1508301

lock类

std::lock_gurad 是 C++11 中定义的模板类。

lock_guard 对象通常用于管理某个锁(Lock)对象,因此与 Mutex RAII 相关,方便线程对互斥量上锁,即在某个 lock_guard 对象的声明周期内,它所管理的锁对象会一直保持上锁状态;而 lock_guard 的生命周期结束之后,它所管理的锁对象会被解锁(注:类似 shared_ptr 等智能指针管理动态分配的内存资源)。


在 lock_guard 对象构造时,传入的 Mutex 对象会被当前线程锁住。在lock_guard 对象被析构时,它所管理的 Mutex 对象会自动解锁,即不需要程序员手动调用 lock 和 unlock 对 Mutex 进行上锁和解锁操作,这种情况下在程序抛出异常后先前已被上锁的 Mutex 对象可以正确进行解锁操作,极大地简化了程序员编写与 Mutex 相关的异常处理代码。


值得注意的是,lock_guard 对象并不负责管理 Mutex 对象的生命周期,lock_guard 对象只是简化了 Mutex 对象的上锁和解锁操作:即在某个 lock_guard 对象的声明周期内,它所管理的锁对象会一直保持上锁状态;而 lock_guard 的生命周期结束之后,它所管理的锁对象会被解锁。(一般是以{}来作为一个生命周期)


例如以下代码:

// 参考https://blog.csdn.net/coolwriter/article/details/79884298
// example  1-4 
#include <iostream>       // std::cout  
#include <thread>         // std::thread  
#include <mutex>          // std::mutex, std::lock_guard  
#include <stdexcept>      // std::logic_error  
std::mutex mtx;
void print_even(int x) {
  if (x % 2 == 0) std::cout << x << " is even\n";
  else throw (std::logic_error("not even"));
}
void print_thread_id(int id) {
  try {
    // using a local lock_guard to lock mtx guarantees unlocking on destruction / exception:  
    std::lock_guard<std::mutex> lck(mtx);
    print_even(id);
  }
  catch (std::logic_error&) {
    std::cout << "[exception caught]\n";
  }
}

int main()
{
  std::thread threads[10];
  // spawn 10 threads:  
  for (int i = 0; i<10; ++i)
    threads[i] = std::thread(print_thread_id, i + 1);

  for (auto& th : threads) th.join();
  system("pause");
  return 0;
}

最后的输出为:

[exception caught]
2 is even
[exception caught]
4 is even
[exception caught]
6 is even
[exception caught]
8 is even
[exception caught]
10 is even
请按任意键继续. . .

综上所述:lock_guard 最大的特点就是安全易于使用,在异常抛出的时候通过 lock_guard 对象管理的 Mutex 可以得到正确地解锁。但它的缺点也很明显:那就是过于简单,不具有灵活性。


针对这一缺点,C++11有如下新特性:unique_lock


unique_lock 对象以独占所有权的方式管理 mutex 对象的上锁和解锁操作,所谓独占所有权,即该mutex 对象的所有权只能由该unique_lock占有。


在构造(或移动(move)赋值)时,unique_lock 对象需要传递一个 Mutex 对象作为它的参数,新创建的 unique_lock 对象负责传入的 Mutex 对象的上锁和解锁操作。


std::unique_lock 对象也能保证在其自身析构时它所管理的 Mutex 对象能够被正确地解锁(即使没有显式地调用 unlock 函数)。因此,和 lock_guard 一样,这也是一种简单而又安全的上锁和解锁方式,尤其是在程序抛出异常后先前已被上锁的 Mutex 对象可以正确进行解锁操作,极大地简化了程序员编写与 Mutex 相关的异常处理代码。


值得注意的是,unique_lock 对象同样也不负责管理 Mutex 对象的生命周期,unique_lock 对象只是简化了 Mutex 对象的上锁和解锁操作。unique_lock 的生命周期结束之后,它所管理的锁对象会被解锁,这一点和 lock_guard 类似,但 unique_lock 给程序员提供了更多的自由。

四、条件变量condition_variable

条件变量(Condtion Variable)是在多线程程序中用来实现**“等待->唤醒”逻辑常用的方法。**


举个简单的例子,应用程序A中包含两个线程t1和t2。t1需要在bool变量test_cond为true时才能继续执行,而test_cond的值是由t2来改变的,这种情况下,如何来写程序呢?可供选择的方案有两种:


第一种是t1定时的去轮询变量test_cond,如果test_cond为false,则继续休眠;如果test_cond为true,则开始执行。

第二种就是上面提到的条件变量,t1在test_cond为false时调用cond_wait进行等待,t2在改变test_cond的值后,调用cond_signal,唤醒在等待中的t1,告诉t1 test_cond的值变了,这样t1便可继续往下执行。

很明显,上面两种方案中,第二种方案是比较优的。在第一种方案中,在每次轮询时,如果t1休眠的时间比较短,会导致cpu资源浪费很厉害;如果t1休眠的时间比较长,又会导致应用逻辑处理不够及时,致使应用程序性能下降。第二种方案就是为了解决轮询的弊端而生的。(参考自https://blog.csdn.net/erickhuang1989/article/details/8754357


请看如下例子:

//example 1-7
#include <thread>                // std::thread
#include <mutex>                // std::mutex, std::unique_lock
#include <condition_variable>    // std::condition_variable
#include<iostream>
std::mutex mtx; // 全局互斥锁.
std::condition_variable cv; // 全局条件变量.
bool ready = false; // 全局标志位.

void do_print_id(int id)
{
  std::unique_lock <std::mutex> lck(mtx);
  while (!ready) // 如果标志位不为 true, 则等待...
    cv.wait(lck); // 当前线程被阻塞, 当全局标志位变为 true 之后,
            // 线程被唤醒, 继续往下执行打印线程编号id.
  std::cout << "thread " << id << '\n';
}

void go()
{
  std ::unique_lock <std::mutex> lck(mtx);
  ready = true; // 设置全局标志位为 true.
  cv.notify_all(); // 唤醒所有线程.
}

int main()
{
  std::thread threads[10];
  // spawn 10 threads:
  for (int i = 0; i < 10; ++i)
    threads[i] = std::thread(do_print_id, i);

  std::cout << "10 threads ready to race...\n";
  go(); // go!

  for (auto & th : threads)
    th.join();
  system("pause");
  return 0;
}

结果为:

10 threads ready to race...
thread 9
thread 6
thread 8
thread 5
thread 7
thread 2
thread 1
thread 4
thread 3
thread 0
请按任意键继续. . .

代码中判断是否ready的时候,使用的是while(),而不是 if(q.empty()),这是因为wait()从阻塞到返回,不一定就是由于notify_one()函数造成的,还有可能由于系统的不确定原因唤醒(可能和条件变量的实现机制有关),这个的时机和频率都是不确定的,被称作伪唤醒,如果在错误的时候被唤醒了,执行后面的语句就会错误,所以需要再次判断队列是否为空,如果还是为空,就继续wait()阻塞。


std::condition_variable提供了两种 wait() 函数。一是当前线程调用 wait() 后将被阻塞(此时当前线程应该获得了锁(mutex),不妨设获得锁 lck),直到另外某个线程调用 notify_* 唤醒了当前线程。


在线程被阻塞时,该函数会自动调用 lck.unlock() 释放锁,使得其他被阻塞在锁竞争上的线程得以继续执行。另外,一旦当前线程获得通知(notified,通常是另外某个线程调用 notify_ 唤醒了当前线程)*,wait()函数也是自动调用 lck.lock(),使得lck的状态和 wait 函数被调用时相同。


在第二种情况下(即设置了 Predicate),**只有当 pred 条件为false 时调用 wait() 才会阻塞当前线程,并且在收到其他线程的通知后只有当 pred 为 true 时才会被解除阻塞。**请注意以下代码跟之前的区别:

#include <thread>                // std::thread, std::this_thread::yield
#include <mutex>                // std::mutex, std::unique_lock
#include <condition_variable>    // std::condition_variable
#include <iostream>
std::mutex mtx;
std::condition_variable cv;
int cargo = 0;
bool shipment_available()
{
  return cargo != 0;
}
// 消费者线程.
void consume(int n)
{
  for (int i = 0; i < n; ++i) {
    std::unique_lock <std::mutex> lck(mtx);
    cv.wait(lck, shipment_available);
    std::cout << cargo << '\n';
    cargo = 0;
  }
}
int main()
{
  std::thread consumer_thread(consume, 10); // 消费者线程.
                        // 主线程为生产者线程, 生产 10 个物品.
  for (int i = 0; i < 10; ++i) {
    while (shipment_available())
      std::this_thread::yield();//当前线程“放弃”执行(在一个时间拍片的时间内),让操作系统调度另一线程继续执行,也就是主线程放弃执行,让子线程先进行
    std::unique_lock <std::mutex> lck(mtx);
    cargo = i + 1;
    cv.notify_one();
  }

  consumer_thread.join();
  system("pause");
  return 0;
}

结果为:

1
2
3
4
5
6
7
8
9
10
请按任意键继续. . .

与std::condition_variable::wait() 类似,**不过 wait_for可以指定一个时间段,在当前线程收到通知或者指定的时间 rel_time 超时之前,该线程都会处于阻塞状态。**而一旦超时或者收到了其他线程的通知,wait_for返回,剩下的处理步骤和 wait()类似。


另外,wait_for 的重载版本的最后一个参数pred表示 wait_for的预测条件,只有当 pred条件为false时调用 wait()才会阻塞当前线程,并且在收到其他线程的通知后只有当 pred为 true时才会被解除阻塞。


详见:https://www.2cto.com/kf/201506/411327.html


值得注意的是:


在C++的condition_variable中,当 std::condition_variable对象的某个wait函数被调用的时候,它使用 std::unique_lock(通过 std::mutex) 来锁住当前线程。当前线程会一直被阻塞,直到另外一个线程在相同的 std::condition_variable 对象上调用了 notification 函数来唤醒当前线程。


std::condition_variable 对象通常使用 std::unique_lock 来等待,如果需要使用另外的 lockable 类型,可以使用std::condition_variable_any类。


这里需要注意,在管理互斥锁的时候,使用的是std::unique_lock而不是std::lock_guard,而且事实上也不能使用std::lock_guard,这需要先解释下wait()函数所做的事情。


可以看到,在wait()函数之前,使用互斥锁保护了,如果wait的时候什么都没做?那生产者也会一直卡住,不能够将数据放入队列中了。所以,wait()函数会先调用互斥锁的unlock()函数,然后再将自己睡眠,在被唤醒后,又会继续持有锁,保护后面变量。而lock_guard没有lock和unlock接口,而unique_lock提供了。这就是必须使用unique_lock的原因。


有关这个部分的进一步解释请参考:


https://blog.csdn.net/shichao1470/article/details/89856443


https://www.cnblogs.com/lidabo/p/11423825.html

五、异步调用future

在上文的代码中基本都用到的thread对象,它是C++11中提供同步创建多线程的工具。但是我们想要从线程中返回异步任务结果,一般需要依靠全局变量;从安全角度看,有些不妥;为此C++11提供了std::future类模板,future对象提供访问异步操作结果的机制,很轻松解决从异步任务中返回结果。

创建方法如下:

auto futureFunction = std::async(helloFunction, “function”);
//参考博客:https://blog.csdn.net/y396397735/article/details/82381874

跟线程的创建一样:一个可调用的对象以及可选的入口参数;


运行futureFunction.get()即可获取该调用对象的返回值;


这个时候需要了解两种策略:Eager or lazy evaluation(急速或惰性求值) 是计算表达式结果的两种策略。


在急速求值的情况下,将立即计算评估表达式。


在惰性求值的情况下,只会在需要的情况下评估表达式。


通常惰性求值被称为call-by-need,按需调用。


值得注意的是,通常情况下, std::async 立即开始执行它的工作包。


C++运行时会决定, 计算是发生在同一个线程还是一个新的线程。


使用std::launch::async参数的话,std::async 将在一个新线程中运行它。


相反,使用参数 std::launch::deferred, std::async将在同一个线程中运行它的工作包,这属于惰性求值。


这意味着,急速求值是立即执行的,惰性求值的策略std::launch::deferred是随着future调用get()后才开始执行。


参考如下代码:

//example 1-8
#include <future>
#include <iostream>
#include<string>
using namespace std;
bool is_prime(int x)
{
  for (int i = 1; i<x; i++)
  {
    if (x % i == 0)
      return false;
  }
  return true;
}
int main()
{
  std::future<bool> fut = std::async(is_prime, 700020007);
  std::cout << "please wait";
  std::chrono::milliseconds span(1);
  while (fut.wait_for(span) != std::future_status::ready)
    std::cout << ".";
  std::cout << std::endl;

  bool ret = fut.get();
  std::cout << "final result: " << std::to_string(ret) << std::endl;
  system("pause");
  return 0;
}

std::async会首先创建线程执行is_prime(700020007), 任务创建之后,std::async立即返回一个std::future对象。


主线程既可使用std::future::get获取结果,如果调用过程中,任务尚未完成,则主线程阻塞至任务完成。


主线程也可使用std::future::wait_for等待结果返回,wait_for可设置超时时间,如果在超时时间之内任务完成,则返回std::future_status::ready状态;如果在超时时间之内任务尚未完成,则返回std::future_status::timeout状态。(参考https://www.cnblogs.com/taiyang-li/p/5914167.html


Std::promise


std::promise是C++11并发编程中常用的一个类,常配合std::future使用。其作用是在一个线程t1中保存一个类型typename T的值,可供相绑定的std::future对象在另一线程t2中获取。


Std::launch是枚举类型,用于启动异步任务时,传递给函数async的参数,它的定义如下:

enum class launch {
async = 0x1,创建线程的时候就开始调用(创建新线程)
deferred = 0x2延迟调用,等到wait或者get的时候才进行调用(直接在原有的线程进行);
};

补充:async与thread的区别:关于系统资源紧张与否、返回值获取的难易程度;thread必定会创建线程,而这可能会造成系统崩溃;

六、atomic


std::atomic为C++11封装的原子数据类型。


从功能上看,简单地说,原子数据类型不会发生数据竞争,能直接用在多线程中而不必我们用户对其进行添加互斥资源锁的类型。从实现上,大家可以理解为这些原子类型内部自己加了锁。


atomic的变量支持–,++,+=等对本体的操作,但是类似于=+1的操作就会出错(相当于调用了两次);


std::atomic_flag


std::atomic_flag是一个原子的布尔类型,可支持两种原子操作:


test_and_set, 如果atomic_flag对象被设置,则返回true; 如果atomic_flag对象未被设置,则设置之,返回false

clear, 清楚atomic_flag对象

std::atomic_flag可用于多线程之间的同步操作,类似于linux中的信号量。使用atomic_flag可实现mutex,请参考如下代码.

//来自https://www.cnblogs.com/taiyang-li/p/5914331.html
#include <iostream>
#include <atomic>
#include <vector>
#include <thread>
#include <sstream>

std::atomic_flag lock = ATOMIC_FLAG_INIT;
std::stringstream stream;
void append_numer(int x)
{
  while (lock.test_and_set());
  stream << "thread#" << x << "\n";
  lock.clear();
}

int main()
{
  std::vector<std::thread> ths;
  for (int i=0; i<10; i++)
    ths.push_back(std::thread(append_numer, i));
  for (int i=0; i<10; i++)
    ths[i].join();
  std::cout << stream.str();
  return 0;
}

std::atomic对int, char, bool等数据结构进行原子性封装,在多线程环境中,对std::atomic对象的访问不会造成竞争-冒险。利用std::atomic可实现数据结构的无锁设计。如下列代码:

//来自https://www.cnblogs.com/taiyang-li/p/5914331.html
#include <iostream>
#include <atomic>
#include <vector>
#include <thread>
#include <sstream>

std::atomic<bool> ready(false);
std::atomic_flag winner = ATOMIC_FLAG_INIT;

void count1m(int i)
{
  while (!ready);
  for (int i=0; i<1000000; i++);
  if (!winner.test_and_set())
    std::cout << "winner: " << i << std::endl;
}

int main()
{
  std::vector<std::thread> ths;
  for (int i=0; i<10; i++)
    ths.push_back(std::thread(count1m, i));
  ready = true;
  for (int i=0; i<10; i++)
    ths[i].join();
  return 0;
}

参考资料

https://www.runoob.com/w3cnote/cpp-std-thread.html

https://github.com/forhappy/Cplusplus-Concurrency-In-Practice/blob/master/zh/chapter3-Thread/Introduction-to-Thread.md

相关文章
|
7天前
|
安全 Java 调度
Java编程时多线程操作单核服务器可以不加锁吗?
Java编程时多线程操作单核服务器可以不加锁吗?
21 2
|
11天前
|
存储 算法 C++
C++提高篇:泛型编程和STL技术详解,探讨C++更深层的使用
文章详细探讨了C++中的泛型编程与STL技术,重点讲解了如何使用模板来创建通用的函数和类,以及模板在提高代码复用性和灵活性方面的作用。
27 2
C++提高篇:泛型编程和STL技术详解,探讨C++更深层的使用
|
3天前
|
程序员 C++
C++编程:While与For循环的流程控制全解析
总结而言,`while`循环和 `for`循环各有千秋,它们在C++编程中扮演着重要的角色。选择哪一种循环结构应根据具体的应用场景、循环逻辑的复杂性以及个人的编程风格偏好来决定。理解这些循环结构的内在机制和它们之间的差异,对于编写高效、易于维护的代码至关重要。
9 1
|
12天前
|
Java 调度 开发者
Java中的多线程编程:从基础到实践
本文旨在深入探讨Java多线程编程的核心概念和实际应用,通过浅显易懂的语言解释多线程的基本原理,并结合实例展示如何在Java中创建、控制和管理线程。我们将从简单的线程创建开始,逐步深入到线程同步、通信以及死锁问题的解决方案,最终通过具体的代码示例来加深理解。无论您是Java初学者还是希望提升多线程编程技能的开发者,本文都将为您提供有价值的见解和实用的技巧。
15 2
|
14天前
|
Java 数据处理
Java中的多线程编程:从基础到实践
本文旨在深入探讨Java中的多线程编程,涵盖其基本概念、创建方法、同步机制及实际应用。通过对多线程基础知识的介绍和具体示例的演示,希望帮助读者更好地理解和应用Java多线程编程,提高程序的效率和性能。
19 1
|
18天前
|
Java
深入理解Java中的多线程编程
本文将探讨Java多线程编程的核心概念和技术,包括线程的创建与管理、同步机制以及并发工具类的应用。我们将通过实例分析,帮助读者更好地理解和应用Java多线程编程,提高程序的性能和响应能力。
20 4
|
26天前
|
Java 调度 开发者
Java并发编程:深入理解线程池
在Java的世界中,线程池是提升应用性能、实现高效并发处理的关键工具。本文将深入浅出地介绍线程池的核心概念、工作原理以及如何在实际应用中有效利用线程池来优化资源管理和任务调度。通过本文的学习,读者能够掌握线程池的基本使用技巧,并理解其背后的设计哲学。
|
17天前
|
安全 Java 调度
Java 并发编程中的线程安全和性能优化
本文将深入探讨Java并发编程中的关键概念,包括线程安全、同步机制以及性能优化。我们将从基础入手,逐步解析高级技术,并通过实例展示如何在实际开发中应用这些知识。阅读完本文后,读者将对如何在多线程环境中编写高效且安全的Java代码有一个全面的了解。
|
27天前
|
算法 Java 数据处理
Java并发编程:解锁多线程的力量
在Java的世界里,掌握并发编程是提升应用性能和响应能力的关键。本文将深入浅出地探讨如何利用Java的多线程特性来优化程序执行效率,从基础的线程创建到高级的并发工具类使用,带领读者一步步解锁Java并发编程的奥秘。你将学习到如何避免常见的并发陷阱,并实际应用这些知识来解决现实世界的问题。让我们一起开启高效编码的旅程吧!
|
6天前
|
Java
COMATE插件实现使用线程池高级并发模型简化多线程编程
本文介绍了COMATE插件的使用,该插件通过线程池实现高级并发模型,简化了多线程编程的过程,并提供了生成结果和代码参考。