C++ 多线程入门
1|0进程与线程的区别
进程就是运行中的程序。
线程就是进程中的进程。
2|0thread
创建线程
这里创建了两个线程,我们运行代码,发现输出格式是混乱的。这其实也反应了我们的线程是并行执行的。该如何解决呢?我们可以使用thread::join()
函数,等待线程完成其执行。
这样后一个进程必须要等待前一个进程被执行完成才能创建。
由此可以引出一个函数thread::joinable()
, 检查线程是否可合并,即潜在运行于并行上下文之中。
什么是可合并的线程?结束执行代码,但仍未合并的线程仍被当作活跃的执行线程,从而是可合并的。
因此其返回值就是:若 std::thread
对象标识活跃的执行线程则为 true,否则为 false。
注意,构造线程对象时,如果需要传引用,则需要std::ref
。
现在我们再来看这个例子
这个例子只有一个线程。但是如果我们运行的话,会发现一个报错terminate called without an active exception
。这是因为当线程被创建后,线程就会开始执行,同时主函数会继续执行。此时主函数就会return 0;
,也就可能会发生主函数已经结束,但是输出还没有完成的情况。因此就会产生这个报错。对于这个问题当然可以用thread::join()
解决。当这样话,代码就会变成顺序执行,也就失去了多线程的意义。
对于这种情况,我们可以用thread::detach()
,从 thread
对象分离执行线程,允许它独立地持续执行。当该线程退出时将释放其分配的任何资源。调用 detach
后*this
不再占有任何线程。
注意当一个线程被detach
后,若主线程(通常是main
函数所在的线程)已结束而该线程仍在运行,该线程会被强制终止,不会继续执行。
3|0悬空引用
这个例子,在绝大多数的情况下,都不会得到正确的结果。这是因为当需要输出(*x)
时,ptr
已经被delete
了。此时x
就变成了悬空指针。
对于这个问题,我们就可以用智能指针实现。
再看下面的例子
这个例子会出现什么问题?可能出现的情况是call()
函数已经结束运行了,但是线程t
还没有结束。
对于这个问题,我们可以用RAII的思路解决。首先先实现一个类。
这个类引用了一个线程,这个类在析构时会保证_t
已经执行完。
4|0互斥量
这个x
的值应该是200000。但你运行的 结果,大概率不是。这是因为线程是可以并行执行的,可能会对数据进行竞争,也就是两个函数同时进行x += 1
操作,也就会造成其中的一个操作无效。
为了避免数据竞争,我们可以通过互斥锁来实现这个。
只要这样,无论怎么运行结果都一定是200000。注意这里的锁并不是锁某个对象,而是锁lock()
与unlock()
之间的操作。也就是两个线程不能同时执行x +=1
操作。
5|0互斥量死锁
看这个例子
运行结果如下
并且程序卡住了,为什么?因为线程t1在等待线程t2释放m2,线程t2在等待线程t1释放m1。两个线程互相等待,就形成了死锁。
6|0lock_guard
我们刚才看的例子,都需要手动进行上锁和解锁。这种做法其实不符合RAII思想。实际上C++内置了一个std::lock_guard
,用法非常简单。当创建 lock_guard
对象时,它尝试接收给定互斥体的所有权。当控制离开创建 lock_guard
对象的作用域时,销毁 lock_guard
并释放互斥体。
我们可以看一下源码的实现
这个实现和我们之前实现thread_guard
非常相似,注意这里有第二个构造函数,这个构造函数并不会上锁,只会记录这个锁状态。如果需要用第二个构造函数可以
7|0unique_lock
类 unique_lock
是一种通用互斥包装器,允许延迟锁定、有时限的锁定尝试、递归锁定、所有权转移和与条件变量一同使用。类 unique_lock
可移动,但不可复制。
自动上锁解锁比较简单。
如果需要延迟上锁
我们来看有时限尝试锁定如何实现。首先这里我们不能使用std::mutex
,而应该使用std::timed_mutex
这个锁会尝试如果2秒内成功上锁就会返回true
。否则,这个锁不会持续阻塞,返回一个false
。
除此之外,他还有一个成员函数是std::unique_lock<Mutex>::try_lock_until
,他需要你传入一个时间点,这个时间点之前他会尝试锁定,超过时间点就终止阻塞。
unique_lock
是支持移动语义。
8|0std::call_once
单例模式是一种创建型设计模式,其核心目标是确保一个类仅有一个实例,并提供全局访问点
核心特点
-
唯一实例性
通过私有构造函数和静态成员变量,严格限制类只能生成一个实例
-
自行创建与初始化
类的实例由自身在首次调用时创建(懒汉式)或类加载时创建(饿汉式)
-
全局访问入口
通过静态方法提供统一的访问入口,确保所有代码操作同一实例
这是一个简单的例子
在这个例子中如果我需要打印日志。
好的,现在有一个问题是如果这个代码在多线程中运行,这个代码就会出现问题,比如if (log == nullptr) log = new Log;
如果发生了数据竞争,这里就会多次构造。就没有保证实例唯一。这样我们可以用解决std::call_once
解决。
我们来看call_once
函数定义,首先需要有一个once_flag
,还有一个可调用对象f
,这样就能保证在多个线程中这个可调用对象只被执行一次。
除此之外,这个代码还有一些问题就是打印的时候没有上锁,可能会日志混乱,_log
没有RAII规范等,之前的代码中实际上就会造成内存泄漏,因为我没有在析构函数中手动deleta _log
。为此可以做出以下更改。
9|0condition_variable 的核心机制
9|1生产者-消费者模型
首先我们先了解一个模型
- 生产者:生成数据并放入共享缓冲区,若缓冲区已满则等待。
- 消费者:从缓冲区取出数据,若缓冲区为空则等待。
- 同步目标:生产者不会覆盖未消费的数据,消费者不会读取无效数据。
9|2condition_variable
- 等待(Wait):线程通过
condition_variable::wait
进入阻塞状态,直到被其他线程唤醒。等待时,线程会释放关联的互斥锁,允许其他线程获取锁。 - 通知(Notify):通过
condition_variable::notify_one
或notify_all
唤醒等待的线程。前者唤醒一个线程,后者唤醒所有等待线程。 - 条件检查:线程被唤醒后需重新检查条件(通常使用循环),防止虚假唤醒(即无明确通知下的意外唤醒)。
在下面的例子中,有一个生产者和两个消费者
10|0异步并发
异步并发是一种结合异步执行和并发处理的编程范式,旨在高效管理多个任务,尤其在处理I/O密集型操作时优化资源利用和响应速度。
- 异步(Asynchronous)
指任务发起后不等待结果,继续执行后续代码,待任务完成后再通过回调、事件通知等方式处理结果。例如,发起网络请求后,程序可继续执行其他逻辑,无需阻塞等待响应。 - 并发(Concurrency)
指在同一时间段内处理多个任务的能力。并发可通过单线程的“任务切换”(如事件循环)或多线程/多核并行实现。例如,在单核CPU中,多个任务看似同时运行,实则是快速切换执行。
10|1std::future
- 功能:用于获取异步操作的结果,是异步任务与主线程之间的桥梁。
- 关键方法
get()
阻塞直到结果就绪,并返回结果(仅可调用一次)。wait()
仅等待结果就绪,不获取值。wait_for()
/wait_until()
:超时等待结果。
- 特性:若异步任务抛出异常,
get()
会重新抛出该异常;future
不可复制,但支持移动语义。
10|2std::async
函数模板 std::async
异步地运行函数 (有可能在可能是线程池一部分的分离线程中),并返回最终将保有该函数调用结果的 std::future
。
有两种启动策略
std::launch::async
:立即在新线程中执行。std::launch::deferred
:延迟到调用get()
或wait()
时执行。
我们下面来看一个简单例子。
首先我定义了一个函数initRandomVector
,并且随机化两个数组,现在我要求出两个数组和的绝对值,我可以写出一下的代码。
然后我们注意到两个数组求和的过程实际上是独立的,也就是可以并行执行的,并且对于a_sum
的值,也只有最后计算绝对值的时候才会用到。因此我们可以把计算a
数组和的部分异步并发也就是先发起求和a
的任务,然后不等待计算结果,继续执行对b
求和。
这样的话代码可以优化成下面的样子
10|3std::packaged_task
类模板 std::packaged_task
包装任何可调用目标(函数、lambda 表达式、bind 表达式或其他函数对象),使得能异步调用它。其返回值或所抛异常被存储于能通过 std::future
对象访问的共享状态中。
关键方法:
operator()
:执行任务,结果自动写入future
。get_future()
:获取关联的future
对象。
注意:std::packaged_task
必须显示的描述可调用函数的类型。
注意:std::packaged_task
不支持复制,只支持移动。这是因为它内部封装了可调用对象和共享状态(用于存储异步结果),这些资源需要独占性管理,避免多个对象同时操作导致数据竞争或逻辑错误
这里是异步执行的,我们创建任务后没有等待任务执行而是继续操作。这里不仅可以手动传入参数,也可以用bind
表达式传入在构造时直接传入参数。
但是这样只能实现异步,如何实现并发?我们可以和thread
结合使用。
这里有几个问题,我们逐一思考
为什么获取关联对象必须在创建线程之前?
因为创建线程中使用到了std::move
,因此创建线程后task_calc
对象变为空状态,因此无法获得关联对象。
为什么必须要使用std::move
?
因为std::packaged_task
不支持拷贝,只支持移动,需要std::move
。
std::thread
接受可调用对象是Function&& f
也就是转发引用(万能引用),为什么一定要std::move
包装成右值引用,不能用std::ref
包装成左值引用?
如果采用左值引用,可能会出现多个线程访问同一任务对象,引发未定义行为。比如如下代码可以正常编译运行,但是会出现运行时错误。
std::futurd::get()
已经可以确保任务完成,为什么还需要std::thread::join()
确保线程结束?
- 阻塞等待结果:
get()
会阻塞当前线程,直到异步任务完成并将结果存储到共享状态中。例如,在std::packaged_task
或std::async
的场景中,get()
会等待任务执行完毕并返回结果。 - 不直接控制线程:
get()
仅保证异步任务的逻辑执行完毕(结果已就绪),但不保证线程本身已终止。例如,如果线程中还有其他代码在std::packaged_task
执行之后运行,这些代码可能未被get()
等待。
比如下述例子中,任务已结束,但是线程没有结束。
因此为了更方便的管理进程,可以用std::async
实现。
与std::async
的关系
async
≈ thread
+ packaged_task
std::async
内部封装了线程创建和任务执行逻辑,返回的future
可直接获取结果,无需显式管理线程。
为什么说约等于?
async
不仅支持异步执行,还支持延迟执行std::launch::deferred
。async
不需要显示的管理线程。
10|4std::promise
类模板 std::promise
提供一种设施用以存储一个值或一个异常,之后通过 std::promise
对象所创建的 std::future
对象异步获得。
成员函数
set_value
设置结果为指定值set_value_at_thread_exit
设置结果为指定值,同时仅在线程退出时分发提醒set_exception
设置结果为指示异常set_exception_at_thread_exit
设置结果为指示异常,同时仅在线程退出时分发提醒
set_value
和 set_value_at_thread_exit
区别
set_value
立即获得共享状态的值,并同步将状态标记修改为ready
。此时任何阻塞在future::get()
或future::wait()
的线程会立即被唤醒并获取结果set_value_at_thread_exit
,设置共享状态的值,但延迟标记状态为 ready,直到当前线程退出且所有线程局部对象被销毁后,共享状态才变为ready
。
异步并发的简单例子
注意,promise
不只用于异步并发,实际上可以用于线程间传递信息。让生产者获得promise
,消费者获得对应的future
即可。
11|0原子操作 std::atomic
C++ 中的原子操作(std::atomic
)是处理多线程并发编程的核心工具,用于确保共享数据的原子性、可见性和顺序一致性。以下是其关键要点:
- 原子性:原子操作是不可分割的单元,执行过程中不会被其他线程中断。例如,fetch_add() 保证对变量的增减操作是整体的,不会出现中间状态。
- 可见性:对原子变量的修改会立即对所有线程可见,避免因缓存不一致导致的数据不同步问题。
- 无锁编程:通过原子操作可以实现无锁数据结构(如自旋锁、计数器),减少锁竞争和上下文切换的开销。
我们来看这样的一个操作
这里上锁是因为有多个线程可能会同时访问一个变量。我们可以用原子操作来省去加锁的步骤,因为原子操作不会被其他进程终端。
11|1常用的成员函数
load()
原子读取,可以std::atomic<T>::load()
读取T
的值。
我们上面的操作cout << x << endl;
是隐式的调用了load()
。也就是
store()
原子写入:将值写入 std::atomic
对象,确保操作是原子的,不会被其他线程的读写操作干扰。
上面赋值实际上就是隐式的调用了store()
函数。
为整数、浮点数(C++20起)、指针的特化成员函数
fetch_add
原子地将实参加到存储于原子对象的值上
fetch_sub
原子地从存储于原子对象的值减去实参
特性 | fetch_add /fetch_sub |
operator+= /operator-= |
---|---|---|
返回值 | 操作前的旧值 | 操作后的新值 |
底层实现 | 原子性“读取-修改-写入”操作 | 通常基于fetch_add /fetch_sub 实现 |
适用场景 | 需要获取旧值(如计数器、CAS循环) | 仅需更新值(简洁语法) |
内存序 | 可显式指定(如std::memory_order_relaxed ) |
默认std::memory_order_seq_cst |
为整数和指针类型特化
opetator++,operator--
令原子值增加或减少一
仅为整数类型特化
fetch_and |
原子地进行实参和原子对象的值的逐位与,并获得先前保有的值 (公开成员函数) |
---|---|
fetch_or |
原子地进行实参和原子对象的值的逐位或,并获得先前保有的值 (公开成员函数) |
fetch_xor |
原子地进行实参和原子对象的值的逐位异或,并获得先前保有的值 (公开成员函数) |
operator&=,operator|=,operator^= |
与原子值进行逐位与、或、异或 |
11|2内存序
C++ 定义了 6 种内存序选项,按约束强度从弱到强排列如下:
-
memory_order_relaxed
- 语义:仅保证原子性,不保证操作的顺序或可见性。
- 场景:适用于对顺序无要求的独立操作(如计数器累加)。
-
memory_order_consume
- 语义:确保后续依赖于当前操作的指令不会被重排到前面(依赖顺序)。
- 注意:实际中较少使用,通常被优化为
memory_order_acquire
-
memory_order_acquire
- 语义:用于读操作,保证当前线程的后续操作不会被重排到该读操作之前。
- 典型应用:与
memory_order_release
配对,实现“生产者-消费者”模型中的同步
-
memory_order_release
- 语义:用于写操作,保证当前线程的前序操作不会被重排到该写操作之后。
- 配对使用:与
memory_order_acquire
结合,确保数据发布到其他线程
-
memory_order_acq_rel
- 语义:结合
acquire
和release
,适用于读-修改-写操作(如compare_exchange_wea
)
- 语义:结合
-
memory_order_seq_cst
- 语义:默认选项,保证全局顺序一致性,所有线程看到的操作顺序一致。
- 代价:性能开销最大,适用于需要强一致性的场景(如银行交易)。
__EOF__

本文链接:https://www.cnblogs.com/PHarr/p/18749703.html
关于博主:前OIer,SMUer
版权声明:CC BY-NC 4.0
声援博主:如果这篇文章对您有帮助,不妨给我点个赞
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 单元测试从入门到精通
· 上周热点回顾(3.3-3.9)
· winform 绘制太阳,地球,月球 运作规律