Timer的实现挺值得拿出来聊一聊的
Anoii网络库的事件循环:
Timer是使用poll的timeout参数实现的,所以定时的精度是毫秒,对于一个网络库而言这足够了。如果不够的话,还可以使用timerfd来提升精度。
由于需要计算poll的timeout是多少,就需要组织起来所有的Timer,计算下一次触发timeout的时间。所以我将所有的Timer用std::set组织起来了。具体的说是std::set<mstime_t, Timer*>
,第一个参数是该Timer被触发的时间,第二个参数就是Timer本身。实际上组织Timer的对象就是TimerQueue
使用std::set而不是std::map<mstime_t, Timer*>
的原因是,同一个时间可能存在两个Timer被触发,另一个做法是unordered_map,不过没必要这么做,而且用它的人也少。
难点
实现可以见https://github.com/Afeather2017/anoii/blob/master/src/timer_queue.cc
设计一个Timer的难点是,如何确保其生命周期?
如果一个Timer的生命周期与程序差不多长,那么就不存在这种问题,但是如果一个临时的对象使用Timer呢?
Anoii中用于建立连接的Connector就存在这样的问题:
Connector在建立连接的过程中需要进行重试,每次重试的时间间隔是1s, 2s, 4s, 8s...,这样就必须使用定时器了。但是Connector建立连接的过程中,用户应当要能够随时取消建立连接,这意味Connector必须要能够取消Timer。
总不能Connector释放了,Timer还继续使用Connector吧?那么如何设计一个Timer的取消?
智能指针
最容易想到的方案是,使用智能指针。Connector保留一份Timer的shared_ptr,然后Timer在调用之前检查一下引用计数,如果为1那么这个Timer就没必要继续调用了。反过来Timer也许要保留一份Connector的weak_ptr,在调用之前要升级为shared_ptr再调用。但是由于我确保了Connector和Timer的处理都是在同一个线程的,这种做法似乎不是很必要。
直接释放内存
调用取消的时候马上就释放Timer是不可行的。对于一个多线程程序而言,一个线程调用Timer的取消的时候,另一个线程可能还在使用Timer。虽然可以通过确保二者在同一个线程来解决这个场景的问题,但是如果Timer进行了自取消呢?
设置标志位
所以给Timer设计一个统一的取消方式:Timer在调用回调的时候,根据返回值决定是否释放该Timer。返回为false就释放该Timer,返回为true,则该Timer继续定时。
然后,给Timer添加一个标志位,如果它被取消了,就设置该标志。如果标志被设置了,那么在下一次处理Timer的时候就释放它。设置标志位的好处是,别的对象可以正常取消Timer,一个Timer可以取消另一个Timer,Timer也可以自取消。
取消的操作移交到处理该Timer的线程操作,所以不需要锁也可以正常设置该标志位。
取消操作的幂等性
接下来还有一个问题,Timer的重复取消问题。
如果一个Timer被取消了,也被释放了,那么此时设置标志位就是个问题。
解决这个问题的方法是,在TimerQueue中添加一个std::map<TimerId, Timer*>
,TimerId是一个64位整数,它只会递增。添加Timer的时候往里面添加其Id以及Timer指针,取消的时候检查一下该Id是否还存在,否则就直接返回。
这样一来,取消操作就具有幂等性了
改进设计
-
由于Timer的回调都能够返回值了,那么下一次定时的长度不妨就直接用这个返回值来做。即如果回调返回的值小于0,就可以释放该定时器,如果大于0,那么下一次定时的时间是当前时间+返回的值。这样一来Connector的逐渐增加定时时长就可以轻易的实现了,这样还可以节省malloc次数
-
Timer自己都带有标志位了,那么就让标志位为其Id得了。第一个Id的值是1,Id大于0表示该Timer还有效,否则无效且可以被释放。取消的时候设置为-Id,这样也不用担心负数设置的时候出现值溢出(因为整数的负数范围比正数的大1,据说Java有个著名的库,好像是Netty,曾经在这上面踩坑,即-INT_MIN = INT_MIN的问题)
所以最终的Timer如下
class Timer {
Timer(std::function<mstime_t(mstime_t)> cb, TimerId id);
void Cancel();
mstime_t Call(mstime_t mstime);
std::function<mstime_t(mstime_t)> cb_;
TimerId timer_id_;
friend TimerQueue;
};
已经修复的BUG1: 定时器时间错误
在编译安卓的时候,发现chargenserver时不时的会阻塞而没有反应。由于缺乏日志,在安卓上又缺乏调试手段,所以一开始没有找到问题。
发现阻塞问题后,我仔细回想了一下哪些地方可能造成长时间阻塞的。第一个是read没有设置非阻塞,第二个是poll中的timeout值为-1。于是在这些地方加了日志,发现poll的timeout设置为-1。果不其然,发现了timeout为-1。
-1的来源是TimerQueue中的SleepTime():
mstime_t TimerQueue::SleepTime() {
struct timeval tv;
gettimeofday(&tv, NULL);
mstime_t temp = timers_.begin()->first - (tv.tv_sec * 1000 + tv.tv_usec / 1000);
if (temp <= 0) {
return 0;
}
return temp;
}
在temp获取之后,加了日志,发现temp的值为负数。
为什么?后面仔细一想,这个函数的行为应当是这样的才对:
- 如果没有定时,返回-1
- 如果有定时,获取第一个定时器的时间,与当前时间相减,如果是负数,返回0,否则返回相减的值
但是上面代码的行为是:
- 如果没有定时,返回timers_.begin()->first - 当前时间,但是由于没有定时,所以timers_.begin()的值可能是一个垃圾值,所以返回的值无法确定,返回-1也是有可能的
- 如果有定时,返回第一个定时器与当前的差值,如果为负数,返回-1,这会让poll阻塞了,否则返回差值
所以修改很简单:
mstime_t TimerQueue::SleepTime() {
if (timers_.size() == 0) return -1;
struct timeval tv;
gettimeofday(&tv, NULL);
mstime_t temp = timers_.begin()->first - (tv.tv_sec * 1000 + tv.tv_usec / 1000);
if (temp <= 0) {
return 0;
}
return temp;
}
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 拒绝繁忙!免费使用 deepseek-r1:671B 参数满血模型
· 本地搭建DeepSeek和知识库 Dify做智能体Agent(推荐)
· DeepSeek-R1本地部署如何选择适合你的版本?看这里
· DeepSeek本地化部署超简单,比装个office还简单
· 基于deepseek模型知识库,Cherry Studio和AnythingLLM使用效果对比