《C++ Concurrency in Action》笔记30 高级线程管理——线程池及中断线程

来源:互联网 发布:打车软件营销方案 编辑:程序博客网 时间:2024/05/21 22:24

9 高级线程管理

在之前的章节,我们通过直接创建std::thread对象来管理线程。有几处你已经看出,这是不可取的,因为之后你必须管理线程对象的生命期,以及确定适合该问题的线程数和当前的硬件,等等。理想的情况是,你可以最大程度的将代码分成可以并行执行的小块,把它们交给编译器和标准库,然后说:“把它们并行化以获得最佳性能”。

这些例子中的另一个常见问题就是,你可能使用了多个线程来解决一个问题,但是希望在某个条件达成时提早结束。这也许是因为结果已经确定了,或者是错误产生,甚至是因为用户指明要终止任务。无论什么原因,线程都需要被通知“请停止”请求,以便于放弃当前任务,清理资源,而且尽快完成。

在这一章,我们将讨论关于管理线程和任务的机制,从自动管理多个线程以及在线程间划分任务开始。

9.1 线程池

在许多公司,通常在办公室度过时间的员工有时需要访问客户或供应商或参加贸易展览或会议。虽然这些外出或许很有必要,任何一天都可能有几个人要外出,甚至有些员工需要外出几个月甚至几年。让每个员工都拥有一辆公车是昂贵的也是不切实际的,通常公司会提供有限数量的汽车供所有员工使用。如果一个员工需要外出,那就在适当的时间预订一辆汽车,当它回到办公室时再交还回去以便其他员工使用。如果当前没有可用的汽车,那员工就不得不将外出时间向后调整了。

线程池的思想很类似。在大多数系统,为每一个任务分配一个线程是不现实的,但是您仍然希望尽可能利用可用的并发。线程池可以帮到你;可以将需要并行执行的任务提交给线程池,线程池将它们放到一个待执行的队列。然后工作线程会逐个将它们取出,然后执行他们,然后再取出下一个,不断循环。

构建一个线程池会遇到几个关键的设计问题,例如使用多少线程,以最有效的方式将任务分配给线程,以及是否可以等待一个任务完成。在这章,我们将看到一些线程池的实现是如何解决这些设计问题的,我们从最简单的线程池开始。

9.1.1 最简单的线程池

作为最简单的线程池,它的线城数是固定的(一般等于std::thread::hardware_concurrency())。当有工作要做时,你调用一个函数将任务放入待运行队列。每个工作线程从队列中取出任务,然后单独运行它,然后回到队列继续取出。在最简单的情况下,没有等待任务完成的方式。如果你需要的话,你必须自己管理同步。

下面列出这种线程池的一个简单实现:

Listing 9.1 Simple thread pool

