C++多线程 第四章 同步并发操作

第四章 同步并发操作


等待事件

设想一个情景:你正坐在一辆从哈尔滨驶向郴州的绿皮火车上,这趟车需要耗时2天2夜,合计3000公里的路程.

于是在这里,我们将你和司机视作为两个线程.你的任务是在目的地下车,司机的任务是将车开到目的地.

假设你和司机坐在同一个车厢内,并且你是个不说话就会死的话痨( ) 司机:倒了八辈子血霉.

绿皮火车作为共享资源由互斥元所保护,而这意味着你将较长时间内无法行动.

于是,这里你将有几个策略来解决这个问题:

  1. 你可以一直在司机的耳旁喋喋不休讨论是否到达了目的地.
    选择这一选择,你需要硬撑两天两夜并且影响司机开车的效率,更不用提司机可能嫌你太吵而把你丢出车外.

  2. 你可以设一个约两天两夜的闹钟.然后喝下昏睡红茶.
    选择这一选择,你可能会提前或延迟醒来,提前醒来后你可能仍会去打扰司机,而延迟醒来你就等着补票+改签吧.

  3. 你可以跟司机沟通好,让司机到达后通知你醒来.然后喝下昏睡红茶.
    这是最佳的选择,因为这样可以让你在合适的时间醒来而又不至于惹怒火车司机.

上面所描述的情景,便是一个经典的 等待事件情景.

其中,1对应着自旋锁;2对应着有粗略时间预测的线程休眠;3对应着条件变量机制.

  • 令线程睡眠指定时长:
std::this_thread::sleep_for(<时长>);
  • 令线程睡眠到执行时间点:
std::this_thread::sleep_until(<时间点>);

条件变量

C++提供了两个条件变量的实现:std::condition_variablestd::condition_variable_any.

这两个实现都在<condition_variable>中实现.

两者都需要与互斥元一起工作以提供恰当的同步,前者仅限于std::mutex,后者则可以为各类互斥元.

下面是一个使用条件变量的例子:

std::queue<double>data_queue;
std::condition_variable data_cond;
std::mutex lk;
const int SIZE = 100;

void data_preparation()
{
	for (is_run()) {
		double data = sqrt(i);
		std::lock_guard<std::mutex>guard(lk);
		data_queue.push(data);
		data_cond.notify_one();
	}
	return;
}

void data_process()
{
	double temp_data;
	while (true) {
		std::unique_lock<std::mutex>ul(lk);
		data_cond.wait(ul, [] {return !data_queue.empty(); });
		temp_data = data_queue.front();
		data_queue.pop();
		ul.unlock();

		std::cout << std::format("the temp_data is {:..15f}",
			temp_data
		) << std::endl;

		if (!preparaing_data())
			break;
	}
	return;
}

条件变量可以多次检测使用,如果等待线程只打算等待一次,那么条件为true时它就不会再等待这个条件变量了,因而可以使用 期值(future).

期值与异步

C++标准库通过future为类一次性事件进行建模.如果一个线程需要等待特定的一次性事件,那么它就会获得一个future来代表这一事件.

C++标准库中有两类future,是由库的头文件中声明的两个类模板实现的:

  • 唯一future(unique futures,std::future<>)
  • 共享future(shared futures,std::shared_future<>)

它们是参照std::unique_ptr和std::shared_ptr建立的.

一个共享的future可以很好地用来线程间的通信.

通常而言,std::future 的使用需要和 std::async 配合.

在不需要立刻得到结果的时候,可以使用std::async来启动一个 异步任务(asynchronous task).

std::async返回一个std::future对象,只要你在std::future对象上调用get(),线程就会阻塞直至future就绪.

下面是使用期值的一个实例:

#include <iostream>
#include <format>
#include <thread>
#include <future>

long fib(int n)
{
	if (n == 1 || n == 0)
		return 1;
	return fib(n - 1) + fib(n - 2);
}

int main()
{
	std::future<long>result = std::async(fib, 42);

	for (int i = 0; i < 10; i++)
		std::cout << "main thread is waiting" << std::endl;
	std::cout << std::format("the result is {}",
		result.get()
	) << std::endl;

	return 0;
}

