【C++】线程库

目录

在C++11之前,涉及到多线程问题,都是和平台相关的,比如windows和以linux为代表的类unix系统下各有自己的接口,这使得代码的可移植性比较差。C++11中最重要的特性就是对线程进行支持了,使得C++在并行编程时不需要依赖第三方库,而且在原子操作中还引入了原子类的概念。要使用标准库中的线程,必须包含 <thread>头文件。

thread库

线程创建

首先来看第二种构造方式,这也是最正常的线程创建方式,我们都知道线程的创建最重要的就是指定线程执行的函数以及传给它的参数(不了解的可以看我的文章【Linux】线程),这个构造函数就是如此,第一个参数接收任何可调用对象,如函数指针、仿函数、lambda、包装器,这是一个万能引用。第二个参数传可变参数模板万能引用,这样就能传递多个想要传递给线程执行函数的参数了。

第一种构造是默认构造,由于我们什么都没有给,所以其实这种构造只是创建一个std::thread对象,该对象处于 "非可结合"(non-joinable) 状态,不关联任何实际的线程。我们可以把它看作一个 "空壳",需要通过移动赋值(operator=)或移动构造(thread(thread&& x))来让它关联一个真实的线程。

拷贝构造被delete,因为线程不存在复制的概念。

虽然拷贝构造被delete,但是移动构造没有,我们想一下的话也合理,因为移动构造是资源的转移,将一个thread对象关联的线程转移给另一个也并没有不合理之处,谁管不是管呢,就怕没人管,所以这里可以。

线程等待

线程跑起来了,主线程还要对它进行join,thread也提供了相关接口,

cpp 复制代码
void join();

很简单我就不多说了,但是这里有一个值得注意的点,那就是这个join不像我们POSIX中的pthread_join,提供了接收线程返回值的手段,C++实现的<thread>库的join没有接收线程返回值的手段,整个类也没有实现接收线程返回值的手段。当然我们也能通过输出型参数进行返回。并且如果我们真的想接收返回值,我们有其他的手段,这个后面会讲到。

这样我们就能写一个最基本最简单的多线程程序了。

cpp 复制代码
#include<iostream>
#include<thread>
#include<mutex>
#include<chrono>

void PRINT(int num)
{
	for (int i = 0; i < num; ++i)
	{
		std::cout << "thread " << std::this_thread::get_id() << ": " << i << std::endl;
		std::this_thread::sleep_for(std::chrono::seconds(1));
	}
}

int main()
{
	std::thread th1(PRINT, 10);
	std::thread th2(PRINT, 10);
	th1.join();
	th2.join();
	return 0;
}

这里实现了两个线程分别打印数字的程序。这里值得注意的是使用了std::this_thread这个类,这个同样也是<thread>库中定义的,是一个命名空间,

都是一些线程运行的的功能函数,get_id获取线程id,yield线程让步,sleep系睡眠函数,sleep_until睡到指定时间点,也就是绝对时间,用的比较少,sleep_for睡蛮指定时间长度,比较常用。大家用到时简单看一下用法就会。sleep系函数传参数时会用到<chrono>库,这是 C++11 引入的标准库,专门用于处理时间、日期和计时,这个库不难,用到时间的场合查一下就行,这里我们明白std::chrono::seconds(1)就是1秒就行。

线程分离

我们如果不想join等待线程,我们可以进行线程分离使用接口

cpp 复制代码
void detach();

可以进行线程分离,之后我们就可以不管这个线程了,但是主线程退出这个线程也会直接结束,这点要注意。

joinablestd::thread的一个核心状态,用来判断一个std::thread对象是否关联着一个正在运行或已经终止但未被回收的底层线程。我们可以使用接口

cpp 复制代码
bool joinable() const noexcept;

来查看,其实我们也能理解成这个线程还能不能join

多种方式初始化thread类

除函数指针之外,我们也有很多种方式给线程传执行函数,这里用线程演示一下,

cpp 复制代码
int i = 0;
int num = 10;
std::thread th1([=]() mutable {
	for (; i < num; ++i)
	{
		std::cout << "thread " << std::this_thread::get_id() << ": " << "hello world" << std::endl;
	}
});
std::thread th2([=]() mutable {
	for (; i < num; ++i)
	{
		std::cout << "thread " << std::this_thread::get_id() << ": " << "hello CPP" << std::endl;
	}
});
th1.join();
th2.join();

可以看到lambda对于这种功能实现不复杂的线程传参还是很舒服的,还能利用捕获功能减少一些传参。

C++作为面向对象的语言,thread也是一个类,是类是对象就能被容器装着,那配合默认构造和移动构造就能完成下面这个效果,