template<typename T>class thread_safe_queue;class join_threads;class thread_pool{std::atomic_bool done;thread_safe_queue<std::function<void()> > work_queue;//(1)std::vector<std::thread> threads;//(2)join_threads joiner;//(3)void worker_thread(){while (!done)//(4){std::function<void()> task;if (work_queue.try_pop(task))//(5)task();//(6)elsestd::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<thread_count; ++i){threads.push_back(std::thread(&thread_pool::worker_thread, this));//(9)}}catch (...){done = true;//(10)throw;}}~thread_pool(){done = true;//(11)}template<typename FunctionType>void submit(FunctionType f){work_queue.push(std::function<void()>(f));//(12)}};

这个实现拥有一个工作线程vector(2),用到了来自第6章的的线程安全队列(1)。这种情况下,用户不能等待任务,而且任务也不能返回任何值,所以你可以使用std::function<void()>来封装任务。submit()函数包装了函数或者一个可调用对象,使其成为一个 std::function<void()>实例,然后放入队列(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_base> impl;template<typename F>struct impl_type : impl_base{F f;impl_type(F&& f_) : f(std::move(f_)) {}void call() { f(); }};public:template<typename F>function_wrapper(F&& f) : impl(new impl_type<F>(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<function_wrapper> work_queue;void worker_thread(){while (!done){function_wrapper task;if (work_queue.try_pop(task)){task();}else{std::this_thread::yield();}}}public:template<typename FunctionType>std::future<typename std::result_of<FunctionType()>::type> submit(FunctionType f)//(1){typedef typename std::result_of<FunctionType()>::type result_type;//(2)std::packaged_task<result_type()> task(std::move(f));//(3)std::future<result_type> res(task.get_future());//(4)work_queue.push(std::move(task));//(5)return res;//(6)}// rest as before};

首先修改submit()函数(1),返回一个 std::future<>对象,允许等待任务结束并获取结果。这就需要你知道函数f的返回值类型,std::result_of<FunctionType()>::type代表调用FunctionType实例的返回值,它没有参数。然后使用它再定义一个result_type类型(2)。

然后将f包装到std::packaged_task<result_type()>中(3),因为f是一个无参数但返回 result_type类型的函数或者可调用对象。然后从std::packaged_task<>中获取future(4),然后将std::packaged_task<>放入队列(5),最后将future返回(6)。注意,必须使用 std::move()将任务放入队列,因为std::packaged_task<>只能移动。现在,队列中保存了function_wrapper对象,而不是std::function<void()>对象。

下面的程序显示了如果使用这个线程池,则parallel_accumulate的实现是这样的:

Listing 9.3 parallel_accumulate using a thread pool with waitable tasks

template<typename Iterator, typename T>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等于1std::vector<std::future<T> > 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<Iterator, T>());//(2)block_start = block_end;}T last_result = accumulate_block<Iterator, T>()(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;}

将这个程序和8.4程序作比较,有几处需要注意。首先,你的工作量是依据数据块的数量(num_blocks(1)),而不是依据线程数量。为了更好的利用线程池的可伸缩性,需要尽可能将工作划分为小的块,以便值得并行执行。当线程池中的线程很少时,每个线程将处理多个块,但是随着硬件并行能力的提升线程数会变多,那么并行处理的块数也会增多。你要小心选择“值得并行工作的块”。提交任务给线程池,让工作线程运行它,并且将返回值通过std::future传回,这些都是固有的开销,并不值得为太小的任务花费资源。如果选择的任务尺寸太小,那么使用线程池可能比使用单个线程运行得更慢。

假设块大小选择得正好,你就不用担心:任务打包,获取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();}}

run_pending_task()函数的实现就是将worker_thread()函数的循环体直接提取出来,worker_thread()函数可以修改为调用run_pending_task()函数。下面的快速排序实现相比于8.1程序来看是是非简单的,因为所有的线程管理逻辑都被移动到线程池中了。

Listing 9.5 A thread pool–based implementation of Quicksort

template<typename T>struct sorter//(1){thread_pool pool;//(2)std::list<T> do_sort(std::list<T>& chunk_data){if (chunk_data.empty()){return chunk_data;}std::list<T> result;result.splice(result.begin(), chunk_data, chunk_data.begin());T const& partition_val = *result.begin();typename std::list<T>::iterator divide_point = std::partition(chunk_data.begin(), chunk_data.end(),[&](T const& val) {return val<partition_val; });std::list<T> new_lower_chunk;new_lower_chunk.splice(new_lower_chunk.end(), chunk_data, chunk_data.begin(), divide_point);std::future<std::list<T> > new_lower = pool.submit(std::bind(&sorter::do_sort, this, //(3)std::move(new_lower_chunk)));std::list<T> 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<typename T>std::list<T> parallel_quick_sort(std::list<T> input){if (input.empty()){return input;}sorter<T> s;return s.do_sort(input);}

就像8.1程序那样,实际的工作委托给了sorter模板类(1)的do_sort()成员函数,尽虽然这里只有sorter类包装了线程池实例(2)。

原本的线程和任务管理不见了,只剩下将任务提交给线程池(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<function_wrapper> pool_work_queue;typedef std::queue<function_wrapper> local_queue_type;//(1)static thread_local std::unique_ptr<local_queue_type> local_work_queue;//(2)void worker_thread(){local_work_queue.reset(new local_queue_type);//(3)while (!done){run_pending_task();}}public:template<typename FunctionType>std::future<typename std::result_of<FunctionType()>::type> submit(FunctionType f){typedef typename std::result_of<FunctionType()>::type result_type;std::packaged_task<result_type()> task(f);std::future<result_type> 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};

使用一个 std::unique_ptr<>保存thread-local工作队列(2),因为不希望非线程池线程也拥有一个队列;在函数worker_thread()中,开始循环之前初始化这个指针(3)。std::unique_ptr<>的析构函数会保证在线程退出时销毁任务队列。

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<data_type> 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<std::mutex> lock(the_mutex);the_queue.push_front(std::move(data));}bool empty() const{std::lock_guard<std::mutex> lock(the_mutex);return the_queue.empty();}bool try_pop(data_type& res){std::lock_guard<std::mutex> 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<std::mutex> lock(the_mutex);if (the_queue.empty()){return false;}res = std::move(the_queue.back());the_queue.pop_back();return true;}};

这个队列简单的包装了std::deque<function_wrapper>(1)。push()(2)和try_pop()(3)工作于队列的前部,try_steal()(4)则工作在尾部。