std::async实际上还有一个参数用于决定其是否启动一个新线程.

当该参数指定为 std::launch::deferred 时,表明该函数调用会延迟至get()或await()执行而不启动新线程.

当该参数指定为 std::launch::async 时,表明该函数会启动新线程来处理异步任务.

而默认情况下,该参数为 std::launch::deferred|std::launch::async,由具体实现来选择.

下面是一个小小的实验:

#include <iostream>
#include <format>
#include <thread>
#include <future>

long fib(int n)
{
	if (n == 1 || n == 0)
		return 1;
	long temp = fib(n - 1) + fib(n - 2);
	if (temp == 14930352)
		std::cout << std::format("now the temp is {},and id is {}",
			temp,
			std::this_thread::get_id()
		) << std::endl;
	return temp;
}

int main()
{
	std::future<long>result_1 = std::async(std::launch::deferred,fib, 35);
	std::future<long>result_2 = std::async(std::launch::async, fib, 35);
	std::cout << std::format("the main thread id is {}",
		std::this_thread::get_id() 
	) << std::endl;

	for (int i = 0; i < 10; i++)
		std::cout << "main thread is waiting" << std::endl;
	std::cout << std::format("the result_1 is {},the result_2 is {}",
		result_1.get(),
		result_2.get()
	) << std::endl;

	return 0;
}

运行结果如下:

the main thread id is 8432
main thread is waiting
main thread is waiting
main thread is waiting
main thread is waiting
main thread is waiting
main thread is waiting
main thread is waiting
main thread is waiting
main thread is waiting
main thread is waiting
now the temp is 14930352,and id is 123304
now the temp is 14930352,and id is 8432
the result_1 is 14930352,the result_2 is 14930352

将任务与期值关联

std::packaged_task<> 将一个std::future绑定到一个函数或可调用对象上,当std::packaged_task<>对象被调用时,它就调用相关联的函数或可调用对象,并且让future就绪,将返回值作为关联数据储存.

std::packaged_task<>类模板的模板参数为函数签名,例如int(std::string&).

当你构造std::packaged_task实例的时候,你必须传入一个函数或可调用对象,类型无需严格匹配(隐式转换).

为了更好地理解std::packaged_task的使用,下面给出一个例子:

#include <iostream>
#include <thread> 
#include <future>

#define ADD [](int a,int b){return a+b;}

void task_thread(int x, int y)
{
	std::packaged_task<int(int, int)> task(ADD);
	std::future<int> result = task.get_future();
	task(x, y);
	std::cout << "task_thread main thread:" << result.get() << std::endl;

	task.reset();

	result = task.get_future();
	std::thread td(move(task), x, y);
	td.join();
	std::cout << "task_thread aysnc:" << result.get() << std::endl;
}

int main()
{
	std::cout << "please input x and y:" << std::endl;

	int x, y;
	std::cin >> x >> y;
	task_thread(x,y);

	return 0;
}

通过对该例子的理解,我们发现:所谓的std::packaged_task实际上就是一个函数与期值的封装.

同时,我们发现可以使用 std::packaged_task<>::reset() 来重置共享状态,这无疑为其提供了良好的可复用性.

下面的例子为我们揭示了一般GUI程序中其他线程如何与绘图线程结合:

#include <iostream>
#include <format>
#include <random>

#include <thread>
#include <mutex>
#include <future>
#include <vector>
#include <deque>

#include <windows.h>
#include "include/graphics.h"
#pragma comment(lib,"graphics64.lib")

#define PRESSED(nVirtKey) ((GetKeyState(nVirtKey) & (1<<(sizeof(SHORT)*8-1))) != 0)
#define TOGGLED(nVirtKey) ((GetKeyState(nVirtKey) & 1) != 0)

typedef struct point_2d {
public:
	double x, y, r;
}point_2d;

std::mutex lk;
std::deque<std::packaged_task<void(point_2d)>>tasks;
std::deque<point_2d>datas;