cpp 复制代码
void PRINT(int num)
{
	for (int i = 0; i < num; ++i)
	{
		std::cout << "thread " << std::this_thread::get_id() << ": " << i << std::endl;
		std::this_thread::sleep_for(std::chrono::seconds(1));
	}
}

int main()
{
	std::vector<std::thread> arr(20);
	for (auto& e : arr)
		e = std::thread(PRINT, 10);
	for (auto& e : arr)
		e.join();
	return 0;
}

可以说很方便,配合移动构造也能实现一些thread对象的续命,

cpp 复制代码
std::thread th1;
{
	std::thread th2(PRINT, 10);
	th1 = std::move(th2);
}
std::unique_ptr<std::thread> th_ptr;
{
	std::thread th2(PRINT, 10);
	th_ptr = std::move(std::unique_ptr<std::thread>(new std::thread(std::move(th2))));
}
th1.join();
th_ptr->join();

ref()

当我们给thread类传函数时,如果函数中有引用参数,那么和bind时一样,我们要加上ref()(在 【C++11】C++11重要新特性详解 介绍过)。

cpp 复制代码
void PRINT(int& num)
{
	num += 10;
}

int main()
{
	int n = 0;
	//std::thread th1(PRINT, n); 会报错
	std::thread th1(PRINT, std::ref(n));
	th1.join();
	std::cout << n << std::endl;
	//打印结果:
	//10
	return 0;
}

我们可以认为这里因为起了一个新的线程,thread是异步执行,临时对象的生命周期无法保证(主线程销毁后临时对象也会销毁),所以C++设计默认值拷贝到线程栈空间,如果我们给一个引用参数直接传值不用ref(),这里会直接报错,bind里倒不是这么处理的,我们可以认为C++在对待线程时更加严格,总之我们记住如果是传引用参数,我们要用ref函数处理,同时保证对象生命周期,不要在线程执行时把被引用的对象销毁了。

其实我们看threadbind,本质都是给一个函数(严格的说是可执行对象,毕竟C++里还有lambda、仿函数什么的)和一些参数,对象内部自己调用,然后把参数传进去,这时它们的做法都是先将值保存起来,但是普通左值引用无法拷贝,引用只是一个别名,这种别名无法拷贝,所以ref的本质就是给引用套了一层壳,包装成了一个可以被拷贝的对象,然后内部还是指向引用的对象,这样就能完成传递了。

什么叫引用没法拷贝呢?我们可以这么理解,正常调用引用参数和普通参数的函数时我们都只用直接传这个对象,那么当这个参数是一个模板时,面对传一个对象,模板会怎么办呢?答案是推导成普通参数,所以我们正常使用时面对这样的情况也是可以使用ref的,

cpp 复制代码
template<class T>
void print(T i)
{
	for (; i < 10; ++i) {}
}

int main()
{
	int a = 0, b = 0;
	print(a);
	print(std::ref(b));
	std::cout << "a: " << a << std::endl;
	std::cout << "b: " << b << std::endl;
	//结果:
	//a: 0
	//b : 10
	return 0;
}

类比到threadbind,我们可以简单认为它们内部也是搞了一个这样的模板,然后分不清引用了,所以就不支持这样的情况(实际根本来说是引用没有拷贝的方法,引用的=只能初始化,为什么只能初始化呢?因为C++的引用不允许改变),我们自己搞一个ref包装一下,其内部可以用指针这样的东西直接指向原对象,重载一些运算符,这样就成了。大概是这样,实际语言内部的实现很深奥很复杂,我们也不必深究,理解到这个地步我自认为很够用了。

当然其实我们现代C++还有lambda呢,我们可以用捕获列表引用捕获局部对象,这样可以避免一些传参的引用了,C++,很神奇吧。

mutex库

写过多线程代码的对锁一定不陌生,这里我也不过多介绍了。C++11推出了<mutex>库,其中提供了很多锁。

mutex

我们先来看最基础的,mutex类,直接用一下

cpp 复制代码
int a = 0;
std::thread th1([&](int num) {
	for (int i = 0; i < num; ++i)
		++a;
}, 10000);
std::thread th2([&](int num) {
	for (int i = 0; i < num; ++i)
		++a;
}, 10000);
th1.join();
th2.join();
std::cout << a << std::endl; // 线程不安全,a是临界资源
cpp 复制代码
int a = 0;
std::mutex mt;
std::thread th1([&](int num) {
	for (int i = 0; i < num; ++i)
	{
		mt.lock();
		++a;
		mt.unlock();
	}
}, 10000);
std::thread th2([&](int num) {
	for (int i = 0; i < num; ++i)
	{
		mt.lock();
		++a;
		mt.unlock();
	}
}, 10000);
th1.join();
th2.join();
std::cout << a << std::endl; // 20000

