js多个定时器 id按顺序么_定时器和时间轮系列(一): 初识

2f0eb3cd7cc6a380d5062b1b5bb4aea6.png

在实现需求的过程中, 经常会遇到如下的一类需求:

  • 在一个间隔时间之后做某事: 例如在最后一次消息发送的5分钟之后, 断开连接
  • 在一个间隔时间之后不停的做某事: 例如每隔5分钟之后刷新内存中的缓存

使用定时器可以方便的实现上述功能. 定时器是一种结构, 它的主要作用是在一个给定的时间间隔之后, 调用一个给定的回调函数或者发出一个信号, 应用可以在回调函数或信号处理函数中实现相应的业务逻辑.

普通用法

在 Go 中, 标准库 time 包提供了一些基本的定时器相关操作, 常见的用法如下:

func delayOnce() {
    n := time.Now()
    fmt.Println("delayOnce start, ", n)

    // delay 1 second
    <-time.After(time.Second)
    fmt.Println("Cost ", time.Since(n))
}

func delayTicker() {
    n := time.Now()
    fmt.Println("delayTicker start, ", n)

    // ticker 1 second, 3 times
    t := time.NewTicker(time.Second)
    for i := 0; i < 3; i++ {
        <-t.C
        fmt.Println("Tick ", time.Since(n))
    }

    t.Stop()
    fmt.Println("Cost ", time.Since(n))
}

示例代码1

可以看到, 有几种方式来使用定时器:

  1. 通过 time.NewTimer 创建一个定时器, 这样可以在循环中对定时器进行复用, 降低 runtime 的压力
  2. 通过 time.After 得到一个 channel, 当该 channel 可读时即定时器到期触发
  3. 通过 time.AfterFunc 在指定时间间隔后运行一个回调函数

上述三种都是一次性的定时器, 还有一种持续性的定时器(ticker):

  1. 通过 time.NewTicker 创建一个定时器, 该定时器会周期性的向 channel 中发送信号(如果 channel 中还有未读取的信号则直接丢弃当次信号)

如何实现

Go 中的定时器代码经过多个版本的演进, 到现在正在开发中的 Go1.14 为止, 主要有三次大的变更.

Go1.10 之前

在这个版本的实现中, Go 将所有的定时器都放在一个最小堆 中, 并且在内部会启动一个 goroutine 持续的检查堆顶定时器是否已经到期, 如果到期则触发对应的回调函数.

创建定时器并添加到最小堆的主要代码如下:

// Add a timer to the heap and start or kick timerproc if the new timer is
// earlier than any of the others.
// Timers are locked.
func addtimerLocked(t *timer) {
    // ...
    t.i = len(timers.t)
    timers.t = append(timers.t, t)
    siftupTimer(t.i)
    if t.i == 0 {
        // siftup moved to top: new earliest deadline.
        if timers.sleeping {
            timers.sleeping = false
            notewakeup(&timers.waitnote)
        }
        // ...
    }
    if !timers.created {
        timers.created = true
        go timerproc()
    }
}
  1. 将定时器放到一个内部的切片中
  2. 调用 siftupTimer 调整最小堆的结构, 时间复杂度为 O(lg^n)
  3. 如果当前的定时器是最接近的, 则唤醒等待的 goroutine
  4. 如果是第一个定时器, 则启动内部的 goroutine

内部 goroutine 的循环代码如下:

// Timerproc runs the time-driven events.
// It sleeps until the next event in the timers heap.
// If addtimer inserts a new earlier event, it wakes timerproc early.
func timerproc() {
    timers.gp = getg()
    for {
        // ...
        for {
            // ...
            t := timers.t[0]
            delta = t.when - now
            if delta > 0 {
                break
            }
            if t.period > 0 {
                // leave in heap but adjust next time to fire
                t.when += t.period * (1 + -delta/t.period)
                siftdownTimer(0)
            } else {
                // remove from heap
                last := len(timers.t) - 1
                if last > 0 {
                    timers.t[0] = timers.t[last]
                    timers.t[0].i = 0
                }
                timers.t[last] = nil
                timers.t = timers.t[:last]
                if last > 0 {
                    siftdownTimer(0)
                }
                t.i = -1 // mark as removed
            }
            // ...
            f(arg, seq)
        }
        // ...

        // At least one timer pending. Sleep until then.
        timers.sleeping = true
        timers.sleepUntil = now + delta
        notetsleepg(&timers.waitnote, delta)
    }
}
  1. 获取堆顶的定时器, 如果到期了则触发回调, 并且如果该定时器是持续的则更新下次到期时间, 并调整最小堆; 如果不是则移除该定时器, 同时也调整一次最小堆
  2. 没到期则等待被唤醒, 或者指定的时间间隔到达