void mainGuiTask()
{
	ege::initgraph(640, 480);
	ege::setcaption(L"parallel draw");
	ege::setbkcolor(ege::BLACK);
	ege::setcolor(ege::LIGHTGREEN);

	for (; ege::is_run(); delay_fps(60)) {
		ege::cleardevice();
		std::packaged_task<void(point_2d)>gui_task;
		point_2d task_data;
		{
			std::lock_guard<std::mutex>guard(lk);
			if (tasks.empty())
				continue;
			gui_task = std::move(tasks.front());
			tasks.pop_front();
			task_data = std::move(datas.front());
			datas.pop_front();
		}
		gui_task(task_data);
	}

	ege::closegraph();
	return;
}

template<typename Func>
std::future<void>postTask(Func func)
{
	std::packaged_task<void(point_2d)>post_task(func);
	std::future<void>res = post_task.get_future();
	
	std::lock_guard<std::mutex>guard(lk);
	tasks.push_back(std::move(post_task));

	return res;
}

void mainVKboardTask()
{
	std::uniform_real_distribution<double> u_x(0.0, 640.0);
	std::uniform_real_distribution<double> u_y(0.0, 480.0);
	std::uniform_real_distribution<double> u_r(50.0, 150.0);
	std::default_random_engine engine(time(0));

	point_2d temp_point;
	POINT cursor;
	double temp_x, temp_y, temp_r;
	for (; ege::is_run();) {
		if (PRESSED(32)) { 
			datas.push_front(temp_point);
			postTask(
				[](point_2d ilist){ ege::circle(
						ilist.x,
						ilist.y,
						ilist.r
					); 
					return; 
				}
			);
			GetCursorPos(&cursor);
			std::this_thread::sleep_for(std::chrono::milliseconds(10));
		}

		temp_x = u_x(engine);
		temp_y = u_y(engine);
		temp_r = u_r(engine);
		temp_point = { temp_x,temp_y,temp_r };
	}
	return;
}


int main()
{
	std::thread gui_thread(mainGuiTask);
	std::thread mouse_thread(mainVKboardTask);
		
	gui_thread.join();
	mouse_thread.join();

	return 0;
}

即通过一个std::packaged_task队列进行通信.

预示

在前面,我们注意到在std::packaged_task的使用中,std::packaged_task与函数相绑定,std::future通过与std::packaged_task绑定来等待任务完成.

而这里我们要引入 std::promise.

我们知道,当有一个需要处理大量网络连接的应用程序时,通常倾向于在独立的线程上分别处理每一个连接.但随着连接数的增加,大量线程会消耗大量操作系统资源,并可能导致大量的上下文切换.

因而,在具有超大量网络连接的应用程序中,通常用少量线程来处理连接,每个线程一次处理多个连接.考虑这类连接,数据包将以基本上随机的顺序来自待处理的各个连接,以基本上随机的顺序排队发送.

std::promise<> 提供了一种设置值方式,它可以在这之后通过相关联的std::future对象进行读取.等待中的线程可以阻塞std::future,同时提供数据的线程可以使用配对中的std::promise来设置值以令std::future就绪.

下面是使用std::promise的一个简单例子:

#include <iostream>
#include <future>
#include <chrono>

void threadFun1(std::promise<int>& p)
{
	std::this_thread::sleep_for(std::chrono::seconds(2));

	int iVal = 233;
	std::cout << "input:" << iVal << std::endl;
	p.set_value(iVal);

	return;
}

void threadFun2(std::future<int>& f)
{
	auto iVal = f.get();
	std::cout << "receive:" << iVal << std::endl;

	return;
}

int main()
{
	std::promise<int> pr1;
	std::future<int> fu1 = pr1.get_future();

	std::thread t1(threadFun1, std::ref(pr1));
	std::thread t2(threadFun2, std::ref(fu1));

	t1.join();
	t2.join();

	return 0;
}

不难发现,std::promise为线程间通信提供了一种合适的机制.

然而,需要补充的:如果销毁std::promise时未设置值,则会存入一个异常.

为future保存异常

考虑下面这样一个情景:

double square_root(double x)
{
	if(x<0)
		throw std::out_of_range("x<0");
	return sqrt(x);
}

其单线程的版本为:

double y=square_root(-1);

而假若以异步的形式调用,有:

std::future<double>f=std::async(square_root,-1);
double y=f.get();

两者行为完全一致自然是最理想的.