此外还支持try_lock,在未获取到锁时不是阻塞,而是返回false,这样我们就可以在未获取锁时做一些别的事。

cpp 复制代码
int a = 0;
int b = 0;
std::mutex mt;
std::thread th1([&](int num) {
	for (int i = 0; i < num; ++i)
	{
		while (!mt.try_lock()) ++b;
		++a;
		mt.unlock();
	}
}, 10000);
std::thread th2([&](int num) {
	for (int i = 0; i < num; ++i)
	{
		while (!mt.try_lock()) ++b;
		++a;
		mt.unlock();
	}
}, 10000);
th1.join();
th2.join();
std::cout << a << std::endl; // 20000
std::cout << b << std::endl; // 未知

使用起来就是这样,很简单。

除了mutex之外,还有其他的锁可以用,

比如这个recursive_mutex,它是是C++里的递归互斥锁,它允许同一个线程多次锁定同一个互斥锁,而不会造成死锁。

cpp 复制代码
int sum = 0;

void print(std::recursive_mutex& rmt)
{
	rmt.lock();
	if (sum == 100) return;
	++sum;
	std::cout << sum << std::endl;
	print(rmt);
	rmt.unlock();
}

int main()
{
	std::recursive_mutex rmt;
	std::thread th3(print, std::ref(rmt));
	th3.join();
	return 0;
}

除此之外还有带超时机制的锁,可以指定时间,指定时间内拿不到锁就出错返回,可以指定时间段或者时间点,用的比较少就不讲了,有用到时看一下就会了。

RAII锁资源管理

接下来我们讲一下lock_guardunique_lock。我们都知道C++会抛异常,如果抛异常了就会进行栈展开,这种场景下如果我们使用mutexlockunlock可能会出问题。

cpp 复制代码
std::mutex mt;

void print_even(int x) {
    if (x % 2 == 0) std::cout << x << " is even\n";
    else throw (std::logic_error("not even"));
}

void print_thread_id(int id)
{
    try
    {
        mt.lock();
        print_even(id);
        mt.unlock();
    }
    catch (std::logic_error&)
    {
        std::cout << "[exception caught]\n";
    }
}

int main()
{
    std::thread threads[10];
    for (int i = 0; i < 10; ++i)
        threads[i] = std::thread(print_thread_id, i + 1);
    for (auto& th : threads) th.join();
    return 0;
}

比如这里,如果lock上了锁了又抛异常直接栈展开,后面的unlock操作永远做不了了,这样其他的线程永远都拿不到锁了,这时就会死锁。

怎么办呢?和智能指针解决内存泄漏一样,我们这里可以用RAII的思想封装一个lock对象,对象析构就会释放锁。我们可以自己写一个,

cpp 复制代码
namespace jiunian
{
    class lock_guard
    {
        std::mutex& _mt;
    public:
        lock_guard(std::mutex& mt) : _mt(mt) { _mt.lock(); }
        ~lock_guard() { _mt.unlock(); }
    };
}

注意这里因为mutex不可以被拷贝所以是引用。

cpp 复制代码
namespace jiunian
{
    class lock_guard
    {
        std::mutex& _mt;
    public:
        lock_guard(std::mutex& mt) : _mt(mt) { _mt.lock(); }
        ~lock_guard() { _mt.unlock(); }
    };
}

std::mutex mt;

void print_even(int x) {
    if (x % 2 == 0) std::cout << x << " is even\n";
    else throw (std::logic_error("not even"));
}

void print_thread_id(int id)
{
    try
    {
        jiunian::lock_guard lock(mt);
        print_even(id);
    }
    catch (std::logic_error&)
    {
        std::cout << "[exception caught]\n";
    }
}

int main()
{
    std::thread threads[10];
    for (int i = 0; i < 10; ++i)
        threads[i] = std::thread(print_thread_id, i + 1);
    for (auto& th : threads) th.join();
    return 0;
}

这样即使抛了异常也不会死锁。

当然系统也为我们提供了这样的类,

两者都是系统提供的RAII所管理器。其中lock_guard功能简单,只有构造析构,轻量化,实现方式估计和我上面的差不多。而unique_lock则功能丰富,支持中途解锁,还能通过构造的标记位选项提供很多功能(延迟加锁、非阻塞加锁、超市加锁)。

cpp 复制代码
std::mutex mt;

void print_even(int x) {
    if (x % 2 == 0) std::cout << x << " is even\n";
    else throw (std::logic_error("not even"));
}