Go1.10 ~ Go1.13

在上面版本的实现里所有的定时器都放在一个最小堆中, 这样就有几个显而易见的缺点:

  1. 当有多个 P 同时运行的时候, 容易造成锁竞争(所有对最小堆的操作都是有锁保护的), 降低吞吐
  2. 当有很多的定时器存在时, 最小堆的插入/删除效率也会降低

在这个阶段, 运行时针对上述的缺点进行了修改, 最主要的方法有如下两点:

  1. 将所有定时器分布到 64 个最小堆中, 减小每个堆的数据量
  2. 插入定时器时用 P 的 id 将其分布到不同的最小堆, 这样插入时就可以降低锁竞争

最主要的插入定时器的代码如下:

func (t *timer) assignBucket() *timersBucket {
    id := uint8(getg().m.p.ptr().id) % timersLen
    t.tb = &timers[id].timersBucket
    return t.tb
}

func addtimer(t *timer) {
    tb := t.assignBucket()
    lock(&tb.lock)
    ok := tb.addtimerLocked(t)
    unlock(&tb.lock)
    if !ok {
        badTimer()
    }
}

Maybe Go1.14 及之后

上述版本在多 GPU 系统的性能仍然不够好, 主要是不是 cpu-scale 的, 见 runtime: timer doesn't scale on multi-CPU systems with a lot of timers.

Go 仍然在对定时器相关的代码进行进一步优化, 可以见 runtime: make timers faster. 主要的思路是:

  1. 将每个定时器直接绑定到 P 上, 这样可以直接随着 P 扩展
  2. 不再采用最小堆, 直接利用 netpoller 来让定时器的到期后直接得到通知

上述的优化仍然在开发的过程中, 可能会在 Go1.14 上发布, 也可能会在更后面的版本.

时间轮

为何要引入时间轮

  1. 在定时器的数量增长到百万级之后, 基于最小堆实现的定时器的性能会显著降低, 需要一种更高效的实现
  2. 在有些场景下的使用不是很方便

例如, 服务器维护有对客户端的连接, 并且定时在连接中发送心跳来确保连接的可用性, 一个普遍的实现方式如下:

func onConnect(ctx context.Context, i int) {
    t := time.NewTicker(time.Second)
    n := time.Now()
    for {
        select {
        case <-ctx.Done():
            fmt.Println("Done")
            return
        case <-t.C:
            fmt.Printf("Tick[%v] %vn", i, time.Since(n))
        }
    }
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()

    for i := 0; i < 10; i++ {
        go onConnect(ctx, i)
    }

    <-ctx.Done()
    time.Sleep(time.Second) // wait all sub goroutine exit, should use WaitGroup
}

示例代码2

但是这种方式中, 每一个连接就需要新增一个 goroutine, 并且对 goroutine 的清理也会比较复杂. 所以, 如果有一个独立的 goroutine 能够对这些定时任务进行触发, 操作上会方便很多.

时间轮

在 Kafka 中, 使用一个叫做 Hashed and Hierarchical Timing Wheels, 层级时间轮 的高性能时间轮数据结构, 实现了自己的时间轮.

一个时间轮就是一个定时器容器, 该容器可以高效的管理定时器. 思路如下:

  1. 轮盘上有多个插槽
  2. 每个定时器都放置到合适的插槽中
  3. 每次轮询时直接获取最早的插槽中的定时器并触发即可

在层级时间轮中, 将插槽分为多个层次, 每一层的时间轮的插槽范围都会扩大, 例如:

  1. 第一层时间轮有20个插槽, 每个插槽为1秒, 那么第二层时间轮每个插槽为20秒, 第三层为400秒, 依次类推, 除第一层外都是按需创建
  2. 当一个10秒的定时器插入时放置到第一层时间轮中, 100秒的定时器则放置到第二层时间轮
  3. 随着时间的流逝, 高层时间轮中的定时任务会降级重新插入低层的时间轮, 直到触发为止
  4. 每个插槽共享一个触发时间, 这样可以显著降低需要触发的事件的个数

