【C++】多线程编程图文详解(多角度详解,小白一看就懂!!)

目录

一、前言

二、什么是C++多线程?

💢线程与进程 

💢并发与并行 

💢多线程 

三、 线程库 - thread

1. 线程对象的构造方式 

无参构造 

带可变参数包的构造 

移动构造

2. thread类的成员函数 

join 和 joinable 

detach 

3. this_thread 类 

get_id 

sleep_for 和 sleep_until

yield

4. 线程函数参数的问题 

[!Abstract] 对线程的初步总结

四、互斥量库 - mutex 

💢为什么需要锁? 

💢引出 mutex 互斥锁

💢标准库提供的四种互斥锁 

1. std::mutex 

2. std::recursive_mutex

3. std::timed_mutex 

4. std::recursive_timed_mutex 

💢RAII 风格的 -- 锁(重点!!) 

1. lock_guard 

2. unique_lock 

五、条件变量(condition_variable)

六、大厂必考面试题 

七、共勉 


一、前言

        关于 多线程 相关操作,Linux 选择使用的是 POSIX 标准,而 Windows 没有选择 POSIX 标准,反而是自己搞了一套 API 和系统调用,称为 Win32 API,意味着 Linux 与 Windows 存在标准差异,直接导致能在 Linux 中运行的程序未必能在 Windows 中运行

      C++11 之前,编写多线程相关代码如果保证兼容性,就需要借助 条件编译,分别实现两份代码,根据不同平台编译不同的代码(非常麻烦)

// 确保平台兼容性
#ifdef __WIN_32__
	CreateThread // Windows 中创建线程的接口
	// ...
#else
	pthread_create // Linux 中创建线程的接口
	// ...
#endif
  • 在 C++11 中,加入了 线程库 这个标准,其中包含了 线程、互斥锁、条件变量 等常用线程操作,并且无需依赖第三方库,也就意味着使用 线程库 编写的代码既能在 Linux 中运行,也能在 Windows 中运行,保障了代码的可移植性,除此之外,线程库 还新加入了 原子相关操作 

 总的来说,C++11 线程库 为 C++ 开发者提供了一个功能全面、易于使用的多线程编程解决方案。让我们一起开始这段关于 多线程--线程库 的学习之旅吧。

二、什么是C++多线程?

在讲解 线程库 之前,我们首先需要搞清楚 C++的多线程是什么?我们应该如何理解它? 

💢线程与进程 

  • 进程:是操作系统进行资源分配和调度的一个独立单位,是应用程序运行的实例。每个进程都有自己的独立内存空间。
  • 线程:是进程中的执行单元,多个线程共享同一进程的内存空间和资源,但每个线程有自己的栈、程序计数器等。线程是CPU调度的基本单位

💢并发与并行 

 在操作系统中,"并行"和"并发"是两个相关但又不同的概念

  • 并发是指两个或多个事件在同一时间间隔发生,并发是针对单核 CPU 提出的,在同一CPU上的多个事件。
  • 并行是指两个或者多个事件在同一时刻发生,并行则是针对多核 CPU 提出,在不同CPU上的多个事件 

💢多线程 

 多线程是实现并发(并行)的手段,并发(并行)即多个线程同时执行,一般而言,多线程就是把执行一件事情的完整步骤拆分为多个子步骤,然后使得这多个步骤同时执行。


三、 线程库 - thread

         在C++11之前,涉及到多线程问题,都是和平台相关的,比如Windows和Linux下各有自己的接口,这使得代码的可移植性比较差。C++11中最重要的特性就是对线程进行支持了,使得C++在并行编程时不需要依赖第三方库,而且在原子操作中还引入了原子类的概念。要使用标准库中的线程,必须包含 <thread>头文件

  • 线程 -- 是操作系统中的一个概念,线程对象可以关联一个线程,用来控制线程以及获取线程的状态。 

1. 线程对象的构造方式 

 thread线程库提供了三种构造方式:

构造函数构造函数(中文解释)函数声明
default (1)无参构造、默认构造thread() noexcept;
initialization (2)带可变参数包的构造emplate <class Fn, class… Args>
explicit thread (Fn&& fn, Args&&… args);
copy [deleted] (3)thread对象无法拷贝构造thread (const thread&) = delete;
move (4)移动构造(传入右值)thread (thread&& x) noexcept;

无参构造 

第一种是无参的构造函数,它创建出来的线程对象没有关联任何线程函数,也就是它没有启动任何线程,比如: 

thread t1;
  • t1 实际 没有对应任何OS中实际的线程。由于 thread 提供了移动赋值函数,因此当后续需要让该线程对象与线程函数关联时,可以以带参的方式创建一个匿名对象,然后调用移动赋值将该匿名对象关联线程的状态转移给该线程对象: 
thread t1;
//... 
t1 = thread(func, 10);
t1.join();

带可变参数包的构造 

