C++多线程编程
一、概念
多线程(英语:multithreading),是指从软件或者硬件上实现多个线程并发执行的技术。
传统的C++(C++11标准之前)中并没有引入线程这个概念,在C++11出来之前,如果我们想要在C++中实现多线程,需要借助操作系统平台提供的API,比如Linux的<pthread.h>,或者windows下的<windows.h> 。
1.1 其他相关概念介绍
- 软件多线程:即便处理器只能运行一个线程,操作系统也可以通过快速的在不同线程之间进行切换,由于时间间隔很小,来给用户造成一种多个线程同时运行的假象;
- 进程:是指计算机中已运行的程序;
- 并行:并列运行,多个CPU下支持;
- 并发:多个任务请求运行,CPU轮流交替运行。
1.2 多线程常见的方式
1.使用标准库提供的thread类:C++11引入了std::thread类,可以用于创建和管理线程。通过包含
2.继承自std::thread的子类:你也可以通过继承std::thread类来创建自己的线程类,以便更好地封装和管理线程。
3.POSIX线程库(pthread):这是一套用于跨平台多线程编程的API,可以在Linux、UNIX等系统上使用。需要包含<pthread.h>头文件,并使用pthread_create函数创建新的线程。
4.使用C++11的std::async和std::future:这种方式可以在一个函数中并发执行多个任务,并获得它们的结果。通过std::async函数创建异步任务,返回一个std::future对象,然后使用std::future来获取结果。
5.OpenMP
二、std::thread类
2.1 创建线程
1.创建线程需要引入头文件 #include
2.语句"std::thread th1(proc1);"创建了一个名为th1的线程,并且线程th1开始执行;
实例化std::thread类对象时,至少需要传递函数名作为参数。如果函数为有参函数,如"void proc2(int a,int b)",那么实例化std::thread类对象时,则需要传递更多参数,参数顺序依次为函数名、该函数的第一个参数、该函数的第二个参数,···,如"std::thread th2(proc2,a,b);"
3.当线程启动后,一定要在和线程相关联的std::thread对象销毁前,对线程运用join()或者detach()方法。
2.2 回收子线程资源
回收子线程的资源有两种方法——join和detach:
2.2.1 join方法
join的意思是父线程等待子线程结束,在子线程结束时,负责回收子线程的资源。
本质上是使当前线程阻塞,执行加入线程的逻辑。
2.2.2 detach方法
detach的含义是父线程和子线程相互分离,分离的线程由操作系统自动管理。
2.2.3 joinable()
joinable()是一个布尔类型的函数,他会返回一个布尔值来表示当前的线程是否是可执行线程(能被join或者detach)。
因为相同的线程不能join两次,也不能join完再detach,同理也不能detach完再join,所以joinable函数就是用来判断当前这个线程是否可以joinable。
通常有以下几种情况不能被joinable:
- thread t;:t由缺省构造函数构建,此时线程未传入具体的线程函数。
- 该thread被move过(包括move构造和move赋值)。【move指的是:线程的所有权将发生转移,原有线程对象的相关标识被清空,失去线程的控制权。其原有线程类对象ID变为0,joinable变为为false。】
- 该线程已经被join或者detach过。
2.3 构造函数
thread() noexcept // 默认构造函数,创建一个空thread对象,该对象非joinable
template <class Fn, class... Args> // 初始化构造函数,创建一个thread对象,该对象会调用Fn函数,Fn函数的参数由args指定,该对象是joinable的
thread (const thread&) = delete // 拷贝构造函数,被禁用,意味着thread对象不可拷贝构造
thread (thread&& x) noexcept // move构造函数,执行成功之后x失效,即x的执行信息被移动到新产生的thread对象,该对象非joinable
2.4 std::thread类成员函数
get_id: // 获取线程ID。返回一个类型为std::thread::id的对象。
joinable: // 检查线程是否可被join。检查当前的线程对象是否表示了一个活动的执行线程
join: // 调用该函数会阻塞当前线程(主调线程)。阻塞调用者(caller)所在的线程(主调线程)直至被join的std::thread对象标识的线程(被调线程)执行结束
detach: // 将当前线程对象所代表的执行实例与该线程对象分离,使得线程的执行可以单独进行。一旦线程执行完毕,它所分配的资源将会被释放。
native_handle: // 该函数返回与std::thread具体实现相关的线程句柄。native_handle_type是连接thread类和操作系统SDK API之间的桥梁,如在Linux g++(libstdc++)里,native_handle_type其实就是pthread里面的pthread_t类型,当thread类的功能不能满足我们的要求的时候(比如改变某个线程的优先级),可以通过thread类实例的native_handle()返回值作为参数来调用相关的pthread函数达到目录。
swap: // 交换两个线程对象所代表的底层句柄
operator=: // 将线程与当前 thread 对象关联
sleep_for: // 线程休眠某个指定的时间片(time span),该线程才被重新唤醒,不过由于线程调度等原因,实际休眠时间可能比 sleep_duration 所表示的时间片更长。
sleep_until: // 线程休眠至某个指定的时刻(time point),该线程才被重新唤醒。
三、Mutex互斥量
C++ 11中与 Mutex 相关的类(包括锁类型)和函数都声明在
3.1 mutex 中类的介绍
3.1.1 mutex 系列类(4 种)
std::mutex // 最基本的Mutex类
std::recursive_mutex // 递归Mutex类
std::time_mutex // 定时Mutex类
std::recursive_timed_mutex // 定时递归Mutex类
3.1.2 lock 类(2种)
std::lock_guard // 与Mutex RAII 相关,方便线程对互斥量上锁
std::unique_lock // 与 Mutex RAII 相关,方便线程对互斥量上锁,但提供了更好的上锁和解锁控制
3.1.3 函数(3种)
std::try_lock // 尝试同时对多个互斥量上锁
std::lock // 可以同时对多个互斥量上锁
std::call_once // 如果多个线程需要同时调用某个函数,call_once 可以保证多个线程对该函数只调用一次。
3.2 std::mutex成员函数
如何理解互斥量:这样比喻,两个人要去银行的柜台办理业务,且银行只有一个柜台,A要办理业务,B也要办理业务,但是柜台同一时间只能给一个人办理,在办理业务时要坐到柜台位置(lock),用完后再离开柜台位置(unlock)。
那么,这个柜台位置就是互斥量,互斥量保证了使用办理业务这一过程不被打断。
3.2.1 构造函数
std::mutex不允许拷贝构造,也不允许 move 拷贝,最初产生的 mutex 对象是处于 unlocked 状态的。
3.2.2 lock()函数
调用线程将锁住该互斥量。
线程调用该函数会发生下面 3 种情况:
1)如果该互斥量当前没有被锁住,则调用线程将该互斥量锁住,直到调用 unlock之前,该线程一直拥有该锁。
2)如果当前互斥量被其他线程锁住,则当前的调用线程被阻塞住。
3)如果当前互斥量被当前调用线程锁住,则会产生死锁(deadlock)。
3.2.3 unlock()函数
解锁,释放对互斥量的所有权。
3.2.4 try_lock()函数
尝试锁住互斥量,如果互斥量被其他线程占有,则当前线程也不会被阻塞。
线程调用该函数也会出现下面 3 种情况,
1)如果当前互斥量没有被其他线程占有,则该线程锁住互斥量,直到该线程调用 unlock 释放互斥量。
2)如果当前互斥量被其他线程锁住,则当前调用线程返回 false,而并不会被阻塞掉。
3)如果当前互斥量被当前调用线程锁住,则会产生死锁(deadlock)。
3.3 std::recursive_mutex
std::recursive_mutex 与 std::mutex 一样,也是一种可以被上锁的对象,但是和 std::mutex 不同的是,std::recursive_mutex 允许同一个线程对互斥量多次上锁(即递归上锁),来获得对互斥量对象的多层所有权,std::recursive_mutex 释放互斥量时需要调用与该锁层次深度相同次数的 unlock(),可理解为 lock() 次数和 unlock() 次数相同,除此之外,std::recursive_mutex 的特性和 std::mutex 大致相同。
3.4 std::time_mutex
std::time_mutex 比 std::mutex 多了两个成员函数,try_lock_for(),try_lock_until()。
try_lock_for 函数接受一个时间范围,表示在这一段时间范围之内线程如果没有获得锁则被阻塞住(与 std::mutex 的 try_lock() 不同,try_lock 如果被调用时没有获得锁则直接返回 false),如果在此期间其他线程释放了锁,则该线程可以获得对互斥量的锁,如果超时(即在指定时间内还是没有获得锁),则返回 false。
try_lock_until 函数则接受一个时间点作为参数,在指定时间点未到来之前线程如果没有获得锁则被阻塞住,如果在此期间其他线程释放了锁,则该线程可以获得对互斥量的锁,如果超时(即在指定时间内还是没有获得锁),则返回 false。
3.5 std::recursive_timed_mutex
和 std:recursive_mutex 与 std::mutex 的关系一样,std::recursive_timed_mutex 的特性也可以从 std::timed_mutex 推导出来。
3.6 std::lock_guard
内部构造时相当于执行了lock,析构时相当于执行unlock。在其析构函数中进行解锁。最终的结果就是:创建即加锁,作用域结束自动解锁。从而使用lock_guard()就可以替代lock()与unlock()
lock_gurad也可以传入两个参数,第一个参数为adopt_lock标识时,表示不再构造函数中不再进行互斥量锁定,因此此时需要提前手动锁定。
mutex m;//实例化m对象,不要理解为定义变量
void proc1(int a)
{
m.lock();//手动锁定
lock_guard<mutex> g1(m, adopt_lock);
cout << "proc1函数正在改写a" << endl;
cout << "原始a为" << a << endl;
cout << "现在a为" << a + 2 << endl;
}//自动解锁
3.7 std::unique_lock
unique_lock类似于lock_guard,只是unique_lock用法更加丰富,同时支持lock_guard()的原有功能。
使用lock_guard后不能手动lock()与手动unlock();
使用unique_lock后可以手动lock()与手动unlock();
unique_lock的第二个参数,除了可以是adopt_lock,还可以是try_to_lock与defer_lock。
try_to_lock:尝试去锁定,得保证锁处于unlock的状态,然后尝试现在能不能获得锁;
尝试用mutx的lock()去锁定这个mutex,但如果没有锁定成功,会立即返回,不会阻塞在那里。
defer_lock: 初始化了一个没有加锁的mutex。
void proc1(int a)
{
unique_lock<mutex> g1(m, defer_lock); //始化了一个没有加锁的mutex
g1.lock(); //手动加锁,注意,不是m.lock()
cout << "proc1函数正在改写a" << endl;
cout << "proc1函数a为" << a << endl;
cout << "proc1函数a+2为" << a + 2 << endl;
g1.unlock(); //临时解锁
cout << "尝试自动解锁" << endl;
g1.lock();
cout << "运行后自动解锁" << endl;
} //自动解锁
void proc2(int a)
{
unique_lock<mutex> g2(m, try_to_lock); //尝试加锁,但如果没有锁定成功,会立即返回,不会阻塞在那里
cout << "proc2函数正在改写a" << endl;
cout << "proc2函数a为" << a << endl;
cout << "proc2函数a+1为" << a + 1 << endl;
} //自动解锁
3.8 condition_variable
std::condition_variable是C++标准库中提供的条件变量,用于线程之间的同步与通信。
它可以配合std::mutex或std::unique_lock一起使用,实现多个线程之间的等待和唤醒机制。
condition_variable头文件有两个variable类,一个是condition_variable
,另一个是condition_variable_any
。
condition_variable必须结合unique_lock使用。condition_variable_any可以使用任何的锁。
condition_variable条件变量可以阻塞(wait、wait_for、wait_until)调用的线程直到使用(notify_one或notify_all)通知恢复为止。
condition_variable是一个类,这个类既有构造函数也有析构函数,使用时需要构造对应的condition_variable对象,调用对象相应的函数来实现上面的功能。
举例如下:
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
std::mutex mtx; // 互斥锁
std::condition_variable cv; // 条件变量
bool ready = false; // 共享变量
void threadFunction()
{
std::this_thread::sleep_for(std::chrono::seconds(2)); // 模拟耗时操作
{
std::lock_guard<std::mutex> lock(mtx); // 获取互斥锁
ready = true;
cv.notify_one(); // 唤醒等待的线程
}
}
int main()
{
std::cout << "Main thread started!" << std::endl;
std::thread t(threadFunction);
{
std::unique_lock<std::mutex> lock(mtx); // 获取互斥锁
while (!ready) { // 当条件不满足时等待
cv.wait(lock); // 等待唤醒通知
}
std::cout << "Main thread resumed!" << std::endl;
}
t.join();
return 0;
}
在上述示例中,主线程等待子线程完成某个操作后才继续执行。主线程通过std::unique_lock获取互斥锁,并在while循环中检查条件是否满足,如果条件不满足,则调用cv.wait()进入等待状态。当子线程完成操作后,它会获取互斥锁并通知主线程(cv.notify_one()),主线程被唤醒后继续执行。
3.8.1 wait函数
当前线程调用 wait() 后将被阻塞(此时当前线程应该获得了锁(mutex),不妨设获得锁 lck),直到另外某个线程调用 notify_* 唤醒了当前线程。
在线程被阻塞时,该函数会自动调用 lck.unlock() 释放锁,使得其他被阻塞在锁竞争上的线程得以继续执行。
另外,一旦当前线程获得通知(notified,通常是另外某个线程调用 notify_* 唤醒了当前线程),wait()函数也是自动调用 lck.lock(),使得lck的状态和 wait 函数被调用时相同。
3.8.2 wait_for
与std::condition_variable::wait() 类似,不过 wait_for可以指定一个时间段,在当前线程收到通知或者指定的时间 rel_time 超时之前,该线程都会处于阻塞状态。
而一旦超时或者收到了其他线程的通知,wait_for返回,剩下的处理步骤和 wait()类似。
template <class Rep, class Period>
cv_status wait_for (unique_lock<mutex>& lck,
const chrono::duration<Rep,Period>& rel_time);
另外,wait_for 的重载版本的最后一个参数pred表示 wait_for的预测条件,只有当 pred条件为false时调用 wait()才会阻塞当前线程,并且在收到其他线程的通知后只有当 pred为 true时才会被解除阻塞。
template <class Rep, class Period, class Predicate>
bool wait_for (unique_lock<mutex>& lck,
const chrono::duration<Rep,Period>& rel_time, Predicate pred);
四、异步线程
4.1 std::future异步线程
需要 #include
如何理解:相当于你去银行业务(主线程),把资料交给了柜台,柜台人员去给你办理(async创建子线程),柜台人员给了你一个单据(future对象),说你的业务正在给你办(子线程正在运行),等段时间你再过来凭这个单据取结果。过了段时间,你去柜台取结果,但是结果还没出来(子线程还没return),你就在柜台人员等着(阻塞),直到你拿到结果(get())你才离开(不再阻塞)。
#include <iostream>
#include <thread>
#include <mutex>
#include<future>
#include<Windows.h>
using namespace std;
double t1(const double a, const double b)
{
double c = a + b;
Sleep(3000); //假设t1函数是个复杂的计算过程,需要消耗3秒
return c;
}
int main()
{
double a = 2.3;
double b = 6.7;
future<double> fu = async(t1, a, b); //创建异步线程线程,并将线程的执行结果用fu占位;
cout << "正在办理业务" << endl;
cout << "马上为您办理好,请您耐心等待" << endl;
cout << "计算结果:" << fu.get() << endl;//阻塞主线程,直至异步线程return,future对象的get()方法只能调用一次。
return 0;
}
4.2 shared_future
future与shard_future的用途都是为了占位,但是两者有些许差别。future的get()成员函数是转移数据所有权;
shared_future的get()成员函数是复制数据。
future对象的get()只能调用一次;无法实现多个线程等待同一个异步线程,一旦其中一个线程获取了异步线程的返回值,其他线程就无法再次获取。
shared_future对象的get()可以调用多次;可以实现多个线程等待同一个异步线程,每个线程都可以获取异步线程的返回值。
五、原子类型automic
互斥量的加锁一般是针对一个代码段,而原子操作针对的一般都是一个变量。
automic是一个模板类,使用该模板类实例化的对象,提供了一些保证原子性的成员函数来实现共享数据的常用操作。
在以前,定义了一个共享的变量(int i=0),多个线程会操作这个变量,那么每次操作这个变量时,都是用lock加锁,操作完毕使用unlock解锁,以保证线程之间不会冲突;
现在,实例化了一个类对象(automic i=0)来代替以前的那个变量,每次操作这个对象时,就不用lock与unlock,这个对象自身就具有原子性,以保证线程之间不会冲突。
automic对象提供了常见的原子操作(通过调用成员函数实现对数据的原子操作):
- store是原子写操作。
- load是原子读操作。
- exchange是于两个数值进行交换的原子操作。
- 即使使用了automic,也要注意执行的操作是否支持原子性。一般atomic原子操作,针对++,–,+=,-=,&=,|=,^=是支持的。
#include <atomic>
#include <thread>
#include <iostream>
using namespace std;
atomic_int64_t total = 0; //atomic_int64_t相当于int64_t,但是本身就拥有原子性
//线程函数,用于累加
void threadFunc(int64_t endNum) {
for (int64_t i = 1; i <= endNum; ++i)
{
total += i;
}
}
int main() {
int64_t endNum = 100;
thread t1(threadFunc, endNum);
thread t2(threadFunc, endNum);
t1.join();
t2.join();
cout << "total=" << total << endl; //10100
}
六、线程池
在一个程序中,如果我们需要多次使用线程,这就意味着,需要多次的创建并销毁线程。
而创建并销毁线程的过程势必会消耗内存,线程过多会带来调动的开销,进而影响缓存局部性和整体性能。
线程的创建并销毁有以下一些缺点:
- 创建太多线程,将会浪费一定的资源,有些线程未被充分使用。
- 销毁太多线程,将导致之后浪费时间再次创建它们。
- 创建线程太慢,将会导致长时间的等待,性能变差。
- 销毁线程太慢,导致其它线程资源饥饿。
线程池维护着多个线程,这避免了在处理短时间任务时,创建与销毁线程的代价。