这实际上意味着,这个队列对于拥有它的线程来说相当于一个后入先出的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<task_type> pool_work_queue;std::vector<std::unique_ptr<work_stealing_queue> > queues;//(1)std::vector<std::thread> 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; i<queues.size(); ++i){unsigned const index = (my_index + i + 1) % queues.size();//(5)从自己的下一个序号开始if (queues[index]->try_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<thread_count; ++i){queues.push_back(std::unique_ptr<work_stealing_queue>(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<typename FunctionType>std::future<typename std::result_of<FunctionType()>::type> submit(FunctionType f){typedef typename std::result_of<FunctionType()>::type result_type;std::packaged_task<result_type()> task(f);std::future<result_type> 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();}}};

这段代码和程序9.6非常相似。第个不同之处就是每一个线程都拥有一个work_stealing_queue,而不是一个普通的std::queue<>(2)。当每个线程被创建时,并不为自己分配一个工作队列,而是由线程池为其分配一个(6),然后将其保存在线程池的队列数组中(1)。这个刚分配的工作队列的序号被传给线程函数,用于从数组中取出对应的指针(3)。这意味着当一个线程没有事情可做时,线程池可以访问队列并尝试窃取任务。现在的run_pending_task()函数将尝试从自身线程拥有的队列中取任务(7),再尝试从线程池所属的任务队列中取任务(8),或者从其他线程所属的任务队列中窃取任务(9)。

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<typename FunctionType>interruptible_thread(FunctionType f);void join();void detach();bool joinable() const;void interrupt();};

在内部可以使用std::thread来管理线程本身,然后使用自定义数据结构来处理中断。从线程自身的角度看该怎么办呢?在最近本的层面,你需要一个中断点。为了不传递数据而达到这个目的,它应该是一个无参数的函数interruption_point()。这就意味着,用于实现中断的数据结构应该能够通过一个线程本地变量来访问,这个本地变量在线程开始时被设置,这样当别的线程调用了你的interruption_point()函数时,它会为当前正在执行的线程去检查数据结构。我们稍后会看到interruption_point()函数的实现。

这个线程本地的标志位就是你不能直接使用普通的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<typename FunctionType>interruptible_thread(FunctionType f){std::promise<interrupt_flag*> 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)}}};

外部提供的函数f被封装到一个lambad表达式中(3),lambad表达式持有一个f的拷贝和p的引用(2)。lambad表达式将promise的值设置为this_thread_interrupt_flag线程本地变量的值(1),然后在新线程中执行f函数的拷贝(4)。然后调用线程等待从promise得到的future的状态变为ready,并把得到的值存放到flag成员变量中(5)。注意,虽然运行在新线程中的lambad表达式持有一个p的悬挂引用,但是不会出问题,因为interruptible_thread的构造函数一直等待,直到p不再被新线程使用。还要注意,这个实现没有考虑join或detach新线程。你需要确保flag变量在新线程退出时被清理,或者与其指向的数据分离,避免悬挂指针(可能f瞬间就执行完了,这样构造函数一结束flag就是悬挂指针了)。

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

虽然这样可行,但是不够理想。中断线程的最好位置是当线程处于阻塞等待状态时,那就意味着这个线程为了调用interruption_point()而不能运行。你需要的是一种方式实现一个可以中断的等待。

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<std::mutex>& 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();}

假设在中断标志位中存在一些函数负责为条件变量执行设置和清理操作,那么这个代码就是简单易用的。它检查中断,为当前线程的interrupt_flag关联条件变量(1),使用这个条件变量进行等待(2),清理条件变量关联(3),再次检查中断。如果线程在等待条件变量期间被中断,那么执行中断操作的线程将会广播条件变量然后将你从等待中唤醒( If the thread is interrupted during the wait on the condition variable, the interrupting thread will broadcast the condition variable and wake you from the wait),所以你可以检测中断。

不幸的是,这段代码有两个问题:第一个问题非常明显,如果处理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<bool> 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<std::mutex> 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<std::mutex> lk(set_clear_mutex);thread_cond = &cv;}void clear_condition_variable(){std::lock_guard<std::mutex> 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<std::mutex>& 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();}

如果在等待中使用谓词,那么1毫秒可以完全被隐藏到谓词循环当中:

template<typename Predicate>void interruptible_wait(std::condition_variable& cv,std::unique_lock<std::mutex>& 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();}

这将导致谓词被频繁的检查,但它很容易取代wait()。实现不同的超时时间也很容易:要么等待一个指定的时间,要么等待1毫秒,无论哪个都需要是最很短。OK,现在已经解决了std::condition_variable的中断等待,那么std::condition_variable_any呢?

9.2.4 中断针对std::condition_variable_any的等待

std::condition_variable_any与std::condition_variable的不同之处在于,它使用任意锁类型,而不是std::unique_lock<std::mutex>。事实证明,这将使事情变得更容易,你可以做的比std::condition_variable更好。因为它使用任意类型的锁,you can build your own lock type that locks/unlocks both the internal set_clear_mutex in your  interrupt_flag and the lock supplied to the wait call, as shown here.

Listing 9.12 interruptible_wait for  std::condition_variable_any

