《C++ Concurrency In Action》Chapter4学习笔记

Chapter4 并发操作的同步


前言

2023-05-14 完成到 packaged_task,这一章是大头,内容很多,hhh。越学越觉得书上例子很丰富,基本上跟着理解就能掌握好知识点。果然看书是进步最快的方法呀。


线程往往需要等待某一事件的发生再行动。如需要等待数据准备好再进行打印。在 c 中使用信号量或条件变量可以达到这一目的。C++则提供了条件变量与 std::future等技术工具帮助我们简化操作

4.1 等待事件或等待其他条件

C++标准库提供了两种条件变量:std::condition_variablestd::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条:

  1. 获取当前时刻很简单,调用某个时钟类的 now() 函数即可。
    如:std::chrono::system_clock::now()可返回系统时钟的当前时刻。
  2. 每个时钟类都具有名为time_point的成员类型,其是时钟类自有的时间点类。所以std::chrono::system_clock::now()返回类型即为std::chrono::system_clock::time_point
  3. 时钟类的计时单元属于名为 period 的成员类型。如std::ratio<1, 25> 即为1秒计数25次
  4. 若时钟计时速率恒定,则称之为 恒稳时钟。时钟类的 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_threadsleep_for(duration) / sleep_until(time_point)
std::condition_variablewait_for(lock, duration) / wait_until(lock, time_point)
std::future<>wait_for(lock, duration) / wait_until(lock, time_point)

就记写的这几个够了。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值