- 作者:老汪软件技巧
- 发表时间:2024-09-12 04:01
- 浏览量:
以下内容为本人的学习笔记,如需要转载,请声明原文链接 微信公众号「ENG八戒」/s/2Kx5JTByZ…
定时器是什么?
定时器有硬件实现,也有软件实现。
在硬件层面,可以配置芯片的寄存器来实现,不同的芯片不一样,也是件麻烦事,但本文重点不在这。
软件实现,有的是系统提供,有的是计算机语言标准库提供,甚至第三方库也有提供。
系统提供的定时器接口,笔者在之前的文章《Linux 定时器介绍》中有专门介绍过 linux 平台提供的能力。
至于专注 C++ 的开发者,可能比较尴尬,标准库目前还没有对应的实现。第三方库虽有提供但是过于庞大,一般不会为了使用定时器而专门引入第三方库。
熟悉 JavaScript 或者 Qt 的伙伴们应该也使用过它们提供的定时器,这些定时器的接口那叫一个爽啊,极为趁手,属于高级接口,因为这些接口的特点都是直接面向任务的。
比如 JavaScript 自带的定时器提供了两接口 setTimeout() 和 setInterval()。setTimeout 函数用来指定某个任务,在多少毫秒之后执行,仅执行一次,也就是单次定时任务。setInterval 函数指定某个任务,每隔一段时间就执行一次,直到被强制终止,也就是循环定时任务。
QTimer 也提供了类似的单次和循环定时触发方式,但使用起来就没有 JavaScript 接口简便。配置循环定时任务,需要先使用 connect 链接一个信号槽,然后再开启定时器计时,等待定时触发信号并执行任务。至于单次的定时任务,反而有单独的接口 QTimer::singleShot,一条指令搞定。
好了,看完上面的介绍,为了弥补 C++ 这个缺陷,不如我们仿照 JavaScript 的接口,自己手撸一个简单的 C++ 定时器?
一个简单的定时器实现
这个定时器既然是仿照 JavaScript 的接口实现,那么这里不妨将其定义为 JTimer,并且有两个基本的接口需要实现,setInterval_ms 实现循环定时任务,setTimeout_ms 实现单次定时任务。
这两接口使用范式可以是这样的,需要将任务和时间间隔分别作为参数传入:
using namespace std;
int main() {
JTimer t;
t.setInterval_ms([&]() {
static int cnt = 0;
cout << "run task @" << ++ cnt << "s" << endl;
}, 1000);
t.setTimeout_ms([&]() {
cout << "3.3s timeout, stop task!" << endl;
t.stop();
}, 3300);
cout << "I am JTimer" << endl;
while(true);
}
调用定时接口时,指定任务使用 lambda 表达式的形式代表可调用对象,这是最直接的方式,省掉多余的函数定义。
循环定时任务接口 setInterval_ms 指定每隔 1s 打印一次,单次定时任务接口 setTimeout_ms 指定 3.3s 之后打印一次并且结束任务,定时器的接口 stop() 负责结束任务。
定时任务接口如何定义?
由于接口传入的可调用对象其实是灵活的类型,并不是一定必须是 lambda 表达式,为了提高接口通用性,可使用模板来定义接口:
class JTimer {
public:
template<typename T>
void setTimeout_ms(T function, int delay);
template<typename T>
void setInterval_ms(T function, int interval);
void stop();
};
很多朋友(包括笔者我)都不喜欢模板写法,但由于可替代的方式不多,逼不得已忍受这又长又难看的写法。事实上,在 C++ 20 标准中引入了函数参数的新类型关键词 auto。这关键词很了不起,有了它,上面的接口声明就可以简化成这样:
class JTimer {
public:
void setTimeout_ms(auto function, int delay);
void setInterval_ms(auto function, int interval);
void stop();
};
这么看 C++ 20 标准很有前景(同时,忍不住给笔者点个赞~)。虽然这个特性是从 C++ 20 才写进标准的,但是经实测,linux gcc 在指定 C++ 14 时也能用这个特性,说明 gcc 对这个特性做了非标拓展。
如何实现定时呢?
最直观的方式就是使用线程,创建子线程,并在线程内休眠指定时间,休眠结束则执行任务。
void setTimeout_ms(auto function, int delay) {
std::thread t([=]() {
std::this_thread::sleep_for(std::chrono::milliseconds(delay));
function();
});
t.detach();
}
void setInterval_ms(auto function, int interval) {
std::thread t([=]() {
while(true) {
std::this_thread::sleep_for(std::chrono::milliseconds(interval));
function();
}
});
t.detach();
}
单次定时任务接口 setTimeout_ms 内创建线程,线程执行实体通过 lambda 表达式指定,表达式内 std::this_thread::sleep_for 实现本线程内休眠指定时间,休眠结束则执行任务 function,最后退出线程执行实体。
循环定时任务接口 setInterval_ms 内创建线程,类似的,线程执行实体也通过 lambda 表达式指定,表达式内 std::this_thread::sleep_for 实现本线程内休眠指定时间,休眠结束则执行任务 function。和单次定时任务不同的是,此时任务结束则再次开始休眠,等待下次任务的定时执行,如此循环往复,直到触发结束任务,主动退出线程。
如何结束任务呢?
加个布尔标志来控制,比如取名 active,顾名思义,active 就是活跃、有效的意思。
这标志在设置定时任务的接口中置 true 表示允许任务继续执行,stop 接口里直接把它置 false 表示禁止任务继续执行:
class JTimer {
bool active{true};
public:
void setTimeout_ms(auto function, int delay) {
active = true;
std::thread t([=]() {
if(!active) return;
std::this_thread::sleep_for(std::chrono::milliseconds(delay));
if(!active) return;
function();
});
t.detach();
}
void setInterval_ms(auto function, int interval) {
active = true;
std::thread t([=]() {
while(active) {
std::this_thread::sleep_for(std::chrono::milliseconds(interval));
if(!active) return;
function();
}
});
t.detach();
}
void stop() { active = false; }
};
在初始化成员 JTimer::active 时使用了 {} 代表列表初始化,这比使用 () 有很多优势。比如对内置类型初始化时,参数为空是不能使用 () 的,或者定义了多个构造函数,() 会被当成函数调用,或者窄化转换时数据可能丢失和类型不安全等问题。
这个变量 JTimer::active 是会被跨线程读写的,所以不能裸露着,需要加同步机制,保护它的读写过程不被打断。现代 C++ 提供了原子操作特性,比使用互斥量要更高效,所以可以使用 std::atomic 封装 JTimer::active。
class JTimer {
std::atomic<bool> active{true};
public:
void setTimeout_ms(auto function, int delay) {
active = true;
std::thread t([=]() {
if(!active.load()) return;
std::this_thread::sleep_for(std::chrono::milliseconds(delay));
if(!active.load()) return;
function();
});
t.detach();
}
void setInterval_ms(auto function, int interval) {
active = true;
std::thread t([=]() {
while(active.load()) {
std::this_thread::sleep_for(std::chrono::milliseconds(interval));
if(!active.load()) return;
function();
}
});
t.detach();
}
void stop() { active = false; }
};
既然使用了原子特性,读取 JTimer::active 的值需要通过 std::atomic::load() 获取,修改 JTimer::active 的值需要通过 std::atomic::store() 写入新值或者直接通过赋值操作符赋值。
其它思路
线程会占用系统资源,由于每个定时任务接口的调用都会创建一个线程,接口如果调用频繁,那么就会导致系统资源被大量占用。
除了使用线程这种方式之外,有没有其它更高效的定时方式?
答案就是事件环。无论是 JavaScript 或者 Qt 的定时器内部实现都是基于事件环的机制。
至于如何创建自己的事件环,不妨关注我,留意笔者后续文章动态。
最后,编译指令需要加上这行标志 -fconcepts -std=c++14 -pthread:
I am JTimer
run task @1s
run task @2s
run task @3s
3.2s timeout, stop task!