同步并发操作(上)
4.1 等待一个事件或其他条件
- 当一个线程等待另一个线程完成任务时,它会有很多选择。
- 第一,它可以持续的检查共享数据标志(用于做保护工作的互斥量),直到另一线程完成工作时对这个标志进行重设。不过,就是一种浪费:线程消耗宝贵的执行时间持续的检查对应标志,并且当互斥量被等待线程上锁后,其他线程就没有办法获取锁,这样线程就会持续等待,且使得执行的线程变慢。
- 第二个选择是在等待线程在检查间隙,使用std::this_thread::sleep_for()进行周期性的间歇
bool flag;
std::mutex m;
void wait_for_flag()
{
std::unique_lock<std::mutex> lk(m);
while(!flag)
{
lk.unlock(); // 1 解锁互斥量
std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 2 休眠100ms
lk.lock(); // 3 再锁互斥量
}
}
- 当线程休眠时,线程没有浪费执行时间,但是很难确定正确的休眠时间。太短的休眠和没有休眠一样,都会浪费执行时间;太长的休眠时间,可能会让任务等待线程醒来。
- 第三个选择(也是优先的选择)是,通过另一线程触发等待事件的机制,称作“条件变量”。当条件达成时,其它线程会通知休眠线程并唤醒其。
4.1.1 等待条件达成
- c++中有两种方式:std::condition_variable和std::condition_variable_any
- 都需包含头文件<condition_variable>
- 前者仅限于与std::mutex一起工作,而后者可以和任何满足最低标准的互斥量一起工作,从而加上了_any的后缀
std::mutex mut;
std::queue<data_chunk> data_queue; // 创建一个队列,在两个线程之间传递数据;应被保护
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); // 加入数据
data_cond.notify_one(); // 唤醒等待的队列(若有)
}
}
void data_processing_thread()
{
while(true)
{
std::unique_lock<std::mutex> lk(mut); // 上锁
data_cond.wait(
lk,[]{return !data_queue.empty();}); // 判断是否要等待;若lambda表达式返回真,则不等待;反之,线程休眠
data_chunk data=data_queue.front();
data_queue.pop();
lk.unlock(); // 解锁
process(data);
if(is_last_chunk(data))
break;
}
}
分析:
-
data_cond.wait() : 第一个参数是锁;第二个参数是lambda表达式,用于判断该队列是否为空;若为空,则解开锁且线程进入休眠状态;直到另一个线程调用notify_one()来唤醒其;若不空,则接着向下执行;
第二个参数不是必须的,若无第二个参数,则会直接进入休眠状态;
第二个参数也可以是其他函数,而不一定是lambda表达式; -
在第二个函数中,上锁使用unique_lock()是因为其更灵活,符合wai的解锁;
-
当休眠的线程被唤醒后,会尝试再次去拿锁;若拿到将再次执行,并判断wait的条件;
4.1.2 使用条件变量构建线程安全队列
队列的基本操作:
- 对整个队列的状态进行查询(empty()和size());
- 查询在队列中的各个元素(front()和back());
- 修改队列的操作(push(), pop()和emplace())。
-
因此你也会遇到在固有接口上的条件竞争,需要将front()和pop()合并成一个函数调用,就像之前在栈实现时合并top()和pop()一样
-
将pop改成:
try_pop() ,尝试从队列中弹出数据,总会直接返回(当有失败时),即使没有值可检索;
wait_and_pop(),将会等待有值可检索的时候才返回。 -
故而线程接口应为:
#include <memory> // 为了使用std::shared_ptr
template<typename T>
class threadsafe_queue
{
public:
threadsafe_queue();
threadsafe_queue(const threadsafe_queue&);
threadsafe_queue& operator=(
const threadsafe_queue&) = delete; // 不允许简单的赋值
void push(T new_value);
bool try_pop(T& value); // 1
std::shared_ptr<T> try_pop(); // 2
void wait_and_pop(T& value);
std::shared_ptr<T> wait_and_pop();
bool empty() const;
};
第一个重载的try_pop()①在引用变量中存储着检索值,所以它可以用来返回队列中值的状态;
当检索到一个变量时,他将返回true,否则将返回false。
第二个重载②就不能做这样了,因为它是用来直接返回检索值的。当没有值可检索时,这个函数可以返回NULL指针。
完整代码:
#include <queue>
#include <memory>
#include <mutex>
#include <condition_variable>
template<typename T>
class threadsafe_queue
{
private:
mutable std::mutex mut; // 1 互斥量必须是可变的
std::queue<T> data_queue;
std::condition_variable data_cond;
public:
threadsafe_queue()
{}
threadsafe_queue(threadsafe_queue const& other)
{
std::lock_guard<std::mutex> lk(other.mut);
data_queue=other.data_queue;
}
void push(T new_value)
{
std::lock_guard<std::mutex> lk(mut);
data_queue.push(new_value);
data_cond.notify_one();
}
void wait_and_pop(T& value)
{
std::unique_lock<std::mutex> lk(mut);
data_cond.wait(lk,[this]{return !data_queue.empty();});
value=data_queue.front();
data_queue.pop();
}
std::shared_ptr<T> wait_and_pop()
{
std::unique_lock<std::mutex> lk(mut);
data_cond.wait(lk,[this]{return !data_queue.empty();});
std::shared_ptr<T> res(std::make_shared<T>(data_queue.front()));
data_queue.pop();
return res;
}
bool try_pop(T& value)
{
std::lock_guard<std::mutex> lk(mut);
if(data_queue.empty())
return false;
value=data_queue.front();
data_queue.pop();
return true;
}
std::shared_ptr<T> try_pop()
{
std::lock_guard<std::mutex> lk(mut);
if(data_queue.empty())
return std::shared_ptr<T>();
std::shared_ptr<T> res(std::make_shared<T>(data_queue.front()));
data_queue.pop();
return res;
}
bool empty() const
{
std::lock_guard<std::mutex> lk(mut);
return data_queue.empty();
}
};
4.2 使用期望(future)等待一次性事件
- 当一个线程需要等待一个特定的一次性事件时,在某种程度上来说它就需要知道这个事件在未来的表现形式
- 之后,这个线程会周期性(较短的周期)的等待或检查,事件是否触发(检查信息板), 在检查期间也会执行其他任务
- 另外,在等待任务期间它可以先执行另外一些任务,直到对应的任务触发,而后等待期望的状态会变为就绪(ready)
- 在C++标准库中,有两种“期望”,使用两种类型模板实现,声明在头文件中: 唯一期望(unique futures)(std::future<>)和共享期望(shared futures)(std::shared_future<>)。
- std::future的实例只能与一个指定事件相关联,而std::shared_future的实例就能关联多个事件
4.2.1 带返回值的后台任务
- 假设,你有一个需要长时间的运算,你需要其能计算出一个有效的值,但是你现在并不迫切需要这个值
- std::thread并不提供直接接收返回值的机制。这里就需要std::async函数模板(头文件:< future> 中声明)了
- std::async会返回一个std::future对象,这个对象持有最终计算出来的结果
- 调用这个future对象的get()成员函数,将会会阻塞线程直到“future”状态为就绪为止并返回计算结果
#include <future>
#include <iostream>
int find_the_answer_to_ltuae();
void do_other_stuff();
int main()
{
std::future<int> the_answer=std::async(find_the_answer_to_ltuae);
do_other_stuff();
std::cout<<"The answer is "<<the_answer.get()<<std::endl;
}
- 与thread相同,其可以用线程函数,类或者类的成员函数构建新的线程;
- std::launch::defered 和 std::launch::async 用于表明该线程是立即开始还是等待到wait或get才开始执行
- std::launch::async 表明建立新线程立即执行,默认设置
- std::launch::defered表明直到wait 或get才建立新线程并执行
格式:
std::async(std::launch::deferred,my_function,std::ref(x));
4.2.2 任务与期望
- std::packaged_task<>对一个函数或可调用对象,绑定一个期望
- 当std::packaged_task<> 对象被调用,它就会调用相关函数或可调用对象,将期望状态置为就绪,返回值也会被存储为相关数据
- 构造一个std::packaged_task<>实例时,你必须传入一个函数或可调用对象,这个函数或可调用的对象需要能接收指定的参数和返回可转换为指定返回类型的值
如: std::packaged_task<double(int)> 函数输入 int 返回值 double类型
std::packaged_task<double(int)> my_task(my_function, int arg);
线程间传递任务
很多图形架构需要特定的线程去更新界面,所以当一个线程需要界面的更新时,它需要发出一条信息给正确的线程,让特定的线程来做界面更新。std::packaged_task提供了完成这种功能的一种方法,且不需要发送一条自定义信息给图形界面相关线程。
#include <deque>
#include <mutex>
#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() // 1
{
while(!gui_shutdown_message_received()) // 2
{
get_and_process_gui_message(); // 3
std::packaged_task(void()) task; //创建task
{
std::lock_guard<std::mutex> lk(m); // 上锁,取任务
if(tasks.empty()) // 若无任务,则进行下一次循环
continue;
task = std::move(tasks.front()); // 取出任务
tasks.pop_front(); // 删除队列中相关项
}
task();
}
}
std::thread gui_bg_thread(gui_thread);
template<typename Func>
std::future<void> post_task_for_gui_thread(Func f) // 向deque中插入任务
{
std::task_pakage<void()> task(f);
std::future<void> res = task.get_future(); // 调用get_future()成员函数获取“期望”对象
std::lock_guard<std::mutex> lk(m);
tasks.push_back(std::move(task));
return res;
}
- 图形界面线程①循环直到收到一条关闭图形界面的信息后关闭②,进行轮询界面消息处理③
- 采用packaged_task可以将不同类型的函数对象封转在其内部,每个线程取走一个packaged_task执行,那么线程池执行的任务可以不同
4.2.3 使用std::promises
- 当你有一个应用,需要处理很多网络连接,它会使用不同线程尝试连接每个接口,因为这能使网络尽早联通,尽早执行程序
- 当连接较少的时候,这样的工作没有问题(也就是线程数量比较少)
- 不幸的是,随着连接数量的增长,这种方式变的越来越不合适;因为大量的线程会消耗大量的系统资源,还有可能造成上下文频繁切换(当线程数量超出硬件可接受的并发数时),这都会对性能有影响
- 最极端的例子就是,因为系统资源被创建的线程消耗殆尽,系统连接网络的能力会变的极差
- 在不同的应用程序中,存在着大量的网络连接,因此不同应用都会拥有一定数量的线程(可能只有一个)来处理网络连接,每个线程处理可同时处理多个连接事件
- std::promise< T>提供设定值的方式(类型为T)
- 通过**get_future()**成员函数来获取与一个给定的std::promise相关的std::future对象
- 在设置值之前销毁std::promise,将会存储一个异常
单线程处理多接口的实现
使用一对std::promise/std::future找出一块传出成功的数据块;与“期望”相关值只是一个简单的“成功/失败”标识。对于传入包,与“期望”相关的数据就是数据包的有效负载
共享状态
#include <future>
void process_connections(connection_set& connections)
{
while(!done(connections)) // 1
{
for(connection_iterator // 2
connection=connections.begin(),end=connections.end();
connection!=end;
++connection)
{
if(connection->has_incoming_data()) // 3
{
data_packet data=connection->incoming();
std::promise<payload_type>& p=
connection->get_promise(data.id); // 4
p.set_value(data.payload);
}
if(connection->has_outgoing_data()) // 5
{
outgoing_packet data=
connection->top_of_outgoing_queue();
connection->send(data.payload);
data.promise.set_value(true); // 6
}
}
}
}
函数process_connections()中,直到done()返回true①为止。每一次循环,程序都会依次的检查每一个连接②,检索是否有数据③或正在发送已入队的传出数据⑤。这里假设输入数据包是具有ID和有效负载的(有实际的数在其中)。一个ID映射到一个std::promise(可能是在相关容器中进行的依次查找)④,并且值是设置在包的有效负载中的。对于传出包,包是从传出队列中进行检索的,实际上从接口直接发送出去。当发送完成,与传出数据相关的“承诺”将置为true,来表明传输成功⑥。这是否能映射到实际网络协议上,取决于网络所用协议;这里的“承诺/期望”组合方式可能会在特殊的情况下无法工作,但是它与一些操作系统支持的异步输入/输出结构类似。
4.2.4 为“期望”存储“异常”
- 函数作为std::async的一部分时,当在调用时抛出一个异常,那么这个异常就会存储到“期望”的结果数据中,之后“期望”的状态被置为“就绪”,之后调用get()会抛出这个存储的异常
- 当你将函数打包入std::packaged_task任务包中后,在这个任务被调用时,同样的事情也会发生;当打包函数抛出一个异常,这个异常将被存储在“期望”的结果中,准备在调用get()再次抛出
- 通过函数的显式调用,std::promise也能提供同样的功能
4.2.5 多个线程的等待
- std::future也有局限性,在很多线程在等待的时候,只有一个线程能获取等待结果
- 当多个线程需要等待相同的事件的结果,你就需要使用std::shared_future来替代std::future了
- 每一个std::shared_future的独立对象上成员函数调用返回的结果还是不同步的,所以为了在多个线程访问一个独立对象时,避免数据竞争,必须使用锁来对访问进行保护
std::promise<std::string> p;
std::shared_future<std::string> sf(p.get_future()); // 1 隐式转移所有权
std::promise< std::map< SomeIndexType, SomeDataType, SomeComparator,
SomeAllocator>::iterator> p;
auto sf=p.get_future().share();
- 在这个例子中,sf的类型推到为std::shared_future<std::map<SomeIndexType,
SomeDataType, SomeComparator, SomeAllocator>::iterator> ; - 隐式转化