在C++中支持函数模板的可变参数 ,这里 thread 的构造函数就是一个模板函数

template <class Fn, class... Args>
explicit thread (Fn&& fn, Args&&... args);

fn:可调用对象,比如:

  1. 函数指针
  2. 仿函数(函数对象)
  3. lambda表达式
#include <iostream>
#include <algorithm>
#include <mutex>  // 锁的头文件
#include <thread> // 线程的头文件
#include <condition_variable> // 条件变量的头文件
#include <Windows.h>
using namespace std;

// 自定义函数
void func1(int start, int end)
{
	for (int i = start; i <= end; i++) 
	{ 
		cout << i << " "; 
	}
	cout << endl;
}

// 仿函数
struct My_class
{
	void operator()(int start, int end)
	{
		for (int i = start; i <= end; i++) 
		{
			cout << i << " "; 
		}
		cout << endl;
	}
};

int main()
{
	//1. 函数指针
	thread t1(func1, 1, 10);

	Sleep(1);

	//2. 仿函数 (函数对象)
	thread t2(My_class(), 10, 20);

	Sleep(1);

	//3. lambda表达式
	thread t3([](const string& str) ->void {cout << str << endl; }, "I am thread-3");

	Sleep(1);

	t1.join();
	t2.join();
	t3.join();
	
	return 0;
}


移动构造

thread 提供了移动构造函数,能够用一个右值线程对象来构造一个线程对象: 

// 一下两种都是 移动构造
thread t3 = thread(func, 4, 20);            // 创建匿名函数对象,赋值给 t3
thread t4(std::move(thread(func, 10, 20))); // 可以显式move一下

要点说明一下: 

  • threah 类 是 ---- 禁止拷贝的不允许拷贝构造和拷贝赋值,但是可以移动构造和移动赋值,可以将一个线程对象关联线程的状态转移给其他线程对象,并且转移期间不影响线程的执行。

2. thread类的成员函数 

thread中常用的成员函数如下: 

成员函数功能
join等待一个线程完成,如果该线程还未执行完毕,则当前线程(一般是主线程)将被阻塞,直到该线程执行完成,主线程才会继续执行。
joinable判断线程是否可以执行join()函数,返回true/false
detach将当前线程与创建的线程分离,使它们分别运行,当分离的线程执行完毕后,系统会自动回收其资源。如果一个线程被分离了,就不能再使用join()函数了,因为线程已经无法被联接了。
get_id获取该线程的 id
swap将两个线程对象关联线程的状态进行交换

join 和 joinable 

joinable函数还可以用于判定线程是否是有效的,如果是以下任意情况,则线程无效: 

  • 采用无参构造函数构造的线程对象。(该线程对象没有关联任何线程)
  • 线程对象的状态已经转移给其他线程对象。(已经将线程交给其他线程对象管理)
  • 线程已经调用 join 或 detach 结束。(线程已经结束)
// 线程函数,打印一条消息
void Print() 
{
    cout << "Hello from thread!" <<endl;
}
 
int main() 
{
    // 创建一个线程对象,并传递线程函数作为可调用对象
   thread t1(Print);
 
    // 判断线程是否可执行
    if (t1.joinable())
    {
       cout << "Thread is joinable." << endl;
    }
    else 
    {
       cout << "Thread is not joinable." << endl;
    }
 
    // 等待线程执行完毕
    t1.join();
 
    // 判断线程是否仍然可执行
    if (t1.joinable())
    {
        cout << "Thread is joinable." << endl;
    }
    else 
    {
        cout << "Thread is not joinable." << endl;
    }
 
    return 0;
}

 【解释说明】:

  • 在这个示例中,我们定义了一个 printMessage() 函数作为线程函数,它会打印一条消息。在main() 函数中,我们创建了 t1,并传递了线程函数 printMessage 作为可调用对象。
  • 接下来,我们通过 joinable() 成员函数判断线程对象是否可执行。在创建线程后但尚未调用join() 函数之前,线程是可执行的。在调用join()函数后,线程会等待线程函数执行完毕以后才结束,并且线程对象不再可执行。
  • 最后,我们再次使用 joinable() 成员函数来判断线程对象是否仍然可执行。在join()函数调用之后,线程对象不再可执行,可以安全地销毁线程对象。

detach 

将该线程与创建线程进行分离,被分离后的线程不再需要创建线程调用join函数对其进行等待 

#include <iostream>
#include <thread>
#include <chrono>

using namespace std;

// 线程函数
void threadFunction() {
    for (int i = 0; i < 5; ++i) {
        cout << "线程正在运行: " << i << endl;
        this_thread::sleep_for(chrono::seconds(1)); // 模拟一些工作
    }
    cout << "线程结束。" << endl;
}

