C++并发编程(一):线程基础

简介

本文学习的是 b 站 up 恋恋风辰的并发编程教学视频做的一些笔记补充。

教程视频链接如下:线程基础:视频教程

文档链接如下:线程基础:笔记文档

理论上直接看 up 提供的笔记文档即可,我这里主要是记录一些我自己觉得可能需要补充的点。

线程发起

cpp 复制代码
void thread_work1(std::string str) {
    std::cout << "str is " << str << std::endl;
}
//1 通过()初始化并启动一个线程
std::thread t1(thead_work1, hellostr);

解释一下这行代码:

cpp 复制代码
std::thread t1(thread_work1, hellostr);

1、创建 std::thread 对象:t1 是一个 std::thread 类型的对象,它代表了一个即将或正在执行的线程。

2、指定线程要执行的任务:通过构造函数,我们告诉 t1 线程应该执行哪个函数。在这个例子中,任务是调用 thread_work1 函数。

3、传递参数给任务:thread_work1 函数需要一个 std::string 类型的参数。在构造 t1 时,我们通过传递 hellostr 变量来提供这个参数。hellostr 是一个已经定义并初始化的 std::string 对象,其值将被传递给 thread_work1 函数。

4、启动线程:当 std::thread 对象被构造时,它会自动启动一个新线程来执行指定的任务(即调用 thread_work1 函数,并将 hellostr 作为参数传递)。这个过程是异步的,意味着主线程(即创建 t1 的线程)将继续执行其后续指令,而不会等待新线程完成。

5、线程管理:一旦线程被启动,它就在自己的执行上下文中独立运行。但是,std::thread 对象 t1 提供了管理这个线程的手段,比如通过调用 t1.join() 来等待线程完成,或者通过调用 t1.detach() 来分离线程(让它在后台运行,而 t1 对象不再管理它)。

注意:普通函数的函数名就是这个函数实际的地址。

我们可以来试验一下这个事情:

cpp 复制代码
#include <iostream> 
#include <string>
#include <thread> # 注意使用多线程时要包含这个头文件

using namespace std;

//线程函数
void thread_work1(std::string str) {
	std::cout << "str is " << str << std::endl;
}

int main() {
	string hellostr = "hello world";

	//通过 () 初始化并启动一个线程
	thread t1(thread_work1, hellostr);
	
	return 0;
}

可以看见运行结果是程序崩溃了,为什么呢?

当我们定义完线程 t1 之后它就会自动初始化并开始运行,那么在后台就会开始执行 thread_work1 这个线程函数然后输出 hello world。但是程序此时继续往下走时我们并没有将这个线程挂起或者停靠结果主线程就结束了,主线程结束就必须要回收 hellostr 这个字符串资源,那么就可能会存在这个资源已经被释放了,那么在 thread_work1 里虽然依然能够调用资源但是有可能会出问题(只是当前例子没出)。

因为在代码中我们可以看到传参是通过值传递,也就是把外部的局部变量作为一个拷贝拷贝给线程函数 thread_work1 ,所以在这个例子中字符串依然可以正常输出,那为什么会崩溃?就是因为主线程退出了,而子线程则有可能是还在运行的,那么就会崩溃。

但要注意,即使我们能够人为的保证让主线程在子线程执行完之后再结束主线程(比如让主线程睡上几秒)上述的崩溃问题也依然存在。

这是因为11新标准的这套线程 API 做了优化,当编译器发现我们启动了一个线程但却没有把这个线程做善后的工作(比如join或者detach掉),那么就会出现主线程在回收资源的时候就会调用这个线程的析构函数,析构函数内部就会执行一个很生硬的 terminate 函数,这个函数就会强制终止,这个函数的强制终止是会调用 assert 断言导致崩溃的。

线程等待

因此为了防止这样的崩溃问题,我们可以加入一个 join 函数:

cpp 复制代码
int main() {
	string hellostr = "hello world";

	//通过 () 初始化并启动一个线程
	thread t1(thread_work1, hellostr);
	
	//主线程等待子线程退出
	t1.join();

	return 0;
}

