type
status
date
slug
summary
tags
category
icon
password
多线程
多线程入门
启动一个多线程,并且输出Hello World。
Q1 我们要开几个线程?
- 以前的CPU:4核8线程;现在CPU分大小核,需要根据具体CPU来看。
- 根据现在文档的要求,较小的文档无需开线程,大的可以试一下开2,3,4个线程测试效率。
使用代码unsigned int max_thread = std::thread::hardware_concurrency();
std::thread::hardware_concurrency()
是C++标准库中的一个函数,它返回一个无符号整数,表示可以同时并发执行的线程数。
当线程函数有参数的时候,将参数依次写在后面即可,比如:
- 当线程
t
启动时,它会执行print
函数,并将value
作为参数传递给print
函数。在print
函数中,参数x
将接收value
的值,即42,并将其输出到标准输出。
Thread:在C++中,std::thread
是用于创建和管理线程的类,它位于<thread>
头文件中。
T1 课堂练习
请写一个例子,这个例子中程序启动一个线程,这个线程的作用是将传入的数字改变为原来的两倍。
参考答案:
数据共享
上面的例子都是一些多个线程之间互不影响的例子,如果线程之间数据有交互,那么就需要考虑多线程之间数据共享的问题了。
例如有100张票,两个平台同时销售,售完为止。
可以发现,这里的总票数是所有线程都可以访问到的数据,每个平台的票数是线程独有的数据,其他线程不能直接访问。
CPU频率:CPU的工作原理是通过时钟信号来同步其内部操作。时钟信号是一个周期性的脉冲,它告诉CPU何时开始执行一个操作。CPU频率就是这个脉冲的频率,即每秒钟脉冲的次数。例如,一个2GHz的CPU意味着它的时钟频率是2,000,000,000赫兹,即每秒可以产生20亿个时钟脉冲。
互斥量
上面演唱会门票的例子在实际运行中是有问题的,因为假设最终还有1张票,两个平台这时同时申请,会出现两个平台都申请到的情况,最终会多卖出1张票。