void print_thread_id(int id)
{
    try
    {
        std::unique_lock<std::mutex> lock(mt);
        print_even(id);
    }
    catch (std::logic_error&)
    {
        std::cout << "[exception caught]\n";
    }
}

int main()
{
    std::thread threads[10];
    for (int i = 0; i < 10; ++i)
        threads[i] = std::thread(print_thread_id, i + 1);
    for (auto& th : threads) th.join();
    return 0;
}

condition_variable库

<condition_variable>是 C++11 及以后标准库中用于多线程同步的头文件。它提供了条件变量相关的类与函数,用于线程间的等待 - 通知机制,让线程可以在某个条件满足时被唤醒,避免了忙等待,极大提升了多线程程序的效率。

最基础最常用的是condition_variable类,使用方法很简单。首先是构造函数

cpp 复制代码
default (1)	
condition_variable();
copy [deleted] (2)	
condition_variable (const condition_variable&) = delete;

如果我们想要线程阻塞等待时,可以使用接口

cpp 复制代码
unconditional (1)	
void wait (unique_lock<mutex>& lck);
predicate (2)	
template <class Predicate>
void wait (unique_lock<mutex>& lck, Predicate pred);

一种是直接等待,一种是带条件等待,这个其实就是直接等待外面套了一个while循环,判断条件是这个函数是否返回true。其实这个函数类比POSIX线程库就是

c 复制代码
int pthread_cond_wait(pthread_cond_t *restrict cond,
              pthread_mutex_t *restrict mutex);

知道的应该秒懂这个接口的原理,这个函数处于临界区内,被阻塞之前释放锁然后阻塞等待,这样所有的线程会陆续到这个函数这阻塞,直到被唤醒,唤醒后第一时间拿锁,防止多个线程被唤醒,我们配合条件来使用的话就是外面套一个while循环,防止被虚假唤醒(系统优化的副作用)或其他情况(比如生产消费者模型中唤醒线程数大于可消费资源数)。

最后是唤醒函数,

cpp 复制代码
void notify_one() noexcept;
cpp 复制代码
void notify_all() noexcept;

一个是唤醒一个,一个是唤醒全部,因为唤醒后还要抢锁,所以即使是唤醒全部,也还是一个个跑,我们用唤醒全部用的少,因为会有惊群效应。注意这个唤醒是唤醒这个条件变量wait队列下的线程。

此外还有几个接口,

这两个和上面的mutex的一样,都是带超时机制的wait函数,wait了指定时间段或到了指定时间点还没被唤醒就超时返回,用的比较少。

我们来用这个条件变量写一个两个线程交替打印奇偶数,

cpp 复制代码
#include<iostream>
#include<thread>
#include<mutex>
#include<condition_variable>

bool is_one = true; // 是不是一号线程打印
int num = 0;

int main()
{
	std::mutex mt;
	std::condition_variable cv;
	std::thread th1([&]() {
		for (int i = 0; i < 10; ++i)
		{
			std::unique_lock<std::mutex> lock(mt);
			cv.wait(lock, []() { return is_one; });
			++num;
			std::cout << "thread " << std::this_thread::get_id() << ": " << num << std::endl;
			is_one = !is_one;
			cv.notify_one();
		}
	});
	std::thread th2([&]() {
		for (int i = 0; i < 10; ++i)
		{
			std::unique_lock<std::mutex> lock(mt);
			cv.wait(lock, []() { return !is_one; });
			++num;
			std::cout << "thread " << std::this_thread::get_id() << ": " << num << std::endl;
			is_one = !is_one;
			cv.notify_one();
		}
	});
	th1.join();
	th2.join();
	return 0;
}

可以看到我们巧妙地运用了一个全局变量is_one使得线程一绝对先执行,且执行后取反使得每个线程只执行一次,这里最好使用一个条件变量来控制,不然会比较难控制。因为环境变量和全局变量本质都是线程间通信的手段,如果是两个环境变量,本质就是相互不打照面,只能靠全局变量控制了,想让线程之间协调,线程通信必不可少。

当然condition_variable这个类有一个特点,那就是wait必须传unique_lock,诚然unique_lock很好用,我们日常用的也多,但是如果我就不是unique_lock,是mutex直接lock的,难不成我还用不了了吗?其实不是,<condition_variable>中还有一个类叫condition_variable_any,听名字就知道,any就是任何吗,这个类的wait函数可以传任何锁。

甚至lock_guard也行,因为我们之前也讲过lock_guard只有构造析构两个接口,而条件变量有得中途解锁,其原理是:在等待前,它会先构造一个unique_lock来接管传入的锁,从而获得临时解锁和重新加锁的能力。等待结束后,它会确保锁被重新锁定,再交还给用户的lock_guard