这个 t1.join 会让主线程去等待子线程 t1 执行完了主线程再继续往下执行。

此时再运行将不会发生问题。

仿函数(函数对象)作为参数

当我们用仿函数作为参数传递给线程时,也可以达到启动线程执行某种操作的含义。

但是要注意一些问题,我们可以来看个例子:

cpp 复制代码
class background_task {
public:
	// 实现了括号运算符的类就称可以创建函数对象
	void operator()() {
		cout << "background_task called" << endl;
	}
};

int main() {

	//t2 被当作函数对象的定义,其类型为返回 thread,参数为 background_task
	thread t2(background_task());
	//t2.join(); 编译错误
	return 0;
}

线程对象 t2 会去执行 background_task 这个类的函数对象,当我们使用 background_task() 的时候会调用这个类的构造函数,结果是会生成一个对象,这个对象传递给了这个线程 t2 作为参数,同时因为我们重载了括号运算符,所以这个对象可以直接执行,也就是可以把对象当成函数来使用,这也是函数对象的意义。

但是可以看到在调用 join 函数时,出现编译错误:

这是因为编译器会将 thread t2(background_task()); 这行代码解释成了一个函数指针, 返回一个 std::thread 类型的值, 这个函数指针的参数也为一个函数指针, 这个函数指针返回值则为background_task, 参数为void。可以理解为如下:

cpp 复制代码
"std::thread (*)(background_task (*)())"

这样看有点不太好懂,因为我们说编译器会将 t2 当成一个函数指针,不妨拆解一下上面这行代码:

std::thread 是返回值,(*) 是一个函数指针,()是参数列表,最后 background_task (*)() 是该函数指针的参数,也是一个函数指针。

再对比一下函数指针的声明形式:

cpp 复制代码
返回类型 (*指针变量名)(参数类型列表);

这样应该就好明白多了。

我们明明是想定义一个线程对象 t2,却被编译器解释成了一个函数指针,这肯定是不行的。

解决方案如下:

cpp 复制代码
//可多加一层()
//此时编译器就会认为其是一个对象,进行正常的线程初始化并启动了
std::thread t2((background_task()));
t2.join();

//可使用{}方式进行变量初始化
std::thread t3{ background_task() };
t3.join();

此时编译就正常了。

lambda表达式

lambda 表达式也可以作为线程的参数传递给thread:

cpp 复制代码
std::thread t4(
	[](std::string  str) { std::cout << "str is " << str << std::endl;},  
	hellostr
	);

t4.join();

线程detach

线程允许采用分离的方式在后台独自运行,C++ concurrent programing书中称其为守护线程。

方式是使用 detach 函数,该函数会让线程在后台运行,它不会受主线程的影响,主线程可以直接执行自己的任务,子线程就和主线程分离了,它们会各自使用自己的资源。

但是这里要注意:分离的时候,一旦子线程需要用到主线程的资源时,由于主线程运行结束,资源释放了,那么子线程在获取主线程资源时就容易产生问题,来看下面的例子。

cpp 复制代码
struct func {
	//对于类和结构体的成员属性是引用的话,那么可以在构造函数初始化列表中初始化
	int& _i;
	func(int& i) : _i(i) {}
	void operator()() {
		for (int i = 0; i < 3; i++) {
			_i = i;
			std::cout << "_i is " << _i << std::endl;
			std::this_thread::sleep_for(std::chrono::seconds(1));
		}
	}
};

void oops() {
	int some_local_state = 0;
	//使用函数对象创建 func 类的对象并调用函数
	func myfunc(some_local_state);
	//创建并启动线程
	std::thread functhread(myfunc);
	//演示隐患,子线程还在访问局部变量 some_local_state,但局部变量可能会随着 } 结束而回收或随着主线程退出而回收
	functhread.detach();
}

int main() {
	// detach 注意事项
	oops();
	//防止主线程退出过快,需要停顿一下,让子线程跑起来detach
	std::this_thread::sleep_for(std::chrono::seconds(1));
	return 0;
}

主线程在执行完 oops() 后又睡了一秒然后就退出了。

