《C++并发实例》 9.2 中断线程
在许多情况下,需要向长时间运行的线程发出信号,表明该停止了。可能是因为它是线程池的工作线程,并且该池现在正在被销毁,或者可能是因为该线程正在完成的工作已被用户显式取消,或者有无数其他原因。无论出于何种原因,其想法都是相同的:您需要从一个线程发出信号,指示另一个线程应该在自然结束之前停止,并且您需要以一种允许该线程良好结束而不是突然终止。
您可以为需要执行此操作的每种情况设计一个单独的机制,但这有点矫枉过正。通用机制不仅使后续编写代码变得更加容易,而且可以让您编写中断代码时不必担心该代码在哪里使用。 C++11 标准没有提供这样的机制,但构建一个相对简单。让我们看看如何做到这一点,从启动和中断线程的接口的角度出发,而不是从被中断的线程的角度出发。
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()。这意味着需要通过线程启动时设置的 thread_local 变量来访问特定于中断的数据结构,以便当线程调用interruption_point() 函数时,它会检查正在执行的线程的数据结构。稍后我们会看interruption_point()的实现。
这个 thread_local 标志是你不能使用普通的 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); - [4]
f();
});
flag=p.get_future().get(); - [5]
}
void interrupt()
{
if(flag)
{
flag->set(); - [6]
}
}
};
接口函数 f 封装在 lambda 函数中 [3],该函数保存 f 的副本和对本地 promise p 的引用 [2]。在调用接口函数的副本之前,lambda 将 promise 的值设置为新线程的 this_thread_interrupt_flag(声明为 thread_local [1])的地址 [4]。然后,调用线程等待与 promise 关联的 future 准备就绪,并将结果存储在成员变量 flag 中 [5]。请注意,即使 lambda 正在新线程上运行并且具有对局部变量 p 的悬空引用,但这也是可以的,因为在返回之前,interruptible_thread 构造函数会等待,直到新线程不再引用 p。请注意,该实现不考虑处理与线程的连接或分离。您需要确保在线程退出或分离时清除标志变量,以避免悬空指针。
那么,interrupt()函数就相对简单了:如果你有一个指向中断标志的有效指针,你就有一个要中断的线程,所以你只需设置标志即可 [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 中断条件变量等待
好的,所以您可以通过显式调用 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],然后再次检查中断。如果线程在等待条件变量期间被中断,则中断线程将广播条件变量并将您从等待中唤醒,以便您可以检查中断。不幸的是,这段代码被破坏了:它有两个问题。第一个问题相对明显,如果你为解决异常安全问题使用 std::condition_variable::wait() 抛出异常,那么可能在不清除关联的中断标志和条件变量的情况下退出。这个问题可以通过在它的析构函数中添加一个删除关联的结构来解决。
第二个不太明显的问题是存在竞争场景。如果线程在首次调用 interrupt_point() 之后调用 wait() 之前被中断,那么条件变量是否已与中断标志关联并不重要,因为线程没有在等待,因此无法通过条件变量上的通知唤醒。您需要确保在最后一次检查中断和调用 wait() 之间不会通知线程。如果不深入研究 std::condition_variable 的内部结构,您只有一种方法:使用 lk 持有的互斥锁来保护它,这需要在调用 set_condition_variable() 时将其传递进去。不幸的是,这会产生它自己的问题:您将传递一个对互斥锁的引用,而在这个锁的整个生命周期中你不知道另一个线程(执行中断的线程)在锁定时(调用 interrupt() ),是否已经持有锁。这有可能导致死锁,并且有可能在互斥锁被销毁后访问它,所以这是不可能实现的。如果不能可靠地中断条件变量等待,那么限制就太严格了——这几乎跟不使用特殊的 interruptible_wait() 效果一样——那么您还有什么其他选择呢?一种选择是设置等待超时;使用 wait_for() 而不是具有较小超时值(例如 1 毫秒)的 wait()。设置线程在检测到中断之前必须等待的时间上限(取决于时钟的滴答粒度)。如果这样做,等待线程将看到更多因超时而导致的“虚假”唤醒,这并不容易解决。下一个列表中显示了这样的实现以及相应的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();
}
如果您有正在等待的断言(predicate),那么 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 毫秒,以最短者为准。好的,现在 std::condition_variable 等待已经处理完毕; std::condition_variable_any 怎么样?这是一样的吗,或者你能做得更好吗?
9.2.4 中断 std::condition_variable_any 等待
std::condition_variable_any 与 std::condition_variable 的不同之处在于它适用于任何锁类型,而不仅仅是 std::unique_lockstd::mutex。事实证明,这使事情变得更加容易,并且使用 std::condition_variable_any 可以比使用 std::condition_variable 做得更好。因为它适用于任何锁类型,所以您可以构建自己的锁类型,以锁定/解锁中断标志中的内部 set_clear_mutex 以及提供给 wait 调用的锁,如下所示。
Listing9.12 interruptible_waitforstd::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 上的锁,并在您正在 wait() 调用时(而不是之前)检查 thread_cond_any 指针。这正是您使用 std::condition_variable 所追求的(但无法管理)。一旦 wait() 完成等待(无论是因为收到通知还是因为虚假唤醒),它将调用您的 lock() 函数,该函数再次获取内部 set_clear_mutex 上的锁和 Lockable 对象上的锁 [4]。现在,您可以在 custom_lock 析构函数清除 thread_cond_any 指针 [5] 之前再次检查 wait() 调用期间发生的中断,析构同时解锁 set_clear_mutex。
9.2.5 中断其他阻塞调用
中断条件变量等待讲的差不多了,但是其他阻塞等待又如何呢:互斥锁、等待 future 、以及类似中断?一般来说,您必须选择适用于 std::condition_variable 的超时选项,因为无法在不访问互斥锁或 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 毫秒。这可能是可接受的,也可能是不可接受的,具体取决于具体情况。如有必要,您始终可以减少超时(并且时钟支持它)。减少超时的缺点是线程将更频繁地唤醒以检查标志,这将增加任务切换开销。
好的,我们已经了解了如何使用 interrupt_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&)
{}
});
现在让我们看一个中断有用的具体例子。
考虑一下桌面搜索应用程序。除了与用户交互之外,应用程序还需要监视文件系统的状态,识别任何更改并更新其索引。这种处理通常留给后台线程,以避免影响 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]。每次循环时,他们都会通过调用interrupt_point() 来检查中断 [1]。
为什么在等待任何线程之前就中断所有线程?为什么不中断每个任务然后等待它再继续下一个任务呢?答案是并发。线程在被中断时可能不会立即完成,因为它们必须继续到下一个中断点,然后在运行所有必要的析构函数调用和异常处理代码后在退出。因此,通过立即加入每个线程,您会导致中断线程等待,即使它仍然可以完成有用的工作 —— 中断其他线程。只有当您没有更多工作要做(所有线程都已中断)时,您才会等待。这还允许所有被中断的线程并行处理它们的中断,并可能更快地完成。
这种中断机制可以很容易地扩展,以添加更多的可中断调用或禁用特定代码块的中断,但这留给读者作为练习。
9.3 总结
在本章中,我们研究了各种“高级”线程管理技术:线程池和中断线程。您已经了解了使用本地工作队列和工作窃取如何减少同步开销并可能提高线程池的吞吐量,以及如何在等待子任务完成时从队列中运行其他任务来消除潜在的死锁。
我们还研究了允许一个线程中断另一个线程处理的各种方法,例如使用特定的中断点和函数,以一种可以中断的方式执行阻塞等待。
浙公网安备 33010602011771号