atomic库

<atomic>是C++11及以后标准库中用于无锁多线程编程的核心头文件,它提供了原子类型和操作,能保证对共享数据的访问不会引发数据竞争,是实现高效并发的基础。

我们在对临界区很小,比如就一个int加加的场景时,如果加锁,那么线程切换也会消耗不少的资源,这样就很浪费,那么这时我们可以这样使用,

cpp 复制代码
std::atomic<int> a = 0;
std::thread th1([&](int num) {
	for (int i = 0; i < num; ++i)
		++a;
}, 10000);
std::thread th2([&](int num) {
	for (int i = 0; i < num; ++i)
		++a;
}, 10000);
th1.join();
th2.join();
std::cout << a << std::endl; // 20000

这是一个明显的线程不安全场景,但是这里a却是正常的结果,而且atomic内部也不是依赖锁实现的,那么atomic是怎么做到的呢?这就要提到CAS了。

CAS

CAS(Compare-And-Swap,比较并交换)是现代 CPU 提供的硬件级原子指令,是实现无锁并发的基石。它的伪代码逻辑是:

c 复制代码
bool compare_and_swap(T* addr, T expected, T desired) {
    if (*addr == expected) {
        *addr = desired;
        return true;
    }
    return false;
}

即有一个old值,是我上次修改完存下的值,我希望内存中应该是这个值,但是如果不是,那么就出错返回,如果是,就交换这个值(寄存器和内存交换)。注意,这个C式的伪代码并不是原子的,这只是对这个指令的演示,一个伪代码,这个指令现在主流的cpu都支持,一个指令就能做到上面的所有事,指令是cpu的执行的基本单位,是原子的(对于RISC精简指令集来说)。通过这个指令,我们的计算机科学家们将它玩出了花,就像一个swap指令整出了锁一样。

关于CAS的相关内容,大家感兴趣的可以去看酷壳 上的无锁队列的实现这篇文章,出自陈皓大佬,这里我就不过多赘述了。

CAS 指令不同的操作操作系统提供的不一样,C++为我们做了封装,我们直接使用atomic类就行了。atomic类支持大多数可平凡拷贝(TriviallyCopyable)类型。其中对整数类型(int / long 等)和指针类型还多一些可用函数,

就是原子增减位运算(&|^),还有++--+=-=&=|=^=的运算符重载语法糖,我们之前用的就是这个。所有类通用的是

cpp 复制代码
bool is_lock_free() const volatile noexcept;
bool is_lock_free() const noexcept;

is_lock_free检查当前原子对象是否是无锁实现(不依赖互斥锁,直接用硬件指令,有些原子类型不一定无锁,取决于平台和类型大小)。

cpp 复制代码
void store (T val, memory_order sync = memory_order_seq_cst) volatile noexcept;
void store (T val, memory_order sync = memory_order_seq_cst) noexcept;

store原子写入新值,支持显式指定内存序。

cpp 复制代码
T load (memory_order sync = memory_order_seq_cst) const volatile noexcept;
T load (memory_order sync = memory_order_seq_cst) const noexcept;

load原子读取当前值,支持显式指定内存序。

cpp 复制代码
operator T() const volatile noexcept;
operator T() const noexcept;

operator T隐式转换为原始类型,等价于 load(),是语法糖(可以转换后被<<打印)。

cpp 复制代码
T exchange (T val, memory_order sync = memory_order_seq_cst) volatile noexcept;
T exchange (T val, memory_order sync = memory_order_seq_cst) noexcept;

exchange原子交换值,写入新值并返回旧值。

cpp 复制代码
std::atomic<int> cnt(0);
int expected = 0;
// 循环使用 weak 版本,直到成功
while (!cnt.compare_exchange_weak(expected, expected + 1)) {
    // 失败后,expected 会被自动更新为当前值
}
std::cout << "CAS 成功,cnt = " << cnt << std::endl; // 输出:1


compare_exchange_weak / compare_exchange_strong原子比较并交换(CAS),如果当前值等于expected,则更新为desired,返回操作是否成功。

区别:
weak:可能出现 "虚假失败"(值相等但返回 false),但性能更高,适合循环使用。
strong:仅当值不相等时才失败,逻辑更直观,适合单次尝试。

cpp 复制代码
std::atomic<int> cnt(0);
int expected = 0;
// 循环使用 weak 版本,直到成功
while (!cnt.compare_exchange_weak(expected, expected + 1)) {
    // 失败后,expected 会被自动更新为当前值
}
std::cout << "CAS 成功,cnt = " << cnt << std::endl; // 输出:1

