9 高级线程管理
在之前的章节,我们通过直接创建std::thread对象来管理线程。有几处你已经看出,这是不可取的,因为之后你必须管理线程对象的生命期,以及确定适合该问题的线程数和当前的硬件,等等。理想的情况是,你可以最大程度的将代码分成可以并行执行的小块,把它们交给编译器和标准库,然后说:“把它们并行化以获得最佳性能”。
这些例子中的另一个常见问题就是,你可能使用了多个线程来解决一个问题,但是希望在某个条件达成时提早结束。这也许是因为结果已经确定了,或者是错误产生,甚至是因为用户指明要终止任务。无论什么原因,线程都需要被通知“请停止”请求,以便于放弃当前任务,清理资源,而且尽快完成。
在这一章,我们将讨论关于管理线程和任务的机制,从自动管理多个线程以及在线程间划分任务开始。
9.1 线程池
在许多公司,通常在办公室度过时间的员工有时需要访问客户或供应商或参加贸易展览或会议。虽然这些外出或许很有必要,任何一天都可能有几个人要外出,甚至有些员工需要外出几个月甚至几年。让每个员工都拥有一辆公车是昂贵的也是不切实际的,通常公司会提供有限数量的汽车供所有员工使用。如果一个员工需要外出,那就在适当的时间预订一辆汽车,当它回到办公室时再交还回去以便其他员工使用。如果当前没有可用的汽车,那员工就不得不将外出时间向后调整了。
线程池的思想很类似。在大多数系统,为每一个任务分配一个线程是不现实的,但是您仍然希望尽可能利用可用的并发。线程池可以帮到你;可以将需要并行执行的任务提交给线程池,线程池将它们放到一个待执行的队列。然后工作线程会逐个将它们取出,然后执行他们,然后再取出下一个,不断循环。
构建一个线程池会遇到几个关键的设计问题,例如使用多少线程,以最有效的方式将任务分配给线程,以及是否可以等待一个任务完成。在这章,我们将看到一些线程池的实现是如何解决这些设计问题的,我们从最简单的线程池开始。
9.1.1 最简单的线程池
作为最简单的线程池,它的线城数是固定的(一般等于std::thread::hardware_concurrency())。当有工作要做时,你调用一个函数将任务放入待运行队列。每个工作线程从队列中取出任务,然后单独运行它,然后回到队列继续取出。在最简单的情况下,没有等待任务完成的方式。如果你需要的话,你必须自己管理同步。
下面列出这种线程池的一个简单实现:
Listing 9.1 Simple thread pool
template
class thread_safe_queue;
class join_threads;
class thread_pool
{
std::atomic_bool done;
thread_safe_queue > work_queue;//(1)
std::vector threads;//(2)
join_threads joiner;//(3)
void worker_thread()
{
while (!done)//(4)
{
std::function task;
if (work_queue.try_pop(task))//(5)
task();//(6)
else
std::this_thread::yield();//(7)
}
}
public:
thread_pool() : done(false), joiner(threads)
{
unsigned const thread_count = std::thread::hardware_concurrency();//(8)
try
{
for (unsigned i = 0; i
void submit(FunctionType f)
{
work_queue.push(std::function(f));//(12)
}
};
线程在构造函数中开始:使用std::thread::hardware_concurrency()获取当前硬件支持的线程数(8)。成员函数 worker_thread()作为线程函数(9)。
如果抛出异常则线程创建就会失败,所以要保证已经开始的线程能够停止并正确清理。如果抛出异常,那么通过 try - catch块捕获,然后设置标志位done(10),伴随一个来自第8章的join_threads的实例,用于join所有线程。它也是在析构函数中工作的,析构时将done设置为true(11),然后join_threads就会确保所有线程在线程池销毁之前执行完毕。注意,成员变量的声明顺序很重要:标志位done和worker_queue必须在线程数组之前声明,而threads必须在joiner之前声明。这是为了确保成员变量按照正确的顺序销毁;例如如果线程还没有结束,那么你就不能安全的销毁队列。(同样,如果先销毁线程后销毁joiner,那么线程就不能join)
worker_thread()函数本身很简单:在循环中检测done(4),从队列中取出任务(5),并执行(6)。如果没有任务可以执行则调用std::this_thread::yield()休息一下(7),在下次循环取出之前给其他线程一个机会向队列中添加任务。
对于一般的事情来说,这样一个简单的线程池都可以满足,特别是在任务完全独立,而且不需要返回值,或者执行任何阻塞操作的情况下。但也有许多情况这个简单的线程池不能满足需求,甚至会导致诸如死锁等问题。而且,简单的情况下,使用std::async可能会比使用这个线程池更好(例如像第8章那样)。在这一章中,我们将研究更复杂的线程池的实现,它们具有更多的特性,满足用户需要,或者减少潜在的问题。首先解决任务等待问题。
9.1.2 等待提交给线程池的任务
在第8章的例子中,显示的创建线程,将任务划分给线程之后,宿主线程总是等待最新创建的线程完成,以确保在将结果返给调用者之前整个任务能够结束。那么对与线程池来说,你必须等待提交给线程池的任务,而不是等待工作线程本身。这类似与第8章中基于std::async的例子,它们等待future。对与9.1中的简单线程池来说,你必须手动使用第4章中的技术:条件变量或者future。这就增加了代码的复杂度;要是可以直接等待任务就好了。
通过把复杂的事务移到线程池内部,你就可以直接等待任务了。令submit()函数返回某种描述的句柄,然后可以使用这个句柄来等待任务完成。这个任务句柄应该包装条件变量或者future,那样会简化线程池的代码。
对于某些情况,主线程需要等待创建的任务完成,以获取任务计算的结果。在这本书中你已经见过这样的例子,例如第二章中的parallel_accumulate()。在这种情况下,可以使用future来合并结果传递的等待。下面的程序9.2显示了这种变化,它允许你等待任务完成并将结果从任务传递给等待线程。因为std::packaged_task<>实例不允许拷贝,只能移动,所以不能再给队列传递std::function<>实例,因为std::function<>要求保存的函数对象是可拷贝构造的。只能换一种方式,使用自定义的函数包装类来处理只能移动的类型。这是一个简单的函数调用操作的类型消除类。你只需要处理无参数无返回值的函数,所以,在实现中,它就一种直接的虚调用。
Listing 9.2 A thread pool with waitable tasks
class function_wrapper
{
struct impl_base {
virtual void call() = 0;
virtual ~impl_base() {}
};
std::unique_ptr impl;
template
struct impl_type : impl_base
{
F f;
impl_type(F&& f_) : f(std::move(f_)) {}
void call() { f(); }
};
public:
template
function_wrapper(F&& f) : impl(new impl_type(std::move(f))){}
void operator()() { impl->call(); }
function_wrapper() = default;
function_wrapper(function_wrapper&& other) : impl(std::move(other.impl)){}
function_wrapper& operator=(function_wrapper&& other)
{
impl = std::move(other.impl);
return *this;
}
function_wrapper(const function_wrapper&) = delete;
function_wrapper(function_wrapper&) = delete;
function_wrapper& operator=(const function_wrapper&) = delete;
};
class thread_pool
{
thread_safe_queue work_queue;
void worker_thread()
{
while (!done)
{
function_wrapper task;
if (work_queue.try_pop(task))
{
task();
}
else
{
std::this_thread::yield();
}
}
}
public:
template
std::future::type> submit(FunctionType f)//(1)
{
typedef typename std::result_of::type result_type;//(2)
std::packaged_task task(std::move(f));//(3)
std::future res(task.get_future());//(4)
work_queue.push(std::move(task));//(5)
return res;//(6)
}
// rest as before
};
然后将f包装到std::packaged_task
下面的程序显示了如果使用这个线程池,则parallel_accumulate的实现是这样的:
Listing 9.3 parallel_accumulate using a thread pool with waitable tasks
template
T parallel_accumulate(Iterator first, Iterator last, T init)
{
unsigned long const length = std::distance(first, last);
if (!length)
return init;
unsigned long const block_size = 25;
unsigned long const num_blocks = (length + block_size - 1) / block_size;//(1)当length小于block_size时num_blocks等于1
std::vector > futures(num_blocks - 1);
thread_pool pool;
Iterator block_start = first;
for (unsigned long i = 0; i<(num_blocks - 1); ++i)
{
Iterator block_end = block_start;
std::advance(block_end, block_size);
futures[i] = pool.submit(accumulate_block());//(2)
block_start = block_end;
}
T last_result = accumulate_block()(block_start, last);
T result = init;
for (unsigned long i = 0; i<(num_blocks - 1); ++i)
{
result += futures[i].get();
}
result += last_result;
return result;
}
假设块大小选择得正好,你就不用担心:任务打包,获取future,以及保存一个thread对象以便于稍后去join它;线程池会帮你管理这些事情。你只需要为你的任务调用submit()。
线程池也会保证线程安全。任务执行时抛出的任何异常都会通过由submi()t返回的future传递出来,如果函数带着一个异常退出,那么线城池的析构函数会放弃任何还没有完成的任务,然后等待所有线程完成(一个疑问:如果一个任务出异常了其他任务会停止执行吗?)。
当任务之间没有依赖关系时,这个线城池可以应对。如果提交给线城池的任务之间相互依赖,那么这个版本的线程池就无法工作了。
9.1.3 任务间等待
快速排序算法是一个贯穿这本书的例子。它在概念上是简单的:要排序的数据被分割成轴心数据项之前和之后两部分。这两部分被递归排序,最后将结果汇合到一起形成完整的序列。当使这个算法并行化时,需要确保这些递归调用利用了并行。
回想第4章,当我第一次介绍这个例子时,我是用std::async来运行每一部分的递归调用,让标准库去选择创建新线程运行。还是当get()被调用时同步运行。它能够很好的运行,因为每个任务要么运行在新线程上,要么当需要结果时采取调用。
当我们再看第8章的实现时,你看到了另外一种结构,它使用了与可用硬件并行能力有关的固定的线程数量。这种情况下,它使用了一个stack去保存待排序的块。随着每个线程对数据执行分割排序,它将一部分数据以新块的形式放入队列,而另一部分则直接排序。在这一点上,直接等待另一块排序完成会导致死锁,因为你从有限的线程中花费一个出来用于等待。那很容易导致因为所有线程都在等待块排序完成,实际上没有线程在做排序的工作。我们这样解决这个问题:当等待的数据还没有被排序,那么就从队列中取出一块对其排序。
如果你想用这个简单的线程池代替std::async,那么你将遇到和第4章同样的问题。只有有限的线程,可能会因为没有可用的线程使得所有任务陷入等待,而且等待的任务没有机会被执行。因此你需要用到一种曾在第8章中用到的解决方案:当你等待一个数据块时,就去处理未完成的数据块。但是如果使用线程池来管理任务列表以及它们关联的线程时——线程池终究是一个整体,你无法访问任务列表,让它做这些事情。你需要做的就是修改线程池,让它自动来完成。
最简单的办法就是在线程池中增加一个函数从队列中取出任务来执行,并且自己管理循环。先进的线程实现可能会在等待函数中增加逻辑,或者增加一个等待函数来处理这种情况,可能优先处理正在等待的任务。下面的程序显示了新函数run_pending_task(),程序9.5列出了一个经修改而使用这个函数的快速排序算法。
Listing 9.4 An implementation of run_pending_task()
void thread_pool::run_pending_task()
{
function_wrapper task;
if (work_queue.try_pop(task))
{
task();
}
else
{
std::this_thread::yield();
}
}
Listing 9.5 A thread pool–based implementation of Quicksort
template
struct sorter//(1)
{
thread_pool pool;//(2)
std::list do_sort(std::list& chunk_data)
{
if (chunk_data.empty())
{
return chunk_data;
}
std::list result;
result.splice(result.begin(), chunk_data, chunk_data.begin());
T const& partition_val = *result.begin();
typename std::list::iterator divide_point = std::partition(chunk_data.begin(), chunk_data.end(),
[&](T const& val) {return val new_lower_chunk;
new_lower_chunk.splice(new_lower_chunk.end(), chunk_data, chunk_data.begin(), divide_point);
std::future > new_lower = pool.submit(std::bind(&sorter::do_sort, this, //(3)
std::move(new_lower_chunk)));
std::list new_higher(do_sort(chunk_data));
result.splice(result.end(), new_higher);
while (!new_lower.wait_for(std::chrono::seconds(0)) == std::future_status::timeout)
{
pool.run_pending_task();//(4)
}
result.splice(result.begin(), new_lower.get());
return result;
}
};
template
std::list parallel_quick_sort(std::list input)
{
if (input.empty())
{
return input;
}
sorter s;
return s.do_sort(input);
}
原本的线程和任务管理不见了,只剩下将任务提交给线程池(3),以及在等待时运行待执行任务(4)。这比程序8.1简单多了,在那里你必须显示的管理线程以及存放要排序的数据块的stack。当给线程池提交任务时,使用一个std::bind()将成员函数和this指针以及要排序的数据块传给线程池,这种情况下,你调用std::move()来移动new_lower_chunk,而不是被拷贝。
尽管现在解决了因任务之间的等待可能造成的死锁问题,但是这个线程池还远不够理想。首先,所有submit()以及所有的run_pending_work()都是访问的同一个队列。你在第8章已经见识过了,多个线程同时修改同一个数据集合对效率的影响是多么的不利,所以需要解决这个问题。
9.1.4 避免工作队列中的数据争夺
每一次一个线程针对一个特定的线程池调用submit(),它就会往唯一的共享队列中放入一个新元素。同样,工作线程也在持续不断地从这个队列中取出元素以执行任务。这意味着,随着处理器个数的增加,对队列的访问争夺会逐渐加剧。这是真实存在的性能下降;即使你使用了一个无锁队列以保证没有显示的等待,但乒乓缓存也着实会消耗时间。
一种避免乒乓缓存的方式是为每一个线程提供一个工作队列。每个线程向自己的队列中放入新的数据项,只有当自己的队列中没有数据时才从全局队列中取出任务。下面的程序列出了一种实现,使用thread_local变量确保每个线程都拥有自己的工作队列,和全局队列一样。
Listing 9.6 A thread pool with thread-local work queues
class thread_pool
{
thread_safe_queue pool_work_queue;
typedef std::queue local_queue_type;//(1)
static thread_local std::unique_ptr local_work_queue;//(2)
void worker_thread()
{
local_work_queue.reset(new local_queue_type);//(3)
while (!done)
{
run_pending_task();
}
}
public:
template
std::future::type> submit(FunctionType f)
{
typedef typename std::result_of::type result_type;
std::packaged_task task(f);
std::future res(task.get_future());
if (local_work_queue)//(4)
{
local_work_queue->push(std::move(task));
}
else
{
pool_work_queue.push(std::move(task));//(5)
}
return res;
}
void run_pending_task()
{
function_wrapper task;
if (local_work_queue && !local_work_queue->empty())//(6)
{
task = std::move(local_work_queue->front());
local_work_queue->pop();
task();
}
else if (pool_work_queue.try_pop(task))//(7)
{
task();
}
else
{
std::this_thread::yield();
}
}
// rest as before
};
submit()检查当前线程是否拥有一个工作线程(4)。如果有,那是一个线程池线程,就可以将任务放入线程本地任务队列;否则,就要像以前一样把任务放入线程池队列(5)。
在 run_pending_task()函数中存在类似的检查(6),除了还需要检查本地队列中是否还有数据项。如果有,则取出一个并处理;注意,本地队列可以是一个std::queue<>对象(1),因为只有一个线程会访问它。如果本地队列中没有任务,那么你就像以前那样尝试从线程池队列中获取任务(7)。
(本人分析:只有线程池创建的线程才会创建线程本地队列,也只有这些线程才能向自己的队列中存入任务)
这个实现可以很好的减少争夺,但是当任务分配不均匀时,将很容易导致某个线程积压了很多任务,而其他的线程却无事可做。例如,在快速排序例子中,只有最上层的数据块才会放入线程池队列,因为剩下的数据块将最终放到处理它的线程的本地任务队列中。这就违背了线程池的初衷。
谢天谢地,这里有一种解决方案:在自己的队列中没有任务并且全局队列中也没有任务时,允许线程从别的线程的本地任务队列中窃取任务来做。
9.1.5 工作窃取
为了允许线程在无事可做的时候可以从别的线程(任务慢慢)那窃取任务来做,必须允许想要窃取任务的线程通过run_pending_tasks()函数来访问别的队列。这就要求每个线程池都要在线程池上注册一个队列,或者由线程池指派给它一个队列。而且,你要确保工作队列中的数据要被适当同步及保护,以保持你的数据一致性。
可以写一个无锁队列允许队列所属线程在一端pop和push,其他线的线程可以从另一端窃取,但是这种实现已经超出了本书的范围。为了阐述观点,我们使用mutex来保护队列数据。我们希望工作窃取是一种效率概率事件,所以在mutex上将存在很少的争夺,因此这样一个简单的队列将具有最小的开销。一个简单的基于锁的队列实现如下:
Listing 9.7 Lock-based queue for work stealing
using namespace m_pool1;//为了让function_wrapper定义可见
class work_stealing_queue
{
private:
typedef function_wrapper data_type;
std::deque the_queue;
mutable std::mutex the_mutex;
public:
work_stealing_queue()
{}
work_stealing_queue(const work_stealing_queue& other) = delete;
work_stealing_queue& operator=(const work_stealing_queue& other) = delete;
void push(data_type data)
{
std::lock_guard lock(the_mutex);
the_queue.push_front(std::move(data));
}
bool empty() const
{
std::lock_guard lock(the_mutex);
return the_queue.empty();
}
bool try_pop(data_type& res)
{
std::lock_guard lock(the_mutex);
if (the_queue.empty())
{
return false;
}
res = std::move(the_queue.front());
the_queue.pop_front();
return true;
}
bool try_steal(data_type& res)
{
std::lock_guard lock(the_mutex);
if (the_queue.empty())
{
return false;
}
res = std::move(the_queue.back());
the_queue.pop_back();
return true;
}
};
这实际上意味着,这个队列对于拥有它的线程来说相当于一个后入先出的stack;最新存入的任务最先被取出。从缓存的角度来看,这样的设计可以提升效率,因为与那个任务相关的数据比之前push进去的任务相关的数据更有可能仍然位于缓存中。而且,它能够更好的对应于类似快速排序这样的算法。在之前的实现中,每个do_sort()调用都将一个数据项放入stack中,然后等待它。通过首先处理最近的数据项,你可以确保被当前调用所需要的数据块能够先被处理,而被其他分支需要的数据块将后被处理,从而减少了活动任务数量和总的stack使用量。try_steal()则从try_pop()操作的相反一端取出数据项,以最小化缓存争夺;你可以使用第6章和第7章中讨论的技术实现try_pop()和try_steal()并发执行。
如何将这个队列用到线程池中呢?下面是一种实现:
Listing 9.8 A thread pool that uses work stealing
class thread_pool
{
typedef function_wrapper task_type;
std::atomic_bool done;
thread_safe_queue pool_work_queue;
std::vector > queues;//(1)
std::vector threads;
join_threads joiner;
static thread_local work_stealing_queue* local_work_queue;//(2)
static thread_local unsigned my_index;
void worker_thread(unsigned my_index_)
{
my_index = my_index_;
local_work_queue = queues[my_index].get();//(3)
while (!done)
{
run_pending_task();
}
}
bool pop_task_from_local_queue(task_type& task)
{
return local_work_queue && local_work_queue->try_pop(task);
}
bool pop_task_from_pool_queue(task_type& task)
{
return pool_work_queue.try_pop(task);
}
bool pop_task_from_other_thread_queue(task_type& task)//(4)
{
for (unsigned i = 0; itry_steal(task))
{
return true;
}
}
return false;
}
public:
thread_pool() : done(false), joiner(threads)
{
unsigned const thread_count = std::thread::hardware_concurrency();
try
{
for (unsigned i = 0; i(new work_stealing_queue));//(6)
threads.push_back(std::thread(&thread_pool::worker_thread, this, i));
}
}
catch (...)
{
done = true;
throw;
}
}
~thread_pool()
{
done = true;
}
template
std::future::type> submit(FunctionType f)
{
typedef typename std::result_of::type result_type;
std::packaged_task task(f);
std::future res(task.get_future());
if (local_work_queue)
{
local_work_queue->push(std::move(task));
}
else
{
pool_work_queue.push(std::move(task));
}
return res;
}
void run_pending_task()
{
task_type task;
if (pop_task_from_local_queue(task) ||//(7)
pop_task_from_pool_queue(task) ||//(8)
pop_task_from_other_thread_queue(task))//(9)
{
task();
}
else
{
std::this_thread::yield();
}
}
};
pop_task_from_other_thread_queue()(4)在线城池中所有线程的任务队列中迭代,以窃取其他每个线程任务队列中的任务。为了避免每个线程都从数组中属于第一个线程的任务队列中窃取任务,每个线程都从自己序号的下一个位置开始窃取(5)。
现在,你的线程池可以适应很多潜在的用法。当然,还有很多种方式提升它以适应各种用法,那就留给读者作为练习吧。一个特殊的方面还没有探讨过,那就是当线程池的所有线程都在阻塞等待某些事例如I/O或者mutex时,可否动态改变线程池的大小以优化CPU的使用。
下一个高级线程管理技术就是——中断线程。
9.2 中断线程
很多情况下,如果能给一个长时间运行的线程发送一个信号令其停止就再好不过了。可能原因是:它有可能是一个线程池中的工作线程,而现在线程池要销毁了,或者因为用户显示的取消了这个任务,或者其他的太多的原因。无论什么原因,想法都是一样的:你需要从一个线程发出一个信号,让另一个线程在自然结束前停止,而且你需要以某种方式让那个线程有好的结束,而不是突然抽掉他脚下的地毯。
你可以为每种情况设计一种机制,但那样就有点过头了。一个通用的机制不仅使在之后的场景上编写代码更加容易,而且它允许你写出可被中断的代码,不用担心那个代码被用在哪里。C++标准没有提供这样的机制,但是创建一个相对比较简单。我们来看看如何做到这一点,从启动和中断线程的接口的角度出发,而不是从线程被中断出发。
9.2.1 启动和中断其他线程
先来看看外部接口。一个可中断线程都需要什么接口?从基本层面看,所需的接口和std::thread一样,再多一个interrupt()接口:
class interruptible_thread
{
public:
template
interruptible_thread(FunctionType f);
void join();
void detach();
bool joinable() const;
void interrupt();
};
这个线程本地的标志位就是你不能直接使用普通的std::thread来管理线程的主要原因;对于最新启动的线程来说,这个标志位需要以某种可以被interruptible_thread实例访问的方式分配。
在构造函数中,在将外部提供的函数传给std::thread并启动之前可以包装一下这个函数。如下所示:
Listing 9.9 Basic implementation of interruptible_thread
class interrupt_flag
{
public:
void set();
bool is_set() const;
};
thread_local interrupt_flag this_thread_interrupt_flag;//(1)
class interruptible_thread
{
std::thread internal_thread;
interrupt_flag* flag;
public:
template
interruptible_thread(FunctionType f)
{
std::promise p;//(2)
internal_thread = std::thread([f, &p] {//(3)
p.set_value(&this_thread_interrupt_flag);
f();//(4)
});
flag = p.get_future().get();//(5)
}
void interrupt()
{
if (flag)
{
flag->set();//(6)
}
}
};
interrupt()函数非常直接:如果你持有一个有效的flag指针,那就直接调用flag的set()函数(6)。然后它就会中断待中断线程的当前动作,我们稍后探讨这个问题。
9.2.2 判断一个线程是否已经被中断
现在你可以设置中断标志位,但是如果线程不去检查这个变量就没什么用了。这是一种最简单的情况,你可以使用interruption_point()函数来作这件事,你可以在一个可以安全中断的点上调用这个函数,如果标志位被设置了,函数就抛出一个thread_interrupted异常:
void interruption_point()
{
if (this_thread_interrupt_flag.is_set())
{
throw thread_interrupted();
}
}
void foo()
{
while (!done)
{
interruption_point();
process_next_item();
}
}
9.2.3 中断针对条件变量的等待
OK,那么你可以在代码中仔细选择一个地方,通过显示的调用interruption_point()来检测中断,但是当你想要阻塞等待时它就不工作了,例如等待一个条件变量被通知。你需要一个新的函数——interruptible_wait()——在这个函数中,你可以为了你要等待的事情去做不同的事,你可以决定怎样中断等待。我已经提到过条件变量,所以让我们从这里开始:为了中断基于条件变量的等待该怎么做呢?最简单的做法就是如果你已经设置了中断标志位,那么就去通知条件变量一次,在等待结束之后立刻放置一个中断点。但是这样一来,你就必须通知等待条件变量的所有线程,以确保你关心的所有线程醒来。等待者无论如何都必须处理虚假唤醒,所以其他的线程将把它等同于虚假唤醒来处理——它们无法区分。interrupt_flag结构需要保存一个指向条件变量的指针,以便于在set()执行时可以通知条件变量。一种基于条件变量的interruptible_wait()的实现如下:
Listing 9.10 A broken version of interruptible_wait for std::condition_variable
void interruptible_wait(std::condition_variable& cv,std::unique_lock& lk)
{
interruption_point();
this_thread_interrupt_flag.set_condition_variable(cv);//(1)
cv.wait(lk);//(2)
this_thread_interrupt_flag.clear_condition_variable();//(3)
interruption_point();
}
不幸的是,这段代码有两个问题:第一个问题非常明显,如果处理std::condition_variable::wait()抛出的异常,那么你可能会没有移除条件变量和中断标志之间的关联。这个容易解决,可以添加一个结构体,在析构时移除关联。
第二个问题不太明显,这里有一个竞争条件。如果在初始调用interruption_point()之后、调用wait()之前,线程被中断(也就是中断标志位执行了set()操作),那么条件变量是否被关联到中断标志位上已经无所谓了,因为此时线程已经没有机会等到条件变量被通知,因此会无限等下去。你需要确保在上次检测中断和执行wait()之间不要通知条件变量。因为没有深入到std::condition_variable内部,所以你只有一种方式来做到这一点:再次使用lk代表的mutex,那就要求在调用set_condition_variable()时将mutex传入。不幸的是,这又导致了它自身的问题:你将一个不知道生命期的mutex引用传给了另一个线程去锁定(在执行interrupt()时),而且还不知道那个线程在调用interrrupt()时是否已经锁定了这个mutex。这就隐含着问题:可能导致死锁或者访问一个已经被销毁的mutex,所以这个办法不行。有没有其它选择呢?一种选择就是在等待时加入超时,使用wait_for()代替wait(),使用一个很小的时间长度,例如1毫秒。这就为线程检测到中断之前的等待提供了一个上限。如果这样做,那么等待线程将遇到非常多的因超时导致的虚假唤醒,but it can’t easily be helped。
下面列出这样的实现,同时列出interrupt_flag的实现:
Listing 9.11 Using a timeout in interruptible_wait for std::condition_variable
class interrupt_flag
{
std::atomic flag;
std::condition_variable* thread_cond;
std::mutex set_clear_mutex;
public:
interrupt_flag() :thread_cond(0)
{}
void set()
{
flag.store(true, std::memory_order_relaxed);
std::lock_guard lk(set_clear_mutex);
if (thread_cond)
{
thread_cond->notify_all();
}
}
bool is_set() const
{
return flag.load(std::memory_order_relaxed);
}
void set_condition_variable(std::condition_variable& cv)
{
std::lock_guard lk(set_clear_mutex);
thread_cond = &cv;
}
void clear_condition_variable()
{
std::lock_guard lk(set_clear_mutex);
thread_cond = 0;
}
struct clear_cv_on_destruct
{
~clear_cv_on_destruct()
{
this_thread_interrupt_flag.clear_condition_variable();
}
};
};
void interruptible_wait(std::condition_variable& cv, std::unique_lock& lk)
{
interruption_point();
this_thread_interrupt_flag.set_condition_variable(cv);
interrupt_flag::clear_cv_on_destruct guard;
interruption_point();
cv.wait_for(lk, std::chrono::milliseconds(1));//还能起到等待的目的吗?
interruption_point();
}
template
void interruptible_wait(std::condition_variable& cv,
std::unique_lock& lk,
Predicate pred)
{
interruption_point();
this_thread_interrupt_flag.set_condition_variable(cv);
interrupt_flag::clear_cv_on_destruct guard;
while (!this_thread_interrupt_flag.is_set() && !pred())
{
cv.wait_for(lk, std::chrono::milliseconds(1));
}
interruption_point();
}
9.2.4 中断针对std::condition_variable_any的等待
std::condition_variable_any与std::condition_variable的不同之处在于,它使用任意锁类型,而不是std::unique_lock
Listing 9.12 interruptible_wait for std::condition_variable_any
class interrupt_flag
{
std::atomic flag;
std::condition_variable* thread_cond;
std::condition_variable_any* thread_cond_any;
std::mutex set_clear_mutex;
public:
interrupt_flag() : thread_cond(0), thread_cond_any(0)
{}
void set()
{
flag.store(true, std::memory_order_relaxed);
std::lock_guard lk(set_clear_mutex);
if (thread_cond)
{
thread_cond->notify_all();
}
else if (thread_cond_any)
{
thread_cond_any->notify_all();
}
}
template
void wait(std::condition_variable_any& cv, Lockable& lk)
{
struct custom_lock
{
interrupt_flag* self;
Lockable& lk;
custom_lock(interrupt_flag* self_, std::condition_variable_any& cond, Lockable& lk_) :
self(self_), lk(lk_)
{
self->set_clear_mutex.lock();//(1)
self->thread_cond_any = &cond;//(2)
}
void unlock()//(3)
{
lk.unlock();
self->set_clear_mutex.unlock();
}
void lock()
{
std::lock(self->set_clear_mutex, lk);//(4)
}
~custom_lock()
{
self->thread_cond_any = 0;//(5)
self->set_clear_mutex.unlock();
}
};
custom_lock cl(this, cv, lk);
interruption_point();
cv.wait(cl);
interruption_point();
}
// rest as before
};
template
void interruptible_wait(std::condition_variable_any& cv, Lockable& lk)
{
this_thread_interrupt_flag.wait(cv, lk);
}
9.2.5 中断其他阻塞调用
之前涉及到的都是围绕中断条件变量等待的讨论,其他的阻塞等待还有:mutex锁,等待future,还有类似的,这些等待如何中断呢?通常来说你不得不像对待std::condition_variable那样,使用超时的方式,因为如果不访问mutex或者future内部,就没有方式可以让条件没达成前中断等待。但是在使用场景你知道你在等什么,所以你可以在interruptible_wait()内部循环。作为一个例子,来看看一个针对std::future<>的interruptible_wait()实现:
template
void interruptible_wait(std::future& uf)
{
while (!this_thread_interrupt_flag.is_set())
{
if (uf.wait_for(lk, std::chrono::milliseconds(1) == std::future_status::ready)
break;
}
interruption_point();
}
OK,那么我们来看看怎样使用interruption_point()和interruptible_wait()函数来检测中断。
9.2.6 处理中断
从线程被中断的视角来看,一个中断就是一个thread_interrupted异常,因此它就像其他异常那样可以被处理。尤其是,你可以在一个标准的catch块中捕获它:
try
{
do_something();
}
catch (thread_interrupted&)
{
handle_interruption();
}
因为thread_interrupted是一种异常,那么调用一个可以被中断的代码时,所有的通常的异常安全措施必须到位,以确保资源不被泄漏、数据结构处于一致的状态。很多时候,我们希望让中断终结线程,所以你可以直接将异常向上传递。但是如果你让异常传递出线程函数给了std::thread的构造函数,那么std::terminate()就会被调用,而且整个应用都会被终止。为了避免需要记住给每一个传给interruptible_thread对象的函数都写一个catch块(专门处理thread_interrupted的),你可以将catch块放到用于初始化interrupt_flag的包装里。这就可以安全的允许传播未处理的终端异常,因为它只能终止单个的进程。interruptible_thread构造函数中的初始化过程看起来是这样的:
internal_thread = std::thread([f, &p] {
p.set_value(&this_thread_interrupt_flag);
try
{
f();
}
catch (thread_interrupted const&)
{
}
});
9.2.7 当应用退出时中断后台任务
思考一个桌面搜索应用程序。就像和用户交互一样,应用需要监视系统文件的状态,识别任何变化并且更新它们的序号。这样的处理一般会留给后台线程去做,避免影响GUI的用户响应力。这个后台线程需要伴随应用的整个生命期而运行;它随着应用程序的初始化开始运行,直到应用被关闭。对于这样的应用程序来说,通常只有当机器被关闭时才退出,因为这个应用需要一直运行以维持最新的序号。无论哪种情况,当应用即将被关闭,都需要有序的关闭后台线程;一种办法就是通过中断。
下面的程序显示了这样的一个系统中的线程管理部分的一个简单的实现:
Listing 9.13 Monitoring the filesystem in the background
std::mutex config_mutex;
std::vector background_threads;
void background_thread(int disk_id)
{
while (true)
{
interruption_point();//(1)
fs_change fsc = get_fs_changes(disk_id);//(2)
if (fsc.has_changes())
{
update_index(fsc);//(3)
}
}
}
void start_background_processing()
{
background_threads.push_back(interruptible_thread(background_thread, disk_1));
background_threads.push_back(interruptible_thread(background_thread, disk_2));
}
int main()
{
start_background_processing();//(4)
process_gui_until_exit();//(5)
std::unique_lock lk(config_mutex);
for (unsigned i = 0; i
为什么你在等待这些线程之前全部中断它们?为什么不中断一个就等待它完成,然后再中断下一个?答案是:并行化。被中断时这些线程可能不会立刻完成,因为它们必须进入下一个中断点,然后执行所有的的析构调用,然后在退出前执行必要的异常处理代码。一个个中断、等待,会让其他线程继续工作。一次性中断所有线程,然后再等待会让它们并行处理退出前的所有操作,完成得更快。
这个实现可以很容易的扩展为更深层次的中断调用,或者通过制定代码禁用中断。这些就留给读者作为练习吧。