Boost.Asio 定时器:周期任务与重设到期时间
用 Boost.Asio 的 basic_waitable_timer(system_timer)做周期任务时有两个容易混淆的问题:handler 到底在什么时候被调用;运行中重设到期时间时,旧的等待会发生什么。本文先用官方文档把语义说清,再用一个可重设周期的定时器类把实际行为走查一遍(行为在 Boost 1.66 上实测,expires_after、async_wait 的语义沿用至今)。
handler 何时被调用
async_wait 的文档给出了明确保证:每次调用 async_wait,提供的 handler 恰好被调用一次,触发时机有两种:
- 定时器到期,此时错误码为空(成功);
- 定时器被取消,此时 handler 收到错误码
boost::asio::error::operation_aborted。
取消的来源主要是 cancel() 和 expires_after()。expires_after 重新设定相对到期时间,同时取消该定时器上所有挂起的异步等待,返回值就是被取消的操作数;每个被取消的等待各回调一次,错误码都是 operation_aborted。一个边界情况:如果调用 expires_after 时定时器已经到期、handler 已投递或即将投递,则无法取消,这些 handler 收到的是表示等待成功的错误码。
可重设周期的定时器
下面是一个完整示例:未启动时设定周期并启动首个等待;运行中再次调用则重设到期时间。周期重启的关键在 handler 里——正常到期(错误码为空)时重新 expires_after + async_wait 排下一轮。
#include <boost/asio.hpp>
#include <chrono>
#include <cstdint>
#include <iostream>
#include <memory>
#include <thread>
class PeriodicTimer
{
public:
~PeriodicTimer()
{
io_.stop();
run_thread_.join();
}
// 设定周期(秒);未启动时启动首个等待,运行中则重设到期时间
void set_interval(std::uint64_t seconds)
{
interval_s_ = seconds;
if (!running_)
{
timer_ = std::make_shared<boost::asio::system_timer>(
io_, std::chrono::seconds(interval_s_));
timer_->async_wait(
[this](const boost::system::error_code& ec) { on_timer(ec); });
}
else
{
// 重设到期时间:挂起的等待被取消,返回取消数
std::size_t cancelled =
timer_->expires_after(std::chrono::seconds(interval_s_));
std::cout << "set_interval: cancelled " << cancelled
<< " pending wait(s)\n";
timer_->async_wait(
[this](const boost::system::error_code& ec) { on_timer(ec); });
}
}
void run()
{
if (running_)
return;
running_ = true;
std::thread th([this]() { io_.run(); });
run_thread_.swap(th);
}
private:
void on_timer(const boost::system::error_code& ec)
{
if (!ec) // 正常到期:排下一轮
{
std::cout << "timeout: " << interval_s_ << "s\n";
timer_->expires_after(std::chrono::seconds(interval_s_));
timer_->async_wait(
[this](const boost::system::error_code& e) { on_timer(e); });
}
// ec 非空:等待被取消(重设到期时间等),不再排下一轮
}
boost::asio::io_context io_;
std::shared_ptr<boost::asio::system_timer> timer_;
std::uint64_t interval_s_ = 0;
std::thread run_thread_;
bool running_ = false;
};
int main()
{
PeriodicTimer t;
t.set_interval(3);
t.run();
std::this_thread::sleep_for(std::chrono::seconds(10));
t.set_interval(4); // 运行中重设:取消当前 3s 等待,改按 4s 触发
std::this_thread::sleep_for(std::chrono::seconds(10));
} 运行行为
一次实际运行的输出(略去时间戳):
set_interval: cancelled 0 pending wait(s) ← 首次设置,无挂起等待
timeout: 3 ← 之后每 3 秒触发
timeout: 3
timeout: 3
set_interval: cancelled 1 pending wait(s) ← 第 10 秒重设为 4s
timeout: 4 ← 旧等待被取消后,按 4s 周期触发
timeout: 4 对照输出可以把三个行为点看清楚:
- 周期触发链:handler 正常到期(
ec为空)→expires_after+async_wait排下一轮,形成周期; - 运行中重设:
expires_after返回 1(取消了 1 个挂起等待)。被取消的旧 handler 仍会被调用一次,但ec为operation_aborted,重启分支被跳过;新排入的等待按新周期触发; - stop 之后没有回调:析构里
io_.stop()之后再join,挂起等待的 handler 不再被调用。
stop() 与挂起 handler 的去向
第 3 点值得单独说明:io_context::stop() 的官方文档只说它让 run()、run_one() 等尽快返回、后续调用立即返回,并没有规定挂起 handler 的去向。实际行为是 stop 之后这些 handler 不会再被调用(上面的程序也印证了这一点:stop 后无任何回调)。
写代码时可以依赖这个行为做干净的关闭:先 stop() 再 join(),此时 handler 不再执行,也就不会在对象析构中途反过来访问 this。但要把「stop 后 handler 不跑」当作实现行为而不是文档保证来对待——涉及关闭顺序的代码,值得在自己的 Boost 版本上验证一次。
结语
async_wait的 handler 恰好被调用一次:到期收空错误码,取消收operation_aborted;expires_after重设到期时间会取消全部挂起等待并返回取消数,配合 handler 里的重启分支即可实现可动态调整的周期任务;io_context::stop()之后挂起 handler 不再回调是实测行为,官方文档未作承诺,关闭顺序敏感的代码要自行验证。