《c++并发编程实战》 笔记
1、你好,C++的并发世界
为什么要使用并发
1、充分利用多核CPU的计算能力; ·
2、方便进行业务拆分,提升应用性能。如耗时操作放在后台,提高界面响应性。
初始线程始于main(),而新线程始于hello()。
第2章 线程管理
2.1.1 启动线程
使用C++线程库启动线程,可以归结为构造std::thread对象:
std::thread
template< class Function, class... Args >
explicit thread( Function&& f, Args&&... args );
- Function 是一个可调用对象(如函数指针、lambda 表达式、函数对象等)的类型。
- Args… 是传递给 Function的参数的类型列表。
std::thread 在 C++ 中扮演着核心角色,特别是在多线程编程领域。它的主要作用是提供一种机制来创建和管理线程,使得程序能够并行地执行多个任务。
- 创建新线程:通过 std::thread的构造函数,可以轻松地创建一个新线程来执行指定的函数或可调用对象。这允许开发者将耗时的操作或可以并行处理的任务放到单独的线程中执行,从而提高程序的性能和响应性。
- 管理线程生命周期:std::thread 对象与它所代表的线程紧密相关。通过调用 join() 或 detach()方法,可以管理线程的生命周期。join() 方法会阻塞当前线程,直到由 std::thread 对象表示的线程完成其执行。detach()方法则允许线程独立于 std::thread 对象运行,此时 std::thread对象不再拥有该线程,且无法再与之交互(除了获取其ID)。
std::thread 对象的 joinable() 方法返回一个布尔值,表示线程是否可以被 join()。 表示资源是否需要清理。
如果thread对象析构时,joinable为true,C++ 标准库会调用 std::terminate() 来终止程序的执行。这是为了防止资源泄露。
线程资源:
如线程栈、线程控制块等。如果这些资源在std::thread对象被销毁时仍未被释放,就会发生资源泄露。
在C++中,std::thread对象通过join()或detach()方法来管理其代表的线程。如果std::thread对象在销毁时仍然是可连接的(即线程仍在运行),且既未调用join()也未调用detach(),则程序会调用std::terminate()来终止执行,以防止潜在的资源泄露。
std::terminate会终止整个程序,而不是线程。操作系统会在程序终止时回收所有由该程序分配的资源。为了防止程序在不确定的线程状态下继续执行,因为此时线程可能还在访问或操作已经销毁的 std::thread 对象所管理的资源。
std::thread对象通常与特定的执行线程相关联,并且一旦线程执行完毕,std::thread对象就不再拥有任何线程(即变为空)。
线程的状态变化:
-
从非joinable到joinable:
- 当通过调用std::thread的构造函数并传入一个可调用对象(如函数指针、Lambda表达式、绑定表达式等)来创建std::thread对象时,如果构造函数成功,则新创建的线程对象将处于joinable状态。这意呀着你可以对该线程调用join()来等待它完成,或者调用detach()来分离它。
-
从joinable到非joinable:
- 一旦对joinable的线程调用了join()或detach(),该线程对象就不再处于joinable状态。对已经join或detach的线程对象再次调用join()或detach()将导致std::system_error异常。
- 如果线程的执行函数已经返回(即线程已结束),并且你尚未对该线程调用join()或detach(),则尝试对该线程对象调用join(),detach仍然有效,但调用后将使线程对象变为非joinable。
- 如果线程对象被销毁时仍然处于joinable状态(即没有调用join()或detach()),则程序将调用std::terminate()来终止执行,以避免资源泄露。因此,重要的是要确保在销毁std::thread对象之前,要么调用join()要么调用detach()。
assert
“断言条件为真,否则抛出异常”。这句话简洁地概括了 assert 的核心功能。
如果expression的值为真(非0),则assert宏不执行任何操作,程序继续执行。
如果expression的值为假(0),则assert会打印一条错误信息(包括源文件、行号、以及失败的表达式),并调用abort()函数终止程序。
2.2 向线程函数传递参数
std::thread 不支持重载,需要显示标记用哪个函数,不然编译器找不到对应函数。
参数传递:
需要注意的是,参数会被拷贝到单独的存储空间中,然后作为右值传递给可调用对象。
如果函数参数是左值引用,那么会导致编译错误,因为不能将一个右值传递给期望左值引用参数的函数。
如string或自己定义的类对象,可以编译通过,但实际上是值拷贝。需要使用引用需要使用std::ref函数。
std::bind 是 C++ 标准库中的一个功能强大的工具,它定义在头文件 中。std::bind 用于将可调用对象(如函数、函数对象、lambda 表达式、成员函数指针等)与其参数绑定在一起,生成一个新的可调用对象。这个新的可调用对象在调用时,会调用原始的可调用对象,并传递给它预先绑定的参数(如果有的话),以及调用新可调用对象时提供的任何额外参数。
2.5 识别线程
2种方法
- 第一种,可以通过调用std::thread对象的成员函数get_id()来直接获取。如果std::thread对象没有与任何执行线程相关联,get_id()将返回std::thread::type默认构造值,这个值表示“没有线程”。
- 第二种,当前线程中调用std::this_thread::get_id()(这个函数定义在头文件中)也可以获得线程标识。
第3章 线程间共享数据
存在问题:条件竞争。
条件竞争是指在多线程或并发环境中,当两个或多个线程以未预知的顺序访问共享资源时,程序的行为可能变得不可预测。特定的条件(如线程执行顺序)的竞争。
为了避免条件竞争,通常需要使用同步机制,如锁、信号量或原子操作,来确保共享资源在某一时刻只被一个线程访问。
3.2.1 C++中使用互斥量
C++中通过实例化std::mutex创建互斥量,通过调用成员函数lock()进行上锁,unlock()进行解锁。实践中更推荐使用RAII语法的模板类std::lock_guard。
在大多数情况下,互斥量通常会与保护的数据放在同一个类中,而不是定义成全局变量。还需要注意成员函数不能把保护数据通过指针或引用传递出去。
线程安全函数是指在多线程环境下,能够安全地被多个线程同时调用的函数。具体来说,当一个函数在同一时刻被多个线程并发执行时,如果每次调用都能产生正确的结果,且不会导致数据损坏或程序崩溃,那么该函数就被认为是线程安全的。
如何实现?
避免共享资源冲突:
使用局部变量:
可重入函数:该函数可以在任何时候被中断,并在稍后的时间点从上次中断的地方重新开始执行,而不会引发任何错误或数据损坏。
如何实现?
确保它不依赖于任何全局或静态变量,只使用局部变量。
重点是保护函数的状态,确保其在函数调用之间不会被破坏。
不能访问共享变量,因为共享变量可能会在中断时改变。
关系:
可重入函数由于其不依赖全局或静态状态,或者在依赖时采取了适当的保护措施,因此在多线程环境中自然是线程安全的。
然而,线程安全的函数可能依赖于全局或静态状态,并通过加锁等机制来保护这些状态。这种函数在并发执行时可能是安全的,但由于其状态可能在函数调用之间持续存在,因此它不一定是可重入的。
锁的粒度问题
1、锁的粒度太小会造成的问题:
单个接口没问题,组合使用有问题,如下
stack<int> s;
if (! s.empty()){ // 1
int const value = s.top(); // 2
s.pop(); // 3
do_something(value);
}
2、锁的粒度太大会造成的问题:影响性能,并会增大死锁的风险。
避免死锁的进阶指导
死锁定义:它指的是两个或多个线程在执行过程中,因争夺资源而造成的一种僵局(互相等待)。
避免死锁的方法:
-
避免使用多个锁
-
保持锁的顺序一致
-
避免嵌套锁
-
使用超时机制
-
使用标准库中的工具
C++标准库提供了一些工具来帮助避免死锁,如std::lock和std::scoped_lock。
std::lock可以一次性为多个互斥量上锁,并且内部使用死锁避免算法。
std::lock_guard RAII(Resource Acquisition Is Initialization)原则,这意味着资源的获取(在这里是互斥量的加锁)是在对象的构造函数中完成的,而资源的释放(在这里是互斥量的解锁)则是在对象的析构函数中完成的。
std::unique_lock提供了比 std::lock_guard 更加灵活的互斥量封装。std::unique_lock 提供了更多的控制,包括延迟加锁、尝试加锁、定时加锁以及手动解锁和重新加锁的能力。 -
持有锁的时间尽可能少,减少死锁概率。
## 保护共享数据的替代设施
保护共享数据的初始化过程
双重检查锁存在的问题:
由于C++11之前的内存模型并没有提供足够的保证来防止指令重排序,因此在多线程环境中使用双重检查锁模式可能会导致未定义行为,比如访问未完全初始化的对象。
std::call_once 是 C++11 引入的一个函数,它属于 头文件。这个函数的主要用途是确保某个函数或可调用对象只被执行一次,即使它被多次调用。这对于初始化全局变量或执行只需要执行一次的昂贵操作特别有用。
std::call_once 的使用通常与 std::once_flag 类型的标志一起,这个标志用来指示函数是否已经被调用过。如果函数已经被调用,那么后续的调用将不会执行任何操作。
在C++11标准中,静态局部变量即便在多线程中,也只被初始化一次。
保护很少更新的数据结构(读多写少)
boost::shared_mutex 是 Boost 库中的一个同步原语,它允许多个线程以共享模式(shared mode)同时读取数据,但写入数据时需要独占访问。
使用 boost::shared_lockboost::shared_mutex 来获取共享锁。
使用 std::unique_lockboost::shared_mutex 来获取独占锁。
本质和读写锁类似。
嵌套锁
std::recursive_mutex,支持一个线程尝试锁多次。适用于获取锁的时候用到了其他函数,其他函数也访问这个锁。
更好的替代方法是:考虑是否可以重新设计函数逻辑,避免递归调用,从而无需使用递归互斥锁。
第4章 同步并发操作
线程会等待一个特定事件的发生,或者等待某一条件达成(为true)。像这种情况就需要在线程中进行同步,C++标准库提供了一些工具可用于同步操作,形式上表现为条件变量(condition variables)和期望(futures)。
4.1 使用条件变量等待一个事件或其他条件
condition_variable
条件变量的基本原理包括两个主要动作:
等待:当某个条件不满足时,一个线程会将自己加入等待队列,并释放持有的互斥锁(Mutex),进入睡眠状态等待条件成立。wait函数,需要带条件,可能虚假唤醒。
唤醒:当条件满足时,另一个线程会通知(signal或broadcast)等待在条件变量上的线程,唤醒它们重新检查条件。被唤醒的线程会重新尝试获取互斥锁,并在获取锁后继续执行。notify_one()与notify_all()。
常与锁结合。
std::mutex mut;
std::queue<data_chunk> data_queue; // 1
std::condition_variable data_cond;
void data_preparation_thread()
{
while(more_data_to_prepare())
{
data_chunk const data=prepare_data();
std::lock_guard<std::mutex> lk(mut);
data_queue.push(data); // 2
data_cond.notify_one(); // 3
}
}
void data_processing_thread()
{
while(true)
{
std::unique_lock<std::mutex> lk(mut); // 4
data_cond.wait(
lk,[]{return !data_queue.empty();}); // 5
data_chunk data=data_queue.front();
data_queue.pop();
lk.unlock(); // 6
process(data);
if(is_last_chunk(data))
break;
}
}
4.2 使用期望等待一次性事件
std::future
获得一个 std::future 对象,这个对象将在未来某个时间点持有异步操作的结果。
关键函数:
- get():这个函数阻塞当前线程,直到异步操作完成,并返回操作的结果。如果异步操作抛出了异常,get()
函数将重新抛出该异常。注意,get() 只能被调用一次,因为一旦结果被取出,std::future 对象就不再持有任何结果了。 - wait():这个函数也会阻塞当前线程,但它只是等待异步操作完成,而不返回结果。如果只是想等待异步操作完成而不关心结果,可以使用这个函数。
- wait_for() 和wait_until():这两个函数提供了更灵活的等待机制。它们允许你指定一个时间段或时间点,然后在这个时间段内等待异步操作完成。如果操作在这段时间内完成了,函数将返回std::future_status::ready;如果操作没有完成,函数将返回 std::future_status::timeout或 std::future_status::deferred(对于 std::async 启动的异步任务,后者几乎不会出现)。
- valid():这个函数检查 std::future 对象是否还持有有效的异步操作结果。一旦 get()被调用或异步操作被取消,valid() 将返回 false。
std::future::get() 不能被多次调用以获得相同的结果,因为第一次调用后,Future 对象将进入一个不可用的状态。多次调用 get() 可能会抛出 std::future_status::no_state 异常,表明尝试从一个已经失去状态(即,结果已经被获取)的 Future 对象中获取状态。而std::shared_future不会进入已经失去状态。
std::future::get 不调用不会报错,重复调用报错。
std::shared_future应该每个线程拷贝一份,而不是引用同一份。
同步和异步
同步操作意味着程序按照代码的顺序一步一步地执行,必须等待当前操作完成并返回结果后才能继续执行下一个操作。
异步操作意味着程序在执行某个操作时不会被阻塞,而是继续执行后续操作。当异步操作完成后,程序会通过回调函数、Promise、Future、事件等方式接收到操作结果,并继续处理后续逻辑。
阻塞和非阻塞
阻塞是指程序在执行过程中,如果遇到某个操作无法立即完成(如等待I/O操作完成、等待某个条件成立等),则程序会暂停执行,直到该操作完成或条件成立。
非阻塞(Non-blocking)是指在程序执行过程中,如果某个操作无法立即完成,程序不会暂停当前线程的执行去等待该操作完成,而是立即返回并继续执行后续的代码。这种机制允许程序在等待某个操作完成的同时,能够处理其他任务,从而提高程序的并发性和效率。
同步与异步关注的是消息通信机制,即调用者是否需要等待被调用者完成操作;而阻塞与非阻塞关注的是程序在等待操作结果时的状态,即当前线程或进程是否会被挂起。这四个概念相互组合,可以形成不同的执行模式,以满足不同场景下的需求。
-
同步阻塞(Synchronous Blocking):
发送方发送请求后,会一直等待接收方的响应,且等待过程中无法进行其他操作。
接收方处理请求时,如果IO操作无法立即完成,也会阻塞等待,直到操作完成。 -
同步非阻塞(Synchronous Non-blocking):
发送方发送请求后,会一直等待接收方的响应。
但接收方在处理请求时,如果IO操作无法立即完成,会立即返回并不阻塞,而是去执行其他任务。然而,由于没有得到请求处理结果,接收方无法响应发送方,因此发送方仍然处于等待状态。 -
异步阻塞(Asynchronous Blocking):
发送方发送请求后,不会等待接收方的响应,而是继续执行其他操作。
但接收方在处理请求时,如果IO操作无法立即完成,会阻塞等待,直到操作完成后再响应发送方。
这种情况在实际应用中较为少见,因为它没有充分利用异步的优势。 -
异步非阻塞(Asynchronous Non-blocking):
发送方发送请求后,不会等待接收方的响应,而是继续执行其他操作。
接收方在处理请求时,如果IO操作无法立即完成,也不会阻塞等待,而是立即返回并执行其他任务。
当IO操作完成后,接收方会通过某种机制(如回调函数、事件通知等)将结果通知给发送方。
异步非阻塞模式是实现高并发、高性能网络通信的关键技术之一。
std::async
std::async 是 C++11 中引入的一个用于异步执行函数的工具。std::async 可以启动一个线程来异步执行给定的函数,并返回一个 std::future 或 std::shared_future 对象,这个对象可以用来获取异步任务的结果或等待任务完成。
std::async可以设置启动策略:
std::launch::async:如果可能,将任务提交给线程池来异步执行。如果没有可用的线程,任务可能在后续调用 std::future::get 或 std::future::wait 时在主线程执行。
std::launch::deferred:调用线程执行。调用get时执行。
默认是std::launch::async | std::launch::deferred指示std::async函数如果可能的话优先选择async策略,如果不可行(例如,无法延迟计算),则选择deferred策略。
std::async只会返回future,但是可以调用shared_future拷贝构造函数生成shared_future
std::packaged_task<>
std::packaged_task<> 是 C++11 引入的一个并行编程工具,它主要用来将一个函数或任务包装为一个可执行实体,以便在不同的线程间传递并执行。std::packaged_task<> 可以接收一个可调用对象,并在其他线程中调用这个对象。
std::future fut = ptask.get_future();
可以通过调用 std::packaged_task<> 的 operator() 来异步地执行封装的可调用对象(比std::async更灵活)。
使用std::promises
std::promise是一个线程间通信的原语,用于异步值的传递。它作为std::future的对端接口,允许从一个线程中设置结果,然后可以在其他线程中通过同一std::promise对象所关联的std::future对象获取这个结果。
主要函数:
- get_future(): 返回与 std::promise 对象关联的 std::future 对象。这个函数只能被调用一次。
- set_value(T value): 设置 std::promise 对象的值。这个函数只能被调用一次,并且只能在std::promise 对象被销毁之前调用。
- set_exception(std::exception_ptr p): 设置std::promise 对象的异常。这个函数也只能被调用一次,并且只能在 std::promise 对象被销毁之前调用。
C++中时间表示
std::chrono类
std::chrono::system_clock::now()是将返回系统时钟的当前时间,是不稳定的,时钟可调。
std::chrono::steady_clock::now()稳定时间
auto const timeout= std::chrono::steady_clock::now()+
std::chrono::milliseconds(500);
std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 2 休眠100ms
std::this_thread::sleep_until();
4.4 使用同步操作简化代码
方法:
1、只关注那些需要同步的操作,而非具体使用的机制。
2、不要共享数据,每个任务拥有自己的数据会更好,结果通过“期望”对其他线程进行广播。