为了解决这类型问题,我们引入了互斥量这一概念,在C++中也就是锁:std::mutex.
mutex的原理是,当一个线程执行lock()函数之后,其他线程可以继续运行,但是当其他线程也运行到lock()函数的时候,就会被阻塞,直到lock()的线程。
当第一个线程执行到lock的时候,它拿到cpu的执行权,继续执行线程逻辑。当另外的线程执行到lock的时候,它会暂时陷入自旋,此时它会独占一个线程通道,自旋期间如果lock的线程执行了unlock它就可以继续执行下去。但是如果lock的线程迟迟执行不到unlock,那么操作系统是不会允许这个线程通道一直被这个自旋的线程独占,这时候会从用户态转入内核态,让出线程通道来让别的线程执行。
Q2 什么时候会进入内核态?
- 开的线程太多,CPU一直在执行别的线程。
- 锁本身不会造成严重的性能开销,但是加锁不当会导致程序转入内核态(涉及下一个部分:粒度)
自旋:while(true)空循环。挂起:线程挂起是指操作系统暂时停止一个线程的执行,将其置于非运行状态,直到某些条件满足后,该线程才可能被重新激活并继续执行。(从用户态转入内核态,即操作系统接管程序,程序效率会大幅度降低)
还有一种不需要进入内核态的锁,是std::atomic ,原子变量的加速无需经过操作系统的处理,是通过编译器用原子指令实现的,所以原子量的效率优于线程锁。
T2 售票练习
有一个演唱会门票的数量是1000,在5个平台进行销售,售完为止。我们用5个线程模拟售票点,假设每个平台一次可以购票50张票,用户只能从平台购买门票,一次购买一张,平台的门票售空才可以再次向总部购票。
可以发现,这里的总票数是所有线程都可以访问到的数据,每个平台的票数是线程独有的数据,其他线程不能直接访问。
粒度
从上一个课题我们可以发现,锁本身不会造成严重的性能开销,但是加锁不当会导致程序转入内核态,这个过程有巨大的性能开销,所以我们在写C++代码的时候除了要注意共享数据的安全性,也要注意程序的性能,核心要点就是不要让一个线程陷入长时间的等待。
程序在哪些关键步骤需要加锁被称之为锁的粒度,如果程序上锁了无需上锁的语句,在逻辑和功能上看起来没有任何问题,但是却会导致性能上面的问题,所以上锁的时候我们首先应该关心锁的正确性,其次就是关心锁的粒度,既在一个多线程环境下,哪些步骤是必须需要加锁的,哪些步骤无需加锁。
lock_guard
lock_guard
是一个RAII(资源获取即初始化)风格的锁管理类,用于自动管理互斥锁(mutex
)的加锁和解锁。
- 它在构造时锁定互斥锁,在析构时自动释放锁,确保即使发生异常也不会忘记解锁。
特点:不支持手动解锁或重新加锁。
Q3 如何控制lock_guard不执行//部分代码减少性能开销?
- 通过加{}控制其生命周期,防止额外的性能开销,即限制了锁的范围。
- 相比于lock,在程序不执行该内容时无需加锁,避免了lock的缺点。
thread_local
thread_local
是一个存储类说明符,用于声明线程局部变量。
- 每个线程都有其独立的变量副本,线程之间不会共享这些变量。
访问
thread_local
变量的开销比普通变量大。unique_lock
unique_lock
是一个更灵活的锁管理类,与lock_guard
类似,但提供了更多功能。
- 它支持手动加锁、解锁、延迟加锁、条件变量等高级功能。
死锁
两个或多个线程在执行过程中,因为争夺资源而造成的一种互相等待的现象,导致这些线程都无法继续执行下去。
例:比如我们有两个线程代表两台服务器,一个服务器用于数据查询存储,一台用于数据计算。然后我们日常生活中有两种任务,一种任务是先查询数据,然后使用查询到的数据进行计算。另一种是先计算数据,然后将计算结果保存。这时候如果我们仅仅对每台服务器单独加锁,就会出现一种场景,当两种任务同时到来的时候,任务A执行完成想要获取计算服务器,但是计算服务器被任务B占用;任务B想要获取查询存储服务器但是被任务A占用。
解决死锁总的来说有两种解法:
- 一种是我们不对可能出现冲突的局部加锁,而是对整个任务加锁。就像上面的例子中,当任务A来的时候,同时对数据服务器和计算服务器加锁,计算完成之后释放。这样就不会出现死锁。
- 另一种解法是当一个任务持有锁超时之后就释放任务,短暂等待后重新执行任务。
Q3 两种死锁解法分别适用于什么场景?
- 整体加锁:任务类型固定(资源访问顺序确定且数量较少),任务执行时间短、避免高频锁竞争。
- 超时释放:任务执行时间较长、动态资源分配、并发度高,竞争严重。
条件变量
日常生活中有一类常见的问题,那就是生产者-消费者问题,这类型问题几乎无处不在。比如我们观看在线视频。这里有一个逻辑就是视频的数据来源于网络,数据的使用者是本地播放器。因此这里就有一个模型,即只有下载够一定数量的数据的时候播放器才开始播放,当下载数据超过一个限度的时候就停止缓存,当播放(使用数据)到一定程度的时候又继续下载。
这个模型里下载器扮演了生产者的角色,播放器扮演了消费者的角色。
T3 生产者-消费者问题
我们假设一个场景,有一个下载线程和一个播放线程,下载线程检测到当前没有任何数据就开始下载,直到下载数据数量达到5(我们假设一次下载任务执行可以下载一个数据),下载数量达到5之后下载线程停止工作,播放线程开始工作,直到消耗完下载线程的所有数据。请用C++代码表示这个过程。
我们假设上面的例子不是一次性的,而是有一个场景,在这个场景下我们这两个线程长期存在,线程A会一致下载文件到本地磁盘中,当线程A下载文件大小占满磁盘空间时候,我们就暂停下载并且开启B线程,B线程会解析处理这些文件,然后将其删除,当B线程删除文件到磁盘空间为0的时候,我们就暂停B线程并重新启动A线程。
请完成上述代码(下述为参考答案)
观察上面代码和其运行结果,并且指出上面代码的缺陷是什么?(从粒度的角度考虑)
线程池
- 减少线程创建销毁的开销:通过复用固定数量的线程,提高性能。
- 任务并行执行:提高 CPU 利用率。
- 避免资源过载:限制同时运行的线程数量,防止系统过载。
RAII(Resource Acquisition Is Initialization)
在RAII中,资源的获取和释放被绑定到对象的构造函数和析构函数上。当对象被创建时,资源被获取并初始化,当对象离开作用域时,析构函数被调用,资源被释放。这样可以确保在任何情况下都能正确地释放资源,无论是程序正常执行还是发生异常。
- 用于防止资源泄露。
RAII的核心思想是将资源的生命周期与对象的生命周期绑定在一起,通过对象的构造函数和析构函数来管理资源。
T4 请尝试将上面代码改为RAII的实现方式。
内存泄漏就让他漏,可行吗?
智能指针
资源与所有权(Ownership)
对象(资源)的生命周期由哪个实体(指针、变量、对象等)管理。所有权决定了 资源何时分配、何时释放,以及哪个对象有权操作资源。
我们仅有在涉及到传递所有权的时候才使用智能指针。这意味着智能指针应该用于管理资源的生命周期,而不应仅仅作为普通指针的替代品。
std::unique_ptr
(唯一所有权)
std::shared_ptr
(共享所有权)
std::weak_ptr
(非所有权)
lambda表达式
定义:一种在调用匿名函数对象或作为函数的参数传递的位置定义匿名函数对象的便捷方法。
另外一种定义:构造一个闭包:一个能够捕获作用域中变量的未命名函数对象(闭包的意思是将函数与环境一起存储的记录。)
语法:
1 capture 子句(在 C++ 规范中也称为 lambda 引入器。)
2 参数列表自选。(也称为 lambda 声明符)
3 可变规范自选。
4 异常规范自选。
5 尾随返回类型自选。
6 λ体。
捕获列表规则:
[] - 不捕捉任何变量
[&] - 捕获外部作用域中所有变量,并作为引用在函数体内使用 (按引用捕
获)
[=] - 捕获外部作用域中所有变量,并作为副本在函数体内使用 (按值捕获)
注:拷贝的副本在匿名函数体内部为const类型.
[=, &foo] - 按值捕获外部作用域中所有变量,并按照引用捕获外部变量 foo
[bar] - 按值捕获 bar 变量,同时不捕获其他变量
[&bar] - 按引用捕获 bar 变量,同时不捕获其他变量
[this] - 捕获当前类中的 this 指针
让 lambda 表达式拥有和当前类成员函数同样的访问权限
如果已经使用了 & 或者 =, 默认添加此选项
lambda表达式匿名性的用法:
lambda可以帮助const变量的初始化
随堂作业
使用STL中的std::sort函数,通过lambda表达式来对一个自定义类型的Vector进行升序排序。
- 一次性函数使用lambda表达式
右值引用
- 移动赋值运算
- 右值就是为了找到移动构造函数,又被称为临时值。
移动语义详解
工程应用
*引用限定符
完美转发
std::move并不移动任何东西,完美转发也并不完美。移动操作并不永远比复制操作更廉价.
新语法和其他
auto
核心:避免重复
为什么我建议优先使用auto,看下面这个例子:
什么情况不使用auto?
decltype
核心:推导类型
返回类型后置
nullptr
nullptr的类型为std::nullptr_t,而NULL的类型为int,可以防止一些类型转化导致的问题。
NULL的类型是数字0(int),可能会存在二义性。(如函数重载)C++11以上推荐始终使用nullptr,因为它类型安全,避免二义性。
新关键词
override(重要且常见)
用于明确指示派生类中的成员函数是对基类中的虚函数的重写。
override
是C++11引入的关键字,用于显式指示子类中的成员函数重写了基类的虚函数。它主要用于编译期检查,可以防止意外的函数重载或拼写错误。重载函数加上override!!!
explicit(重要且常见)
发生了隐式类型转换的有风险代码:
使用
explicit
关键字的无风险代码:delete
用于阻止特殊成员函数的生成或者禁用某些成员函数。可以通过 delete 来删除或禁用特殊成员函数,例如禁止复制构造函数或移动赋值运算符的使用。
default
用于生成默认的特殊成员函数实现。
final
用于标记虚函数,表示该函数不能在派生类中再次被重写。
constexpr
常量函数计算
构建静态数据表
安全类型的枚举
std::tuple
- Author:Koreyoshi
- URL:https://Koreyoshi1216.com/article/1bfc7b13-c6a7-80a3-8666-c726d49424dc
- Copyright:All articles in this blog, except for special statements, adopt BY-NC-SA agreement. Please indicate the source!
Relate Posts