int main() {
    // 创建线程
    thread t(threadFunction);

    // 分离线程
    t.detach();

    // 主线程继续执行
    cout << "主线程继续工作..." << endl;

    // 等待一段时间以确保子线程有机会运行
    this_thread::sleep_for(chrono::seconds(6));

    cout << "主线程结束。" << endl;

    return 0;
}
  1. 线程函数 threadFunction

    • 在这个函数中,线程将打印数字 0 到 4,每次打印后暂停 1 秒,模拟一些工作。
  2. 主函数 main

    • 创建一个新线程 t,执行 threadFunction
    • 调用 t.detach(),将线程分离,使其在后台运行,主线程不再管理这个线程
    • 主线程打印消息并继续执行,最后等待 6 秒,以确保分离的线程有机会运行完成。

【注意事项】:

  • 一旦线程被分离,它将独立于主线程执行。主线程结束后,分离的线程可能仍在运行,但程序不再等待它完成。
  • 使用 detach() 后,不可以再对该线程进行操作,如 join(),否则会导致程序崩溃

3. this_thread 类 

在C++中,this_thread类提供了一些关于当前线程的功能函数。具体如下: 

函数名功能
get_id获取当前前程的ID

sleep_for

当前线程休眠一个时间段
sleep_until当前休眠道一个具体的时间
yield当前线程“放弃”执行,让操作系统调度另一个线程继续执行

get_id 

调用 thread的成员函数get_id可以获取线程的 id,但该方法必须通过线程对象来调用 get_id函数 ,如果要单独使用 get_id ,可以 this_thread::get_id();

void threadFunction() 
{
    cout << "Thread ID: " << std::this_thread::get_id() << endl;
}
 
int main() 
{
    thread t1(threadFunction);
    thread t2(threadFunction);
 
    cout << "Main thread ID: " << std::this_thread::get_id() << endl;
 
    if (t1.get_id() == t2.get_id()) 
    {
       cout << "t1 and t2 have the same thread ID." <<endl;
    }
    else 
    {
       cout << "t1 and t2 have different thread IDs." << endl;
    }
 
    t1.join();
    t2.join();
 
    return 0;
}

【解释说明】:

  • 创建了两个对象 t1 t2,它们分别关联到一个线程函数 threadFunction。在 threadFunction函数中,我们打印线程的唯一标识符。
  • 紧接着在主函数中,我们首先打印主线程的唯一标识符。然后,我们通过 get_id() 函数分别获取 t1 t2 的线程ID,并使用比较操作符对它们进行比较。

sleep_for 和 sleep_until

sleep_util 表示休眠一个 绝对时间,比如线程运行后,休眠至明天 6::00 才接着运行;sleep_for 则是让线程休眠一个 相对时间,比如休眠 3 秒后继续运行,休眠 绝对时间 用的比较少,这里来看看如何休眠 相对时间

  • 相对时间 有很多种:时、分、秒、毫秒、微秒…,这些单位包含于 chrono 类中 

int main()
{
	vector<thread> vts(5); // 5 个次线程(未完全创建)

	for (int i = 0; i < 5; i++)
	{
		// 移动构造
		vts[i] = thread([]()->void  // lambda 表达式
			{
				for (int i = 0; i < 10; i++)
				{
					// 获取 id
					auto id = this_thread::get_id();
					cout << "我是线程 " << id << " 我正在运行..." << endl;

					// 休眠 200 毫秒
					this_thread::sleep_for(chrono::milliseconds(200));
				}
			});
	}

	// 等待线程退出
	for (auto& t : vts)
		t.join();

	return 0;
}

yield

最后在 this_thread 命名空间中还存在一个特殊的函数:yield

  • 这里的 yield 表示 让步、放弃,带入多线程环境中就表示 主动让出当前的时间片
  • yield 主要用于 无锁编程(尽量减少使用锁),而无锁编程的实现基于 原子操作 CAS,关于原子的详细知识放到后面讲解

原子操作 CAS 是一个不断重复尝试的过程,如果尝试的时间过久,就会影响整体效率,因为此时是在做无用功,而 yield 可以主动让出当前线程的时间片,避免大量重复,把 CPU 资源让出去,从而提高整体效率


4. 线程函数参数的问题 

线程函数的参数】 是以【值拷贝的方式】拷贝到线程栈空间中的,就算线程函数的参数为引用类型,在线程函数中修改后也不会影响到外部实参,因为其实际引用的是线程栈中的拷贝,而不是外部实参。比如:

void add(int& num)
{
	num++;
}
int main()
{
	int num = 0;
	thread t(add, num);
	t.join();

	cout << num << endl; //0
	return 0;
}

 如果要通过线程函数的形参改变外部的实参,可以参考以下三种方式:

#include <thread>
#include <iostream>