class interrupt_flag{std::atomic<bool> 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<std::mutex> lk(set_clear_mutex);if (thread_cond){thread_cond->notify_all();}else if (thread_cond_any){thread_cond_any->notify_all();}}template<typename Lockable>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<typename Lockable>void interruptible_wait(std::condition_variable_any& cv, Lockable& lk){this_thread_interrupt_flag.wait(cv, lk);}

自定义锁类型需要在构造时(1)锁定set_clear_mutex,然后让thread_cond_any指针指向构造函数传入的std::condition_variable_any对象(2)。Lockable引用必须已经被锁定。现在你可以检查中断,不必担心竞争。如果此刻中断标志位已经被设置了,那它一定是在你尝试锁定set_clear_mutex 之前就设置的。当条件变量在wait()函数内部调用你的unlock()时,你解锁Lockable对象以及内部的set_clear_mutex(3)。这就允许想要中断你的线程可以锁定set_clear_mutex并且检查thread_cond_any指针,这些操作只能在当你进入wait()内部时才可以执行,而不是之前。This is exactly what you were after (but couldn’t manage) with  std::condition_variable。你带wait()函数结束等待(要么因为被通知到,要么因为虚假等待),它将调用你的lock()函数,再次锁定内部set_clear_mutex以及Lockable对象(4)。在custom_lock的构造函数清理thread_cond_any指针之前,你可以再次检查一下,在你执行wait()期间是否有中断请求。

9.2.5 中断其他阻塞调用

之前涉及到的都是围绕中断条件变量等待的讨论,其他的阻塞等待还有:mutex锁,等待future,还有类似的,这些等待如何中断呢?通常来说你不得不像对待std::condition_variable那样,使用超时的方式,因为如果不访问mutex或者future内部,就没有方式可以让条件没达成前中断等待。但是在使用场景你知道你在等什么,所以你可以在interruptible_wait()内部循环。作为一个例子,来看看一个针对std::future<>的interruptible_wait()实现:

template<typename T>void interruptible_wait(std::future<T>& uf){while (!this_thread_interrupt_flag.is_set()){if (uf.wait_for(lk, std::chrono::milliseconds(1) == std::future_status::ready)break;}interruption_point();}

它一直等待直到中断标志被设置或者future状态变为ready,但是每次都有一个future的1毫秒的阻塞等待。在高分辨率时钟条件下,这意味着平均来说,在确认中断请求之前大约需要0.5毫秒。wait_for将等待至少一整个时钟周期,所以如果你的时钟周期是每15毫秒一次的话,那么你需要等待大约15毫秒而不是1毫秒。这也许能够被接受,也许不能,取决于不同情况。你可以减少超时时间(如果时钟精度支持的话)。但减少超时时间的缺点就是线程将频繁醒来去检查标志位,这将增加任务切换的开销。

OK,那么我们来看看怎样使用interruption_point()和interruptible_wait()函数来检测中断。

9.2.6 处理中断

从线程被中断的视角来看,一个中断就是一个thread_interrupted异常,因此它就像其他异常那样可以被处理。尤其是,你可以在一个标准的catch块中捕获它:

try{do_something();}catch (thread_interrupted&){handle_interruption();}

这意味着你可以捕获中断,以某种方式处理它,然后继续执行。如果你这样做,然后其他的线程再次调用interrupt(),你的线程在下一次检查中断点时会再次被中断。如果你的线程正在做一系列相互独立的任务时你可能希望这样做;中断一个任务将导致那个任务被抛弃,然后线程可以转头去做列表中下一个任务。

因为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<interruptible_thread> 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<std::mutex> lk(config_mutex);for (unsigned i = 0; i<background_threads.size(); ++i){background_threads[i].interrupt();//(6)}for (unsigned i = 0; i<background_threads.size(); ++i){background_threads[i].join();//(7)}}

开始运行时,后台线程被启动(4)。主线程然后开始处理GUI(5)。当用户要求应用退出时,后台线程被中断(6),然后主线程在退出前等待所有后台线程完成(7)。后台线程运行在一个循环中,检查硬盘的变化(2)然后更新序号(3)。循环的每一个周期都通过interruption_point()检查中断点。

为什么你在等待这些线程之前全部中断它们?为什么不中断一个就等待它完成,然后再中断下一个?答案是:并行化。被中断时这些线程可能不会立刻完成,因为它们必须进入下一个中断点,然后执行所有的的析构调用,然后在退出前执行必要的异常处理代码。一个个中断、等待,会让其他线程继续工作。一次性中断所有线程,然后再等待会让它们并行处理退出前的所有操作,完成得更快。

这个实现可以很容易的扩展为更深层次的中断调用,或者通过制定代码禁用中断。这些就留给读者作为练习吧。










阅读全文
0 0
原创粉丝点击