一个示意图如下:

cf93db3f5cf1c45659b06053e9ed75c9.png

总结

本篇文章为系列的第一篇, 主要介绍了 Go 标准库中对定时器的处理方式, 以及对层级时间轮进行了大致介绍. 后续文章会进一步介绍一个层级时间轮的简单实现, 并如何进行简单的测试与调优.

参考资料

  • How Do They Do It: Timers in Go
  • Hashed and Hierarchical Timing Wheels, 层级时间轮
  • Kafka Purgatory
  • Timing Wheel
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
### 回答1: 在Linux中,同一个线程可以创建多个定时器。在Linux系统中,有一个名为timerfd的机制可以用于创建定时器。通过创建多个timerfd,可以实现多个定时器。 创建定时器需要以下步骤: 1. 使用`timerfd_create`函数创建一个新的定时器描述符。该函数返回一个整数值,该值可以被用作后续操作的参数。 2. 使用`timerfd_settime`函数设置定时器的初始时间和间隔时间。该函数将定时器描述符作为参数,因此可以使用不同的timerfd来设置不同的定时器。 3. 使用`read`或`poll`等函数来等待定时器超时事件。当定时器达到指定的时间时,定时器描述符会变得可读,进而可以进行相应的处理。 需要注意的是,虽然同一个线程可以创建多个定时器,但是定时器一般是在操作系统内部进行管理的。因此,对于同一个线程来说,并不能直接控制定时器的触发顺序。所以,在使用多个定时器时,应该合理安排定时器的触发时机,以避免逻辑上的混乱。 总而言之,使用Linux系统提供的timerfd机制,同一个线程可以创建多个定时器,通过适当设置定时器的初始时间和间隔,可以实现不同定时器的功能。 ### 回答2: 在Linux中,同一个线程可以创建多个定时器定时器是一种用来指定在未来的某个时间点执行特定任务的机制。Linux提供了一些API用于创建和管理定时器,最常用的是timer_create、timer_settime和timer_delete等函数。 在同一个线程中创建多个定时器可以用于同时执行多个不同的任务或者按照不同的时间间隔执行同一任务。 首先,需要调用timer_create函数来创建定时器。该函数会返回一个定时器的标识符,我们可以根据这个标识符来设置定时器的属性。 然后,使用timer_settime函数来设置定时器的首次超时时间和周期性超时时间。这个函数需要传入定时器的标识符、定时器的属性和一个结构体来指定每次超时后的处理方式(例如执行一个回调函数)。 最后,如果不再需要某个定时器,可以使用timer_delete函数来删除它。 需要注意的是,使用多个定时器时要注意避免竞争条件或者资源争用问题。可以利用互斥锁或者条件变量等同步机制来保证在多个定时器之间的正确执行顺序和资源的正确访问。 总之,在Linux中,同一个线程是可以创建多个定时器的,可以根据需求设置不同的超时时间和处理方式,从而实现多任务或者多时间间隔执行的功能。 ### 回答3: 在Linux中,可以使用线程来创建多个定时器。每个线程可以通过调用`timer_create()`函数来创建一个定时器,并使用`timer_settime()`函数来设置定时器的参数和启动定时器。 要在同一个线程中创建多个定时器,可以在使用`timer_create()`函数时,为每个定时器指定不同的定时器ID。这样,每个定时器都会有一个唯一的标识符,以便在后续的操作中能够区分和处理不同的定时器。 在每个定时器到期时,可以通过为每个定时器关联一个回调函数来执行相应的操作。当定时器到期时,内核会调用指定的回调函数,然后可以在回调函数中进行相应的处理逻辑。 除了创建和设置定时器,还可以使用`timer_delete()`函数来删除和销毁不再需要的定时器。在不需要某个定时器时,可以调用该函数将其从系统中删除,从而释放相关的资源。 需要注意的是,如果在同一个线程中创建多个定时器,要确保能够正确处理并发的问题。可能需要使用同步机制(如互斥锁或条件变量)来保护共享的数据结构,以防止多个定时器同时访问和修改同一份数据。 总之,可以通过在同一个线程中使用不同的定时器ID来创建多个定时器。在每个定时器到期时,通过关联的回调函数可以执行相应的操作。然后,在不需要的定时器时,可以使用`timer_delete()`函数将其删除和销毁。为了避免并发问题,可能需要使用同步机制来保护共享数据。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值