void ThreadFunc1(int& x)
{
	x += 10;
}
void ThreadFunc2(int* x)
{
	*x += 10;
}
int main()
{
	int a = 10;

	// 问题:在线程函数中对a修改,不会影响外部实参
	// 因为:线程函数参数虽然是引用方式,但其实际引用的是线程栈中的拷贝
	//std::thread t1(ThreadFunc1, a); // 这里的a传过去的不是引用哦!只是一份值拷贝
	//t1.join();
	//std::cout << a << std::endl;

	// 解决方法:
	// 1. 如果想要通过形参改变外部实参时,必须借助std::ref()函数
	std::thread t2(ThreadFunc1, std::ref(a));
	t2.join();
	std::cout << a << std::endl;

	// 2. 地址的拷贝
	std::thread t3(ThreadFunc2, &a);
	t3.join();
	std::cout << a << std::endl;

	// 3. lambda表达式,在捕捉列表中添加a的引用
	std::thread t4([&a] {a += 10;});
	t4.join();
	std::cout << a << std::endl;

	return 0;
}

[!Abstract] 对线程的初步总结

线程是操作系统中的一个概念,线程对象可以关联一个线程,用来控制线程以及获取线程的状态。

  • 当创建一个线程对象后,没有提供线程函数,该对象实际没有对应任何线程。
  • thread类是防拷贝的,不允许拷贝构造以及赋值,但是可以移动构造和移动赋值,即将一个线程对象关联线程的状态转移给其他线程对象,转移期间不意向线程的执行。

四、互斥量库 - mutex 

 Mutex 又称互斥量,C++ 11中与 Mutex 相关的类(包括锁类型)和函数都声明在 <mutex> 头文件中,所以如果你需要使用 std::mutex,就必须包含 <mutex> 头文件。

💢为什么需要锁? 

在多线程编程中,多个线程可以同时访问和操作共享资源(比如一个变量、文件、数据库等)。当多个线程同时读写这些共享资源时,可能会产生数据不一致或冲突的情况,这种情况称为竞争条件(Race Condition)。 

  • 是一种机制,用来确保在同一时刻只有一个线程可以访问共享资源。通过使用锁,可以防止多个线程同时修改共享资源,从而保证数据的一致性和正确性。

如何理解:

  • 这样比喻,两个人要去银行的柜台办理业务,且银行只有一个柜台,A要办理业务,B也要办理业务但是柜台同一时间只能给一个人办理,在办理业务时要坐到柜台位置(lock),用完后再离开柜台位置(unlock)。那么,这个柜台位置就是互斥量,互斥量保证了使用办理业务这一过程不被打断。 


💢引出 mutex 互斥锁

 多线程编程需要确保 线程安全 问题

  • 首先要明白 线程拥有自己独立的栈结构,但对于全局变量等 临界资源,是直接被多个线程共享的 

比如通过以下代码证明 线程独立栈 的存在 

int g_val = 0;

void Func(int n)
{
	cout << "&g_val: " << &g_val << " &n: " << &n << endl << endl;
}

int main()
{
	int n = 10;
	thread t1(Func, n);
	thread t2(Func, n);

	t1.join();
	t2.join();
	return 0;
}

可以看到,全局变量 g_val 的地址是一样,而局部变量 n 的地址相差很远,证明这两个局部变量不处于同一个栈区中,而是分别存在线程的 独立栈 


如果多个线程同时对同一个 临界资源(全局变量) 进行操作操作次数较少时,近似原子

  • 操作次数多时,有线程安全问题

