Chapter4 并发操作的同步
前言
2023-05-14 完成到 packaged_task,这一章是大头,内容很多,hhh。越学越觉得书上例子很丰富,基本上跟着理解就能掌握好知识点。果然看书是进步最快的方法呀。
线程往往需要等待某一事件的发生再行动。如需要等待数据准备好再进行打印。在 c 中使用信号量或条件变量可以达到这一目的。C++则提供了条件变量与 std::future
等技术工具帮助我们简化操作
4.1 等待事件或等待其他条件
C++标准库提供了两种条件变量:std::condition_variable
和std::condition_variable_any
。只要满足互斥的最低标准,都可以与后者搭配使用。然而可能产生额外开销。所以大部分情况,我们使用前者,其只能与std::mutex
搭配
使用例子
std::mutex mtx;
std::queue<data_chunk> data_queue;
std::condition_variable data_cond;
void data_preparation() {
while (more_data_to_prepare) {
const data_chunk = prepare_data();
{
std::lock_guard guard1(mtx);
data_queue.push(data);
}
data_cond.notify_one();
}
}
void data_processing_thread() {
while (true) {
std::unique_lock guard1(mtx);
data_cond.wait(
guard1, []{ return !data_queue.empty(); });
data_queue.pop();
mtx.unlock();
process(data);
if (is_last_chunk(data))
break;
}
}
这里的精髓在于 wait 函数,如果判断条件为 true,则通过,否则其解锁互斥并阻塞线程,直到条件变量被notify_one
。然后再进行下一轮检查。
这里我们使用std::unique_lock
管理,原因在于我们需要保证其能在 wait 中解锁,而std::lock_guard
显然不能。
下面借助条件变量实现一个线程安全的队列
#include <condition_variable>
#include <iostream>
#include <memory>
#include <mutex>
#include <queue>
#include <thread>
#include <vector>
template<typename T>
class threadsafe_queue {
private:
mutable std::mutex mtx;
std::queue<T> data;
std::condition_variable data_cond;
public:
threadsafe_queue() = default;
threadsafe_queue(const threadsafe_queue &data_);
threadsafe_queue& operator=(const threadsafe_queue &data_);
void push(T value);
std::shared_ptr<T> try_pop();
void wait_and_pop(T &value);
[[nodiscard]] bool empty() const;
};
template<typename T>
threadsafe_queue<T>::threadsafe_queue(const threadsafe_queue &data_) {
std::lock_guard guard1(data_.mtx);
data = data_;
}
template<typename T>
threadsafe_queue<T>& threadsafe_queue<T>::operator=(const threadsafe_queue &data_) {
std::lock_guard guard1(data_.mtx);
data = data_;
return *this;
}
// pop and return the pop value
template<typename T>
std::shared_ptr<T> threadsafe_queue<T>::try_pop() {
std::lock_guard guard1(mtx);
if (data.empty())
return nullptr;
T& temp = data.front();
data.pop();
return std::make_shared<T>(temp);
}
// we wait until there is an element which can be popped
template<typename T>
void threadsafe_queue<T>::wait_and_pop(T &value) {
std::unique_lock guard1(mtx);
data_cond.wait(guard1, [this]{ return !data.empty(); });
value = data.front();
data.pop();
}
template<typename T>
void threadsafe_queue<T>::push(T value) {
{
std::lock_guard guard1(mtx);
data.push(value);
}
data_cond.notify_one();
}
template<typename T>
bool threadsafe_queue<T>::empty() const {
std::lock_guard guard1(mtx);
return data.empty();
}
int main() {
std::vector<std::thread> tasks;
threadsafe_queue<int> int_queue;
std::queue<int> unsafe_queue;
std::cout << "safe queue : ";
for (int i = 0; i < 5; ++i) {
tasks.emplace_back([=, &int_queue]{
int_queue.push(i);
});
}
for (int i = 0; i < 5; ++i) {
tasks.emplace_back([&]{
int value;
int_queue.wait_and_pop(value);
std::cout << value << " ";
});
}
while (!int_queue.empty());
std::cout << std::endl << "unsafe queue: ";
for (int i = 0; i < 5; ++i) {
tasks.emplace_back([=, &unsafe_queue]{
unsafe_queue.push(i);
});
}
for (int i = 0; i < 5; ++i) {
tasks.emplace_back([&unsafe_queue] {
std::cout << unsafe_queue.front() << " ";
unsafe_queue.pop();
});
}
for (auto &task : tasks)
task.join();
}
这里我写了一个线程安全的队列,一个 STL 的队列。后者因为不是线程安全所以得到了错误的输出。
4.2 使用 future 等待一次性事件发生
从后台任务返回值
线程有一个不方便的地方在于其不能返回值。
我们可以使用std::async()
按异步方式启动任务。我们从std::async()
获得std::future
对象,运行的函数一旦完成,其返回值由该对象持有。若要用这个值,使用 get ,当前线程就会阻塞直到 future 得到返回值。
#include <future>
#include <iostream>
int add() {
int sum = 0;
for (int i = 0; i < 1000; ++i)
sum += i;
return sum;
}
int main() {
std::future<int> res = std::async(add);
std::cout << res.get() << std::endl;
}
其构造函数使用方式基本与使用std::thread
一致。如果其异步运行类成员函数,则第一个参数传递其函数指针,第二个参数传递该对象(地址、引用、值均可),以在其上调用之,随后传递的参数将被传递到函数参数中。
class X {
...
void foo(std::string &s);
};
X x;
std::string s;
std::async(&X::foo, x, s); // 值方式
std::async(&X::foo, std::ref(x), s); // 引用方式
std::async(&X::foo, &x, s); // 地址方式
默认,std::async
会自己决定等待 future 时,是启动线程,还是同步执行函数。我们也可以手动声明之。
使用参数,其类型为std::launch
可以是std::launch::deferred
或std::launch::async
。前者指定在当前线程上延后调用任务函数,等到在 future 上调用了 wait 或 get 才会执行。后者指定开启专属线程运行任务函数。
关联 future 实例和任务
std::packaged_task<>
连接了 future 对象和函数(或 Callable object) std::packaged_task<>
执行函数时,会调用关联函数,把返回值保存为 future 内部数据。
它可作为线程池的构件单元。若一项复杂任务可以被分解为多个子任务,则 可把子任务分别包装到多个std::packaged_task<>
实例中,再传递给任务调度器或线程池。这就隐藏了细节,使任务抽象化,让调度器得以专注处理std::packaged_task<>
实例
std::packaged_task<>
是类模板,其模板参数是函数签名。它具有 get_future 函数,返回std::future<>
对象
std::packaged_task<>
是可调用对象,我们可以直接调用,还可以用std::function
包装之,当作线程函数传递给std::thread
等等需要可调用对象的地方。
若std::packaged_task<>
作为函数对象被调用,它会接受参数并传递给内部函数,由其异步运行得出结果,并将结果保存到 std::future
内部。为了在未来的某一时刻完成任务,我们可以将任务包装在std::packaged_task<>
,然后取得对应的 future ,随后将std::packaged_task<>
传递给工作线程。我们只需要等待 future 结果即可。
这里我写了一个短例子,直白地演示了这一想法,也明白了 std::future 真正的语义
#include <future>
#include <thread>
#include <iostream>
int hash(int val) {
return (val >> 2) & 0b11;
}
// wrap the hash function
std::packaged_task<int(int)> task1(hash);
void work_thread() {
// work
task1(16);
}
int main() {
// we don't know the answer, but the answer does exist.
// It will be calculated in the future.
std::future res1 = task1.get_future();
std::thread thread1(work_thread);
std::cout << res1.get();
thread1.join();
}
书上的例子是关于 图形用户界面(GUI)的。有一个线程专门用于更新界面,别的线程向其发送信息请求更新,然后由这个线程进行更新。
#include <deque>
#include <mutux>
#include <future>
#include <thread>
#include <utility>
std::mutex m;
std::deque<std::packaged_task<void()>> tasks;
bool gui_shutdown_message_received();
void get_and_process_gui_message();
void gui_thread(){
while (!gui_shutdown_message_received()) {
get_and_process_gui_message();
std::packaged_task<void()> task;
{
std::lock_guard guard1(m);
if (tasks.empty())
continue;
task = std::move(tasks.front());
tasks.pop_front();
}
task();
}
}
std::thread gui_bf_thread(gui_thread);
template<typename Func>
std::future<void> post_task_for_gui_thread(Func f) {
std::packaged_task<void()> task(f);
std::future<void> res = task.get_future();
std::lock_guard guard1(m);
tasks.push_back(std::move(task));
return res;
}
创建 std::promise
std::promise<T>
给出了一种异步求值的方法,某个std::future<T>
与之关联,能延后读出需要求的值。配对后,它们能实现下面的功能:等待数据的线程在 future 上阻塞,提供数据的线程利用配对的 promise 设定关联的值,使 future 准备就绪。
promise 的值通过 set_value 设置,只要设置好,future就准备就绪。。如果 promise 销毁时还未设置值,值将被异常代替。
书上的例子是利用多个 promise 在单个线程中处理多个网络连接。
#include <future>
void process_connections(connection_set& connections) {
while (!done(connections)) {
for (auto connection : connections) {
if (connection->has_incoming_data()) {
data_packet data = connection->incoming();
std::promise<payload_type> &p =
connection->get_promise(data.id);
p.set_value(data.payload);
}
if (connection->has_outgoing_data()) {
outgoing_packet data =
connection->top_of_outgoing_queue();
connection->send(data.payload);
data.promise.set_value(true);
}
}
}
}
书上的例子这里我认为并不好,原因是其并没有写全这一整个过程,导致理解起来很不顺畅。
connection 是一个网络连接。
- 如果我们检测到网络连接有到来的数据,那我们接收之。随后我们利用 data.id 获得对应的 promise 。应当是有一个类似哈希表的组织来进行一一对应,然后我们设置 promise 的值为数据内容。这样的话,在某个工作函数中的 future 便能读到这里的值从而处理数据。
- 如果我们检测到网络连接有需要发送的数据。那我们将这个数据上的 promise 值设置为 true 示意发送成功
将异常保存到 future 中
如果异步函数中发生异常,最终异常结果会保存到 future 中,等待被释放。
多个线程一起等待
之前的例子都是只有一个线程等待结果,若我们要让多个线程等待同一个目标事件,则需要改用 std::shared_future
这里快速跳过了,因为感觉这辈子都用不上,如果以后需要再来学习吧。
- shared_future
4.3限时等待
前面的调用都为阻塞调用,如果我们等不及了不想等了,我们就需要一个超时机制来帮助我们计时,一旦超时,我们就自动跳出采取别的操作。
有两种超时机制:
- 迟延超时:线程根据指定的时长进行等待,以
_for
为后缀 - 绝对超时:在某特定时间点来临之前,线程一直等待,以
_until
为后缀
在学之前,我们先需要了解C++的时间表示方法
时钟类
在C++中每个时钟都是一个类,提供4项信息:
- 当前时刻
- 时间值的类型
- 该时钟的计时单元的长度,即多少时间记一次
- 计时速率是否恒定
对应上面4条:
- 获取当前时刻很简单,调用某个时钟类的 now() 函数即可。
如:std::chrono::system_clock::now()
可返回系统时钟的当前时刻。 - 每个时钟类都具有名为
time_point
的成员类型,其是时钟类自有的时间点类。所以std::chrono::system_clock::now()
返回类型即为std::chrono::system_clock::time_point
- 时钟类的计时单元属于名为 period 的成员类型。如
std::ratio<1, 25>
即为1秒计数25次 - 若时钟计时速率恒定,则称之为 恒稳时钟。时钟类的
is_steady
表明其是否为恒稳时钟。如 std::chrono::system_clock 不是恒稳时钟,调用两次 now 可能第二次比第一次早。。
C++提供恒稳时钟类std::chrono::steady_clock
、系统时钟类std::chrono::system_clock
、高精度时钟类std::chrono::high_resolution_clock
它们都在 <chrono>
中
时长类
std::chrono::duration<>
具有两个模板参数,前者表示采用何种类型表示计时单元的数量,后者是一个分数,设定一个计时单元多少秒。
如std::chrono::duration<short, std::ratio<60, 1>>
表示以short计数,一个计时单元60秒。标准库在 std::chrono 命名空间里预设了一组 typedef ,有 nanoseconds, microseconds, millisecond, seconds, minutes, hours。
为了方便,C++14之后还引入了 std::chrono_literals 命名空间,允许我们这样写时间:
using namespace std::chrono_literals;
auto one_day = 24h;
auto half_an_hour = 30min;
auto max_time_between_messages = 30ms;
于是 15ns 等价于 std::chrono::nanoseconds(15)
一个迟延超时的例子,我们等待某个 future 进入就绪状态,并以35 ms 为限
std::future<int> f = std::async(some_task);
if (f.wait_for(std::chrono::milliseconds(35)) == std::future_status::ready)
do_something_with(f.get());
如果超时,返回std::future_status::timeout
,反之返回std::future_status::ready
时间点类
std::chrono::time_point<>
。第一个模板参数为参考的时钟,第二个表明计时单元。时间点表示一个时间跨度,始于一个特殊时刻,如1970年1月1日或电脑开机的时刻等等等。
如 std::chrono::time_point<std::chrono::system_clock, std::chrono::minutes>
表示某个时间点,计时单元为分钟。
时间点能加减,这一特性允许我们计时
auto start = std::chrono::high_resolution_clock::now();
do_something();
auto stop = std::chrono::high_resolution_clock::now();
std::cout << "do something took"
<< std::chrono::duration<double, std::chrono::seconds>(stop - start).count()
<< "seconds" << std::endl;
这一特性还允许我们撰写 绝对超时
#include <condition_variable>
#include <mutex>
#include <chrono>
std::condition_variable cv;
bool done;
std::mutex m;
bool wait_loop() {
const auto timeout = std::chrono::steady_clock::now() +
std::chrono::milliseconds(500);
std::unique_lock lk(m);
while (!done) {
if (cv.wait_until(lk, timeout) == std::cv_status::timeout)
break;
}
return done;
}
通过这种方法,我们设定了循环的总耗时。
接受超时时限的函数
类/命名空间 | 函数 |
---|---|
std::this_thread | sleep_for(duration) / sleep_until(time_point) |
std::condition_variable | wait_for(lock, duration) / wait_until(lock, time_point) |
std::future<> | wait_for(lock, duration) / wait_until(lock, time_point) |
… | … |
就记写的这几个够了。