C++学习笔记——thread模块(多线程)
c++的多线程<thread>
、同步与互斥std::mutex
与条件变量std::condition_variable
的学习笔记。
运行系统:ubuntu
使用IDE:Visual Studio Code
运行方式:cmake项目
参考资料:
菜鸟教程C++多线程
菜鸟教程C++ std::thread
C++多线程并发基础入门教程
C++11实现自旋锁
std::condition_variable notify_one()与notify_all()的区别
1. cmake配置
在网上自行搜索后,发现了两种调用C++多线程的方式#include <pthread.h>
与#include <thread>
。
询问导师后,导师说#include <thread>
其实就是调用了#include <pthread.h>
,在实际工作中只需要学习和关心#include <thread>
就可以了。
在尝试着编写c++多线程程序时出现对‘pthread_create’未定义的引用
错误。
在查询资料后发现,即使是使用#include <thread>
,也必须在CMakeLists.txt
文件中加入链接库:
target_link_libraries(thread_test -lpthread -luuid)
完整样例:
# 添加c++11标准支持
set(CMAKE_CXX_FLAGS "-std=c++11")
# 最低 c++ 版本要求
cmake_minimum_required( VERSION 2.8 )
# 创建项目
project( thread_test )
# 添加一个运行程序
add_executable( thread_test thread_test.cpp )
# 将执行程序与库链接
# 线程库thread需要在cmake中链接才能通过编译
target_link_libraries(thread_test -lpthread -luuid)
修改CMakeLists.txt
文件后,程序正常编译运行。
2. 多线程thread
可以从函数指针、函数对象或者lambda 表达式中创建多线程对象。
2.1 线程创建
创建一个基本线程并且运行:
#include <iostream>
// 导入thread线程库
#include <thread>
using namespace std;
// 创建一个在线程中运行的函数
void thread1_func(int num)
{
for (int i = 0; i < num; i++) {
usleep(1E6);
cout << "线程1正在运行" << endl;
}
}
// 用于测试多线程运行效果的主函数
int main(int argc, char ** argv) {
// 创建线程的同时也输入了函数的参数
thread thread1(thread1_func, 5);
// 运行线程
thread1.join();
// 结束程序
cout << "Done." << endl;
return 0;
}
以不同方式创建多个线程并且运行:
#include <iostream>
// 用于随机休眠的线程库
#include <unistd.h>
// 导入thread线程库
#include <thread>
using namespace std;
// 创建一个在线程中运行的函数
void thread1_func(int num)
{
for (int i = 0; i < num; i++) {
cout << "线程1正在运行" << endl;
}
}
// 创建一个类作为线程
class thread2_func {
public:
// 重载运算符()
void operator() (int num)
{
for (int i = 0; i < num; i++)
cout << "线程2正在运行" << endl;
}
};
// 用于测试多线程运行效果的主函数
int main(int argc, char ** argv) {
// 创建线程的同时也输入了函数的参数
thread thread1(thread1_func, 5);
thread thread2(thread2_func(), 5);
// thread thread2(thread2_func(), 5);
// 使用lambda表达式创建线程
// 定义 表达式
auto thread3_func = [](int num) {
for (int i = 0; i < num; i++)
cout << "线程3正在运行" << endl;
};
// 使用 lambda 表达式作为创建线程的参数
thread thread3(thread3_func, 5);
// 运行线程
thread1.join();
thread2.join();
thread3.join();
// 结束程序
cout << "Done." << endl;
return 0;
}
运行得到:
线程1正在运行线程3正在运行
线程1正在运行
线程2正在运行
线程2正在运行
线程2正在运行
线程3正在运行
线程3正在运行
线程2正在运行
线程3正在运行
线程2正在运行
线程3正在运行
线程1正在运行
线程1正在运行
线程1正在运行
Done.
可以看到输出时出现了相互交错,代表程序是交错运行的。
有意思的是,试验表明cout的输出也是分开执行的,而且有可能被别的线程抢占,将endl
改为在字符串中加入\n
可以保证连贯性,或者直接为输出流加锁。
2.2 线程的连接join与分离detach
join意味着会等待线程运行结束再进行下一步操作,而detach则不会等待。
举例:在主线程中用a.join(),那么主线程会等待线程a执行完毕后再继续从这里执行下去。
假如将上述代码中的启动换成detach:
#include <iostream>
// 用于随机休眠的线程库
#include <unistd.h>
// 导入thread线程库
#include <thread>
using namespace std;
// 创建一个在线程中运行的函数
void thread1_func(int num)
{
for (int i = 0; i < num; i++) {
cout << "线程1正在运行" << endl;
}
}
// 创建一个在线程中运行的函数
void thread2_func(int num)
{
for (int i = 0; i < num; i++) {
cout << "线程2正在运行" << endl;
}
}
// 用于测试多线程运行效果的主函数
int main(int argc, char ** argv) {
// 创建线程的同时也输入了函数的参数
thread thread1(thread1_func, 5);
thread thread2(thread2_func, 5);
// 运行线程
thread1.detach();
thread2.detach();
// 结束程序
cout << "Done." << endl;
return 0;
}
运行得到:
Done.
主线程直接结束了!它完全没有等子线程的工作,子线程的结果也没有被显示到屏幕上。
那么试着让主线程休眠一秒,执行慢一点,在
return 0;
的上一行加入:
// 让主线程休眠一秒
sleep(1);
运行得到:
线程1正在运行Done.
线程1正在运行
线程1正在运行
线程1正在运行
线程1正在运行
线程2正在运行
线程2正在运行
线程2正在运行
线程2正在运行
线程2正在运行
多次重复测试,发现每次Done的位置都会发生改变,与线程运行的具体状态有关。
join()和datch()都只能被调用一次,都使用joinable()来判断是否可以调用。
cout << thread1.joinable() << endl;
// 运行线程
thread1.join();
cout << thread1.joinable() << endl;
运行得到:
1
线程1正在运行
0
3. 同步与互斥std::mutex
使用时需要加入:
#include <mutex>;
mutex是用于解决多线程执行过程中的资源读写冲突的读写锁。
3.1 线程锁的类别
具体分为多种不同的锁。
注:锁仅仅是一种自觉的约束行为,它与要锁定的共享变量本身并没有直接绑定,在获取共享变量之前需要先获取锁只是一种线程编程时写入的自觉行为规范,并不具有强制性约束力。
即使无视锁,线程也可以强行访问共享变量,但是读写冲突可能会产生不可预测的未知后果。
3.1.1 互斥锁std::mutex
互斥锁mutex表示资源同一时间只能被一个线程读或者写。
加锁失败的线程会被阻塞,阻塞的线程不耗费CPU资源
导致模式切换,使用互斥锁加锁会进入内核态,阻塞时还会引发调度,运行时重新进入用户态
使用lock,unlock进行线程的锁定与解锁。
3.1.2 读写锁std::shared_mutex
读写锁shared_mutex表示资源可以同时被多个线程读,但只能同时被一个线程写。
不能同时被读线程和写线程占有,只能有其中一种!
使用lock_shared,unlock_shared进行读线程的锁定与解锁。
使用lock,unlock进行写线程的锁定与解锁。
3.1.3 自旋锁,原子操作std::atomic
转自https://zhuanlan.zhihu.com/p/194198073:
std::atomic<>用来定义一个自动加锁解锁的共享变量,供多个线程访问而不发生冲突。
“定义”“变量”用词在这里是不准确的,但是更加符合它的实际功能。
即使使用了std::atomic<>,也要注意执行的操作是否支持原子性。
//原子类型的简单使用
std::atomic<bool> a(true);
a=false;
调用资源的线程会在原地循环等待调用了自旋锁的资源被释放但仍然有尝试的次数上限,超出上限后线程将被挂起。
自旋锁使用了忙等待,加锁失败的线程会一直重复尝试加锁,耗费CPU资源
使用机器指令实现,不涉及模式切换,也不会引发调度
加载和返回都具有原子性【不可分割】
因此适用于单次只需要上锁很短时间的资源。
实现参考:C++11实现自旋锁
3.2 加锁与解锁
3.2.1 手动加锁解锁
在进入临界区之前对互斥量加锁,退出临界区时对互斥量解锁。
例如:
#include<iostream>
#include<thread>
#include<mutex>
using namespace std;
// 实例化锁
mutex m;
void thread1(int num)
{
m.lock();
cout << num << endl;
num = num + 1;
cout << num << endl;
m.unlock();
}
3.2.2 lock_guard
转发:声明一个局部的std::lock_guard对象,
在其构造函数中进行加锁,在其析构函数中进行解锁。
最终的结果就是:创建即加锁,作用域结束自动解锁。
从而使用std::lock_guard()就可以替代lock()与unlock()。
通过设定作用域,使得std::lock_guard在合适的地方被析构。
通过使用{}来调整作用域范围,可使得互斥量m在合适的地方被解锁。
#include<iostream>
#include<thread>
#include<mutex>
using namespace std;
// 实例化互斥锁
mutex m;
// 线程函数
void thread1_func(int num)
{
// 使用lock_guard构造了一个变量lg对锁m进行锁定
lock_guard<mutex> lg(m);
cout << num << endl;
num = num + 1;
cout << num << endl;
// 不需要对lg进行任何操作,它作为临时变量被调用析构函数释放时,也同时解锁了
}
如果使用adopt_lock参数,即lock_guard<mutex> lg(m, adopt_lock);
,那就只会自动解锁,而不会自动锁定,需要在这一行之前手动使用m.lock()
进行锁定。
3.2.3 unique_lock
功能类似lock_guard,但使用更加灵活。
即使已经使用了unique_lock,依然可以手动使用lock()与unlock()进行锁定和解锁操作,但使用了lock_guard之后,就不能再手动操作了。
同样有adopt_lock参数,此外还可以是其他的参数:
try_to_lock: 尝试锁定,如果是未锁定状态就加锁,如果已经被加锁无法锁定,不会阻塞,而是会继续执行下一步程序。
注意:需要使用if (lg.owns_lock()) {...}
来判断是否锁成功,只有锁成功后才应该执行互斥代码段,而且失败后程序不会再次尝试上锁。
defer_lock: 初始化的时候不进行默认的上锁操作。
用法样例:多线程编程(五)——unique_lock defer_lock的应用
unique_lock能够将一个互斥变量的所有权用unique_lock<mutex> lg2(move(lg));
在实例化新变量时直接转移给另一个unique_lock,但adopt_lock不能转移。
adopt_lock和unique_lock都不能被复制。
adopt_lock没有unique_lock灵活,但相应的,执行速度也更快,因此在可以的情况下尽可能使用adopt_lock来提高代码的执行效率。
4. 条件变量std::condition_variable
需要导入头文件#include <condition_variable>
作为一个线程间通讯与唤醒的flag存在:线程2调用condition_variable.wait(mutex_lock)
持续检测变量的值,当线程1改变了这个值,作为一个启动信号时,线程2就开始执行。
wait()函数需要传入一个锁作为参数(一般是std::unique_lock),在被调用后,自动调用unlock释放这个锁,使得其他线程得以使用这个锁,直到这个线程被notify方法唤醒之后再次自动调用lock占有锁。
notify方法包括:
condition_variable.notify_one():
随机唤醒一个等待的线程,使它立刻获得锁
condition_variable.notify_all():
唤醒所有等待的线程,只有一个能获得锁。
但其它的线程不会再被阻塞,而是会继续竞争等待锁的释放。
总结:条件变量condition_variable用于阻塞线程,之后只能通过notify唤醒线程,而不会因为锁的解锁被唤醒。如果选择了notify_all(),相当于不再用条件变量阻塞所有线程,任其自由竞争。
用一个样例说明wait,notify和join的运行顺序关系:
// 此处代码改编自:https://blog.csdn.net/fxfeixue/article/details/113727334
#include <iostream>
#include <thread>
// 导入锁
#include <mutex>
// 导入条件变量
#include <condition_variable>
using namespace std;
// 创建一把锁
mutex m;
// 创建一个条件变量
condition_variable cv;
// 用于判断条件的标记性变量
bool start = false;
// 用不同的id标记不同的线程
void print_thread_id(int id) {
// 通过打印线程id来显示出运行状态
cout << "开始线程 " << id << endl;
// 自动上锁
unique_lock<mutex> lock(m);
// 通过打印线程id来显示出运行状态
cout << "执行线程 " << id << endl;
// 使用标记性变量与条件变量阻塞线程
// 只有当start变量被修改后,才能打印,否则就会被阻塞
// 被阻塞后等待条件变量的唤醒,如果唤醒时start已经被修改,即可以继续
while (!start) {
cv.wait(lock);
}
// 通过打印线程id来显示出运行状态
cout << "结束线程 " << id << endl;
}
// 用于唤醒线程的函数
void go() {
// 自动上锁
unique_lock<mutex> lock(m);
cout << "醒醒,该干活了 " << endl;
// 修改标记性变量使得线程可以继续运行下去
start = true;
// 唤醒所有被条件变量阻塞的线程
cv.notify_all();
}
int main()
{
// 创建10个线程
thread threads[5];
// 为每个线程分配一个不同的id
for (int i = 0; i < 5; ++i)
threads[i] = thread(print_thread_id, i);
cout << "所有线程创建完毕" << endl;
// 线程可以开始了
go();
cout << "开始连接线程" << endl;
// 以连接方式启动线程
for (auto& th : threads) {
th.join();
}
cout << "主线程结束" << endl;
return 0;
}
运行得到:
开始线程 0
执行线程 0
所有线程创建完毕
开始线程 开始线程 2
开始线程 3
醒醒,该干活了
开始连接线程
结束线程 0
4
执行线程 4
结束线程 4
执行线程 2
结束线程 2
执行线程 3
结束线程 3
开始线程 1
执行线程 1
结束线程 1
主线程结束
由此可见,线程应该是在创建时就已经开始了运行,而不是等到join()才开始运行的。
在样例中,在唤醒后才能继续执行获取锁之后的代码。
5. 异步线程async与future
c++中的异步函数,类似python中async与await关键词所起到的作用。
#include<future>
// 构建一个异步函数
double 异步函数名(参数1, 参数2) {...}
// 用一个future接受异步函数的返回结果
// 类似python里面用await关键词启动一个异步函数并等待返回值
// 只不过c++必须用future类型来接收返回值
future<double> fu = async(异步函数名, 参数1, 参数2);
6. 线程与进程的中止
子线程在实例化的时候就已经开始运行,而不是在join()或者detach()的时候才开始运行。
线程包含在进程中,当所属进程被终止时,所有包含线程不论是否有没有运行结束都会被中止。
进程中止的标志是main函数ruturn返回值。
因此,虽然理论上主线程的中断不会影响子线程,但是在detach()模式,即主线程不会等待子线程执行完毕的情况下,即使子线程尚未运行完毕,但是主线程已经return返回值,那么进程就会被操作系统中止,所有包含在进程中的子线程也随之中止,而不会继续执行下去。