• 作者:老汪软件技巧
  • 发表时间: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。这关键词很了不起,有了它,上面的接口声明就可以简化成这样:

接口获取接口实现类_c语言编写代码实现简单计算器_

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!