其中一些函数我们看到后面还有一个标志位参数,这个就是atomic中定义的一个枚举,因为每个函数保证原子性,都是一个个原子指令,这个枚举的变量传进函数用来控制指令重排序和内存可见性,

memory_order_relaxed:最宽松,只保证操作是原子的,不保证顺序。
memory_order_acquire:保证后续读操作不会被重排到这个操作之前。
memory_order_release:保证之前的写操作对其他线程可见。
memory_order_acq_rel:同时具备 acquire 和 release 的效果。
memory_order_seq_cst:默认值,最严格,保证所有线程看到的操作顺序完全一致。

默认的就是最严格的,我们初学者其实不传就行了,高手才会自己控制指令顺序,一般人不同用了反而可能会出错。

atomic_flag

std::atomic_flag 是C++ 标准库中最基础、最轻量化的原子布尔类型,也是唯一被 C++ 标准保证在所有平台上都是无锁(lock-free)的原子类型。有人会说有atomic<bool>啊,但是其不保证无锁,std::atomic_flag是底层同步标志,是实现自旋锁、信号量等基础同步工具的唯一可靠基石。

只支持默认构造,然而默认构造的bool值又是未定义的,所以我们也能使用ATOMIC_FLAG_INIT宏来初始化,将其初始化为未置位状态(即逻辑 false)。顺带一提,还有一个宏ATOMIC_VAR_INIT可以用来初始化除 atomic_flag之外的其他原子类型(如int、bool),将其初始化为指定的value,因为在 C++11 标准中,原子类型的初始化规则比较严格,其他原子类型的拷贝构造函数被删除,无法直接用 = 赋值,必须用 ATOMIC_VAR_INIT,从 C++20 开始,原子类型支持了直接初始化,这两个宏的使用场景就大大减少了,但为了兼容旧代码,它们仍然被保留(atomic_flag还要用宏)。

test_and_set原子地将标志设置为true,并返回操作前的旧值。
clear原子地将标志设置为false

我们可以利用这个实现一个简单的自旋锁。

cpp 复制代码
#include <atomic>
#include <thread>
#include <iostream>
class SpinLock
{
    std::atomic_flag flag = ATOMIC_FLAG_INIT;
public:
    void lock()
    {
        // 等待直到锁被释放
        while(flag.test_and_set(std::memory_order_acquire));
    }
    void unlock() { flag.clear(std::memory_order_release); }
};

int i = 0;

void task(SpinLock& spinlock)
{
    int num = 10000;
    while(num--)
    {
        spinlock.lock();
        // 执行临界区代码
        ++i;
        spinlock.unlock();
    }
}

int main()
{
    SpinLock spinlock;
    std::thread t1(task, std::ref(spinlock));
    std::thread t2(task, std::ref(spinlock));
    t1.join();
    t2.join();
    std::cout << i << std::endl; // 20000
    return 0;
}

future库

promise和future

<future>是C++11引入的并发库,用于处理异步任务的结果,让你可以在任务执行的同时继续其他工作,之后再获取结果,它是实现异步编程和并行计算的核心工具。之前我说C++实现的<thread>库的join没有接收线程返回值的手段,整个类也没有实现接收线程返回值的手段,如果我们真的想接收返回值,我们有其他的手段,就是指的这个。

首先我们要明白future库最基础最核心的两个类是promisefuturepromise即承诺,future即结果,意思就是我承诺在未来给你一个结果,

我们会让一个线程持有promise,一个线程持有future,持有promise的线程可以通过接口set_value设置值,持有future可以通过get接口获取这个值也就是结果。shared_state是一个线程安全的中间存储区域,用来存放promise传递的值或异常,它是promisefuture之间的纽带,两者通过它来实现同步和数据传递。当我们的future调用接口get时,如果shared_state中还没有存数据,那么会阻塞,这就是未就绪状态,我们使用set_value设置之后,就会变成就绪,然后唤醒get接口(不存在误唤醒),这样get接口才会返回。

好了,说了这么多我们直接来用一下,

cpp 复制代码
void test(std::promise<int>& pr)
{
	pr.set_value(100);
}

int main()
{
	std::promise<int> pr;
	std::future<int> fu = pr.get_future();
	std::thread th1(test, std::ref(pr));
	int ret = fu.get();
	std::cout << ret << std::endl;
	th1.join();
	return 0;
}

可以看到,我们先创建promisepromise有一个接口可以获取future,然后将promise给要给结果的线程,future给拿结果的线程,这样就能实现数据的传递。或者我们也能传递异常,

cpp 复制代码
void test(std::promise<int>& pr)
{
	try
	{
		throw std::runtime_error("出错了");
	}
	catch (...)
	{
		pr.set_exception(std::current_exception()); // 传递异常
	}
}

