正确使用Go的Timer
我们总是会使用Timer去执行一些定时任务,最近在Go语言的定时器使用上面不小心踩到一点问题,这里记录一下。
go demo(input)
func demo(input chan interface{}) {
for {
select {
case msg <- input:
println(msg)
case <-time.After(time.Second * 5):
println("5s timer")
case <-time.After(time.Second * 10):
println("10s timer")
}
}
}
写出上面这段程序的目的是从 input channel 持续接收消息加以处理,同时希望每过5秒钟和每过10秒钟就分别执行一个定时任务。但是当你执行这段程序的时候,只要 input channel 中的消息来得足够快,永不间断,你会发现启动的两个定时任务都永远不会执行;即使没有消息到来,第二个10s的定时器也是永远不会执行的。原因就是 select 每次执行都会重新执行 case 条件语句,并重新注册到 select 中,因此这两个定时任务在每次执行 select 的时候,都是启动了一个新的从头开始计时的 Timer 对象,所以这两个定时任务永远不会执行。
其实,
select {
case msg <- input:
case <-time.After(time.Second)
}
这个利用 time.After() 启动 Timer 的编程手法主要是用来解决 channel 操作的 Timeout 问题,而不是执行定时任务。Go 语言采用这种方式来实现 channel 的 Timeout 究竟怎么样?这个话题暂时不在这里分析。
如何正确使用 Timer 来完成上面提到的定时任务?
func demo(input chan interface{}) {
t1 := time.NewTimer(time.Second * 5)
t2 := time.NewTimer(time.Second * 10)
for {
select {
case msg <- input:
println(msg)
case <-t1.C:
println("5s timer")
t1.Reset(time.Second * 5)
case <-t2.C:
println("10s timer")
t2.Reset(time.Second * 10)
}
}
}
改正后的程序,原理上是自定义两个全局的 Timer,每次执行 select 都重复使用这两个 Timer,而不是每次都生成全新的。这样才可以真正做到在接收消息的同时,还能够定时的执行相应的任务。
深入讨论channel timeout
Go 语言的 channel 本身是不支持 timeout 的,所以一般实现 channel 的读写超时都采用 select,如下:
select {
case <-c:
case <-time.After(time.Second):
}
这两天在写码的过程中突然对这样实现 channel 超时产生了怀疑,这种方式真的好吗?于是我写了这样一个测试程序:
package main
import (
"os"
"time"
)
func main() {
c := make(chan int, 100)
go func() {
for i := 0; i < 10; i++ {
c <- 1
time.Sleep(time.Second)
}
os.Exit(0)
}()
for {
select {
case n := <-c:
println(n)
case <-timeAfter(time.Second * 2):
}
}
}
func timeAfter(d time.Duration) chan int {
q := make(chan int, 1)
time.AfterFunc(d, func() {
q <- 1
println("run") // 重点在这里
})
return q
}
这个程序很简单,你会发现运行结果将会输出 10 次 “run”,也就是每一遍执行 select 注册的 timer 最终都执行了,虽然这里读 channel 都没有超时。原因其实很简单,每次执行 select 语句,都会将 case 条件语句给执行一遍,于是 timeAfter 的执行结果就是会创建一个定时器,并注册到 runtime 中,select 语句执行完成后,这个定时器本身并没有撤销,还继续保留在 runtime 的小顶堆中,所以这些 timer 一超时就会执行挂载的函数。
当然,用 time.After() 函数来做 channel 的读写超时,在应用层根本感受不到底层的定时器还保留着、继续执行;问题是,如果这里的 select 语句在循环中执行得非常快,也就是 channel 中的消息来得非常频繁,会出现的问题就是 runtime 中会有大量的定时器存在,timeout 的时间设置得越长,底层维护的定时器就会越多。原因就是每次 select 都会注册一个新的 timer,并且 timer 只有在它超时后才会被删除。
想想,自己的 channel 每秒钟将传输成千上万的消息,将会有多少 timer 对象存在底层 runtime 中。大量的临时对象会不会影响内存?大量的 timer 会不会影响其他定时器的准确度?
最后,我觉得正确的 channel timeout 也许应该这么做:
to := time.NewTimer(time.Second)
for {
to.Reset(time.Second)
select {
case <-c:
case <-to.C:
}
}
这样做就是为了维护一个全局单一的定时器,每次操作前调整一下定时器的超时时间,从而避免每次循环都生成新的定时器对象。
简单测试了一下两种 channel 超时实现方式,在全力收发数据的情况的内存对象和 gc 情况。
* 蓝线是采用 time.After(),并设置4s 超时的堆内存对象分配的数量 * 绿线是采用 time.After(),并设置2s 超时的堆内存对象分配的数量 * 黄线是采用全局 timer,并设置4s 超时的堆内存对象分配的数量
这个现象其实是预料之中的,重点可以注意设置的超时时间越长,time.After() 的表现将越糟糕。
这三条线和上图的三条线描述的对象是一样的,图中的 gc 时间是平均每次 gc 的时间。
针对这个 channel timeout,我没有去测试是否会影响其他定时器的准确性,但我认为这是必然的,随着定时器的增多。
最后,我始终觉得 channel 本身应该支持超时机制,而不是利用 select 来实现。
timer in Go's runtime
12 September 2013
by skoo
我们总是使用sleep()类函数来让线程暂停一段时间,在Go语言里,也是使用Sleep()来暂停goroutine。 那么Go语言的sleep究竟是如何现实的呢?当然你翻看标准库中的time包里面的sleep.go源码时, 你可能会觉得看不明白,因为支持sleep功能的真正实现是在runtime里面。不难想到sleep功能是根据定时器来实现的, 因此接下来看看runtime中的timer究竟长什么样子。
timer的实现主要位于runtime/time.goc文件中。
####主要数据结构
struct Timers
{
Lock;
G *timerproc;
bool sleeping;
bool rescheduling;
Note waitnote;
Timer **t;
int32 len;
int32 cap;
};
struct Timer
{
int32 i; // heap index
// Timer wakes up at when, and then at when+period, ... (period > 0 only)
// each time calling f(now, arg) in the timer goroutine, so f must be
// a well-behaved function and not block.
int64 when;
int64 period;
FuncVal *fv;
Eface arg;
};
这两个结构是定义在runtime.h文件中。
调用一次sleep其实就是生成一个Timer,然后添加到Timers中。可以看出来Timers就是维护所有Timer的一个集合。除了可以向Timers中添加Timer外,还要从Timers中删除超时的Timer。所以,Timers采用小顶堆来维护,小顶堆是常用来管理定时器的结构,有的地方也使用红黑树。
Timers
- Timers结构中有一个Lock, 大概猜测一下就知道是用来保护添加/删除Timer的,实际上也是干这件事的。
- timerproc指针维护的是一个goroutine,这个goroutine的主要功能就是检查小顶堆中的Timer是否超时。当然,超时就是删除Timer,并且执行Timer对应的动作。
- t显然就是存储所有Timer的堆了。
省略几个字段放到下文再介绍。
Timer
- when就是定时器超时的时间
- fv和arg挂载的是Timer超时后需要执行的方法。
到此,Go语言的定时器大概模型就能想象出来了。其实,所有定时器的实现都大同小异,长得都差不多。
####timerproc goroutine 上文提到timerproc维护的是一个goroutine,这个goroutine就做一件事情——不断的循环检查堆,删除掉那些超时的Timer,并执行Timer。下面精简一下代码,看个大概主干就足够明白了。
static void
timerproc(void)
{
for(;;) {
for(;;) {
// 判断Timer是否超时
t = timers.t[0];
delta = t->when - now;
if(delta > 0)
break;
// TODO: 删除Timer, 代码被删除
// 这里的f调用就是执行Timer了
f(now, arg);
}
// 这个过程是,堆中没有任何Timer的时候,就把这个goroutine给挂起,不运行。
// 添加Timer的时候才会让它ready。
if(delta < 0) {
// No timers left - put goroutine to sleep.
timers.rescheduling = true;
runtime·park(runtime·unlock, &timers, "timer goroutine (idle)");
continue;
}
// 这里干的时候就让这个goroutine也sleep, 等待最近的Timer超时,再开始执行上面的循环检查。当然,这里的sleep不是用本文的定时器来实现的,而是futex锁实现。
// At least one timer pending. Sleep until then.
timers.sleeping = true;
runtime·notetsleep(&timers.waitnote, delta);
}
}
}
这里一定要记住,timerproc是在一个独立的goroutine中执行的。梳理一下上面代码的过程:
- 判断堆中是否有Timer? 如果没有就将Timers的rescheduling设置为true的状态,true就代表timerproc goroutine被挂起,需要重新调度。这个重新调度的时刻就是在添加一个Timer进来的时候,会ready这个goroutine。这里挂起goroutine使用的是runtime·park()函数。
- 如果堆中有Timer存在,就取出堆顶的一个Timer,判断是否超时。超时后,就删除Timer,执行Timer中挂载的方法。这一步是循环检查堆,直到堆中没有Timer或者没有超时的Timer为止。
- 在堆中的Timer还没超时之前,这个goroutine将处于sleep状态,也就是设置Timers的sleeping为true状态。这个地方是通过runtime·notesleep()函数来完成的,其实现是依赖futex锁。这里,goroutine将sleep多久呢?它将sleep到最近一个Timer超时的时候,就开始执行。
维护Timers超时的goroutine干的所有事情也就这么一点,这里除了堆的维护外,就是goroutine的调度了。
####添加一个定时器
另外一个重要的过程就是如何完成一个Timer的添加? 同样精简掉代码,最好是对照完整的源码看。
static void
addtimer(Timer *t)
{
if(timers.len >= timers.cap) {
// TODO 这里是堆没有剩余的空间了,需要分配一个更大的堆来完成添加Timer。
}
// 这里添加Timer到堆中.
t->i = timers.len++;
timers.t[t->i] = t;
siftup(t->i);
// 这个地方比较重要,这是发生在添加的Timer直接位于堆顶的时候,堆顶位置就代表最近的一个超时Timer.
if(t->i == 0) {
// siftup moved to top: new earliest deadline.
if(timers.sleeping) {
timers.sleeping = false;
runtime·notewakeup(&timers.waitnote);
}
if(timers.rescheduling) {
timers.rescheduling = false;
runtime·ready(timers.timerproc);
}
}
}
从代码可以看到新添加的Timer如果是堆顶的话,会检查Timers的sleeping和rescheduling两个状态。上文已经提过了,这两个状态代表timeproc goroutine的状态,如果处于sleeping,那就wakeup它; 如果是rescheduling就ready它。这么做的原因就是通知那个wait的goroutine——”堆中有一个Timer了”或者”堆顶的Timer易主了”,你赶紧来检查一下它是否超时。
添加一个Timer的过程实在太简单了,关键之处就是最后的Timers状态检查逻辑。
####Sleep()的实现 上面的内容阐述了runtime的定时器是如何运行的,那么Go语言又是如何在定时器的基础上实现Sleep()呢?
Go程序中调用time.Sleep()后将进入runtime,执行下面的代码:
void
runtime·tsleep(int64 ns, int8 *reason)
{
Timer t;
if(ns <= 0)
return;
t.when = runtime·nanotime() + ns;
t.period = 0;
t.fv = &readyv;
t.arg.data = g;
runtime·lock(&timers);
addtimer(&t);
runtime·park(runtime·unlock, &timers, reason);
}
sleep原来就是创建一个Timer,添加到Timers中去,最后调用runtime·park()将当前调用Sleep()的goroutine给挂起就完事了。
关键是,goroutine被挂起后,如何在超时后被唤醒继续运行呢?这里就是Timer中fv和arg两个字段挂载的东西来完成的了。此处,fv挂载了&readyv,看一下readyv的定义:
static void
ready(int64 now, Eface e)
{
USED(now);
runtime·ready(e.data);
}
static FuncVal readyv = {(void(*)(void))ready};
readyv其实就是指向了ready函数,这个ready函数就是在Timer超时的时候将会被执行,它将ready被挂起的goroutine。t.arg.data = g; 这行代码就是在保存当前goroutine了。
Sleep()实现总结起来就三大步:
- 创建一个Timer添加到Timers中
- 挂起当前goroutine
- Timer超时ready当前goroutine
Go语言的定时器实现还是比较清晰的,没有什么繁琐的逻辑。相比,其他地方(如:Nginx)的实现来说,这里可能就是多了goroutine的调度逻辑。
看一个东西的实现,重要的是知道作者为何要这样做。只有弄明白了why, how才有价值。
func main(){
fmt.Println("----方式一:每次执行结果一样----------")
for i:=0; i<10; i++ {
fmt.Print(rand.Intn(100),"/")
}
fmt.Println("------方式2-----------")
r := rand.New(rand.NewSource(time.Now().UnixNano()))
for i:=0; i<10; i++ {
fmt.Print(r.Intn(100),"/")
}
fmt.Println("------方式2--------")
rand.Seed(time.Now().UnixNano())
for i := 0; i < 10; i++ {
x := rand.Intn(100)
fmt.Print(x,"/")
}
}