但是,事实上,实际情况是: 如果作为std::async一部分的函数调用引发了异常,该异常会被存储在future中,代替所存储的值,future变为就绪,并且对get()的调用会引发所存储的异常.

这同样发生在std::packaged_task发生异常时.

而std::promise也提供了一种显式方式存储异常:使用set_exception()而不是set_value来使std::future存储异常.

std::promise<double>some_promise;

try{
	some_promise.set_value(calculate_value());
}
catch(...){
	some_promise.set_exception(std::current_exception());
}

std::current_exception用于获得已引发的异常,而std::copy_exception()则可以在不引发的情况下直接存储新的异常.例如:

some_promise.set_exception(std::copy_exception(std::logic_error("foo")));

等待自多个线程

尽管std::future能处理从一个线程向另一个线程转移数据所需的全部必须的同步,但是get()却会移动资源的所有权.

因而,为了让多个线程能够等待同一个时间,应该使用 std::shared_future.

std::future是可移动的,std::shared_future是可复制的.

然而,为了避免数据竞争,对std::shared_future进行共享仍然需要锁的保护.

需要补充的,引用了异步状态的std::shared_future实例可以通过引用这些状态的std::future实例来构造.

但是,从std::future到std::shared_future实际上发生了隐式的所有权转移.

而且std::future本身有一个方法std::future::share()用于显式转换为std::shared_future.

std::promise<double>pr;
std::shared_future<double>fu = pr.get_future().share();

有时间限制的等待

时钟

就C++标准库所关注而言,时钟是时间信息的来源.

时钟的当前时间可以通过该时钟类的静态成员now()来获取.

例如std::chrono::system_clock::now()返回系统时钟的当前时间.

时钟的节拍周期是由分数秒决定的.

如果一个时钟以均匀速率计时且不能被调整,则该时钟被称为匀速(steady)时钟.如果时钟是匀速的,则时钟类的is_steady静态数据成员为true.

通常而言,std::chrono::system_clock是不匀速的,因为时钟可以调整.

如果需要匀速时钟,使用std::chrono::system_clock.

时间段

时间段均由std::chrono::duration<>类模板处理

标准库在std::chrono命名空间中为各种时间提供了一组预定义的typedef,包括nanoseconds,microseconds,milliseconds,seconds,minutes和hours.

在无需截断值的场合,时间段之间的转换是隐式的,显式转换可以通过std::chrono::duration_cast实现

时间段支持算术运算.

基于时间段的等待是通过 std::chrono::duration<> 实现的.例如:

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());

这个例子表示等待future最多35毫秒.

如果等待超时,将返回std::future_status::timeout,否则返回std::future_status::ready.

如果任务推迟,那么返回std::future_status::deferred.

时间点

时间点通过std::chrono::time_point<>类模板实例来表示.

时间点的值是时间的长度,因而一个特定时间点被称为时钟的纪元.

时钟可以共享纪元或者拥有独立的纪元.

下面这个例子演示了一个具有超时的条件变量的使用.

#include <iostream>
#include <condition_variable>
#include <future>
#include <mutex>
#include <chrono>
#include <omp.h>

std::condition_variable cv;
std::mutex m;
bool done;

bool wait_loop()
{
	auto const timeout = std::chrono::steady_clock::now() + std::chrono::milliseconds(1000);
	std::unique_lock<std::mutex>lk(m);
	while (!done)
		if (cv.wait_until(lk, timeout) == std::cv_status::timeout)
			break; 
	return done;
}

int main()
{
	double begin_time = omp_get_wtime();
	std::future<bool>fu = std::async(std::launch::async, wait_loop);

	if (fu.get())
		std::cout << "task done" << std::endl;
	else
		std::cout << "time out" << std::endl;

	double run_time = omp_get_wtime() - begin_time;
	std::cout << "time:" << run_time << std::endl;
	
	return 0;
}

接受超时的函数

超时的最简单用法,是将延迟添加到特定线程的处理过程中,一边在他无所事事时避免占用其他线程的处理时间.

例如:

  • std::this_thread::sleep_for()
  • std::this_thread::sleep_until()

睡眠并不是唯一接受超时的工具,事实上future也可以与超时结合使用.

如果互斥元支持的话,甚至可以试图在互斥元获得锁时使用超时.

