线程
- 一、介绍
- 二、thread库
- 1、构造函数
- (1)函数
- (2)说明
- (3)注意
- 2、join函数
- 3、detach
- 4、joinable函数
- 5、get_id函数
- 三、mutex的种类
- 1、mutex
- (1)介绍
- (2)lock
- (3)unlock
- (4)try_lock
- 2、recursive_mutex
- (1)介绍
- (2)示例代码
- 3、timed_mutex
- (1)介绍
- (2)成员函数
- 四、lock_guard
- 1、介绍
- 2、特性
- 3、示例代码
- 五、unique_lock
- 1、介绍
- 2、特性
- 六、condition_variable
- 1、介绍
- 2、wait
- (1)函数
- (2)介绍
- 3、等待一段时间
- (1)函数
- (2)介绍
- 4、通知
- 七、this_thread
- 1、介绍
- 2、常用的函数
- 3、示例代码
- 八、应用
- 1、题目
- 2、解析
- (1)场景一
- (2)场景二
- (3)总结
- 3、代码
- 4、运行结果
- 九、atomic
- 1、介绍
- 2、基本用法
- 3、注意事项
- 4、示例代码
- 十、相关概念
- 1、内存屏障
- 2、并发
- 3、并行
- 十一、无锁编程
- 1、介绍
- 2、优点
- 3、挑战
- 4、示例代码
- 十二、相关文章
一、介绍
- 在C++11之前,涉及到多线程问题的都是和平台相关的,比如windows和Linux下各有自己的接口,这使得代码的可移植性比较差。
- C++11中最重要的特性就是对线程进行支持了,使得C++在进行编程时不需要依赖第三方库。
- C++11引入了thread库,为C++程序提供了基本的线程支持。这个库使得C++程序能够更方便地创建和管理线程,从而能够利用多核处理器的计算能力,提高程序的执行效率和响应速度。
- 线程是操作系统中的一个概念,线程对象可以关联一个线程,用来控制线程以及获取线程的状态。
- 当创建一个线程对象后,没有提供线程函数,则该对象实际没有对应任何线程。
二、thread库
1、构造函数
(1)函数
(2)说明
- 默认构造函数构造一个不表示任何执行线程的线程对象。
- 初始化构造函数构造一个线程对象,表示一个新的joinable执行线程。新的执行线程调用fn,将args作为参数传递(使用其左值或右值引用的衰减副本)。此构造的完成与调用此fn副本的开始同步。
- 复制构造函数是被删除的,即无法复制线程对象。
- 移动构造函数构造一个线程对象,获取由x表示的执行线程(如果有的话)。此操作不会以任何方式影响移动线程的执行,它只是传输其处理程序。执行该移动构造函数后,x对象不再表示任何执行线程。
(3)注意
- 当创建一个线程对象后,并且给线程关联线程函数,该线程就被启动,与主线程一起运行。
- 线程函数一般情况下可以是函数指针、仿函数和lambda表达式。
- 线程函数的参数是以值拷贝的方式拷贝到线程栈空间中的。因此,即使线程参数为引用类型,在线程中修改后也不能修改外部实参,因为其实际引用的是线程栈中的拷贝,而不是外部实参。
- 如果是类成员函数作为线程参数时,必须将this作为线程函数参数传递。
2、join函数
- 该函数调用后会阻塞住主线程(执行该语句的线程),当join的线程结束(构造时调用的函数返回)后,主线程继续执行。此函数返回的时刻与线程中所有操作的完成同步。
- 调用此函数后,线程对象变得不可连接(non-joinable),可以安全地销毁。
- joinable的线程对象在销毁前应是joined或detached的。
3、detach
- 将对象表示的线程与调用线程分离,即执行程序语句的主线程与调用函数的线程分离,允许它们彼此独立执行。
- 两个线程都继续运行,不会以任何方式阻塞或同步。但当任何一个结束执行时,对应线程的资源都会被释放。
- 调用此函数后,线程对象变成non-joinable的,可以安全地被销毁。
4、joinable函数
- 检查调用函数的线程是否是joinable的。
- 如果一个线程对象代表一个正在执行中的线程,那么它就是joinable的。
- 如果它是默认构造的、已被move,即不表示任何执行线程、线程对象的状态已经转移给其他线程对象以及其任何一个成员被调用了join或detach成功的任何一种。线程对象都不是joinable的。
5、get_id函数
- get_id函数的作用为获取线程id。
- 如果线程对象是joinable的,则函数返回一个唯一标识线程的值。
- 如果线程对象不是joinable的,则函数返回一个默认构造的成员类型为thread::id的对象。
三、mutex的种类
1、mutex
(1)介绍
- 在C++中,mutex(互斥锁)是用于保护共享数据免受多线程并发访问造成的数据竞争和条件竞争的一种同步机制。
- 通过使用mutex,我们可以确保在任何时刻只有一个线程可以访问被保护的代码段(通常称为临界区)。这有助于维护数据的完整性和一致性。
(2)lock
- 锁定互斥锁,调用线程锁定互斥锁,必要时阻塞。
- 如果该互斥锁当前没有被锁住,则调用线程将该互斥锁锁住,直到调用unlock之前,该线程一直拥有该锁。
- 如果当前互斥锁被其他线程锁住,则当前的调用该函数(lock)的线程被阻塞住。
- 如果当前互斥锁被当前调用该函数(lock)的线程锁住,而该线程还在申请该锁,则会产生死锁(deadlock)。死锁相关内容参见线程与多线程(二)。
(3)unlock
- 解锁互斥锁,释放对它的所有权。
(4)try_lock
- 如果当前互斥锁没有被其他线程占有,则该线程锁住该互斥锁,获取它的所有权,直到该线程调用unlock释放互斥锁。
- 如果当前互斥锁被其他线程锁住,则当前调用该函数(try_lock)的线程返回 false,而不会被阻塞住。
- 如果当前互斥锁被当前调用该函数(try_lock)的线程锁住,而该线程还在申请该锁,则会产生死锁(deadlock)。
2、recursive_mutex
(1)介绍
- 在C++中,recursive_mutex是一个特殊的互斥锁(mutex),它允许同一个线程多次获得锁而不发生死锁。这是通过维护一个所有权计数来实现的,每次线程成功获得锁时,该计数就会增加;每次线程释放锁时,计数就会减少。只有当计数降到零时,锁才真正被释放,允许其他线程获得它,即互斥对象在这期间将保持锁定状态,直到其成员解锁被调用的次数与该所有权级别一样多。
- 这种互斥锁在递归函数调用或者需要在同一个线程中多次锁定同一资源的场景中非常有用。与普通的mutex不同,mutex在同一个线程中多次尝试加锁会导致死锁,因为mutex不允许同一线程多次加锁。
- 尽管recursive_mutex允许同一个线程多次加锁,但它并不应该作为解决设计问题的首选方法。在可能的情况下,应该尽量避免在同一个线程中多次请求相同的锁,因为这可能会隐藏代码中的潜在问题,并可能导致性能下降。
(2)示例代码
void TestFunc1()
{
recursive_mutex rmx;
rmx.lock();
cout << "recursive_mutex lock one" << endl;
rmx.lock();
cout << "recursive_mutex lock two" << endl;
rmx.lock();
cout << "recursive_mutex lock three" << endl;
rmx.unlock();
rmx.unlock();
rmx.unlock();
cout << "recursive_mutex unlock over" << endl;
}
int main()
{
thread t1(TestFunc1);
t1.join();
return 0;
}
3、timed_mutex
(1)介绍
- 在C++中,timed_mutex是一个特殊的互斥锁(mutex),它提供了在尝试锁定互斥锁时设置超时时间的功能。这意味着如果互斥锁在指定的时间内没有被成功获取,那么尝试获取锁的操作将失败,而不是无限期地等待。
- timed_mutex与recursive_mutex相似,但它关注的是锁定操作的超时机制,而不是是否允许同一线程多次锁定。
- timed_mutex提供了两个成员函数try_lock_until和try_lock_for来支持超时锁定。
- try_lock_until函数尝试锁定互斥锁,直到指定的时间点abs_time。如果互斥锁在abs_time之前变得可用并被成功锁定,则返回true;如果超时,即到达abs_time时仍未成功锁定,则返回false。
- try_lock_for函数尝试锁定互斥锁,直到指定的时间段rel_time过期。如果互斥锁在rel_time指定的时间段内变得可用并被成功锁定,则返回true;如果超时,则返回false。
- 使用timed_mutex时,通常会结合C++11引入的< chrono >库来指定超时时间。
(2)成员函数
四、lock_guard
1、介绍
- lock_guard用于管理一个互斥量(mutex)的锁定和解锁,以简化同步代码的编写,并减少死锁的风险。
- lock_guard通过作用域(RAII,Resource Acquisition Is Initialization)的方式来管理锁的生命周期,即在构造时自动加锁,在析构时自动解锁。这种方式确保了即使在发生异常的情况下,锁也能被正确释放。
- RAII相关内容参见智能指针(RAII)。
2、特性
- 不可复制:lock_guard是不可复制的,即不能将它的一个实例赋值给另一个实例,也不能将它作为函数参数传递,除非是通过引用或指针。这是因为复制或移动lock_guard可能会导致锁的所有权不明确,从而增加死锁的风险。
- 不可递归:lock_guard不支持递归锁定,即不能在一个已经被lock_guard锁定的互斥量上再次使用lock_guard来加锁。
3、示例代码
void test_lock_guard()
{
mutex mtx;
int num = 0;
thread t1([&]() {
for (int i = 0; i < 500000; ++i)
{
lock_guard<mutex> lg(mtx);
++num;
}
});
thread t2([&]() {
for (int i = 0; i < 500000; ++i)
{
mtx.lock();
++num;
mtx.unlock();
}
});
t1.join();
t2.join();
cout << num << endl;
}
五、unique_lock
1、介绍
- unique_lock提供了一种比lock_guard更灵活的互斥量(mutex)管理方式。与lock_guard类似,unique_lock也在构造时自动加锁,并在析构时自动解锁。但它还提供了更多的控制选项,比如手动解锁、条件变量支持、以及延迟锁定等。
2、特性
- 可延迟锁定:unique_lock允许在构造时不立即加锁,而是稍后通过调用lock、try_lock或lock_interruptibly方法来加锁。
- 可手动解锁:通过调用unlock方法,可以在任何时候手动解锁互斥量,并在之后再次加锁(如果需要的话)。
- 条件变量支持:unique_lock与condition_variable一起使用时,可以更容易地实现线程间的同步和通信。
- 可移动但不可复制:与lock_guard一样,unique_lock也是不可复制的,但它是可移动的。这意味着可以将unique_lock的一个实例赋值给另一个实例(通过移动语义),但不能通过复制来做到这一点。
六、condition_variable
1、介绍
- condition_variable是C++11引入的一个同步原语,用于在多个线程之间同步执行。它通常与unique_lock一起使用,以允许一个或多个线程在某个条件成立之前等待。
- condition_variable提供了两种主要的等待函数,即wait和wait_for/wait_until,以及一个通知函数notify_one和一个广播通知函数notify_all。
2、wait
(1)函数
(2)介绍
- 原子地释放锁(lck),阻塞当前线程直到其他在此条件变量下的线程调用notify_one或notify_all,即在收到通知之前,当前线程(申请锁定lck的互斥体)的执行将被阻止。
- 一旦收到由其他线程显式的通知(调用同一个condition_variable的notify相关函数),函数就会解除阻塞并调用lck.lock(),使lck处于与调用wait函数时(阻塞前)相同的状态。
- 通常,函数会被另一个线程中对成员notify_one或成员notify_all的调用通知唤醒。但是,某些实现可能会在不调用任何这些函数的情况下产生虚假的唤醒调用。因此,使用该功能的用户应确保其恢复条件得到满足。
- 如果指定了pred,则该函数仅在pred返回false时阻止,通知只能在其变为true时解除阻止线程,实现类似于while (!pred()) wait(lck); ,这对于检查虚假唤醒调用特别有用。
- 参数pred是一个可调用的对象或函数,它不接受任何参数,并返回一个可以作为布尔值计算的值。它会被反复调用,直到它的计算结果为true。
3、等待一段时间
(1)函数
(2)介绍
- wait_for的作用类似于wait,但线程只会在指定的相对时间rel_time内等待条件pred成立。如果时间到了但条件仍未成立,则函数返回,并且锁仍然被当前线程持有。
- wait_until的作用类似于wait_for,但使用绝对时间点abs_time,而不是相对时间。
4、通知
- notify_one:解除当前正在等待此条件变量condition的一个线程。如果没有线程在等待,则函数什么也不做;如果有多个线程,则选择哪个线程是未指定。
- notify_all:解除当前正在等待此条件变量condition的所有线程。如果没有线程在等待,则函数什么也不做。
七、this_thread
1、介绍
- this_thread是C++11及以后版本中引入的一个命名空间,它定义在头文件中。
- this_thread命名空间提供了一系列函数,用于对当前线程执行各种操作,如睡眠、获取当前线程的ID等等。这对于编写多线程程序时控制或查询当前线程的行为非常有用。
2、常用的函数
- sleep_for:使当前线程暂停执行指定的时间间隔。这个函数接受一个时间段作为参数,时间段类型可以是chrono库中的任何duration类型,如chrono::seconds、chrono::milliseconds等等。
- sleep_until:使当前线程暂停执行,直到指定的时间点。这个函数接受一个时间点(chrono::time_point类型)作为参数,当前线程会在这个时间点之前保持休眠状态。
- yield:提示操作系统重新调度当前线程,让出CPU给其他线程。但这并不意味着当前线程会被立即挂起或放弃其时间片,但它确实允许其他线程有机会运行。
- get_id:获取当前线程的ID。每个线程都有一个唯一的ID,可以在多线程程序中用于标识对应的线程。
3、示例代码
void Test8()
{
mutex mtx;
int x = 0, n = 50;
thread t1([&, n]() {
for (int i = 0; i < n; ++i)
{
while (!mtx.try_lock())
this_thread::yield();
++x;
cout << "t1 (" << this_thread::get_id() << "):" << i << endl;
this_thread::sleep_for(chrono::milliseconds(50));
mtx.unlock();
}
});
thread t2([&, n]() {
for (int i = 0; i < n; ++i)
{
while (!mtx.try_lock())
this_thread::yield();
++x;
cout << "t2 (" << this_thread::get_id() << "):" << i << endl;
this_thread::sleep_for(chrono::milliseconds(50));
mtx.unlock();
}
});
t1.join();
t2.join();
cout << x << endl;
}
八、应用
1、题目
创建两个线程交替打印,一个线程只打印奇数,另一个只打印偶数。
2、解析
(1)场景一
- 线程t1比t2先运行,t1先抢到互斥锁并lock,因为此时flag是false,则t1会先打印,再将flag改为true。接着执行后续代码,出了作用域,unique_lock自动解锁。
- 如果此时线程t2还没启动,或者没有分到时间片。此时线程t1继续执行时就会执行条件变量的wait,t1上一次的notetify操作没有起到作用。但t2总会开始运行并打印,最后,它会将flag改成false,调用notify相关函数唤醒t1,后续线程t1与t2类似交替运行的方式进行打印。
- 如果线程t2已经运行并且lock阻塞住了,则线程t1上一次的notify语句唤醒线程t2后解锁互斥锁。而线程t2获取到锁,因为此时falg是true,t2不会阻塞住,正常执行。后续线程t1与t2类似交替运行的方式进行打印。
(2)场景二
- 线程t2比t1先运行,则t2获取到锁后会执行条件变量的wait,释放互斥锁并阻塞住。
- 如果此时线程t1还没启动,或者没有分到时间片。但t1总会开始运行并打印,最后,它会将flag改成true,notify唤醒t2,后续线程t1与t2类似交替运行的方式进行打印。
- 如果线程t1比t2慢启动,但是也分到时间片开始执行了,则t1在获取互斥锁时会lock阻塞住,当t2执行条件变量的wait时,会自动(unlock)释放锁,这时,t1会被唤醒并获取锁进行后续操作,后续线程t1与t2类似交替运行的方式进行打印。
(3)总结
- 为了实现两个线程交替执行,需要保证其中一个线程先执行,上面的解析是保证线程t1先执行。
- 通过一个bool类型的变量确定对应线程是否需要阻塞住让另一个线程先运行,由此达到两个线程交替运行的效果。
3、代码
void Test6()
{
mutex mtx;
condition_variable cv;
int x = 1, n = 50;
bool flag = false;
thread t1([&, n] {
for (int i = 0; i < n; ++i)
{
unique_lock<mutex> ul(mtx);
if (flag)
cv.wait(ul);
cout << "t1(" << this_thread::get_id() << "): " << x << endl;
++x;
flag = true;
cv.notify_one();
this_thread::sleep_for(chrono::milliseconds(60));
}
});
thread t2([&, n] {
for (int i = 0; i < n; ++i)
{
unique_lock<mutex> ul(mtx);
if (!flag)
cv.wait(ul);
cout << "t2(" << this_thread::get_id() << "): " << x << endl;
++x;
flag = false;
cv.notify_one();
this_thread::sleep_for(chrono::milliseconds(60));
}
});
t1.join();
t2.join();
}
4、运行结果
九、atomic
1、介绍
- 在C++中,atomic类型和相关的操作提供了一种在多线程程序中安全地访问和操作单个数据项的方法。
- 这是通过确保在读取和写入数据时操作的原子性来实现的,即这些操作在执行过程中不会被其他线程的操作打断。
2、基本用法
- atomic可以用于任何整型、指针类型、以及用户定义的类型(只要它们满足特定的要求,如提供无锁的复制和赋值操作)。但是,对于用户定义的类型,更推荐使用atomic_flag或者通过atomic特化模板为bool类型(atomic< bool >),因为对于复杂的用户定义类型,确保原子性可能更为复杂且效率较低。
3、注意事项
- 使用atomic可以帮助避免数据竞争,但它不解决所有并发编程中的问题,如死锁、活锁、饥饿等。
- 对于复杂的同步需求,可能需要使用其他同步机制,如互斥锁(mutex)、条件变量(condition_variable)等。
- 在某些情况下,过度使用atomic可能会降低性能,因为它可能会引入额外的开销,如内存屏障(memory barriers)。因此,在性能敏感的应用中,应该谨慎使用。
4、示例代码
void Test7()
{
mutex mtx;
int n = 100000;
//size_t x = 0;
atomic<size_t> x = 0;
thread t1([&, n] {
for (int i = 0; i < n; ++i)
++x;
});
thread t2([&, n] {
for (int i = 0; i < n; ++i)
++x;
});
t1.join();
t2.join();
cout << "x = " << x << endl;
}
十、相关概念
1、内存屏障
- 在C++(以及更广泛的C和C++11及以后版本)中,内存屏障(Memory Barrier)是一个重要的概念,特别是在编写多线程程序时。
- 内存屏障用于控制内存访问的顺序,确保在屏障前后的操作按照预定的顺序执行,从而避免由于编译器优化或处理器乱序执行引起的内存访问问题。
2、并发
- 并发是指两个或多个事件在同一时间间隔内发生,这些事件在宏观上看起来是同时进行的,但在微观上,即具体到某一时刻,只有一个事件在执行。
- 并发通过快速切换多个任务来模拟同时执行的效果,实际上是任务在时间上的复用。
- 并发侧重于在同一实体(如单个处理器或CPU核心)上同时处理多个任务。这种同时是通过时间片轮转、任务调度等方式实现的,每个任务轮流占用处理器资源。
3、并行
- 并行是指两个或多个事件在同一时刻真正同时发生。
- 并行要求有多个实体(如多个处理器或CPU核心)同时参与,每个实体独立执行一个任务。
- 并行侧重于在不同实体上同时处理多个任务,每个任务由独立的处理器或CPU核心来执行,从而实现真正的并行处理。
十一、无锁编程
1、介绍
- C++无锁编程(Lock-Free Programming)是一种并发编程技术,它旨在通过避免使用传统的互斥锁(mutexes)或其他同步机制来减少线程间的竞争和等待时间,从而提高程序的性能和可伸缩性。
- 无锁编程通常依赖于原子操作(atomic operations)来确保数据的一致性和线程安全。
2、优点
- 减少上下文切换:由于不需要等待锁,减少了线程因等待锁而阻塞的情况,从而减少了上下文切换的开销。
- 减少死锁和活锁的风险:不使用锁可以避免死锁和活锁等并发问题。
- 提高性能:在高并发场景下,无锁编程通常能提供更好的性能。
3、挑战
- 复杂性:无锁编程通常需要更复杂的算法和逻辑来确保数据的一致性和线程安全。
- 调试困难:无锁编程中的错误可能更难以发现和调试,因为问题可能表现为非重复性的、难以预测的行为。
- 平台依赖性:不同的硬件和编译器对原子操作的支持程度不同,可能导致代码的可移植性降低。
4、示例代码
void Test9()
{
mutex mtx;
int n = 50;
atomic<size_t> x = 0;
thread t1([&, n]() {
for (int i = 0; i < n; ++i)
{
size_t oldvl, newvl;
do {
oldvl = x;
newvl = x + 1;
} while (!atomic_compare_exchange_weak(&x, &oldvl, newvl));
//cout << "t1 (" << this_thread::get_id() << "):" << i << endl;
//this_thread::sleep_for(chrono::milliseconds(50));
}
});
thread t2([&, n]() {
for (int i = 0; i < n; ++i)
{
size_t oldvl, newvl;
do {
oldvl = x;
newvl = x + 1;
} while (!atomic_compare_exchange_weak(&x, &oldvl, newvl));
//cout << "t2 (" << this_thread::get_id() << "):" << i << endl;
//this_thread::sleep_for(chrono::milliseconds(50));
}
});
t1.join();
t2.join();
cout << x << endl;
}
十二、相关文章
- Linux下的线程相关内容参见线程与多线程(一)和线程与多线程(二)。
本文到这里就结束了,如有错误或者不清楚的地方欢迎评论或者私信
创作不易,如果觉得博主写得不错,请点赞、收藏加关注支持一下💕💕💕