💓博主CSDN主页:麻辣韭菜💓
⏩专栏分类:C++修炼之路⏪
🚚代码仓库:C++高阶🚚
🌹关注我🫵带你学习更多C++知识
🔝🔝
目录
前言
一、thread类的简单介绍
get_id
编辑
构造函数
编辑
joinable
join编辑
native_handle
detach
std::thread::operator=编辑
std::thread::swap编辑
二、mutex
mutex
recursive_mutex
三、RAII 风格的锁
四、条件变量 condition_variable
等待函数:
通知函数:
交替打印数字
五、原子类 atomic
load、store 编辑
六、包装器
bind
前言
在C++11之前由于没有线程库,这就导致了在Linux能跑的代码,在windows下就不行,反之也是一样。导致了代码的可移植性差!!!而C++11之后出现了线程库,并行编译时不需要依赖第三方库。而且在原子操作中还引入了原子类的概念。
一、thread类的简单介绍
类定义
std::thread
是C++标准库中的一个类,用于表示和控制线程。它允许你创建、管理、同步线程,并且可以与操作系统的线程进行交互。
成员类型
id
: 线程的唯一标识符。它是一个类型为std::thread::id
的公共成员类型,可以用来比较不同线程是否相同。native_handle_type
: 表示线程的原生操作系统句柄。这个类型是平台相关的,可以用来与操作系统的API进行交互。
成员函数
- 构造函数: 可以以多种方式构造
std::thread
对象,比如传递一个可调用对象(函数、lambda表达式、函数对象等)作为参数。 - 析构函数: 当
std::thread
对象被销毁时,如果它是一个可加入的线程并且没有被join
或detach
,那么析构函数会调用std::terminate
,这将终止程序。 - 移动赋值操作符
operator=
: 允许线程对象之间的所有权转移。移动之后,源线程对象将不再代表一个活跃的线程。 get_id
: 返回当前线程的唯一标识符。joinable
: 检查线程是否可加入。如果线程已经加入或者分离,返回false
。join
: 等待线程结束。如果线程已经结束了,调用join
将不会做任何事情。detach
: 分离线程,使其在完成时不会自动销毁。分离后的线程必须由操作系统来管理。swap
: 交换两个线程对象的内部状态,使得一个线程对象代表另一个线程的执行。native_handle
: 返回线程的原生句柄,这可以用来直接与操作系统的线程管理功能交互。hardware_concurrency
: 静态成员函数,返回硬件支持的线程并发数量,即可以同时运行的线程数。
非成员函数
swap
: 一个非成员函数,用于交换两个std::thread
对象的线程。它提供了与成员swap
函数相同的功能,但是可以用于按值传递线程对象。
我们先来一个传统的写法
#include <iostream>
#include <thread>
using namespace std;
void Func(int n, int num)
{
for (int i = 0; i < num; i++)
{
cout <<"线程:"<< n << " " << i << endl;
}
cout << endl;
}
int main()
{
thread t1(Func, 1, 20);
thread t2(Func, 2, 30);
t1.join();
t2.join();
return 0;
}
线程的ID如何获取?
get_id
在C++标准库中,std::thread::get_id
是一个公共成员函数,属于std::thread
类。这个函数用于获取与std::thread
对象关联的线程的唯一标识符(thread id)。以下是关于std::thread::get_id
成员函数的详细信息:
函数原型
id get_id() const noexcept;
功能描述
std::thread::get_id
函数返回一个std::thread::id
类型的值,该值是线程的唯一标识符。
行为
- 如果
std::thread
对象是可加入的(joinable),即它代表了一个活跃的线程,那么get_id
函数将返回一个值,该值唯一地标识了这个线程。 - 如果
std::thread
对象不是可加入的,例如它是默认构造的或者已经被移动(move)了,那么get_id
函数将返回一个默认构造的对象,该对象不代表任何活跃的线程。
参数
无(该函数不接受任何参数)。
返回值
- 当线程对象是可加入的,返回一个
std::thread::id
类型的值,它唯一标识了线程。 - 当线程对象不是可加入的,返回一个默认构造的
std::thread::id
对象。
异常安全性
get_id
函数被声明为noexcept
,这意味着它保证不会抛出异常。
代码示例
#include <iostream>
#include <thread>
using namespace std;
#include <vector>
int main()
{
int m, n;
cin >> m >> n;
vector<int> arr;
arr.push_back(m);
arr.push_back(n);
vector<thread> vthds(m);
for (int i = 0; i < arr[0]; i++)
{
vthds[i] = thread([i,&arr,&vthds]()
{
for (int j = 0; j< arr[1]; j++)
{
cout << "线程:" << vthds[i].get_id() << " "
<< "j:" << j << endl;
}
cout << endl;
});
}
for (auto& t : vthds)
{
t.join();
}
return 0;
}
但是我们一般都不会用对象去调用,而是用this_thread
std::this_thread
是 C++ 标准库 <thread>
中定义的一个命名空间,它包含了一组与当前线程相关的函数。这些函数提供了对当前线程的访问和控制,允许开发者执行如休眠当前线程、获取当前线程的ID等操作。以下是 std::this_thread
命名空间中的一些常用功能:
成员函数
sleep_for
: 使当前线程暂停执行指定的时间长度。例如,std::this_thread::sleep_for(std::chrono::seconds(1));
会使当前线程休眠一秒。sleep_until
: 使当前线程休眠直到达到某个指定的时间点。yield
: 暗示调度器当前线程愿意让出对处理器的使用,调度器可以选择另一个线程来运行。get_id
: 返回一个标识当前线程的std::thread::id
类型的唯一标识符。
原子操作
CAS
是一个不断重复尝试的过程,如果尝试的时间过久,就会影响整体效率,因为此时是在做无用功,而yield
可以主动让出当前线程的时间片,避免大量重复,把CPU
资源让出去,从而提高整体效率 具体实现原理可以看陈浩大佬无锁队列的实现 | 酷 壳 - CoolShell
构造函数
-
默认构造函数:
当你使用std::thread
的默认构造函数时,你得到的是一个没有关联任何线程的线程对象。这相当于一个空壳,没有实际的执行内容。这个构造函数通常用于那些可能但不一定需要与线程关联的情况。 -
初始化构造函数:
这种构造函数允许你创建一个新的线程对象,并立即启动一个线程执行指定的函数或任务。你可以传递函数和相应的参数给构造函数,这些参数将被复制或移动到新线程中(根据它们的值类别,即左值或右值)。重要的是,新线程的执行开始于构造函数完成时,这意味着一旦线程对象被创建,它就开始执行指定的任务。 -
复制构造函数(已删除):
C++中的std::thread
对象不支持复制语义。这意味着你不能通过简单的复制操作来创建线程对象的副本。这是因为复制线程对象可能导致多个对象尝试管理同一个底层线程,这会带来同步和生命周期管理上的复杂性。 -
移动构造函数:
移动构造函数允许你将一个线程对象的所有权转移给另一个线程对象。这通常发生在你需要重新分配线程资源时。例如,如果你在一个线程对象中创建了一个线程,但后来决定将其转移给另一个线程对象来管理,你可以使用移动构造函数来实现这一点。转移后,原始线程对象将不再关联任何线程,而新的对象将接管线程的执行。 -
销毁:
当一个std::thread
对象的生命周期结束时,如果它是一个可加入的线程(即它通过初始化构造函数创建并启动了一个线程),它必须被适当地处理。你可以通过调用join()
方法来等待线程完成其任务,或者通过调用detach()
方法来分离线程,使其在没有管理的情况下继续执行。如果可加入的线程对象在销毁前既没有被加入也没有被分离,程序将调用std::terminate
,这将导致程序立即终止。
joinable
std::thread::joinable
是 C++ 标准库 <thread>
中 std::thread
类的一个成员函数,它用于检查线程对象是否可加入。以下是对这个函数的详细解释:
函数原型
bool joinable() const noexcept;
功能描述
joinable()
函数返回一个布尔值,指示线程对象是否可加入。如果线程对象关联了一个线程执行流,并且该线程尚未结束,那么它就是可加入的。
行为
- 可加入的线程对象:如果线程对象在创建时通过初始化构造函数与一个新线程关联,或者它通过移动构造函数从另一个线程对象那里接管了线程执行,那么它是可加入的。
- 不可加入的线程对象:如果线程对象是以下情况之一,它将不可加入:
- 使用默认构造函数创建的,没有关联任何线程执行。
- 通过移动操作从其他线程对象转移而来,原对象不再关联任何线程。
- 对象的
join()
或detach()
成员函数已经被调用过,线程已经完成或分离。
注意事项
joinable()
函数被声明为noexcept
,意味着它保证不会抛出异常。- 在调用
join()
或detach()
之前,使用joinable()
进行检查是一种良好的编程实践,可以避免对已经结束或分离的线程执行非法操作。
#include <iostream>
#include <thread>
void thread_function() {
// 线程执行的代码
}
int main() {
std::thread t(thread_function);
// 等待线程结束
if (t.joinable()) {
t.join(); // 等待线程完成
} else {
std::cout << "Thread is not joinable." << std::endl;
}
return 0;
}
在这个示例中,我们首先创建了一个线程 t
。然后,我们检查它是否可加入。如果是,我们调用 join()
等待线程结束。如果线程不可加入,我们将输出一条消息说明这一点。
总结
std::thread::joinable
是一个重要的成员函数,它提供了一种机制来判断线程对象是否可以安全地调用 join()
或 detach()
函数。正确使用 joinable()
可以帮助避免多线程编程中的常见错误和潜在的资源管理问题。
join
std::thread::join
是 C++ 标准库中 <thread>
头文件定义的 std::thread
类的一个成员函数,用于等待由 std::thread
对象表示的线程完成其执行。以下是对该函数的详细说明:
函数原型
void join();
功能描述
join()
函数的作用是等待当前 std::thread
对象所关联的线程执行完成。调用此函数的线程(通常是主线程或其他线程)将被阻塞,直到 std::thread
对象所代表的线程终止。
行为
- 当
join()
被调用时,如果std::thread
对象所关联的线程尚未结束,调用线程将等待直到该线程完成其所有操作。 join()
函数与线程完成的所有操作同步,这意味着一旦join()
返回,被等待线程中的所有工作都已经完成。
效果
- 在
join()
函数调用后,std::thread
对象将变为不可加入状态。这意味着你不能再次对同一个线程对象调用join()
或detach()
。 - 一旦线程对象变为不可加入状态,它就可以被安全地销毁,因为它不再关联任何活跃的线程。
注意事项
- 如果
std::thread
对象是默认构造的,或者已经被移动到另一个std::thread
对象,或者已经调用过join()
或detach()
,则join()
函数将立即返回,不会产生阻塞效果。 - 在多线程程序中,合理使用
join()
可以确保线程的执行结果被正确处理,并且线程资源得到适当释放。
native_handle
std::thread::native_handle()
是 C++ 标准库 <thread>
中 std::thread
类的一个成员函数,它用于获取与线程对象关联的原生线程句柄。以下是对这个函数的详细解释:
函数原型
native_handle_type native_handle();
功能描述
native_handle()
函数返回一个特定于实现的值,这个值提供了对底层操作系统线程表示的访问。这个原生句柄可以用于直接与操作系统的线程管理功能交互,例如查询线程状态或执行特定于平台的线程操作。
行为
- 此函数只有在库的实现支持时才存在于
std::thread
类中。 - 它返回一个
thread::native_handle_type
类型的值,这个值是特定于实现的,并且可以用来操作底层线程。
参数
- 此函数不接受任何参数。
返回值
- 返回一个
thread::native_handle_type
类型的值,表示线程的原生句柄。
注意事项
native_handle()
函数的使用可能会涉及未指定的数据竞争和异常安全性问题。使用此函数时,需要确保对线程的访问是同步的,并且考虑到可能的异常安全问题。- 由于
native_handle_type
是特定于实现的,它的具体类型和使用方法将依赖于编译器和操作系统。因此,使用native_handle()
可能需要特定平台的编程知识。
这个了解就好了。
detach
std::thread::detach
是 C++ 标准库 <thread>
头文件中 std::thread
类的一个成员函数,用于分离线程对象。以下是对这个函数的详细解释:
函数原型
void detach();
功能描述
detach()
函数将 std::thread
对象所代表的线程与调用它的线程分离,允许这两个线程独立执行。分离操作意味着两个线程将继续它们的执行,而不会相互阻塞或同步。
行为
- 分离线程后,原线程对象不再控制或等待被分离的线程结束。
- 如果分离的线程结束执行,它的资源将被操作系统自动释放。
- 如果原线程(调用
detach()
的线程)在分离的线程结束之前结束,分离的线程将继续运行直到它完成执行,然后由操作系统释放它的资源。
效果
- 调用
detach()
后,std::thread
对象变为不可加入状态,即你不能对这个对象调用join()
。 - 不可加入的线程对象可以被安全地销毁,因为它不再持有对任何活跃线程的引用。
注意事项
- 分离线程是一个不可逆的操作。一旦调用了
detach()
,你将无法再等待或加入这个线程。 - 如果分离的线程在执行过程中发生异常,调用
detach()
的线程将不会得到通知,异常也不会传播到调用线程。 - 在分离线程之前,确保线程的执行不会导致资源泄漏或未完成的任务。
#include <iostream>
#include <thread>
void thread_function() {
std::cout << "线程正在执行工作..." << std::endl;
// 模拟一些工作负载
std::this_thread::sleep_for(std::chrono::seconds(2));
std::cout << "线程工作完成。" << std::endl;
}
int main() {
std::thread t(thread_function);
// 分离线程
t.detach();
// 主线程继续执行,不会等待分离的线程结束
std::cout << "主线程继续执行,不会等待线程 " << t.get_id() << " 结束。" << std::endl;
// 由于线程已经被分离,这里调用 join() 将无效果
// t.join(); // 这行代码将导致未定义行为
return 0;
}
总结
std::thread::detach
是一个重要的成员函数,它提供了一种机制来分离线程,使得线程可以独立于创建它的线程执行。使用 detach()
可以避免不必要的同步等待,但也需要谨慎使用,以确保资源得到正确管理。
std::thread::operator=
功能描述
移动赋值操作符允许一个std::thread
对象(通常称为右侧对象,rhs)将其线程执行的所有权转移给另一个std::thread
对象(通常称为左侧对象,*this)。这个操作是C++移动语义的一部分,用于高效地重新分配资源。
行为
-
如果左侧对象(*this)当前不是可加入的(即它不关联任何线程或者已经分离了线程),它将接管右侧对象(rhs)所代表的线程执行。这包括关联的线程和其所有状态。
- 如果左侧对象是可加入的(即它关联了一个活跃的线程),则调用
terminate()
函数。这将尝试立即终止线程的执行,这可能导致资源未被正确释放或数据不一致。 - 移动赋值操作之后,右侧对象(rhs)将不再代表任何线程执行。它就像使用默认构造函数创建的对象一样,不关联任何线程。
注意事项
- 移动赋值操作符是不可逆的。一旦执行,原对象的状态将被清除,并且不能再用来控制或同步线程。
std::thread
对象不能被复制,只能被移动。这意味着没有复制构造函数,只有移动构造函数和移动赋值操作符。- 在移动赋值之后,原对象应该被视为无效,并且不应该再被使用来控制线程。
#include <iostream>
#include <thread>
void thread_function() {
std::cout << "线程正在执行工作..." << std::endl;
// 模拟一些工作负载
std::this_thread::sleep_for(std::chrono::seconds(1));
std::cout << "线程工作完成。" << std::endl;
}
int main() {
std::thread t1(thread_function); // 创建并启动线程
std::thread t2; // 创建一个空的线程对象
// 移动t1中的线程所有权到t2
t2 = std::move(t1); // t1 现在不再代表任何线程
// 等待t2中的线程完成
if (t2.joinable()) {
t2.join();
}
// 尝试使用已经移动的t1将导致未定义行为
// if (t1.joinable()) {
// t1.join(); // 这将是错误的使用
// }
return 0;
}
std::thread::swap
函数原型
void swap(std::thread& x);
功能描述
swap
函数交换调用对象(*this
)和参数 x
的线程状态。这意味着两个 std::thread
对象关联的线程执行将被交换。
行为
- 如果调用对象和
x
都是可加入的,它们关联的线程将被交换。 - 如果其中一个或两个是不可加入的(例如,它们已经被分离或默认构造的),那么交换操作将只影响可加入的线程对象。
参数
x
:要与当前对象交换状态的std::thread
对象。
返回值
- 无(
void
类型)。
thread类的成员函数介绍完了 ,最先开始的代码打印错乱。 显示器也是临界资源,两个线程共享临界资源,同时往显示器上打印就会出现错乱的问题。这时候我们就需要用到锁了。
二、mutex
互斥体类型(Mutex Types)
这些是用于保护代码的关键部分以实现互斥访问的可锁类型:
mutex
:基本的互斥体,提供独占访问。recursive_mutex
:递归互斥体,允许同一个线程多次锁定它。timed_mutex
:带超时功能的互斥体,尝试锁定时可以指定超时时间。recursive_timed_mutex
:递归带超时功能的互斥体,结合了上述两种特性。
锁类型(Locks)
这些对象管理互斥体的锁定状态,并将其与对象的生命周期相关联:
lock_guard
:当构造时锁定互斥体,在析构时自动解锁。它提供了作用域锁定。unique_lock
:与lock_guard
类似,但提供了更多的灵活性,例如尝试锁定和锁定超时。
函数
这些函数用于更高级的锁定操作:
std::lock
:原子地锁定多个互斥体,防止死锁。std::try_lock
:尝试锁定一个或多个互斥体,如果无法立即锁定,则可以不阻塞地失败。std::call_once
:确保某个函数(通常用于初始化)在程序的生命周期内只被调用一次,即使多次请求也是如此。
上面的锁,我们一步一步的来,先说mutex的lock。
mutex
#include <vector>
#include <mutex>
int main()
{
int m, n;
cin >> m >> n;
vector<int> arr;
mutex mtx;
arr.push_back(m);
arr.push_back(n);
vector<thread> vthds(m);
for (int i = 0; i < arr[0]; i++)
{
vthds[i] = thread([i,arr,&mtx]()
{
mtx.lock();
for (int j = 0; j< arr[1]; j++)
{
cout << "线程:" << this_thread::get_id()
<< " " << "j:" << j << endl;
this_thread::sleep_for(chrono::milliseconds(200));
}
mtx.unlock();
cout << endl;
});
}
for (auto& t : vthds)
{
t.join();
}
return 0;
}
这里从运行结果来看我们打印的结果没有错乱了。但是这些线程是并行在跑,并不是我们想要的。
我们再来看一段代码
void Func(int n)
{
// 并行
for (int i = 0; i < n; i++)
{
++x;
}
}
int main()
{
int n = 10000;
thread t1(Func, n);
thread t2(Func, n);
t1.join();
t2.join();
cout << x << endl;
return 0;
}
当我们的n较小时,x++为正确,如果x的值比较大又会是什么样? 比如n为100000
结果超出我们预期。这里的x是全局变量,和显示器一样都是临界资源。两个线程同时对它++就会出现线程安全的问题,这时我们也是需要加锁。
这里就有个问题了如何加锁?
理论来说并行是要比串行快的,这里串行快是因为代码比较简单while
循环内只需要进行 ++x
就行了,并行化中频繁加锁、解锁的开销要远大于串行化单纯的进行 while
循环
,如果多来点IO操作,就会发现并行快 。
#include<mutex>
int main()
{
int n = 100000;
int x = 0;
mutex mtx;
size_t begin = clock();
thread t1([&,n](){
mtx.lock();
for (int i = 0; i < n; i++)
{
cout << &x << endl;
cout << &n << endl;
++x;
}
mtx.unlock();
});
thread t2([&,n]() {
for (int i = 0; i < n; i++)
{
cout << &x << endl;
cout << &n << endl;
mtx.lock();
++x;
mtx.unlock();
}
});
t1.join();
t2.join();
size_t end = clock();
cout << x << endl;
cout << end - begin << endl;
return 0;
}
这时我们就加了两条打印语句,两者差距就很接近了。如果还有其他不涉及临界资源的语句,明显并行就快了。因为没有锁的情况,其他线程是可以执行其他语句的。但串行只能拿到锁后才能执行。
这个大家根据实际的场景来选择加锁的位置。
recursive_mutex
#include<mutex>
int x = 0;
recursive_mutex mtx;
void Func(int n)
{
if (n == 0)
return;
mtx.lock();
++x;
Func(n - 1);
mtx.unlock();
}
int main()
{
thread t1(Func, 10000);
thread t2(Func, 20000);
t1.join();
t2.join();
cout << x << endl;
return 0;
}
上面这种代码就是典型的死锁问题,即使我们把解锁放在++x后面也会出现栈溢出的问题。所以能写循环的尽量在循环体里面用互斥锁,递归互斥锁尽量不要用。
后面两种不常用,这里就不做过多讲解。
三、RAII 风格的锁
上面都是需要我们自己手动加锁和解锁,万一我们有时候忘记加锁和解锁,即使我们没有忘记手动加锁和解锁,代码很容易出现死锁的问题。比如后面章节要讲解的异常,代码引入异常处理之后,如果是临界资源出现了问题,代码会跳转至 catch 中 捕捉异常。这时代码异常退出。而unlock还没有执行。锁的资源没有释放,这就导致死锁。
int main()
{
int n = 100000;
int x = 0;
mutex mtx;
thread t1([&, n]() {
try {
mtx.lock();
for (int i = 0; i < n; i++)
{
if (i % 2 == 0)
throw exception("异常");
++x;
}
mtx.unlock();
}
catch (const exception& e)
{
cout << e.what() << endl;
}
});
thread t2([&, n]() {
for (int i = 0; i < n; i++)
{
mtx.lock();
++x;
mtx.unlock();
}
});
t1.join();
t2.join();
size_t end = clock();
cout << x << endl;
return 0;
}
上面的代码出现的死锁的问题,就导致t2这线程一直在申请锁资源,但是一直申请不到锁。进程卡卡住。
修改之前的代码,不用自己手动加锁和解锁
文档是关于对lock_guard介绍。
说人话:这里lock_guard利用类的特性,对象实例化时会自动调用构造函数,出对象作用域时会自动调用析构函数。而构造函数就是加锁,析构函数就是解锁。
还有一种
lock_guard
和unique_lock
都是C++11标准库中的互斥锁管理工具,用于简化互斥锁的使用和管理,它们都遵循RAII(资源获取即初始化)原则,确保在作用域结束时自动释放锁。尽管它们的基本功能相似,但它们之间存在一些关键的区别:
-
自动类型转换:
lock_guard
不提供对锁类型的自动转换。它需要在构造时显式指定互斥锁的类型。unique_lock
提供了对mutex
和recursive_mutex
的自动类型转换,允许使用相同的模板代码来锁定不同类型的互斥锁。
-
递归锁支持:
lock_guard
不支持递归互斥锁(recursive_mutex
),因为它的设计不包括递归锁定的能力。unique_lock
可以与递归互斥锁一起使用,允许同一个线程多次锁定同一个递归互斥锁。
-
锁所有权转移:
lock_guard
不支持转移锁的所有权。一旦构造,它就会锁定互斥锁,并在销毁时自动解锁。unique_lock
允许通过移动语义转移锁的所有权。例如,可以将一个unique_lock
对象的锁所有权移动到另一个unique_lock
对象。
-
锁的尝试与释放:
lock_guard
不支持尝试锁定或手动释放锁。它在构造时锁定互斥锁,并在析构时解锁。unique_lock
提供了try_lock
、try_lock_for
、try_lock_until
等成员函数来尝试锁定互斥锁,以及release
成员函数来手动释放锁,如果已经锁定的话。
-
使用场景:
lock_guard
适用于简单的锁管理,当你知道在作用域结束时需要释放锁,并且不需要尝试锁定或转移锁所有权时。unique_lock
适用于更复杂的场景,可能需要尝试锁定、定时锁定、递归锁定或转移锁所有权。
-
性能:
- 由于
unique_lock
提供了更多的功能,它可能比lock_guard
有更多的运行时开销。然而,如果你需要这些额外的功能,unique_lock
是更合适的选择。
- 由于
总结来说,lock_guard
是一个更简单的锁管理工具,适用于不需要额外功能的简单场景。而unique_lock
提供了更多的灵活性和控制能力,适用于需要这些高级特性的复杂场景。
四、条件变量 condition_variable
我们加入锁之后,线程之间互斥了,为了让他们同步,需要用到条件变量。
<condition_variable>
是 C++ 标准库中的一个头文件,它声明了与条件变量相关的类型和函数。条件变量是一种同步机制,用于在多线程编程中,让一个或多个线程等待某个条件为真,直到被另一个线程通知。
等待函数:
- wait:
void wait(std::unique_lock<std::mutex>& lock);
:等待另一个线程的通知。调用此函数的线程必须已经通过unique_lock
对一个互斥锁进行了加锁。当调用wait
时,线程将释放互斥锁,并进入等待状态,直到被另一个线程通知。 - wait_for:
template <class Rep, class Period> cv_status wait_for(std::unique_lock<std::mutex>& lock, const std::chrono::duration<Rep, Period>& timeout_duration);
:与wait
类似,但增加了超时时间。如果在超时时间内没有收到通知,线程将退出等待状态。 - wait_until:
template <class Clock, class Duration> cv_status wait_until(std::unique_lock<std::mutex>& lock, const std::chrono::time_point<Clock, Duration>& timeout_time);
:与wait_for
类似,但超时条件是指定的时间点,而不是持续时间。
通知函数:
- notify_one:
void notify_one();
:唤醒在该条件变量上等待的一个线程。如果有多个线程在等待,将唤醒其中一个线程。如果没有任何线程在等待,则不执行任何操作。 - notify_all:
void notify_all();
:唤醒所有在该条件变量上等待的线程。
std::condition_variable
对象在使用时,通常与 std::unique_lock
或 std::mutex
结合使用,以确保在等待和通知过程中对共享资源的访问是互斥的。使用条件变量可以避免竞态条件,并实现更高效的线程间通信
交替打印数字
题目要求
给你两个线程 t1
、t2,要求 t1
打印奇数,t2
打印偶数,数字范围为 [1, 100]
,两个线程必须交替打印
这里有两个问题:
1 如何确定t1先打印?
这个我们可以利用条件变量的wait函数的特性,如果一个线程等待,会自动释放锁。当x=1时是奇数,这时t1就一定会先运行。
2.如何让另一个线程不打印?
利用条件条件变量 分别让t1、t2满足条件进行阻塞等待。
#include <thread>
#include <condition_variable>
#include <mutex>
int x = 1;
int n = 100;
mutex mtx;
condition_variable cv;
void fucn1(int n)
{
while(true)
{
unique_lock<mutex> lck(mtx);
if (x >= 100)
break;
if (x % 2 == 0) //是偶数就阻塞等待
cv.wait(lck);
cout << "t1:" << this_thread::get_id() << ":" << x << endl;
++x;
cv.notify_one();
}
}
void fucn2(int n)
{
while(true)
{
unique_lock<mutex> lck(mtx);
if (x >= 100)
break;
if (x % 2 != 0) //是奇数就阻塞等待
cv.wait(lck);
cout << "t2:" << this_thread::get_id() << ":" << x << endl;
++x;
cv.notify_one();
}
}
int main()
{
thread t1(fucn1, n);
thread t2(fucn2, n);
t1.join();
t2.join();
return 0;
}
五、原子类 atomic
前面提到频繁的加锁、解锁会导致效率下降,比如上面的代码其实临界资源就只有一个x我们对它++或者-- 再或者位操作。有没有其他办法不用加锁?有的原子操作
<atomic>
是 C++ 标准库中的一个头文件,它提供了一系列用于实现原子操作的模板类和类型定义。原子操作是保证在多线程环境中安全执行的指令,不会出现数据竞争的问题。
类:
- atomic:原子模板类,封装了一个可以原子地访问的值。这个模板支持多种数据类型,如
int
、float
、pointer
等,以及它们的无符号和长整型版本。 - atomic_flag:原子标志类,用于实现低级别的同步操作,如自旋锁。
通用原子操作:
is_lock_free
:检查原子操作是否无锁(即是否使用非阻塞算法)。store
:将一个值存储到原子对象中,可以指定内存顺序。load
:从原子对象中读取值,可以指定内存顺序。operator T
:获取原子对象中的值的副本。exchange
:交换原子对象中的值,并返回旧值。compare_exchange_weak
:弱比较并交换操作,用于实现原子条件赋值。compare_exchange_strong
:强比较并交换操作,同样用于原子条件赋值。
特定专业化支持的操作(例如整数和/或指针):
fetch_add
:将一个值加到原子对象上,并返回原始值。fetch_sub
:从原子对象中减去一个值,并返回原始值。fetch_and
:对原子对象中的值应用按位与操作,并返回原始值。fetch_or
:对原子对象中的值应用按位或操作,并返回原始值。fetch_xor
:对原子对象中的值应用按位异或操作,并返回原始值。operator++
和operator--
:递增和递减原子对象中的值。- 复合赋值运算符:如
+=
、-=
、&=
、|=
、^=
等,提供复合赋值操作。
#include <iostream>
#include <atomic>
#include <thread>
#include <vector>
std::atomic<int> counter(0); // 线程安全的原子计数器
int main() {
const int num_threads = 4;
std::vector<std::thread> threads;
// 使用 lambda 表达式创建并启动线程
for (int i = 0; i < num_threads; ++i) {
threads.push_back(std::thread([=]() {
int n = 100; // 每个线程递增的次数
for (int j = 0; j < n; ++j) {
// 使用 fetch_add 原子地递增 counter
counter.fetch_add(1, std::memory_order_relaxed);
}
}));
}
// 等待所有线程完成
for (auto& th : threads) {
if (th.joinable()) {
th.join();
}
}
// 输出最终的计数器值
std::cout << "Final counter value: " << counter << std::endl;
return 0;
}
这里使用
std::memory_order_relaxed
因为我们不关心操作的内存顺序,只要求操作是原子的。
如果是用printf打印就会编译出错
load、store
因为counter是原子类型、而我们的是%d,类型不匹配。这时我们可以用load
#include <atomic>
#include <iostream>
int main() {
std::atomic<int> atomicInt(0);
// 使用 store 写入原子变量
atomicInt.store(10, std::memory_order_relaxed); // 写入值10
// 使用 load 读取原子变量
int value = atomicInt.load(std::memory_order_acquire); // 读取原子变量的值
std::cout << "Value of atomicInt: " << value << std::endl;
return 0;
}
使用 load
的场景:
- 读取共享数据:当需要读取由多个线程共享的原子变量的值时,使用
load
可以确保读取操作的原子性和内存顺序,防止读取过程中其他线程的写入干扰。 - 内存顺序要求:当对内存顺序有特定要求,例如需要保证某个操作的内存效果对其他线程可见时,可以使用
load
并指定适当的内存顺序参数,如std::memory_order_acquire
。
使用 store
的场景:
- 写入共享数据:当需要修改由多个线程共享的原子变量的值时,使用
store
可以确保写入操作的原子性和内存顺序,防止写入过程中其他线程的读取干扰。 - 发布操作:在发布-订阅模式中,当一个线程创建了一个对象,并希望其他线程能够安全地访问这个对象时,可以使用
store
并指定std::memory_order_release
来确保对象的构造和发布操作对其他线程可见。
六、包装器
在C++中什么可以被调用?函数对象(仿函数)、函数指针、函数名、lambda、这些都是可以被调用的,那这么多类型,我们用模板传参时,可能会导致效率低下。
所以C++11推出了包装器,也叫做适配器C++中的function本质是一个类模板,也是一个包装器。
底层用的还是仿函数。
int f(int a, int b)
{
cout << "int f(int a, int b)" << endl;
return a + b;
}
struct Functor
{
public:
int operator() (int a, int b)
{
cout << "int operator() (int a, int b)" << endl;
return a + b;
}
};
就比如上面我要用map进行封装,可是他们的类型不同,模板参数如何传参?
map<string, >??
这时候我们需要用包装器
我们来一个简单的用法
int main()
{
//int(*pf1)(int,int) = f;
//map<string, >
function<int(int, int)> f1 = f;
function<int(int, int)> f2 = Functor();
function<int(int, int)> f3 = [](int a, int b) {
cout << "[](int a, int b) {return a + b;}" << endl;
return a + b;
};
cout << f1(1, 2) << endl;
cout << f2(10, 20) << endl;
cout << f3(100, 200) << endl;
return 0;
}
上面代码f1 f2 f3就用functional这个类进行包装了,类型一样,那map就可以进行模板参数传参了。下面在网络用的比较多,指令集。什么指令执行什么任务。
int main()
{
//int(*pf1)(int,int) = f;
//map<string, >
map<string, function<int(int, int)>> opFuncMap;
opFuncMap["函数指针"] = f;
opFuncMap["仿函数"] = Functor();
opFuncMap["lambda"] = [](int a, int b) {
cout << "[](int a, int b) {return a + b;}" << endl;
return a + b;
};
cout << opFuncMap["函数指针"](1, 2) << endl;
cout<< opFuncMap["仿函数"](1, 2) << endl;
cout << opFuncMap["lambda"](1, 2) << endl;
return 0;
}
包装器对于类成员函数也有不同比如普通成员函数和静态成员函数的包装就不一样
class Plus
{
public:
Plus(int rate = 2)
:_rate(rate)
{}
static int plusi(int a, int b)
{
return a + b;
}
double plusd(double a, double b)
{
return (a + b)* _rate;
}
private:
int _rate = 2;
};
int main()
{
//function<int(int, int)> f1 = &Plus::plusi;
function<int(int, int)> f1 = Plus::plusi; //静态成员函数正常包装
function<double(Plus, double, double)> f2 = &Plus::plusd; //普通成员函数
//模板参数要有this*指针 但是实际上不用,直接传对象,而赋值时要加&不加编译不过
cout << f1(1, 2) << endl;
cout << f2(Plus(), 20, 20) << endl;
Plus pl(3);
cout << f2(pl, 20, 20) << endl;
return 0;
}
bind
bind有两个作用:
一是交换参数
二是修个参数个数
基本用法:
std::bind
的基本语法如下:
std::bind<ReturnType>(Function, Args...)(std::placeholders::_1, ...);
其中:
ReturnType
是被绑定函数的返回类型。Function
是要绑定的可调用对象。Args...
是要绑定的参数列表。std::placeholders::_1, ...
是用于占位的参数,表示将来调用时需要提供的参数。
#include <iostream>
#include <functional>
void print(int a, int b) {
std::cout << a << " and " << b << std::endl;
}
int main() {
print(10, 5);
// 创建一个 std::function 对象,绑定 print_sum 函数
// 但是交换了参数的顺序
auto swapped_function = std::bind(print, std::placeholders::_1, std::placeholders::_2);
// 调用交换参数后的函数
swapped_function(5, 10); // 输出 "The sum of 10 and 5 is 15"
return 0;
}
关于参数交换这个用处不大。
修改参数个数场景还是很多的。比如一个类的成员函数是加减乘除,实际这个类的成员函数的参数是多少?
成员函数参数的个数是n+1个,类还有隐藏的this*啊。
如果用包装器进行包装,比如现在把类和lambda还有仿函数一起包装起来。问题是lambda只有n个参数,这时就需要用bind来修改参数个数。
#include <iostream>
#include <functional>
#include <map>
#include <string>
// 一个示例类
class Calculator {
public:
// 类成员函数,接受两个参数
int add(int a, int b) {
return a + b;
}
};
// 一个自由函数,接受两个参数
int multiply(int a, int b) {
return a * b;
}
int main() {
//用bind直接绑死对象。
std::function<int(int, int)> add1 = std::bind(&Calculator::add, Calculator(), std::placeholders::_1, std::placeholders::_2);
std::map<std::string, std::function<int(int, int)>> funcMap =
{
{"*",multiply},
{"+",add1 }
};
//std::cout << funcMap["*"](1, 2) << std::endl;
//std::cout << funcMap["+"](10, 30) << std::endl;
for (auto& e : funcMap)
{
std::cout << "[" << e.first <<"]: " << e.second(10, 20) << std::endl;
}
return 0;
}
C++11 的常用内容到这里就讲解完毕了下节预告异常智能指针关注我带你学习更多C++知识。