作为开发过程中手册浏览。
1.1. 线程代码示例
1.2. 对象生命周期和线程等待和分离
课程中用的vs2019,可以看到thread类的源码,可以看到构造、析构的写法(若没有join则删除对象,释放空间)。
- 如果调用
detach
,则分离,主线程中不用再关注该线程 - 若
join
方式来精确控制子线程,则一般需要维系该线程对象,一般将th对象放在成员函数中
当线程的执行结果不需要返回给主线程,或者可以通过其他方式通知主线程时,可以使用detach()
。例如,一个后台日志记录线程,它只负责将日志信息写入文件,不需要和主线程交互,就可以使用detach()
。
当线程的执行时间不确定,或者可能比主线程更长时,可以使用detach()
。例如,一个网络请求线程,它可能会因为网络延迟或者服务器故障而阻塞很久,如果使用join()
,就会导致主线程也无法继续运行,影响用户体验。如果使用detach()
,就可以让主线程继续响应用户操作,而网络请求线程在后台等待结果。
当线程的执行逻辑和主线程无关,或者可以独立完成时,可以使用detach()
。例如,一个音乐播放器程序,它可以创建一个音乐播放线程来控制音乐的播放、暂停、切换等操作,而主线程只负责显示界面和接收用户输入。这样的话,音乐播放线程就可以使用detach()
,让它和主线程分离。
detach()
也有一些潜在的风险和问题,例如:
detach()
后的线程可能会访问已经被销毁的对象或变量,导致程序崩溃或者数据不一致。例如,如果一个线程要访问主线中的对象以及指针问题。detach()
后的线程可能会在主线程结束后还没有执行完毕,导致程序异常退出或者资源泄露。例如,如果一个线程要写入文件或者数据库等外部资源。detach()
后的线程可能会难以控制和监控,导致程序逻辑混乱或者性能下降。例如,如果一个程序创建了过多的detach()
的线程。
因此,在使用detach()
时,需要注意以下几点:
- 尽量避免在
detach()
的线程中访问主线中的对象或变量,或者使用同步机制来保护共享资源。 - 尽量确保
detach()
的线程在主线结束前能够正常退出,或者提供一种机制来通知detach()的线程退出。 - 尽量控制
detach()
的线程的数量和质量,避免过度消耗系统资源和影响程序稳定性。
1.3. 全局函数作为线程入口分析参数传递内存操作
如何避免复制,1.4. 线程函数传递指针和引用 中解释。
1.4. 线程函数传递指针和引用
1.5. 使用成员函数作为线程入口并封装线程基类接口
1.6. lambda临时函数作为线程入口
1.7. 多线程的状态及其切换流程分析
线程状态说明:
初始化:线程正在创建
- 初始化内存空间等,代码干预不多,从初始化到就绪有一定的消耗,所以才会引入线程池,为了减少类似消耗
- 就绪:线程在就绪列表中,等待CPU调度
- 运行:该线程正在运行
- 阻塞:线程被阻塞挂起。Blocked状态包括:pend(锁、事件、信号量等阻塞)、suspend(主动pend)、delay(延时阻塞)、pendtime(因为锁、事件、信号量事件等超时等待)
- 退出:线程运行结束,等待父线程回收其控制块资源(栈等,不会释放线程运行中创建的堆资源)
1.8. 竞争状态和临界区介绍_互斥锁mutex代码演示
临界区:读写共享数据的代码片段
1.9. 互斥锁的坑_线程抢占不到资源原因和解决方法
1.10. 超时锁timed_mutex和可重入锁recursive
还是推荐一下cppreference.com的示例代码:
timed_mutex 类是能用于保护数据免受多个线程同时访问的同步原语。
以类似 mutex 的行为, timed_mutex 提供排他性非递归所有权语义。另外, timed_mutex 提供通过 try_lock_for() 和 try_lock_until() 方法试图带时限地要求 timed_mutex 所有权的能力。
timed_mutex 类满足定时互斥与标准布局类型的所有要求。
recursive_mutex 的使用场景之一是保护类中的共享状态,而类的成员函数可能相互调用:
1.11. 共享锁shared_mutex解决读写问题
共享锁中主要包含两个锁,一个是共享锁,一个是互斥锁。
cppreference.com示例代码:
其实这个示例更多的是学习两个锁的初始化方式..
其实上述代码很巧妙,也很可能是开发过程中难以调试的bug。
1.12. 手动实现RAII管理mutex资源_锁自动释放
这种方式是手动方式,c++11提供了RAII控制锁lock_guard,见:c++11RAII控制锁lock\_guard
1.13. c++11RAII控制锁lock_guard
cppreference.com:
资源获取即初始化(Resource Acquisition Is Initialization),或称 RAII,是一种 C++ 编程技术,它将必须在使用前请求的资源(分配的堆内存、执行线程、打开的套接字、打开的文件、锁定的互斥体、磁盘空间、数据库连接等——任何存在受限供给中的事物)的生命周期与一个对象的生存期相绑定。
RAII 保证资源能够用于任何会访问该对象的函数(资源可用性是一种类不变式,这会消除冗余的运行时测试)。它也保证对象在自己生存期结束时会以获取顺序的逆序释放它控制的所有资源。类似地,如果资源获取失败(构造函数以异常退出),那么已经构造完成的对象和基类子对象所获取的所有资源就会以初始化顺序的逆序释放。这有效地利用了语言特性(对象生存期、退出作用域、初始化顺序以及栈回溯)以消除内存泄漏并保证异常安全。根据 RAII 对象的生存期在退出作用域时结束这一基本状况,此技术也被称为作用域界定的资源管理(Scope-Bound Resource Management,SBRM)。
RAII 可以总结如下:
将每个资源封装入一个类,其中:
- 构造函数请求资源,并建立所有类不变式,或在它无法完成时抛出异常,
- 析构函数释放资源并且决不会抛出异常;
在使用资源时始终通过 RAII 类的满足以下要求的实例:
- 自身拥有自动存储期或临时生存期,或
- 具有与自动或临时对象的生存期绑定的生存期
移动语义使得在对象间,跨作用域,以及在线程内外安全地移动所有权,而同时维护资源安全成为可能。(C++11 起)
拥有 open()/close()
、lock()/unlock()
,或 init()/copyFrom()/destroy()
成员函数的类是典型的非 RAII 类的例子:
std::lock_guard示例代码:
lock_guard建立在栈上,离开大括号后自动释放锁,查看lock_guard源码,对应析构函数可以看到。
另一例:
lock_guard
什么时候释解锁,如果将其放在大括号内,则互斥量充满整个大括号,那么着是不是我们理想的效果,需要注意。
另外,临界区内不应该包含sleep()
,sleep()
的时候,锁还在占用,资源还在占用状态,这样会存在巨大风险。
lock_guard
构造函数支持两种:
第一种直接mutex类型即可完成构造,也就是有lock()
和unlock()
就可以。
第二种知识将锁存下来,但是并未进行锁操作,相同的,析构函数都会unlock()
lock_guard
无法转移,不能由一个lock_guard
到另一个lock_guard
。
需要注意的是,lock_guard默认互斥资源没有被锁。
gmutex已经在lock_guard之外锁住了资源,上述代码会抛出异常,如果不想锁相关资源,那么利用重载,调用不同构造,传入一个常量参数即可。
1.14. unique_lock可临时解锁控制超时的互斥体包装器
- unique_lock C++11 实现可移动的互斥体所有权包装器
- 支持临时释放锁 unlock
- 支持 adopt_lock(已经拥有锁,不加锁,出栈区会释放)
- 支持 defer_lock (延后拥有,不加锁,出栈区不释放)
- 支持 try_to_lock 尝试获得互斥的所有权而不阻塞 ,获取失败退出栈区不会释放,通过 owns_lock()函数判断
- 支持超时参数,超时不拥有锁
注意:
defer_lock功能时,__owns_(false),同步析构时不会解锁
只能手动去lock或unlock
1.15. C++14shared_lock共享锁包装器
以下这段代码值得商榷,主要看main函数中前两个大括号内的使用方法。
1.16. c++17scoped_lock解决互锁造成的死锁问题
使用场景:在一个临界区代码中需要使用到两个锁,先用第一个锁lock,再用第二个锁lock。
会出现问题:第一个线程把第一个锁锁住,另外一个线程把第二个锁锁住。
紧接着要访问第二个锁,会造成阻塞。
因为业务代码的问题,很难将锁的顺序定死,在这里用到scoped_lock
测试很难复线,很难测
示例代码:
析构函数(__unlock_unpack):
1.17. 项目案例线程通信使用互斥锁和list实现线程通信
thread_msg_server.md
xmsg_server.md
xthread.md
xmsg_server.md
xthread.md
1.18. 条件变量应用场景_生产者消费者信号处理步骤
(一)改变共享变量的线程步骤
准备好信号量
std::condition_variable cv;
- 获得 std::mutex (常通过 std::unique_lock )
unique_lock lock(mux);
- 在获取锁时进行修改
msgs_.push_back(data);
- 释放锁并通知读取线程
(二)等待信号读取共享变量的线程步骤
- 获得与改变共享变量线程共同的mutex
unique_lock lock(mux);
wait() 等待信号通知
2.1 无lambada 表达式
2.2 lambada 表 达 式
cv.wait(lock, [] {return !msgs_.empty();});
只在 std::unique_lock<std::mutex>
上工作的 std::condition_variable
1.19. condition_variable代码示例读写线程同步
一个生产者线程,写入,然后通知多个线程处理。重点是看wait函数,是否锁定的条件。
1.20. 条件变量应用线程通信解决线程退出时的阻塞问题
thread_msg_server_condition.cpp
xmsg_server.cpp
xthread.cpp
xmsg_server.h
xthread.h
1.21. promise和future多线程异步传值
何时获取线程结果是不确定的,也就是启动线程和获取结果是在两个接口当中。
std::promise
和std::future
在C++中是用于同步两个或更多并发任务的机制。试想一下,你有两个线程,这两个线程需要共享数据。在这种情况下,你可能会使用std::promise
和std::future
来进行同步。
让我们先来看一下每一个的基本定义:
std::promise
:在某个线程中储存一个值或异常,这个储存的值或异常可被另一个关联的std::future
对象获取。std::future
:在某个线程中获取一个值或异常,这个值或异常是由之前关联的std::promise
对象设置的。
下面是一些具体的使用场景:
- 多线程数据交换:你可以在一个线程内设置promise,然后在另一个线程中通过与此promise对应的future来获取这个promise设定的结果。
- 异步操作:当你要在另一个线程中异步执行一些操作并获取结果时,可以使用promise和future机制。你先创建一个promise,然后将这个promise的future交给异步执行的函数。这个异步函数在完成计算后会设置promise的值。之后,主线程可以通过检查future的状态,或者直接调用future的get函数来获取异步操作的结果。
- 将任务分离为生产者和消费者:这是一种经典的并发设计模式,生产者生产数据并设置promise的值,消费者通过future获取这些数据进行处理。这样可以有效地分离两种类型的任务,提高程序的结构化和并发性能。
- 线程间的同步:Future对象的get()会阻塞主线程,直到promised的值被设置,这样就能保证在继续执行程序前,必要的数据或计算结果已经准备好。
一个demo:
clang编译器:
MSVC编译器:
总之end future.get()
和end TestFuture
的顺序可以看出,get返回不受线程是否退出影响的,另外get是阻塞的,一旦set_value就会取消。
1.22. packaged_task 异步调用函数打包
这个代码演示了使用线程(特别是使用std::packaged_task
)的几个理由和其优点:
- 并发执行任务:这段代码中的
TestPack
函数需要耗费2秒(由于std::this_thread::sleep_for(std::chrono::seconds(2))
)。如果不使用线程,main()
函数会被阻塞,直到TestPack
函数执行完成。但是,通过在新线程中运行TestPack
,main()
函数可以同时执行其它任务。这样,你就可以充分利用多核CPU,并且在等待一个耗时操作(比如I/O操作)完成的同时,运行其他任务。 - 获取异步任务的结果:
std::packaged_task
将任务的结果存储在std::future
对象中。result.get()
允许你在任务完成后获得其结果。这意味着你可以在任何你想获取结果的地方获取到它,而不管它是在哪个线程中被计算出来的。 - 方便处理耗时操作:
std::packaged_task
和std::future
在处理耗时操作时非常有用。例如,你可以通过检查std::future
的状态来查看操作是否已完成,而不是不断轮询或者阻塞直到操作完成。在上述代码中,我们通过不断检查result
的状态来查看TestPack
函数是否已完成。
总的来说,使用线程和std::packaged_task
,可以让代码并发执行任务,不阻塞主线程,同时便于获取异步任务的结果,处理耗时操作。
类模板 std::packaged_task 包装任何可调用 (Callable) 目标(函数、 lambda 表达式、 bind 表达式或其他函数对象),使得能异步调用它。其返回值或所抛异常被存储于能通过 std::future 对象访问的共享状态中。
正如 std::function , std::packaged_task 是多态、具分配器的容器:可在堆上或以提供的分配器分配存储的可调用对象。
1.23. async创建异步线程替代thread
这段代码让我们来看看 C++ 中的异步编程。"async" 和 "future" 使用来创建和同步异步操作。
这里的 "async" 函数有两个主要作用:
- 创建一个异步任务。该任务将在单独的线程(可能是现有的线程)上执行。通过这种方式,代码可以同时在多个线程上运行,从而改善程序的性能。
- 返回一个 "future" 对象。这个对象代表了异步任务的结果。当异步任务完成时,可以使用
get()
函数从 "future" 对象中获取结果。如果调用get()
时任务仍然在运行,那么调用get()
的线程将阻塞,直到任务完成为止。
现在,让我们来具体看看这段代码中的异步操作。在这段代码中,我们创建了两个异步任务。我们使用了两种不同的方式来使用 std::async
函数:
- 对于第一个异步任务,我们使用
async(std::launch::deferred, TestAsync, 100)
。这里的std::launch::deferred
参数意味着异步任务将会在future.get()
被调用时才执行,而不是立即执行。这种方式下,并没有创建新的线程,而是复用了主线程。 - 对于第二个异步任务,我们使用
async(TestAsync, 101)
。在这个调用方式中,std::async
函数将立即创建一个新的线程(除非系统决定复用现有的线程)并开始执行TestAsync
。TestAsync
任务的执行跟主线程是并行的,因此这是真正的异步调用。
你可以通过 std::this_thread::get_id()
的不同输出来区分是否创建了新的线程。在不创建新线程的情况下,std::this_thread::get_id()
的输出将会和主线程的线程 ID 相同。若是创建了新线程,则线程 ID 将会不同。
std::async
和 std::thread
都是 C++11 中用于创建并发任务的方式。不过它们的使用方式和关注点有些不同。
std::thread
: 它是比较底层的线程创建和操作方式。创建一个std::thread
会产生一个新的操作系统线程和对应的栈空间,并立即开始并发执行。std::thread
提供了更多的控制,你可以细粒度地管理和调度线程,包括线程优先级、线程间同步、线程局部存储等。但是,使用std::thread
需要负责处理所有线程同步的问题,比如数据竞争、死锁等,并且还需要手动管理线程的生命周期,比如合适的调用join()
或detach()
。std::thread
的使用场景通常是在性能优先,且你愿意并熟悉如何处理多线程编程中各种复杂问题的时候。std::async
和std::future
:这是一种更高层次的并发抽象,主要关注的是任务并发,而不是线程并发。std::async
在创建任务的同时,返回一个std::future
,代表异步任务的结果。std::future::get
将阻塞等待任务完成并返回结果。任务在何时何地运行,是由实现决定的,可以是新建的线程,也可以是任务队列中的现有线程,甚至可以是调用get()
的线程(当启动策略是std::launch::deferred
时)。std::async
自动处理线程的生命周期管理,并为你处理部分线程同步问题。同时,由于使用std::future
获取结果,也帮你处理了结果的返回和异常处理等问题。std::async
通常适用于需要并发并希望简化多线程编程的场景。
所以,你要根据实际的需求和上下文来选择使用 std::async
还是 std::thread
,如果你需要对线程有更细的控制,或者需要优化到OS级别的并发,那么 std::thread
可能是一个更好的选择。如果你更关注任务结果,并愿意将具体的运行方式交给系统来决定,希望能简化并发编程,确保异常安全,那么 std::async
可能是更合适的选择。
1.23.1. std::launch::async
和std::launch::deferred
在C++中,std::launch
是一个枚举类型,它与std::async
函数一起使用,以控制异步操作的行为。这个函数主要用于异步的执行一个任务,返回一个std::future
对象,你可以通过这个对象来获取任务的结果。std::launch
有两个可能的值:std::launch::async
和std::launch::deferred
,这两个选项告诉std::async
如何执行给定的任务。
std::launch::async
:- 当使用该选项时,
std::async
会尝试在另一个线程上立即启动所提供的任务。 - 这意味着任务会并行执行,与主线程同时进行。
- 当你需要任务并行执行,不阻塞当前线程时使用
std::launch::async
。 std::future
对象会与一个正在运行的异步任务相关联,你可以等待或获取这个任务的结果。
- 当使用该选项时,
std::launch::deferred
:- 当使用该选项时,任务不会立即启动,而是延迟到对应的
std::future
对象的get()
或wait()
成员函数被调用时才执行。 - 这意味着任务会在这些函数被调用时在调用者线程上执行,也就是说,它实际上变成了惰性求值。
- 当你不需要立刻执行任务,或者希望在将来的某个点上,根据需要执行任务时使用
std::launch::deferred
。 - 如果从未调用
get()
或wait()
,则对应的任务可能永远不会执行。
- 当使用该选项时,任务不会立即启动,而是延迟到对应的
使用std::async
时可以选择给这两个选项中的一个,或者不给,不给的话,实现可以自行选择是立即执行还是延迟执行。如果你想要确保异步任务的执行方式,你应该指定std::launch::async
或std::launch::deferred
。
这是一个简单的例子,展示了如何使用std::async
和std::launch::async
启动一个异步任务:
在这个例子中,任务会在一个新的线程中异步地启动。当main
函数继续执行时,任务也在并行运行。一旦调用了result.wait()
,主线程将会等待直到异步任务完成。如果改用std::launch::deferred
,则任务会在调用result.wait()
或result.get()
时在主线程上同步执行。
1.24. c++多核计算分析并实现base16编码
一个简单实例
c++11实现多核base16编码并与单核性能测试对比
请注意一点,hardware_concurrency,如果线程数多余系统硬件线程数,如果您在一个仅支持两个硬件线程的计算机上创建四个线程,这可能不会产生立即明显的负面影响,但以下是一些可能的影响和考虑因素:
- 上下文切换: 当有多个线程可运行时,操作系统需要在它们之间进行上下文切换。每次切换都涉及保存当前线程的状态并加载下一个线程的状态,这些操作会产生开销。如果线程数量远多于处理器核心,这种上下文切换的成本可能会显著影响性能。
- 资源竞争: 如果线程需要共享资源(如内存、I/O设备等),那么多个线程可能会导致竞争条件和锁争用。这些因素会降低并发性能。
- 线程调度开销: 线程调度器需要跟踪更多的线程,分配CPU时间可能会变得更复杂,并导致额外的开销。
- 应用逻辑复杂性: 编写多线程代码需要仔细的设计来避免死锁、竞态条件和其他多线程问题。线程越多,管理这些问题的难度就越大。
然而,如果这些线程不会经常同时运行(即它们大部分时间都在等待外部事件或I/O操作完成),那么创建多于处理器核心数量的线程并不一定会对性能产生负面影响。实际上,这种情况下添加更多的线程可能会帮助提高CPU利用率,因为当其中的一些线程由于I/O操作而阻塞时,其他线程可以继续执行处理。
对于I/O密集型应用(比如网络服务器或数据库应用),通常可以通过创建远多于CPU核心数的线程来提高并发性和吞吐量,因为这些线程大部分时间都在等待I/O操作的完成。
总之,创建的线程数量是否会对应用程序产生负面影响取决于多种因素,包括应用程序的性能要求、线程的实际工作负载,以及系统的整体行为。通常,在设计多线程应用时,开发者需要仔细评估和测试以确保理想的性能。
c++17for_each多核运算示例编码base16
clang的c++2017暂未支持execution
线程池实现步骤说明
高并发运算时,线程频繁创建和销毁会有性能损耗,我们实现线程池直接在里面取即可。
- 初始化线程池
- 启动所有线程
- 准备好任务处理基类和插入任务
- 获取任务接口
- 执行任务线程入口函数
完成线程池的初始化和启动
demo里仅有初始化和启动
CMakeLists
main.cpp
XThreadPool.cpp
XThreadPool.h
完成线程池任务调度
cv_.wait(lock, predicate)
实际上类似于下面这样:
在这个重载版本的wait()调用中,当线程被唤醒时(不管是因为notify_*调用还是假唤醒),它会自动检查lambda表达式。只有当lambda表达式返回true(这里是!tasks_.empty()),wait()函数才会返回。如果谓词返回false,wait()会再次阻塞线程,这实现了内部的循环检查机制,而不需要显式的while循环。
有哪些优化:
针对减少虚假唤醒带来的性能影响,你可以考虑通过减少条件变量的使用或者增加批量处理来实现。由于没有具体的任务处理代码,以下是一段示例代码,说明如何在接收到一个任务时尝试处理多个任务:
这个代码示例展示了如何让工作线程不只是处理单个任务,而是尝试一次性处理多个任务,以此来减少锁的频繁请求和条件变量的多次等待。这样,拿到锁的线程会尽可能多地完成任务,减少下次需要等待和锁定的概率,同时减少了对条件变量的依赖,也就减少了虚假唤醒所造成的性能影响。
对于C++线程池,有一些成熟的库和框架可供选择。其中一些包括:
- ThreadPool:
这是一个轻量级且易于使用的开源C++11线程池库,可在GitHub上找到。 - Intel TBB(Threading Building Blocks):
由英特尔开发的TBB库提供了丰富的并行算法和数据结构,包括线程池。它是一个高度成熟的并行编程库,可用于各种并行化需求。 - PPL(Parallel Patterns Library):
PPL是Microsoft Visual C++中的库,提供了用于并行编程的线程池和并行算法。它提供了一种简单的方式来利用多核处理器的能力。
以上是一些常用和成熟的C++线程池库和框架,可以根据项目需求和偏好选择合适的库。