熟悉一下 C++20 引入的 stop_xxx 即 <stop_token> 头文件提供的 Thread cancellation 的支持。
熟悉多线程编程了,一般 demux 程序(比如基于 epoll/select/poll 的大部分时间的“死”循环)就是一个状态机。过程中我们如果希望调控他的话,就要使用变量(作为状态机的输入),达到一个检查状态的时候,程序(一般是我们写的死循环)就会响应我们的输入,给出输出。或者说是一种信号机制吧,Nginx 的信号控制进程的停止、reload 升级也是通过信号机制,不是实际实现一般就是通过一些线程安全的变量吧。比如用 atomic,然后一般也要加上锁。
首先我们回忆一下自己写的时候是怎么实现这个多线程的 stop 的?(这一段只更新在了博客里,过有空了整理一下本地的笔记然后搬到本地笔记上)
一般至少会有这样的代码:
void sun(){
while(stopped == false)
// event loop
}
void stop(){
stopped = true;
}
当然上面这个东西根本就用不了,因为这样造成了多线程的数据读写冲突(undefined behavior)。
当然,写 spsc lock free queue (此笔记没有上传,主要是通过对 DPDK 、linux 内部无锁队列等源代码的学习然后仿照一个 C++ 的出来,涉及的知识点有 cache 的 false sharing、atomic/CAS、内存屏障等,思路是 local copy 以及最终一致性)的时候我们还用过一个 volatile 关键字,使用 volatile 的原因是因为他是编译器语义从而不会引发 UB,但是他本身不能保证 MESI 和 cache coherence 以及没有任何的 memory barrier 和 memory fence 的功能。即,volatile 保证优先重新读入内存,而不是Cache 的缓存或者寄存器缓存。
然后对于 CAS 和 memory barrier 的区别一定要明白的。
CAS 是等于你要先读后写,通过硬件的compare and swap 旋转出来,如果原来的值和预期的不符合,就不写。主要应用就是实现先读判断后写的原子性,实际他是一个机器指令,实现的支持是处理器通过数电来实现这个逻辑。
memory barrier 或者 fence (因为 x86 用的指令就是 mfence)主要是为了解决本线程写后,另一线程不可见的问题,以及单核机器的指令重排引发的问题,实际作用和 CPU 内存模型有关,对编译器来说是禁止barrier 前后的交叉重排,在 CPU 上可能会被翻译为一个触发互相的 cache 失效以及??冲刷流水线的操作或者内存锁、等(后面的是猜的,现代处理器已经很复杂了不能知道具体实现,乱猜的)。
atomic 变量的读写实际就是加了 fence 的 volatile 变量的作用是一样的,然后内部封装了处理器(os 或者汇编的)的 cas 指令。由于存在 volatile 和 fence,每次读写的时候都是最新的值,然后就可以利用 CAS 实现类似 i++、i--、等的原子性问题了。至于 load 和 store,本身就是 volatile + fence 也能实现的 (只有数据读写冲突的问题,没有原子性要求)。(C++11 以前)
对于这里,我们本来需要的是 volatile 就行了,因为只不过是需要他空转一下,最后能最终一致,有延迟也没关系的。最好还是用 volatile + fence。然而 C++11 的时候把 data race 的 UB 也放到 volatile 上面了。于是结果是 volatile 只适用于嵌入式、硬件开发时需要访问内存 map io 的时候。其他时候,一律使用 std::atomic。 语言标准上的说法可以在这里看到:
c++ - When to use volatile with multi threading? - Stack Overflowhttps://stackoverflow.com/questions/4557979/when-to-use-volatile-with-multi-threading不过实际编译器对 volatile + fence 和 atomic 中的 load store 这些编译出来的汇编可能是一样的,不过标准指定了 UB,就不要再用了。
这就明白了,而且是没有任何锁争用的 overhead 的(volatile 是必须的,不然直接 UB 了你也别想运行了属于是,等于死循环直接优化为常数值)加了 fence 的话
C++ 20 提出了 std::stop_token 作为 a thread-safe "view" of the associated stop-state. 这个东西要配合一个 stop_source 的东西使用
以下内容基于 MSVC++ 14 (就 jthread 上来说大家的实现都差不多)。首先是对于 jthread 的说明,jthread 是 (would be)joined 的 thread 的意思吧。然后对于 stop_token 的支持是如果你的线程函数的第一个参数是 stop_token 类型,就会传入他自己的 stop_source.
看一个例子:
#include <iostream>
#include <thread>
using namespace std;
using namespace std::literals::chrono_literals;
void test(stop_token t) {
while (t.stop_requested() == false) {
std::cout << "t is: " << t.stop_requested() << endl;
}
std::cout << "stop! " << t.stop_requested() << endl;
}
int main(void) {
jthread th(test);
this_thread::sleep_for(3s);
th.request_stop();
this_thread::sleep_for(3s);
std::cout << "Main end" << endl;
}
其执行结果是这样的:
首先 jthread 实现特殊参数控制用到的 type_traits 是 is_invocable_v<decay_t<_Fn>(防止忘记此处复习一下 decay 是用来 removes cv-qualifiers, and defines the resulting type as the member typedef type,即获取原本的样子,毕竟 is_invocable 这些用来万能引用的模板都会导致 T 有可能推导为 decay<T>&) 。当然,is_invocable 本身是 C++17 提供支持的,C++11 是没有的。
template <class _Fn, class... _Args, enable_if_t<!is_same_v<remove_cvref_t<_Fn>, jthread>, int> = 0>
_NODISCARD_CTOR explicit jthread(_Fn&& _Fx, _Args&&... _Ax) {
if constexpr (is_invocable_v<decay_t<_Fn>, stop_token, decay_t<_Args>...>) {
_Impl._Start(_STD forward<_Fn>(_Fx), _Ssource.get_token(), _STD forward<_Args>(_Ax)...);
} else {
_Impl._Start(_STD forward<_Fn>(_Fx), _STD forward<_Args>(_Ax)...);
}
}
jthread 就绷到这里。然后我们来看怎么写自己的 stop_token。其实也很简单,我们抄 jthread 的源码就行了。
// 删减了无关部分。。
#if _HAS_CXX20
class jthread {
public:
using id = thread::id;
using native_handle_type = thread::native_handle_type;
jthread() noexcept : _Impl{}, _Ssource{nostopstate} {}
template <class _Fn, class... _Args, enable_if_t<!is_same_v<remove_cvref_t<_Fn>, jthread>, int> = 0>
_NODISCARD_CTOR explicit jthread(_Fn&& _Fx, _Args&&... _Ax);
~jthread() {
_Try_cancel_and_join();
}
jthread(const jthread&) = delete;
jthread(jthread&&) noexcept = default;
jthread& operator=(const jthread&) = delete;
jthread& operator=(jthread&& _Other) noexcept;
_NODISCARD bool joinable() const noexcept {
return _Impl.joinable();
}
_NODISCARD stop_source get_stop_source() noexcept {
return _Ssource;
}
_NODISCARD stop_token get_stop_token() const noexcept {
return _Ssource.get_token();
}
bool request_stop() noexcept {
return _Ssource.request_stop();
}
private:
void _Try_cancel_and_join() noexcept {
if (_Impl.joinable()) {
_Ssource.request_stop();
_Impl.join();
}
}
thread _Impl;
stop_source _Ssource;
};
好了,就这样。
当然,如果你不是要通过一些奇怪的方法编写自己的线程池的话,比如你的 async logger,perhaps 你只需要一个 jthread 后台做一些 formatting、persistence 的工作,那么使用 jthread 这个自带的方案并不是一个很糟糕的选择。
如果你要做线程池的话,那还是自己写吧。(或者你也可以直接管理 jthread,这样你的 loop 的函数第一个参数必须是 stop_token )。
用上新特性,避免自己研究这些什么 CAS、什么 volatile、memory fence、当当当,看着就晕。
对于用 event loop 的来说,好像还有一种方案直接保证 stop 总是在 event loop 线程发生修改,从而不用用到 fence 和 volatile。但是这样做在循环本来就在运行的时候停止实际增加了一次系统调用(当然,对于 io_uring 来说你可以批量提交,但是具体怎么实现高效率也是一个很复杂的事情,目前一种思路是全部 submit 都在 eventloop 里面进行,好像也是最好的了)。然而停止并不是一个经常发生的事情,甚至我们都不支持 restart 的功能(如果纯无状态的话,,就可以不停机更换配置文件了,这样也太复杂了),所以 stop 的突然造成一些多的开销一点关系也没有,反而是不用 volatile 可以达到 cache 里甚至寄存器里面保存着这个变量。
考虑下面的情况:
void eventloop(){
while(!stopped){
epoll_wait(...)
...
_for_each(...){
...
if(IS(eventfd)){
...
if(msg & STOP_MSG){
stopped = true;
}
}
...
}
...
}
}
void stop(){
_write_event_fd(efd_, STOP_MSG);
}
即你能保证 stop 这个总是 runInLoop 的就行了,不过这样必须增加一个检查就是