例如:

  • std::timed_mutex
  • std::recursive_timed_mutex

这两种类型均支持try_lock_for()和try_lock_until()成员函数,它们可以在指定时间段内或指定时间点之前尝试获取所.

接受超时的函数:

类/名称空间 函数 返回值
std::this_thread命名空间 sleep_for(duration)
sleep_until(time_point)
none
std::condition_variable
std::condition_variable_any
wait_for(lock,duration)
wait_until(lock,time_point)
std::cv_status::timeout
std::cv_status::no_timeout
std::timed_mutex
std::recursive_timed_mutex
try_lock_for(duration)
try_lock_until(time_point)
bool-true获得锁
std::unique_lock<TimedLockable> unique_lock(lockable,duration)
unique_lock(lockable,time_point)
bool-true获得锁
std::future<ValueType>
std::shared_future<ValueType>
wait_for(duration)
wait_until(time_point)
std::future_status::timeout超时
std::future_status::ready就绪
std::future_status::deferred还未开始

操作同步

函数式编程(functional programming,FP): 一种编程风格,函数调用的结果仅单纯依赖于该函数的参数而不依赖于任何外部状态.

函数式编程意味着以同样参数运行同一个函数多次将得到相同的结果.

为了说明FP编程的思想,我们在这里通过一个简单的快速排序实现来说明:

template<typename T>
std::list<T>sequential_quick_sort(std::list<T>input)
{
	if(input.empty)
		return input;
	std::list<T>result;

	result.splice(result.begin(),input,input.begin());
	T const& pivot = *result.begin();
	auto divide_point=std::partition(
		input.begin(),
		input.end(),
		[&](T const&t){return t<pivot;}
	);

	std::list<T>lower_part;
	lower_part.splice(lower_part.end(),input,input.begin(),divide_point);
	auto new_lower(sequential_quick_sort(std::move(lower_part)));
	auto new_higher(sequential_quick_sort(std::move(input)));

	result.splice(result.end(),new_higher);
	result.splice(result.begin(),new_lower);
	
	return result;
}

由于采用了FP式编程风格,现在我们可以通过future轻易将其并行化.

template<typename T>
std::list<T>parallel_quick_sort(std::list<T>input)
{
	if(input.empty)
		return input;
	std::list<T>result;

	result.splice(result.begin(),input,input.begin());
	T const& pivot = *result.begin();
	auto divide_point=std::partition(
		input.begin(),
		input.end(),
		[&](T const&t){return t<pivot;}
	);

	std::list<T>lower_part;
	lower_part.splice(lower_part.end(),input,input.begin(),divide_point);
	std::future<std::list<T>>new_lower(std::async(
			&parallel_quick_sort<T>,
			std::move(lower_part))
		);
	auto new_higher(parallel_quick_sort(std::move(input)));

	result.splice(result.end(),new_higher);
	result.splice(result.begin(),new_lower.get());
	
	return result;
}

然而,与其使用std::async()不如自行编写spawn_task()函数作为std::packaged_task和std::thread的简单封装.

template<typename F,typename A>
std::future<std::result_of<F(A&&)>::type>spawn_task(F&& f,A&& a)
{
	typedef std::result_of<F(A&&)>::type result_type;
	std::packaged_task<result_type(A&&)>task(std::move(f));
	std::future<result_type>res(task.get_future());
	std::thread t(std::move(task),std::move);
	t.detach();
	return res;
}

函数式编程并不是唯一的避开共享可变数据的并发编程范式;另一种范式为CSP(Communicating Sequential Process,通信顺序处理),这种范式下线程在概念上独立,没有共享数据,但是具有允许消息在它们之间进行传递的通信通道.

具有消息传递的同步

CSP的实现很简单:若没有共享数据,则每个线程可以完全独立地推理得到,只需基于它对所接收到的消息如何进行反应.

因而每个线程实际上可以等效为一个状态机:当它接收到消息时,它会根据初始状态进行操作,并以某种方式更新其状态,且可能想其他线程发送一个或多个消息.

编写这种线程的一种方式,是将其形式化并实现一个有限状态机模型.

posted @ 2024-02-07 20:12  Mesonoxian  阅读(24)  评论(0编辑  收藏  举报