int main()
{
	std::promise<int> pr;
	std::future<int> fu = pr.get_future();
	std::thread th1(test, std::ref(pr));
	int ret = 0;
	try
	{
		ret = fu.get(); // 重新抛出异常
	}
	catch (const std::runtime_error& e)
	{
		std::cout << "捕获异常:" << e.what() << std::endl;
	}
	std::cout << ret << std::endl;
	th1.join();
	return 0;
}

get函数被唤醒发现是异常时,不会返回数据,而是重新抛出promise存入的异常(线程 B 可以通过try-catch捕获,实现跨线程异常传递)。这样就能实现自己线程的异常别的线程处理,还是比较有用的(比如生产消费者模型中)。

另外,promise中的get_future只能用一次,因为设计本质就是一个promise对应唯一的一个future,它们共享同一个 "结果状态"。如果允许多次调用get_future,就会生成多个future绑定到同一个promise,这会导致结果被多个消费者重复获取,破坏了 "生产者 - 消费者" 模型的一对一关系。共享状态的就绪、异常等事件需要精确通知到唯一的future,多个future会让状态同步变得复杂且容易出错。限制为一对一后,底层实现无需处理多future的竞态,性能更高。

promiseset_value / set_exception也只能调用一次,因为异步任务的结果 / 异常是一次性的,一个任务要么成功返回一个值,要么抛出一个异常,不可能同时返回多个结果或异常。共享状态一旦进入 "就绪" 状态(无论是值还是异常),就不能再被修改,否则会导致等待的future得到前后矛盾的结果。如果允许多次设置,底层需要维护复杂的状态队列,增加了死锁和内存泄露的风险。

最后futureget也只能调用一次,std::future是 "一次性消费" 的结果句柄,获取结果后就完成了它的使命。get会将结果从共享状态中移动出来(而非拷贝),避免了不必要的内存开销。移动后共享状态就为空了,无法再次获取。如果允许多次get,会导致同一个结果被重复处理,这在多线程场景下容易引发逻辑错误(比如重复执行回调)。如果需要多个线程共享同一个结果,标准库提供了shared_future,它支持多次调用get,这是专门为共享场景设计的。

future调用完get后,promise调用完set_value / set_exception后。都会失效,再次调用都会报错。

明白了最基本的用法,我们看看futurepromise的其他的接口,那些基本不常用,也不难,了解一下。

future

默认构造没什么用,创建一个无效的future,一般都是移动构造和移动赋值,左值版本的构造和赋值都被delete了,因为设计出来就是一对一,不允许直接拷贝的。share后面讲。vaild检查future是否持有有效的共享状态。wait阻塞等待共享状态就绪,但不获取结果,其余两个不多说,有超时机制版本的。

promise

同样左值版本的构造和赋值都被delete,不过promise一般移动构造用的少,基本是直接构造。因为future是通过promise接口获取,promise得自己构造。set_value_at_thread_exit就是设置结果值,但共享状态会等到当前线程退出时才变为 "就绪"。在必须确保线程的所有局部变量都已销毁后,再通知future的场景有用。set_exception_at_thread_exit同理。

shared_future

shared_future这个类其实很简单,他就是future的可拷贝版,之前我们都说future的左值拷贝赋值都被delete了,这个就没有,都能用,之前我们说futureget只能用一次,用完资源会被移走,future会失效,这个想怎么get,用不失效,多线程同时get都行。shared_future<future>中专为多线程共享场景设计的结果消费句柄,是future的 "共享版"------ 解决了future一次性消费、无法多线程共享结果的问题,支持多个线程多次、安全地获取同一个异步任务的结果 / 异常。

packaged_task

packaged_task也很简单,他就是一层封装,核心作用是将任意可调用对象(函数、lambda、仿函数、函数指针、包装器等)包装为 "异步任务",自动绑定一个共享状态,执行后会将结果 / 异常自动存入共享状态,配合 future / shared_future实现结果的异步获取,是 "将普通函数转为异步任务" 的便捷工具。说人话就是把普通函数传进去,你抛的异常直接帮你set_exception,你的函数返回值直接帮你改成set_value,通过这个类的接口可以获取对应promisefuture,从而get到这个值,就是多一层封装,简单吧。

cpp 复制代码
int test()
{
	return 100;
}

int main()
{
	std::function<int()>Test(test);
	std::packaged_task<int()> pt(Test);
	auto fu = pt.get_future();
	// pt(); 可以同步执行
	// std::thread th1(pt); 错误,不可拷贝
	std::thread th1(std::move(pt));
	std::cout << fu.get() << std::endl;
	th1.join();
	return 0;
}
cpp 复制代码
int test()
{
	throw std::runtime_error("出错了");
	return 100;
}