虽然主线程退出了,但是子线程还在执行。不过这里要注意,因为主线程就是进程存在的主要形式,进程包括主线程以及其衍生的一众子线程,因此主线程如果结束了那么整个进程也就结束了,那自然而然所有的子线程即使是在 detach 的状态也会被操作系统给全部回收掉。

上面的例子存在隐患,因为some_local_state是局部变量, 当oops调用结束后局部变量some_local_state就可能被释放了,而线程还在detach后台运行,容易出现崩溃。

所以当我们在线程中使用局部变量的时候可以采取几个措施解决局部变量的问题

1、通过智能指针传递参数,因为引用计数会随着赋值增加,可保证局部变量在使用期间不被释放,这也就是我们之前提到的伪闭包策略。

2、将局部变量的值作为参数传递,这么做需要局部变量有拷贝复制的功能,而且拷贝耗费空间和效率。

3、将线程运行的方式修改为join,这样能保证局部变量被释放前线程已经运行结束。但是这么做可能会影响运行逻辑。

比如下面的修改:

cpp 复制代码
void use_join() {
    int some_local_state = 0;
    func myfunc(some_local_state);
    std::thread functhread(myfunc);
    functhread.join();
}

// join 用法
use_join();

异常处理

当我们启动一个线程后,如果主线程产生崩溃,会导致子线程也会异常退出(因为主进程就是依赖于主线程存在的),也就是之前说的调用terminate。如果子线程在进行一些重要的操作比如将充值信息入库等,那么丢失这些信息是很危险的。所以常用的做法是捕获异常,并且在异常情况下保证子线程稳定运行结束后,主线程再抛出异常结束运行。如下面的逻辑:

cpp 复制代码
void catch_exception() {
    int some_local_state = 0;
    func myfunc(some_local_state);
    std::thread  functhread{ myfunc };
    try {
        //本线程做一些事情,假设可能引发崩溃
        std::this_thread::sleep_for(std::chrono::seconds(1));
    }catch (std::exception& e) {
    	//一旦引发崩溃,那么就会在这里被捕获住
    	//捕获到异常后主线程不能马上崩溃,要先等子线程运行结束
        functhread.join();
        //子线程运行结束后,此时主线程再来处理该异常
        throw;
    }
    //如果没有异常那就正常继续下去即可
    functhread.join();
}

但是用这种方式编码,会显得臃肿,可以采用 RAII 技术,保证线程对象析构的时候等待线程运行结束,回收资源。

介绍一下 RAII 技术:

那么我们就来写一个简单的线程守卫:

cpp 复制代码
class thread_guard {
private:
    std::thread& _t;
public:
    explicit thread_guard(std::thread& t):_t(t){}
    ~thread_guard() {
        //join只能调用一次
        if (_t.joinable()) {
            _t.join();
        }
    }
    thread_guard(thread_guard const&) = delete;
    thread_guard& operator=(thread_guard const&) = delete;
};

可以这么使用:

cpp 复制代码
void auto_guard() {
    int some_local_state = 0;
    func my_func(some_local_state);
    std::thread  t(my_func);
    thread_guard g(t);
    //本线程做一些事情
    std::cout << "auto guard finished " << std::endl;
}
auto_guard();

慎用隐式转换

C++中会有一些隐式转换,比如 char* 转换为 string 等。这些隐式转换在线程的调用上可能会造成崩溃问题:

cpp 复制代码
void danger_oops(int som_param) {
    char buffer[1024];
    sprintf(buffer, "%i", som_param);
    //在线程内部将char const* 转化为std::string
    std::thread t(print_str, 3, buffer);
    t.detach();
    std::cout << "danger oops finished " << std::endl;
}

当我们定义一个线程变量thread t时,传递给这个线程的参数buffer会被保存到thread的成员变量中。

而在线程对象t内部启动并运行线程时,参数才会被传递给调用函数print_str。

而此时 buffer 可能随着 } 运行结束而释放了。

改进的方式很简单,我们将参数传递给thread时显示转换为string就可以了,这样thread内部保存的是string类型。

