hello !大家好呀! 欢迎大家来到我的Linux高性能服务器编程系列之《线程纵横:C++并发编程的深度解析与实践》,在这篇文章中,你将会学习到C++新特性,并发编程,以及其如何带来的高性能的魅力 ,以及手绘UML图来帮助大家来理解,希望能让大家更能了解网络编程技术!!!
希望这篇文章能对你有所帮助,大家要是觉得我写的不错的话,那就点点免费的小爱心吧!(注:这章对于高性能服务器的架构非常重要哟!!!)
前言:在当今多核处理器时代,并发编程已成为提高应用程序性能的关键。C++,这一长期以来备受青睐的语言,在C++11及以后的版本中引入了强大的线程库,为开发者提供了丰富的并发编程工具。本文将深入探讨C++线程库的奥秘,揭示并发编程的复杂性与美妙,并通过实际案例,带你领略C++并发编程的魅力。我们将一起探索线程的创建与管理,理解互斥锁、条件变量等同步机制,并学习如何利用这些工具解决实际问题。无论是初学者还是经验丰富的开发者,都能从中获得宝贵的知识和启示。让我们一起踏上这场并发编程的探索之旅吧!
目录
[1.1 thread简单类的简单介绍](#1.1 thread简单类的简单介绍)
[1.2 线程函数](#1.2 线程函数)
[1.3 线程函数参数](#1.3 线程函数参数)
[2.1 原子操作](#2.1 原子操作)
[2.2 mutex的多种互斥量](#2.2 mutex的多种互斥量)
[2.3 lock_guard与unique_lock](#2.3 lock_guard与unique_lock)
[2.4 线程交互实例](#2.4 线程交互实例)
一.线程库
1.1 thread简单类的简单介绍
在 C++11 之前,涉及到多线程问题,都是和平台相关的,比如 windows 和 linux 下各有自己的接 口,这使得代码的可移植性比较差 。 C++11 中最重要的特性就是对线程进行支持了,使得 C++ 在 并行编程时不需要依赖第三方库 ,而且在原子操作中还引入了原子类的概念。要使用标准库中的 线程,必须包含< thread > 头文件。
常见线程函数简介:
std::thread:
- 用于创建和管理的线程对象。通过构造函数启动线程,并将一个可调用对象(如函数、Lambda表达式、函数对象)作为参数传递给线程。
std::this_thread::get_id():
- 返回当前线程的ID。
std::this_thread::sleep_for:
- 使当前线程暂停执行指定的时间长度。
std::this_thread::sleep_until:
- 使当前线程暂停执行直到指定的时间点。
std::mutex:
- 提供基本的互斥锁功能,用于保护共享数据免受多线程同时访问。
std::lock_guard:
- 用于管理互斥锁的RAII(Resource Acquisition Is Initialization)包装器,确保在作用域结束时会自动释放锁。
std::unique_lock:
- 提供比
std::lock_guard
更灵活的互斥锁管理。允许手动锁定和解锁,支持条件变量,并且可以转移锁的所有权。std::condition_variable:
- 用于在多线程编程中同步操作。通常与
std::mutex
一起使用,用于等待某个条件成立或通知其他线程条件已经改变。std::condition_variable_any:
- 与
std::condition_variable
类似,但可以与任何类型的锁(满足基本锁概念)一起使用。std::promise:
- 用于在线程之间传递一个值。可以在线程中设置值,然后其他线程可以获取这个值。
std::future:
- 用于获取
std::promise
设置的值。可以查询std::promise
设置的结果,并检查它是否已准备好。std::packaged_task:
- 用于将任何可调用对象打包成一个可以异步执行的函数对象。与
std::future
结合使用,可以获取异步操作的结果。std::async:
- 用于异步执行函数或可调用对象,并返回一个
std::future
对象,用于获取异步操作的结果。
这些是C++线程库中一些常用的函数和类。它们提供了线程的创建、同步、通信等基本功能,是进行多线程编程的基础。
注意:
- 线程是操作系统中的一个概念, 线程对象可以关联一个线程,用来控制线程以及获取线程的
状态 。 - 当创建一个线程对象后,没有提供线程函数,该对象实际没有对应任何线程。
1.2 线程函数
当创建一个线程对象后,并且给线程关联线程函数,该线程就被启动,与主线程一起运行。
线程函数一般情况下可按照以下三种方式提供:
1) 函数指针
2) ambda表达式
3) 函数对象
给大家一个简单例子:
cpp
#include <iostream>
using namespace std;
#include <thread>
void ThreadFunc(int a)
{
cout << "Thread1" << a << endl;
}
class TF
{
public:
void operator()()
{
cout << "Thread3" << endl;
}
};
int main()
{
// 线程函数为函数指针
thread t1(ThreadFunc, 10);
// 线程函数为lambda表达式
thread t2([]{cout << "Thread2" << endl; });
// 线程函数为函数对象
TF tf;
thread t3(tf);
t1.join();
t2.join();
t3.join();
cout << "Main thread!" << endl;
return 0;
}
注意:thread 类是防拷贝的,不允许拷贝构造以及赋值,但是可以移动构造和移动赋值,即将一个 线程对象关联线程的状态转移给其他线程对象,转移期间不意向线程的执行。
可以通过 jionable() 函数判断线程是否是有效的,如果是以下任意情况,则线程无效:
1)采用无参构造函数构造的线程对象
2)线程对象的状态已经转移给其他线程对象
3)线程已经调用 jion 或者 detach 结束
1.3 线程函数参数
线程函数的参数是以值拷贝的方式拷贝到线程栈空间中的 ,因此:即使线程参数为引用类型,在
线程中修改后也不能修改外部实参,因为 其实际引用的是线程栈中的拷贝,而不是外部实参 。
给大家一个具体例子体会:
cpp
#include <thread>
void ThreadFunc1(int& x)
{
x += 10;
}
void ThreadFunc2(int* x)
{
*x += 10;
}
int main()
{
int a = 10;
// 在线程函数中对a修改,不会影响外部实参,因为:线程函数参数虽然是引用方式,但其实际
引用的是线程栈中的拷贝
thread t1(ThreadFunc1, a);
t1.join();
cout << a << endl;
// 如果想要通过形参改变外部实参时,必须借助std::ref()函数
thread t2(ThreadFunc1, std::ref(a);
t2.join();
cout << a << endl;
// 地址的拷贝
thread t3(ThreadFunc2, &a);
t3.join();
cout << a << endl;
return 0;
}
二.线程并发
2.1 原子操作
多线程最主要的问题是共享数据带来的问题**(即线程安全)**。如果共享数据都是只读的,那么没问 题,因为只读操作不会影响到数据,更不会涉及对数据的修改,所以所有线程都会获得同样的数 据。但是,当一个或多个线程要修改共享数据时,就会产生很多潜在的麻烦。
对于传统的并发,我们可以使用加锁的方式,来保护线程数据安全:
cpp
std::mutex m;
unsigned long sum = 0L;
void fun(size_t num)
{
for (size_t i = 0; i < num; ++i)
{
m.lock();
sum++;
m.unlock();
}
}
int main()
{
cout << "Before joining,sum = " << sum << std::endl;
thread t1(fun, 10000000);
thread t2(fun, 10000000);
t1.join();
t2.join();
cout << "After joining,sum = " << sum << std::endl;
return 0;
}
但是缺点也非常明显:只要一个线程在对sum++的时候,其它线程会被阻断,影响控制效率,而且锁控制不好,容易造成死锁。
因此 C++11 中引入了原子操作。所谓原子操作:即不可被中断的一个或一系列操作, C++11 引入
的原子操作类型,使得线程间数据的同步变得非常高效。
这是系统自带的原子数据,可以保护指定原子数据的线程安全。
例如直接将sum改为原子数据:
cpp
#include <iostream>
using namespace std;
#include <thread>
#include <atomic>
atomic_long sum{ 0 };
void fun(size_t num)
{
for (size_t i = 0; i < num; ++i)
sum ++; // 原子操作
}
int main()
{
cout << "Before joining, sum = " << sum << std::endl;
thread t1(fun, 1000000);
thread t2(fun, 1000000);
t1.join();
t2.join();
cout << "After joining, sum = " << sum << std::endl;
return 0;
}
更为普遍的,我们可以使用atomic模板,定义自己需要的原子类型:
cpp
atmoic<T> t; // 声明一个类型为T的原子类型变量t
注意:原子类型通常属于 " 资源型 " 数据,多个线程只能访问单个原子类型的拷贝,因此 在 C++11 中,原子类型只能从其模板参数中进行构造,不允许原子类型进行拷贝构造、移动构造以及 operator= 等,为了防止意外,标准库已经将 atmoic 模板类中的拷贝构造、移动构造、赋值运算 符重载默认删除掉了。
2.2 mutex的多种互斥量
在C++11中,<mutex>
头文件提供了几种不同类型的互斥量(mutex),以满足不同的同步需求。这些互斥量包括:
-
std::mutex:
- 最基本的互斥量类型,提供基本的互斥锁功能。它用于保护共享数据,确保同一时间只有一个线程可以访问该数据。
-
std::recursive_mutex:
- 允许同一个线程多次获得互斥锁的递归互斥量。这对于递归函数或需要多次进入临界区的代码非常有用。
-
std::timed_mutex:
- 与
std::mutex
类似,但提供了两个额外的成员函数try_lock_for
和try_lock_until
,允许线程尝试在指定的时间范围内获取互斥锁。
- 与
-
std::recursive_timed_mutex:
- 结合了
std::recursive_mutex
和std::timed_mutex
的特性,允许递归锁定,并且可以设置超时。
- 结合了
注意,线程函数调用 lock() 时,可能会发生以下三种情况:
1)如果该互斥量当前没有被锁住,则调用线程将该互斥量锁住,直到调用 unlock 之前,
该线程一直拥有该锁。
2)如果当前互斥量被其他线程锁住,则当前的调用线程被阻塞住
3)如果当前互斥量被当前调用线程锁住,则会产生死锁 (deadlock)
线程函数调用 try_lock() 时,可能会发生以下三种情况:
1)如果当前互斥量没有被其他线程占有,则该线程锁住互斥量,直到该线程调用 unlock
释放互斥量
2)如果当前互斥量被其他线程锁住,则当前调用线程返回 false ,而并不会被阻塞掉
3)如果当前互斥量被当前调用线程锁住,则会产生死锁 (deadlock)
try_lock_for()
接受一个时间范围,表示在这一段时间范围之内线程如果没有获得锁则被阻塞住(与
std::mutex 的 try_lock() 不同, try_lock 如果被调用时没有获得锁则直接返回
false ),如果在此期间其他线程释放了锁,则该线程可以获得对互斥量的锁,如果超
时(即在指定时间内还是没有获得锁),则返回 false 。
try_lock_until()
接受一个时间点作为参数,在指定时间点未到来之前线程如果没有获得锁则被阻塞住,
如果在此期间其他线程释放了锁,则该线程可以获得对互斥量的锁,如果超时(即在指
定时间内还是没有获得锁),则返回 false 。
2.3 lock_guard与unique_lock
在多线程环境下,如果想要保证某个变量的安全性,只要将其设置成对应的原子类型即可,即高
效又不容易出现死锁问题。但是有些情况下,我们可能需要保证一段代码的安全性,那么就只能
通过锁的方式来进行控制。
如下为lock_guard的模板源码:
cpp
template<class _Mutex>
class lock_guard
{
public:
// 在构造lock_gard时,_Mtx还没有被上锁
explicit lock_guard(_Mutex& _Mtx)
: _MyMutex(_Mtx)
{
_MyMutex.lock();
}
// 在构造lock_gard时,_Mtx已经被上锁,此处不需要再上锁
lock_guard(_Mutex& _Mtx, adopt_lock_t)
: _MyMutex(_Mtx)
{}
~lock_guard() _NOEXCEPT
{
_MyMutex.unlock();
}
lock_guard(const lock_guard&) = delete;
lock_guard& operator=(const lock_guard&) = delete;
private:
_Mutex& _MyMutex;
};
通过上述代码可以看到, lock_guard 类模板主要是通过 RAII 的方式,对其管理的互斥量进行了封 装 ,在需要加锁的地方,只需要用上述介绍的 任意互斥体实例化一个 lock_guard ,调用构造函数 成功上锁,出作用域前, lock_guard 对象要被销毁,调用析构函数自动解锁,可以有效避免死锁 问题。
但是缺点依然明显:太单一,用户没办法对该锁进行控制,故c++11又提供了uniqu_lock.
std::unique_lock
是C++11标准库中提供的一个模板类,用于管理互斥锁(mutex)。与std::lock_guard
类似,std::unique_lock
也是一个RAII(Resource Acquisition Is Initialization)包装器,用于在作用域结束或异常发生时自动释放锁。不过,std::unique_lock
比std::lock_guard
提供了更多的灵活性和控制。
std::unique_lock
的特点包括:
-
灵活的锁管理 :
std::unique_lock
允许你在任何时候手动锁定或解锁互斥锁,而std::lock_guard
在构造时锁定,在析构时解锁,期间不能手动控制。 -
条件变量支持 :
std::unique_lock
可以与std::condition_variable
一起使用,用于等待特定的条件成立。这在生产者-消费者模式或其他需要等待特定信号的场景中非常有用。 -
所有权转移 :
std::unique_lock
对象可以通过移动构造函数和移动赋值操作符进行所有权转移。这意味着锁的所有权可以从一个std::unique_lock
对象转移到另一个。 -
锁策略 :
std::unique_lock
的构造函数接受一个策略参数,可以指定在构造时是否立即锁定互斥锁,或者是否采用延迟锁定的策略。 -
锁的所有权 :与
std::lock_guard
不同,std::unique_lock
可以没有锁的所有权。这意味着它可以被用来包装一个已经锁定的互斥锁,而不需要立即解锁。
cpp
#include <mutex>
#include <iostream>
#include <thread>
std::mutex mtx; // 创建一个互斥锁
std::condition_variable cv; // 创建一个条件变量
void print_thread_id(int id) {
std::unique_lock<std::mutex> lock(mtx); // 在作用域开始时自动锁定互斥锁
// 使用条件变量等待
cv.wait(lock, []{ return true; }); // 此处仅为示例,实际应用中应有具体的条件判断
std::cout << "Thread #" << id << '\n';
// 作用域结束时,lock对象被销毁,自动解锁互斥锁
}
int main() {
// 假设有多个线程调用print_thread_id
// 每个线程都会在print_thread_id函数中安全地访问共享资源
}
2.4 线程交互实例
cpp
#include <thread>
#include <mutex>
#include <condition_variable>
void two_thread_print()
{
std::mutex mtx;
condition_variable c;
int n = 100;
bool flag = true;
thread t1([&](){
int i = 0;
while (i < n)
{
unique_lock<mutex> lock(mtx);
c.wait(lock, [&]()->bool{return flag; });
cout << i << endl;
flag = false;
i += 2; // 偶数
c.notify_one();
}
});
thread t2([&](){
int j = 1;
while (j < n)
{
unique_lock<mutex> lock(mtx);
c.wait(lock, [&]()->bool{return !flag; });
cout << j << endl;
j += 2; // 奇数
flag = true;
c.notify_one();
}});
t1.join();
t2.join();
}
int main()
{
two_thread_print();
return 0;
}
这段代码展示了如何在两个线程之间交替打印奇数和偶数。这里使用了std::mutex
、std::unique_lock
和std::condition_variable
来实现线程间的同步。
让我们逐步解释代码的工作原理:
-
函数
two_thread_print
:- 此函数设置了一个多线程打印的情景。它创建了一个互斥锁
mtx
和一个条件变量c
,并初始化一个整型变量n
为100,以及一个布尔型变量flag
为true
。
- 此函数设置了一个多线程打印的情景。它创建了一个互斥锁
-
线程
t1
:t1
是一个Lambda表达式创建的线程,它打印偶数。i
初始化为0,然后进入一个循环,直到i
小于n
。- 使用
unique_lock<mutex>
锁定互斥锁mtx
,然后调用c.wait
等待条件变量c
的通知。 c.wait
的第二个参数是一个Lambda表达式,它返回flag
的值。这意味着t1
线程将在flag
为true
时继续执行。- 当
t1
获得通知并继续执行时,它打印当前的i
值,然后将flag
设置为false
,表示下一个应该打印奇数。 i
增加2,然后调用c.notify_one
通知另一个线程。
-
线程
t2
:t2
也是一个Lambda表达式创建的线程,它打印奇数。j
初始化为1,然后进入一个循环,直到j
小于n
。- 使用
unique_lock<mutex>
锁定互斥锁mtx
,然后调用c.wait
等待条件变量c
的通知。 c.wait
的第二个参数是一个Lambda表达式,它返回!flag
的值。这意味着t2
线程将在flag
为false
时继续执行。- 当
t2
获得通知并继续执行时,它打印当前的j
值,然后将flag
设置为true
,表示下一个应该打印偶数。 j
增加2,然后调用c.notify_one
通知另一个线程。
-
主线程:
- 主线程创建
t1
和t2
线程,然后分别调用它们的join
方法等待它们完成。
- 主线程创建
-
总结:
- 这个程序的关键在于
flag
变量和条件变量c
的使用。flag
用于控制哪个线程应该打印,而条件变量c
用于线程间的同步。 - 当一个线程打印一个数字并改变
flag
的值后,它通过notify_one
通知另一个线程。 - 另一个线程等待条件变量
c
的通知,并在flag
的值改变后继续执行。
- 这个程序的关键在于
这个程序展示了如何在C++中使用线程和条件变量来实现复杂的线程间同步。
好啦!到这里这篇文章就结束啦,关于实例代码中我写了很多注释,如果大家还有不懂得,可以评论区或者私信我都可以哦!! 感谢大家的阅读,我还会持续创造网络编程相关内容的,记得点点小爱心和关注哟!