多线程基础
1 thread
std::thread
是C++标准库提供的类,用于创建和管理线程。位于
#include <iostream>
#include <thread>
void func(int a){
while(true){
std::cout << "hello world" <<std::endl;
std::this_thread::sleep_for(std::chrono::microseconds(10000));
}
}
int main()
{
int a = 0;
std::thread t1(func, a);
while(true){
std::cout << "hi world" <<std::endl;
std::this_thread::sleep_for(std::chrono::microseconds(10000));
}
}
在上面这段程序中我们将会看到hi world和hello world交替的输出,实现了主线程与子线程之间的并发输出。
另外需要注意的是,参数传递给线程的可调用对象是通过值传递的,而不是引用传递。如果需要引用传递参数,可以使用std::ref函数将参数包装为引用类型。
对于在创建的线程内部的一些延时或查看线程id的操作,要用this_thread来操作。对于子线程要用join来确保主线程不会提前结束掉
#include <iostream>
#include <thread>
void func(int a){
std::cout << "hello world" <<std::endl;
std::cout << std::this_thread::get_id() << std::endl;
}
int main()
{
int a = 0;
std::thread t1(func, a);
t1.join();//主线程等待子线程,不推荐用detach来分离线程
return 0;
}
2 互斥量mutex
mutex
std::mutex
是C++标准库提供的互斥量(Mutex)类,用于实现线程之间的互斥和同步操作。互斥量是一种同步原语,用于确保在多线程环境下对共享资源的安全访问。
首先来看这样一个程序:
#include <iostream>
#include <thread>
static int var = 0;
void task(void ){
for(int i = 0; i < 1000000; i++){
var ++;
var --;
}
}
int main()
{
std::thread t1(task);
std::thread t2(task);
t1.join();
t2.join();
std::cout << var <<std::endl;
return 0;
}
在这个程序中设置两个线程来并发的执行task任务,但是最后输出var的结果并不是0,而且每次运行结束后的结果都不一样,因为这样的一个执行过程是异步的,也可以把它理解为随机,两个线程每次按照一个随机的顺序进行执行,得到的结果自然每次都是不一样的。如果我们希望它最后输出为0,也就是希望线程按照一定的顺序执行,那么最后就要就行同步操作,即使用互斥锁mutex。
互斥量提供了两种主要操作:锁定(lock)和解锁(unlock)。只有拥有锁的线程才能访问由互斥量保护的共享资源,其他线程需要等待该锁被释放才能获取锁。
回顾操作系统所学的知识,对于临界区的访问,要在进入临界区之前上锁,在离开临界区的时候归还这把锁,因此只需将task做如下改动即可
void task(void ){
for(int i = 0; i < 1000000; i++){
mtx.lock();
var ++;
var --;
mtx.unlock();
}
}
这样一来就实现了同步,但这只是最简单的情况,实际的临界区代码往往比这要复杂的多,比如函数有可能在临界区内抛出了一个异常,或者经过某个判断条件就提前返回了,这时就产生了一个比较严重的后果,就是死锁,线程拿着这把锁不还,其他线程只能无限等待。
lock_guard
这个时候如果有一个东西可以帮我们来归还锁而避免手动操作出现的问题就好了,C++提供了lock_guard和unique_lock,二者的使用方式差不多,只是unique_lock比前者更为灵活,其提供了手动解锁和上锁的功能,以及超时等待。这里以lock_guard进行介绍
std::lock_guard
在构造时自动锁定互斥锁,并在析构时自动解锁互斥锁,利用了C++对象的生命周期管理机制,确保互斥锁的正确使用,防止忘记解锁的情况,在使用时创建一个对象利用其构造函数对信号量进行加锁:
std::lock_guard<std::mutex> lock(mtx);//前面是要实例化的类型std::mutex,后面则是传入的对象mtx
提到C++生命周期管理机制,这里简单介绍一下RAII(Resource Acquisition Is Initialization),一种编程范式,也是C++编程中的一种常用技术。它通过利用对象的生命周期和析构函数的自动调用来管理资源的获取和释放,以确保资源的正确处理和释放,从而避免资源泄漏和错误。
注:std::unique_lock相比于std::lock_guard更为灵活,但也更为重量级,因为它需要额外的空间来存储互斥量的状态信息。因此,如果不需要手动锁定和解锁互斥量,建议使用std::lock_guard以获得更轻量级的互斥量保护。
lock
std::lock 是 C++ 标准库中提供的一个函数模板,用于在多线程环境下同时获取多个互斥锁,以避免死锁和竞争条件。
std::lock 可以接受多个互斥锁作为参数,并按照特定的顺序对这些互斥锁进行加锁。如果成功获取了所有的互斥锁,std::lock 函数会立即返回,而如果无法获取某个互斥锁,std::lock 函数会阻塞当前线程,直到所有互斥锁都被成功获取为止。
以下是 std::lock 的使用示例:
std::mutex mutex1;
std::mutex mutex2;
void function() {
std::lock(mutex1, mutex2); // 获取多个互斥锁
// 临界区代码,同时拥有 mutex1 和 mutex2 的所有权
// 解锁互斥锁
mutex1.unlock();
mutex2.unlock();
}
3 条件变量condition_variable
首先我们需要知道使用while循环是一种很浪费CPU的行为,因此使用while来进行线程的忙等是一种不太明智的行为。C++提供了条件变量来取代这种行为,线程可以等待某个条件的满足,而不需要使用忙等待等不可取的方式。它常用于生产者-消费者模式、任务调度等多线程编程场景中。
std::condition_variable 是 C++ 标准库中的一个模板类,用于实现线程之间的条件变量同步。它通常与 std::mutex 和 std::unique_lock 一起使用,用于实现线程的等待和通知机制。
std::condition_variable 允许线程等待某个条件的发生,并在条件满足时通知等待的线程继续执行。它提供了以下几个成员函数:
- wait:使当前线程等待直到收到通知。在等待期间,条件变量会自动解锁与之关联的互斥锁,并使线程进入阻塞状态。当收到通知后,线程会重新获取互斥锁,并继续执行。
- wait_for:使当前线程等待一段时间,或直到收到通知。可以指定等待的时间段,如果在指定时间内没有收到通知,则线程会被唤醒继续执行。
- wait_until:使当前线程等待直到指定时间点,或直到收到通知。可以指定等待的时间点,如果在指定时间点之前没有收到通知,则线程会被唤醒继续执行。
- notify_one:通知一个等待的线程,唤醒其中一个线程继续执行。
- notify_all:通知所有等待的线程,唤醒所有线程继续执行。
简单写一个一对一的生产者消费者模型:
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <deque>
std::deque<int> q;
std::mutex mtx;
std::condition_variable cv;
//生产者
void task1(){
int i = 0;
while(true){
std::unique_lock<std::mutex> lock(mtx);
q.push_back(i);
cv.notify_one();
if(i < 9999){
i++;
}else{
i=0;
}
}
}
//消费者
void task2(){
int data = 0;
while(true){
std::unique_lock<std::mutex> lock(mtx);//不能放在循环外,因为每一次的操作都需要上锁和开锁
if(q.empty()){
cv.wait(lock);
}else{
data = q.front();
q.pop_front();
std::cout << data << std::endl;
}
}
}
int main()
{
std::thread t1(task1);
std::thread t2(task2);
t1.join();
t2.join();
return 0;
}
生产者用条件变量来通知消费者,而消费者在队列为空的时候等待锁的释放。
此时如果我们再添加多个消费者,可能会出现虚假唤醒的问题,即队列为空的时候把线程给唤醒了。虚假唤醒是指在没有接收到通知的情况下线程就被唤醒了。
虚假唤醒可能发生的原因包括但不限于:
- 系统内部实现:操作系统或标准库实现可能会在某些情况下对线程进行虚假唤醒,尽管这是罕见的。
- 信号丢失:当条件变量的通知发生在等待线程调用 wait() 之前,通知可能会丢失,导致等待线程在没有明确通知的情况下被唤醒。
那么为什么在上面的程序中再添加一个消费者就会造成虚假唤醒呢?本质上是因为这里采用了if判断队列为空产生了安全隐患,应当注意一点:我们在做wait之前的判断时应当采用while循环进行轮询,避免信号丢失的情况
注:其实个人对于虚假唤醒是很困惑的,不太理解为什么在有互斥锁的情况下还会发生虚假唤醒,但我在网上查到了这样一段注解:虚假唤醒的发生是由于条件变量的实现和操作系统调度的特性导致的,与锁机制本身无关。虽然条件变量通常与互斥锁一起使用,但互斥锁只负责提供互斥访问,而条件变量用于线程的等待和通知。
因此我认为可能是唤醒机制绕过了互斥锁强行唤醒线程执行,最终导致了线程间调度失败。但是最有效避免这种情况的出现还是应该使用while而不是if。
4 promise、future
在使用线程的时候,并没有提供方法来直接获取线程的返回值,要用promise和future来获取。
std::promise
和std::future
是一对用于在多线程环境中进行值传递和线程同步的工具,它们可以结合使用来实现线程间的协作和数据传递。
生产者线程用std::promise
来设置值:
std::promise<int> prom;
std::future<int> fut = prom.get_future();//get_future返回一个future类型对象
// 在生产者线程中设置值
prom.set_value(42);
消费者线程使用 std::future 来获取值:
// 在消费者线程中获取值
int result = fut.get();
通过这种方式,生产者线程可以将一个值设置到 std::promise 对象中,并且消费者线程可以使用关联的 std::future 对象来获取该值。需要注意的是,一旦值设置到 std::promise 中,消费者线程可以通过 std::future 的 get() 成员函数来获取该值,如果值尚未设置,get() 会阻塞等待直到值可用。
示例:
void task(int a,int b, std::promise<int> &ret){
int ret_a = a * a;
int ret_b = 2 * b;
ret.set_value(ret_a + ret_b);//用promise的引用建立通道,利用get_value传递数据
}
int main()
{
std::promise<int> p;
std::future<int> f = p.get_future();//通过promise.get_future把两个类型联系在一起
std::thread t(task, 1, 2, std::ref(p));
std::cout << f.get() << std::endl;
return 0;
}
5 async
使用promise和future来进行线程间值的获取是很让人头大的一件事,这里介绍一种更简单的方法std::async
std::async 是 C++ 标准库中提供的一个函数模板,用于异步执行函数或函数对象,并返回一个 std::future 对象来获取异步操作的结果。
#include <iostream>
#include <future>
int calculateSum(int a, int b) {
return a + b;
}
int main() {
std::future<int> futureResult = std::async(calculateSum, 3, 4);
// 执行其他操作...
// 阻塞等待异步任务完成并获取结果
int result = futureResult.get();
std::cout << "Sum: " << result << std::endl;
return 0;
}
在上述示例中,通过 std::async 异步执行函数 calculateSum,并将参数 3 和 4 传递给该函数。std::async 返回一个 std::future 对象 futureResult,用于获取异步操作的结果。通过调用 futureResult.get() 阻塞等待异步任务完成,并获取结果。
它可以与其他多线程机制(如 std::thread、std::promise 等)结合使用,以实现更复杂的异步编程模型。需要注意的是,默认情况下,std::async 会在一个新线程中执行函数,但具体的线程调度方式取决于实现。可以通过指定 std::launch 枚举值来修改调用 std::async 时的调度策略。如:
···
//使用async参数时指定要求去创建一个新的线程来执行这个函数
std::future
//延时调用,是在当前线程中执行的
std::future
···