cpp 复制代码
void safe_oops(int some_param) {
    char buffer[1024];
    sprintf(buffer, "%i", some_param);
    std::thread t(print_str, 3, std::string(buffer));
    t.detach();
}

关于为什么参数会像我说的这样保存和调用,我在之后会按照源码给大家讲一讲。

引用参数

当线程要调用的回调函数参数为引用类型时,需要将参数显示转化为引用对象传递给线程的构造函数,如果采用如下调用会编译失败:

cpp 复制代码
void change_param(int& param) {
    param++;
}
void ref_oops(int some_param) {
    std::cout << "before change , param is " << some_param << std::endl;
    //需使用引用显示转换
    std::thread  t2(change_param, some_param);
    t2.join();
    std::cout << "after change , param is " << some_param << std::endl;
}

即使函数 change_param 的参数为 int& 类型,我们传递给 t2 的构造函数为 some_param, 也不会达到在change_param 函数内部修改关联到外部some_param的效果。因为 some_param 在传递给 thread 的构造函数后会转变为右值保存,右值传递给一个左值引用会出问题,所以编译出了问题。

改为如下调用就可以了:

cpp 复制代码
void ref_oops(int some_param) {
    std::cout << "before change , param is " << some_param << std::endl;
    //需使用引用显示转换
    std::thread  t2(change_param, std::ref(some_param));
    t2.join();
    std::cout << "after change , param is " << some_param << std::endl;
}

绑定类成员函数

有时候我们需要绑定一个类的成员函数:

cpp 复制代码
class X
{
public:
    void do_lengthy_work() {
        std::cout << "do_lengthy_work " << std::endl;
    }
};
void bind_class_oops() {
    X my_x;
    std::thread t(&X::do_lengthy_work, &my_x);
    t.join();
}

这里大家注意一下,如果thread绑定的回调函数是普通函数,可以在函数前加 & 或者不加 & ,因为编译器默认将普通函数名作为函数地址,如下两种写法都正确:

cpp 复制代码
void thead_work1(std::string str) {
    std::cout << "str is " << str << std::endl;
}
std::string hellostr = "hello world!";
//两种方式都正确
std::thread t1(thead_work1, hellostr);
std::thread t2(&thead_work1, hellostr);

但是如果是绑定类的成员函数,必须添加 & 。

使用move操作

有时候传递给线程的参数是独占的,所谓独占就是不支持拷贝赋值和构造,但是我们可以通过std::move的方式将参数的所有权转移给线程,如下:

cpp 复制代码
void deal_unique(std::unique_ptr<int> p) {
    std::cout << "unique ptr data is " << *p << std::endl;
    (*p)++;
    std::cout << "after unique ptr data is " << *p << std::endl;
}
void move_oops() {
    auto p = std::make_unique<int>(100);
    std::thread  t(deal_unique, std::move(p));
    t.join();
    //不能再使用p了,p已经被move废弃
   // std::cout << "after unique ptr data is " << *p << std::endl;
}
相关推荐
forNoWhat6 分钟前
java小知识点:比较器
java·开发语言
坐井观老天12 分钟前
在C#中使用资源保存图像和文本和其他数据并在运行时加载
开发语言·c#
代码中の快捷键19 分钟前
java开发面试有2年经验
java·开发语言·面试
Marzlam20 分钟前
sql server索引优化语句
开发语言·数据库
谢家小布柔25 分钟前
Java 中的字符串
java·开发语言
码老白25 分钟前
【老白学 Java】HashSet 应用 - 卡拉 OK(五)
java·开发语言
染指111031 分钟前
50.第二阶段x86游戏实战2-lua获取本地寻路,跨地图寻路和获取当前地图id
c++·windows·lua·游戏安全·反游戏外挂·游戏逆向·luastudio
Code out the future1 小时前
【C++——临时对象,const T&】
开发语言·c++
taoyong0011 小时前
Java线程核心01-中断线程的理论原理
java·开发语言
一雨方知深秋1 小时前
智慧商城:封装getters实现动态统计 + 全选反选功能
开发语言·javascript·vue2·foreach·find·every