int main()
{
	std::function<int()>Test(test);
	std::packaged_task<int()> pt(Test);
	auto fu = pt.get_future();
	// pt(); 可以同步执行
	// std::thread th1(pt); 错误,不可拷贝
	std::thread th1(std::move(pt));
	int ret = 0;
	try
	{
		ret = fu.get(); // 重新抛出异常
	}
	catch (const std::runtime_error& e)
	{
		std::cout << "捕获异常:" << e.what() << std::endl;
	}
	std::cout << ret << std::endl;
	th1.join();
	return 0;
}

再来看剩下的一些接口,

又又又又是同样左值版本的构造和赋值都被deletevalid判断packaged_task是否持有有效的可调用对象和共享状态。make_ready_at_thread_exitpromiseset_value_at_thread_exitset_exception_at_thread_exit同理,执行任务,但共享状态会等到当前线程完全退出时才标记为 "就绪"。reset销毁当前共享状态,重新创建一个新的共享状态,使任务可以再次执行,就是重置任务。

cpp 复制代码
std::packaged_task<int()> task([]{ return 42; });
task(); // 第一次执行
task.reset(); // 重置
std::future<int> fut2 = task.get_future();
task(); // 第二次执行

async

async<future>中最便捷的异步任务启动方式,它可以直接创建并启动一个异步任务,同时返回一个future用于获取结果或异常。无需手动管理promisepackaged_task,大大简化了异步编程的代码量。说人话就是最无脑的版本,这玩意是个函数,你传任意可调用对象(函数、lambda、仿函数、函数指针、包装器等),要传参后面带上参数,它直接给你返回一个future,万事你用它获取结果就完事了。

async本质上是一个高层封装,底层会根据你指定的启动策略,自动选择用packaged_taskthread来执行任务,并返回一个绑定了结果的future

cpp 复制代码
unspecified policy (1)	
template <class Fn, class... Args>
future<typename result_of<Fn(Args...)>::type> async (Fn&& fn, Args&&... args);
specific policy (2)	
template <class Fn, class... Args>
future<typename result_of<Fn(Args...)>::type> async (launch policy, Fn&& fn, Args&&... args);

它有两个版本,一个是指定启动策略的,一个是不指定的,启动策略是什么呢?这其实是一个枚举类,

它包含两个最常用的枚举值,你可以单独使用,也可以组合使用:
launch::async:强制在一个新创建的独立线程中异步执行任务。
launch::deferred:延迟执行,直到你调用future.get()future.wait()时,才会在当前线程(调用get() / wait()的线程)中同步执行。

如果我们不显式指定策略时,async会使用默认策略:std::launch::async | std::launch::deferred。意思是运行时会根据系统负载、线程池状态等因素,决定是异步执行还是延迟执行。这是不确定的,所以最好是指定一下。

我们来用一下。

cpp 复制代码
int test()
{
	//throw std::runtime_error("出错了");
	return 100;
}

int main()
{
	auto fu = std::async(std::launch::async, test);
	int ret = 0;
	try
	{
		ret = fu.get(); // 重新抛出异常
	}
	catch (const std::runtime_error& e)
	{
		std::cout << "捕获异常:" << e.what() << std::endl;
	}
	std::cout << ret << std::endl;
	return 0;
}
相关推荐
0x532 小时前
JAVA|智能仿真并发项目-并行与并发
java·开发语言
漫漫求2 小时前
1、IM:基础连接
开发语言·后端·golang
gjxDaniel2 小时前
JavaScript编程语言入门与常见问题
开发语言·javascript
kk哥88992 小时前
C++新手入门
开发语言·c++
Hello eveybody2 小时前
Java发明者介绍
java·开发语言
Coding茶水间2 小时前
基于深度学习的红外镜头下的行人识别系统演示与介绍(YOLOv12/v11/v8/v5模型+Pyqt5界面+训练代码+数据集)
开发语言·人工智能·深度学习·yolo·目标检测·机器学习
代码游侠2 小时前
嵌入式开发代码实践——串口通信(UART)开发
c语言·开发语言·笔记·单片机·嵌入式硬件·重构
没有梦想的咸鱼185-1037-16632 小时前
AI大模型支持下的:R-Meta分析核心技术:从热点挖掘到高级模型、助力高效科研与论文发表
开发语言·人工智能·机器学习·chatgpt·数据分析·r语言·ai写作
gihigo19982 小时前
MATLAB中点扩散函数(PSF)的实现方案
开发语言·matlab