这里同时对 g_val 进行 n 次 ++ 操作,当 n = 100 时,结果还算正常(正确结果为 200

int g_val = 0; // 全局变量

void Func(int n)
{
	while (n--)
		g_val++;
}

int main()
{
	int n = 100;
	thread t1(Func, n);
	thread t2(Func, n);

	t1.join();
	t2.join();

	cout << "g_val: " << g_val << endl;
	return 0;
}

但如果将 n 改为 2000,程序就出问题了(正确结果为 4000) 

n = 2000;
  • 发现并且几乎每一次运行结果都不一样,这就是由于 线程安全 问题带来的 不确定性 导致的 

  • 线程1 读取了 g_val,并且正准备执行递增操作。
  • 线程2 在此时也读取了 g_val(此时的值是相同的),也准备执行递增操作。
  • 两个线程都对同一个值进行了递增,然后写回的值是相同的,导致有一次加法操作被“覆盖”了。
  • 这种情况下,g_val 的值可能少于预期的 4000,因为有些加法操作被丢失了。

所以在多线程编程中,当多个线程同时访问和修改同一个共享变量时,如果没有对共享资源进行适当的同步控制,可能会导致线程竞争,导致程序的结果不确定。 

  • 为了确保 线程安全 的手段之一就是 加锁 保护,C++11 中就有一个 mutex 类,其中包含了 互斥锁 的各种常用操作 

💢标准库提供的四种互斥锁 

1. std::mutex 

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

  • 比如创建一个 mutex 互斥锁 对象,当然 互斥锁也是不支持拷贝的mutex 互斥锁 类也没有提供移动语义相关的构造函数,因为锁资源一般是不允许被剥夺的 

互斥锁 对象的构造很简单,使用也很简单,常用的操作有:加锁、尝试加锁、解锁 

成员函数功能
lock对互斥量进行加锁
try_lock尝试对互斥量进行加锁
unlock对互斥量进行解锁,释放互斥量的所有权
  • 这些操作使用起来十分简单,下来,我们就对上面的程序进行加锁保护
  • 注:使用 mutex 类需要包含 mutex 这个头文件
int g_val = 0; // 全局变量

// 互斥锁对象
mutex mtx;

void Func(int n)
{
	while (n--)
	{
		mtx.lock(); // 加锁
		g_val++;
		mtx.unlock();// 解锁
	}
}

int main()
{
	int n = 20000;
	thread t1(Func, n);
	thread t2(Func, n);

	t1.join();
	t2.join();


	cout << "g_val: " << g_val << endl;
	return 0;
}

线程安全

  • 互斥锁的使用确保了在任一时刻,只有一个线程可以修改 g_val,避免了线程竞争(race condition)。
  • 如果没有使用互斥锁,g_val 的值可能会少于 40000,因为两个线程可能会同时读取和写入 g_val,导致某些递增操作被“覆盖”。

并行与串行的对比 

互斥锁 的加锁、解锁位置也是有讲究的,比如只把 g_val++ 这个操作加锁,此时程序就是 并行化 运行,线程 A 与 线程 B 都可以进入循环,但两者需要在循环中竞争 锁资源,只有抢到 锁资源 的线程才能进行 g_val++,两个线程同时竞争,相当于同时进行操作 

  • 也可以把整个 while 循环加锁,程序就会变成 串行化,线程 A 或者 线程 B 抢到 锁资源 后,就会不断进行 g_val++,直到循环结束,才会把 锁资源 让出

  • 理论上来说,并行化 要比 串行化 快,实际结果可以通过代码呈现 
int main()
{
	int n = 20000;

	size_t begin = clock(); // 计算时间
	thread t1(Func, n);
	thread t2(Func, n);

	t1.join();
	t2.join();
	size_t end = clock();

	cout << "g_val: " << g_val << endl;
	cout << "time: " << end - begin << " ms" << endl;
	return 0;
}
  • 首先来看看在 n = 20000 的情况下,并行化 耗时 

注:测试性能需要在 release 模式下进行 

  • 耗时 9ms,似乎还挺快,接下来看看 串行化 耗时 

  • 串行化 只花了 7ms,比 并行化 还要快 

 为什么?

  • 因为现在的程序比较简单,while 循环内只需要进行 g_val++ 就行了,并行化中频繁加锁、解锁的开销要远大于串行化单纯的进行 while 循环 
  • 如果循环中的操作变得复杂,那么 并行化 是要比 串行化 快的,所以加锁时选择 并行化 还是 串行化,需要结合具体的场景进行判断 

2. std::recursive_mutex

recursive_mutex 递归互斥锁,这把锁主要用来 递归加锁 的场景中,可以看作 mutex 互斥锁 的递归升级版,专门用在递归加锁的场景中 

  •  比如在下面的代码中,使用普通的 mutex 互斥锁 会导致 死锁问题,最终程序异常终止
// 普通互斥锁
mutex mtx;

void func(int n)
{
	if (n == 0)
		return;

	mtx.lock();
	n--;

	func(n);
	mtx.unlock();
}

int main()
{
	int n = 1000;
	thread t1(func, n);
	thread t2(func, n);
	
	t1.join();
	t2.join();
	return 0;
}

为什么会出现 死锁

  • 因为当前在进入递归函数前,申请了锁资源,进入递归函数后(还没有释放锁资源),再次申请锁资源,此时就会出现 锁在我手里,但我还申请不到 的现象,也就是 死锁  

解决这个 死锁 问题的关键在于 自己在持有锁资源的情况下,不必再申请,此时就要用到 recursive_mutex 递归互斥锁了 

// 递归互斥锁
recursive_mutex mtx;
  • 使用 recursive_mutex 递归互斥锁 后,程序正常运行 


3. std::timed_mutex 

timed_mutex 时间互斥锁,这把锁中新增了 定时解锁 的功能,可以在程序运行指定时间后,自动解锁(如果还没有解锁的话) 

  • 其中的 try_lock_for 是按照 相对时间 进行自动解锁,而 try_lock_until 则是按照 绝对时间 进行自动解锁 

比如在下面的程序中,使用 timed_mutex 时间互斥锁,设置为 3 秒后自动解锁,线程获取锁资源后,睡眠 5 秒,即便睡眠时间还没有到,其他线程也可以在 3 秒后获取锁资源,同样进入睡眠 


4. std::recursive_timed_mutex 

至于最后一个 recursive_timed_mutex 递归时间互斥锁,就是对 timed_mutex 时间互斥锁 做了 递归 方面的升级,使其在面对 递归 场景时,不会出现 死锁 


💢RAII 风格的 -- 锁(重点!!) 

手动加锁、解锁可能会面临 死锁 问题,比如在引入 异常处理 后,如果在 临界区 内出现了异常,程序会直接跳转至 catch 中捕获异常,这就导致 锁资源 没有被释放,其他线程申请锁资源时,就会出现 死锁 问题

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

std::mutex mtx;

void dangerousFunction(int id) {
    // 手动加锁
    mtx.lock();

    std::cout << "Thread " << id << " is running." << std::endl;

    // 模拟一个异常情况,没有解锁就退出
    if (id == 1) {
        throw std::runtime_error("Thread 1 encountered an error!");
    }

    // 手动解锁(如果有异常发生,这行代码不会执行)
    mtx.unlock();
}

int main() {
    try {
        std::thread t1(dangerousFunction, 1);
        std::thread t2(dangerousFunction, 2);

        t1.join();
        t2.join();
    } catch (const std::exception &e) {
        std::cerr << "Exception caught: " << e.what() << std::endl;
    }

    return 0;
}

问题说明:

在这个示例中,mtx.lock() 手动加锁,但如果在 dangerousFunction 中抛出异常,mtx.unlock() 将永远不会被调用,导致 死锁。线程 t2 由于获取不到锁,将会一直等待。

死锁的原因:

  • 线程 1 获得了锁,但抛出异常后没有解锁,导致锁被永久占用。
  • 线程 2 在等待线程 1 释放锁,但由于线程 1 没有解锁,线程 2 永远无法继续。

所以,我们呢需要,使用 RAII 风格的锁管理可以有效避免手动加锁和解锁的失误 

std::mutex mtx;

void dangerousFunction(int id) {
    try {
        // 使用 RAII 风格的锁管理
        std::lock_guard<std::mutex> lock(mtx);

        std::cout << "Thread " << id << " is running." << std::endl;

        // 模拟一个异常情况,抛出异常
        if (id == 1) {
            throw std::runtime_error("Thread 1 encountered an error!");
        }

    }
    catch (const std::exception& e) {
        std::cerr << "Exception caught in thread " << id << ": " << e.what() << std::endl;
    }

    // 无需手动解锁,std::lock_guard 会在作用域结束时自动解锁
}

int main() {
    try {
        std::thread t1(dangerousFunction, 1);
        std::thread t2(dangerousFunction, 2);

        t1.join();
        t2.join();
    }
    catch (const std::exception& e) {
        std::cerr << "Exception caught: " << e.what() << std::endl;
    }

    return 0;
}


1. lock_guard 

std::lock_guard是C++标准库中的一个模板类,用于实现资源的自动加锁和解锁。它是基于RAII(资源获取即初始化)的设计理念,能够确保在作用域结束时自动释放锁资源,避免了手动管理锁的复杂性和可能出现的错误。 

std::lock_guard的主要特点如下: 

  • 自动加锁: 在创建std::lock_guard对象时,会立即对指定的互斥量进行加锁操作。这样可以确保在进入作用域后,互斥量已经被锁定,避免了并发访问资源的竞争条件。
  • 自动解锁:std::lock_guard对象在作用域结束时,会自动释放互斥量。无论作用域是通过正常的流程结束、异常抛出还是使用return语句提前返回,std::lock_guard都能保证互斥量被正确解锁,避免了资源泄漏和死锁的风险。
  • 适用于局部锁定: 由于std::lock_guard是通过栈上的对象实现的,因此适用于在局部范围内锁定互斥量。当超出std::lock_guard对象的作用域时,互斥量会自动解锁,释放控制权。

使用std::lock_guard的一般步骤如下:

  1. 创建一个std::lock_guard对象,传入要加锁的互斥量作为参数。
  2. 执行需要加锁保护的代码块。
  3. std::lock_guard对象的作用域结束时,自动调用析构函数解锁互斥量。
#include <iostream>
#include <thread>
#include <mutex>

std::mutex mtx;  // 互斥量

void thread_function()
{
    std::lock_guard<std::mutex> lock(mtx);  // 加锁互斥量
    std::cout << "Thread running" << std::endl;
    // 执行需要加锁保护的代码
}  // lock_guard对象的析构函数自动解锁互斥量

int main()
{
    std::thread t1(thread_function);
    t1.join();
    std::cout << "Main thread exits!" << std::endl;
    return 0;
}
  • 在上述示例中,std::lock_guard 对象 lock 会在 thread_function 中加锁互斥量,保护了输出语句的执行。当 thread_function 结束时,lock_guard 对象的析构函数会自动解锁互斥量。这样可以确保互斥量在合适的时候被锁定和解锁,避免了多线程间的竞争问题。

总而言之,std::lock_guard 提供了一种简单而安全的方式来管理互斥量的锁定和解锁,使多线程编程更加方便和可靠。


2. unique_lock 

std::unique_lock是C++标准库中的一个模板类,用于实现更加灵活的互斥量的加锁和解锁操作。它提供了比std::lock_guard更多的功能和灵活性。

std::unique_lock的主要特点如下: 

  • 自动加锁和解锁:std::lock_guard 类似,std::unique_lock 在创建对象时立即对指定的互斥量进行加锁操作,确保互斥量被锁定。在对象的生命周期结束时,会自动解锁互斥量。这种自动加锁和解锁的机制避免了手动管理锁的复杂性和可能出现的错误。
  • 支持灵活的加锁和解锁: 相对于 std::lock_guard 的自动加锁和解锁,std::unique_lock 提供了更灵活的方式。它可以在需要的时候手动加锁和解锁互斥量,允许在不同的代码块中对互斥量进行多次加锁和解锁操作。
  • 支持延迟加锁和条件变量:std::unique_lock 还支持延迟加锁的功能,可以在不立即加锁的情况下创建对象,稍后根据需要进行加锁操作。此外,它还可以与条件变量(std::condition_variable)一起使用,实现更复杂的线程同步和等待机制。

使用 std::unique_lock的一般步骤如下: 

  1. 创建一个std::unique_lock对象,传入要加锁的互斥量作为参数。
  2. 执行需要加锁保护的代码块。
  3. 可选地手动调用lock函数对互斥量进行加锁,或者在需要时调用unlock函数手动解锁互斥量。
#include <iostream>
#include <thread>
#include <mutex>

std::mutex mtx;  // 互斥量

void thread_function()
{
    std::unique_lock<std::mutex> lock(mtx);  // 加锁互斥量
    std::cout << "Thread running" << std::endl;
    // 执行需要加锁保护的代码
    lock.unlock();  // 手动解锁互斥量
    // 执行不需要加锁保护的代码
    lock.lock();  // 再次加锁互斥量
    // 执行需要加锁保护的代码
}  
// unique_lock对象的析构函数自动解锁互斥量

int main()
{
    std::thread t1(thread_function);
    t1.join();
    std::cout << "Main thread exits!" << std::endl;
    return 0;
}
  • 在上述示例中,std::unique_lock 对象 lock 会在创建时自动加锁互斥量,析构时自动解锁互斥量。我们可以通过调用lock和unlock函数手动控制加锁和解锁的时机,以实现更灵活的操作。

总而言之,std::unique_lock提供了更灵活和功能丰富的互斥量的加锁和解锁机制,使多线程编程更加便捷和安全。它在处理复杂的同步需求、延迟加锁以及与条件变量的结合等方面非常有用。


五、条件变量(condition_variable

std::condition_variable 是C++标准库中的一个类,用于在多线程编程中实现线程间的条件变量和线程同步。它提供了等待通知的机制,使得线程可以等待某个条件成立时被唤醒,或者在满足某个条件时通知其他等待的线程。其提供了以下几个函数用于等待和通知线程: 

方法说明
wait使当前线程进入等待状态,直到被其他线程通过notify_one()notify_all()函数唤醒。该函数需要一个互斥锁作为参数,调用时会自动释放互斥锁,并在被唤醒后重新获取互斥锁。
wait_for

使当前线程进入等待状态,最多等待一定的时间,直到被其他线程通过notify_one()notify_all()函数唤醒,或者等待超时。该函数需要一个互斥锁和一个时间段作为参数,返回时有两种情况:等待超时返回std::cv_status::timeout,被唤醒返回std::cv_status::no_timeout

wait_until

使当前线程进入等待状态,直到被其他线程通过notify_one()notify_all()函数唤醒,或者等待时间达到指定的绝对时间点。该函数需要一个互斥锁和一个绝对时间点作为参数,返回时有两种情况:时间到达返回std::cv_status::timeout,被唤醒返回std::cv_status::no_timeout

notify_one唤醒一个等待中的线程,如果有多个线程在等待,则选择其中一个线程唤醒
notify_all唤醒所有等待中的线程,使它们从等待状态返回

std::condition_variable的主要特点如下:

  • 等待和通知机制:std::condition_variable 允许线程进入等待状态,直到某个条件满足时才被唤醒。线程可以调用wait函数进入等待状态,并指定一个互斥量作为参数,以确保线程在等待期间互斥量被锁定。当其他线程满足条件并调用 notify_onenotify_all 函数时,等待的线程将被唤醒并继续执行。
  • 与互斥量配合使用:std::condition_variable 需要与互斥量(std::mutex或std::unique_lock<std::mutex>)配合使用,以确保线程之间的互斥性。在等待之前,线程必须先锁定互斥量,以避免竞争条件。当条件满足时,通知其他等待的线程之前,必须再次锁定互斥量。
  • 支持超时等待:std::condition_variable提供了带有超时参数的等待函数 wait_forwait_until,允许线程在等待一段时间后自动被唤醒。这对于处理超时情况或限时等待非常有用。

使用std::condition_variable的一般步骤如下:

  1. 创建一个std::condition_variable对象。
  2. 创建一个互斥量对象(std::mutex或std::unique_lock<std::mutex>)。
  3. 在等待线程中,使用std::unique_lock锁定互斥量,并调用wait函数进入等待状态。
  4. 在唤醒线程中,使用std::unique_lock锁定互斥量,并调用notify_onenotify_all函数通知等待的线程。
  5. 等待线程被唤醒后,继续执行相应的操作。

示例: 

模拟一个简单的计数器。一个线程负责增加计数,另一个线程等待并打印计数的值。 

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

// 定义共享变量和相关的同步工具
int count = 0; // 计数器
std::mutex mtx; // 互斥锁
std::condition_variable cv; // 条件变量

// 增加计数的线程函数
void increment() {
    for (int i = 0; i < 5; ++i) {
        std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 模拟工作
        std::unique_lock<std::mutex> lock(mtx); // 使用 unique_lock
        count++; // 增加计数
        std::cout << "计数增加到: " << count << std::endl;
        cv.notify_one(); // 通知其他线程
    }
}

// 打印计数的线程函数
void print() {
    for (int i = 0; i < 5; ++i) {
        std::unique_lock<std::mutex> lock(mtx); // 加锁
        cv.wait(lock); // 等待通知
        std::cout << "当前计数是: " << count << std::endl; // 打印计数
    }
}

int main() {
    std::thread t1(increment); // 创建增加计数的线程
    std::thread t2(print); // 创建打印计数的线程

    t1.join(); // 等待线程完成
    t2.join();

    return 0;
}

共享变量

  • int count = 0;:定义一个共享的计数器。
  • std::mutex mtx;:定义一个互斥锁,用于保护共享变量 count
  • std::condition_variable cv;:定义一个条件变量,用于线程同步。

增加计数的线程 (increment 函数):

  • 使用 std::this_thread::sleep_for 模拟工作,增加计数器的值。
  • 使用 std::lock_guard 加锁,以确保在修改 count 时没有其他线程干扰。
  • 增加计数并打印当前值,然后使用 cv.notify_one() 通知等待的线程。

打印计数的线程 (print 函数):

  • 使用 cv.wait(lock) 等待通知,只有当 increment 函数通知时才会继续执行。
  • 打印当前的计数值。

六、大厂必考面试题 

用C++实现两个线程交替打印一个1-100的奇偶数字。

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

// 定义共享变量
int number = 1; // 当前要打印的数字
std::mutex mtx; // 互斥锁
std::condition_variable cv; // 条件变量

// 打印奇数的线程函数
void printOdd() {
    while (number <= 100) {
        std::unique_lock<std::mutex> lock(mtx); // 加锁
        // 等待直到当前数字是奇数
        cv.wait(lock, [] { return number % 2 != 0; }); 
        if (number <= 100) {
            std::cout << number << " "; // 打印奇数
            number++; // 增加数字
        }
        cv.notify_all(); // 通知另一个线程
    }
}

// 打印偶数的线程函数
void printEven() {
    while (number <= 100) {
        std::unique_lock<std::mutex> lock(mtx); // 加锁
        // 等待直到当前数字是偶数
        cv.wait(lock, [] { return number % 2 == 0; }); 
        if (number <= 100) {
            std::cout << number << " "; // 打印偶数
            number++; // 增加数字
        }
        cv.notify_all(); // 通知另一个线程
    }
}

int main() {
    // 创建线程,分别负责打印奇数和偶数
    std::thread oddThread(printOdd); 
    std::thread evenThread(printEven); 

    // 等待线程完成
    oddThread.join(); 
    evenThread.join(); 

    return 0;
}
  • 共享变量

    • int number = 1;:这是当前要打印的数字,初始值为 1。
  • 同步工具

    • std::mutex mtx;:互斥锁,用于保护共享变量 number
    • std::condition_variable cv;:条件变量,用于线程之间的同步。
  • 打印奇数的线程函数 (printOdd):

    • 使用 while (number <= 100) 循环,直到打印完所有数字。
    • 使用 std::unique_lock 加锁以保护对 number 的访问。
    • cv.wait(lock, [] { return number % 2 != 0; });:线程等待,直到当前数字是奇数。
    • 如果 number 小于等于 100,打印当前数字并将 number 加 1。
    • 使用 cv.notify_all(); 通知其他线程。
  • 主函数 (main):

    • 创建两个线程 oddThreadevenThread,分别调用 printOddprintEven
    • 使用 join() 等待两个线程完成。

七、共勉 

 以下就是我对 【C++】多线程编程 的理解,如果有不懂和发现问题的小伙伴,请在评论区说出哦,同时我还会继续更新【C++】请持续关注我哦!!!  

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值