1

In practical applications, we often need to do something after a specific delay or timed. At this time, you need to use timers. Go language provides one-time timer time.Timer and periodic timer time.Ticker.

how to use

Timer is a one-time timer that triggers an event after a specified time, which is notified through the channel provided by itself. The main methods related to it are as follows:

// 创建 Timer
func NewTimer(d Duration) *Timer
// 停止 Timer
func (t *Timer) Stop() bool
// 重置 Timer
func (t *Timer) Reset(d Duration) bool
// 创建 Timer,返回它的 channel
func After(d Duration) <-chan Time
// 创建一个延迟执行 f 函数的 Timer
func AfterFunc(d Duration, f func()) *Timer

The main usage scenarios of Timer are:

  1. Set the timeout time;
  2. Delay execution of a method.

Ticker is a periodic timer, which triggers an event periodically and transmits the event through the pipeline provided by Ticker. The main methods are:

// 创建 Ticker
func NewTicker(d Duration) *Ticker
// 停止 Ticker,Ticker 在使用完后务必要释放,否则会产生资源泄露
func (t *Ticker) Stop()
// 启动一个匿名的 Ticker(无法停止)
func Tick(d Duration) <-chan Time

The usage scenarios of Ticker are all related to timing tasks, such as batch processing such as timing aggregation.

implementation principle

Let's first take a look at the data structures of Timer and Ticker:

type Timer struct {
    C <-chan Time
    r runtimeTimer
}

type Ticker struct {
    C <-chan Time
    r runtimeTimer
}

It is found that the two are exactly the same, and both contain the runtimeTimer field. This runtimeTimer is the real data structure at the bottom of the timer. Looking at the relevant implementation codes of Timer and Ticker, the logic related to these two structures is very simple. The real complexity is the design and maintenance of the underlying runtimeTimer, which is what we want to focus on.

evolution history

The timer implementation of the Go language has gone through many versions of iterations. As of the latest version, the implementation of the timer has gone through the following histories:

  1. Before Go 1.9, all timers were maintained by a globally unique quad heap;
  2. Go 1.10 ~ 1.13 versions, use 64 quad heaps to maintain all timers globally, and the timers created by each processor (P) will be maintained by the corresponding quad heap;
  3. Since Go 1.14, each processor manages timers individually and fires them via a network poller.

In the initial implementation, all timers created by the runtime will be added to the globally unique quad heap, and then there is a special coroutine timerproc to manage these timers, and the runtime will be added when the timer expires or is added. Wake up timerproc to handle earlier timers. In this way, two performance problems will arise. The first is the lock contention problem caused by the globally unique quad heap, and the second is the context switching problem caused by waking up timerproc.

In Go 1.10 version, the global quad heap is divided into 64 smaller quad heaps. This way of fragmentation reduces the granularity of locks and solves the first problem mentioned above, but the second The question remains unresolved.

In the latest version of the implementation, all timers are stored in the processor runtime.p in the form of a minimum quad heap, which is designed in such a way that both performance problems are solved.

data structure

runtime.timer is the internal representation of Go language timers. Each timer is stored in the minimum quad heap of the corresponding processor. The following is the structure corresponding to the runtime timer:

type timer struct {
    pp puintptr

    when     int64
    period   int64
    f        func(interface{}, uintptr)
    arg      interface{}
    seq      uintptr
    nextwhen int64
    status   uint32
}
  • pp: pointer address of processor P where the timer is located.
  • when: The time the timer was woken up.
  • period: The time interval for the timer to wake up again, only used by Ticker.
  • f: The callback function, which is called every time the timer is woken up.
  • arg: The parameter of the callback function f.
  • seq: The parameter of the callback function f, which is only used in the application scenario of netpoll.
  • nextwhen: When the timer state is timerModifiedXX, it will use the value of nextwhen to set to the when field.
  • status: The current status value of the timer.

status

The current state of the timer includes the following:

conditionmeaning
timerNoStatusThe timer has not been set
timerWaitingWait for the timer to start
timerRunningCallback method for running timer
timerDeletedThe timer has been removed, but is still in some P's heap
timerRemovingTimer is being deleted
timerRemovedThe timer has stopped and is not in any P's heap
timerModifyingThe timer is being modified
timerModifiedEarlierThe timer has been modified to an earlier time
timerModifiedLaterThe timer has been modified to a later time
timerMovingThe timer has been modified and is being moved
  • The time in the timerRunning, timerRemoving, timerModifying, and timerMoving states is relatively short.
  • Timers are on the processor (P) heap while in the timerWaiting, timerRunning, timerDeleted, timerRemoving, timerModifying, timerModifiedEarlier, timerModifiedLater, and timerMoving states.
  • Timers are not on the heap while in the timerNoStatus and timerRemoved states.
  • Timers in the timerModifiedEarlier and timerModifiedLater states, although on the heap, may be in the wrong place and need to be reordered.

Related operations

add timer

When we call time.NewTimer or time.NewTicker, the runtime.addtimer function is executed to add a timer:

func addtimer(t *timer) {
    if t.when < 0 {
        t.when = maxWhen
    }
    if t.status != timerNoStatus {
        throw("addtimer called with initialized timer")
    }
    t.status = timerWaiting

    when := t.when

    pp := getg().m.p.ptr()
    lock(&pp.timersLock)
    cleantimers(pp)
    doaddtimer(pp, t)
    unlock(&pp.timersLock)

    wakeNetPoller(when)
}
  1. Boundary processing and status judgment;
  2. Call cleantimers to clean up the timers in the processor;
  3. Call doaddtimer to initialize the network poller, and add the current timer to the processor's timers quad heap;
  4. Call wakeNetPoller to interrupt the blocking network polling, and judge whether it is necessary to wake up the sleeping thread in the network poller according to the time.
delete timer

In the use of timers, the timer.Stop() method is generally called to stop the timer, which essentially makes the timer disappear from the poller, that is, removes the timer from the processor P's heap:

func deltimer(t *timer) bool {
    for {
        switch s := atomic.Load(&t.status); s {
        case timerWaiting, timerModifiedLater:
            // timerWaiting/timerModifiedLater -> timerDeleted
            ...
        case timerModifiedEarlier:
            // timerModifiedEarlier -> timerModifying -> timerDeleted
            ...
        case timerDeleted, timerRemoving, timerRemoved:
            // timerDeleted/timerRemoving/timerRemoved 
            return false
        case timerRunning, timerMoving:
            // timerRunning/timerMoving
            osyield()
        case timerNoStatus:
            return false
        case timerModifying:
            osyield()
        default:
            badTimer()
        }
    }
}

The basic rules for processing are followed in deltimer:

  1. timerWaiting/timerModifiedLater -> timerDeleted。
  2. timerModifiedEarlier -> timerModifying -> timerDeleted。
  3. timerDeleted/timerRemoving/timerRemoved -> No need to change, the condition has been met.
  4. timerRunning/timerMoving/timerModifying -> is executing, moving, cannot be stopped, wait for the next status check before processing.
  5. timerNoStatus -> can't stop, the condition is not met.
Modified timer

When we call the timer.Reset method to reset the Duration value, we are modifying the underlying timer, which corresponds to the runtime.modtimer method. This method is more complicated and will not be introduced in detail. If you are interested, you can study it yourself. modtimer follows the following rules:

  1. timerWaiting -> timerModifying -> timerModifiedXX。
  2. timerModifiedXX -> timerModifying -> timerModifiedYY。
  3. timerNoStatus -> timerModifying -> timerWaiting。
  4. timerRemoved -> timerModifying -> timerWaiting。
  5. timerDeleted -> timerModifying -> timerModifiedXX。
  6. timerRunning -> wait for the state to change before proceeding to the next step.
  7. timerMoving -> wait for the state to change before proceeding to the next step.
  8. timerRemoving -> wait for the state to change before proceeding to the next step.
  9. timerModifying -> wait for the state to change before proceeding to the next step.

After completing the state processing of the timer, it will be divided into two cases:

  1. The timer to be modified has been deleted: since the existing timer is no longer available, the doaddtimer method will be called to create a new timer, the original timer attribute will be assigned, and then the wakeNetPoller method will be called to wake up the network round at a predetermined time. interrogator.
  2. Normal logic processing: If the trigger time of the modified timer is less than the original trigger time, modify the state of the timer to timerModifiedEarlier, and call the wakeNetPoller method to wake up the network poller at a predetermined time.
trigger timer

The Go language will trigger the timer in two scenarios, running the function saved in the timer:

  • When the scheduler schedules, it checks whether the timer in the processor is ready;
  • System monitoring checks for unexecuted expiration timers.

The trigger of the scheduler is divided into two cases, one is in the scheduling loop schedule, the other is that the current processor P has no executable G and timer, and goes to other P to steal the timer and G's findrunnable function. When the timer is triggered, the checkTimers function is executed to analyze its general process.

func checkTimers(pp *p, now int64) (rnow, pollUntil int64, ran bool) {
    if atomic.Load(&pp.adjustTimers) == 0 {
        next := int64(atomic.Load64(&pp.timer0When))
        if next == 0 {
            return now, 0, false
        }
        if now == 0 {
            now = nanotime()
        }
        if now < next {
            if pp != getg().m.p.ptr() || int(atomic.Load(&pp.deletedTimers)) <= int(atomic.Load(&pp.numTimers)/4) {
                return now, next, false
            }
        }
    }

    lock(&pp.timersLock)

    adjusttimers(pp)

This section is the process of adjusting the timer in the heap:

  • First, check whether there is a timer that needs to be adjusted in the current processor P through pp.adjustTimers, if not:

    • When there is no timer to execute, return directly;
    • It will return directly when the next timer has not expired and no more than 1/4 of the total number of timers need to be deleted.
  • If there are timers in the processor that need to be adjusted, runtime.adjusttimers is called to rearrange the timers slices according to time.
rnow = now
    if len(pp.timers) > 0 {
        if rnow == 0 {
            rnow = nanotime()
        }
        for len(pp.timers) > 0 {
            if tw := runtimer(pp, rnow); tw != 0 {
                if tw > 0 {
                    pollUntil = tw
                }
                break
            }
            ran = true
        }
    }

After executing the logic of the adjustment phase, it is the code that runs the timer. This section finds and executes the timers that need to be executed in the heap through runtime.runtimer:

  • If the execution is successful, the runtimer returns 0;
  • If there are no timers to execute, the runtimer returns the trigger time of the most recent timer, records this time and returns.
if pp == getg().m.p.ptr() && int(atomic.Load(&pp.deletedTimers)) > len(pp.timers)/4 {
        clearDeletedTimers(pp)
    }

    unlock(&pp.timersLock)
    return rnow, pollUntil, ran
}

In the final deletion phase, if the current Goroutine's processor is the same as the incoming processor, and the deleted (timerDeleted state) timers in the processor account for more than 1/4 of the timers in the heap, runtime.clearDeletedTimers will be called. Clean up all timers marked as timerDeleted in the handler.

Even if it is triggered every time the scheduler schedules and steals, it still has a certain degree of uncertainty, so Go uses system monitoring triggers to make a bottom line:

func sysmon() {
    ...
    for {
        ...
        now := nanotime()
        next, _ := timeSleepUntil()
        ...
        lastpoll := int64(atomic.Load64(&sched.lastpoll))
        if netpollinited() && lastpoll != 0 && lastpoll+10*1000*1000 < now {
            atomic.Cas64(&sched.lastpoll, uint64(lastpoll), uint64(now))
            list := netpoll(0)
            if !list.empty() {
                incidlelocked(-1)
                injectglist(&list)
                incidlelocked(1)
            }
        }
        if next < now {
            startm(nil, false)
        }
        ...
}
  • Call runtime.timeSleepUntil to get the expiration time of the timer and the heap holding the timer;
  • If there is no network polling for more than 10ms, call runtime.netpoll polling;
  • If there is currently a timer that should be running that is not executed, there may be a processor that cannot be preempted, and a new thread is started to process the timer.
running timer

The runtime.runtimer function checks the topmost timer on the processor's quad heap, and it also handles timer deletions and updates:

func runtimer(pp *p, now int64) int64 {
    for {
        t := pp.timers[0]
        switch s := atomic.Load(&t.status); s {
        case timerWaiting:
            if t.when > now {
                return t.when
            }

            runOneTimer(pp, t, now)
            return 0

        case timerDeleted:
            // 删除堆中的计时器
        case timerModifiedEarlier, timerModifiedLater:
            // 修改计时器的时间
        case timerModifying:
            osyield()
        case timerNoStatus, timerRemoved:
            badTimer()
        case timerRunning, timerRemoving, timerMoving:
            badTimer()
        default:
            badTimer()
        }
    }
}

It handles timers according to the following rules:

  • timerNoStatus -> crash: uninitialized timer
  • timerWaiting -> timerWaiting
  • timerWaiting -> timerRunning -> timerNoStatus
  • timerWaiting -> timerRunning -> timerWaiting
  • timerModifying -> wait for state to change
  • timerModifiedXX -> timerMoving -> timerWaiting
  • timerDeleted -> timerRemoving -> timerRemoved
  • timerRunning -> crash: the function is called concurrently
  • timerRemoved, timerRemoving, timerMoving -> crash: inconsistent timer heap

If the timer at the top of the processor quad heap does not reach the trigger time, it will return directly, otherwise call runtime.runOneTimer to run the timer at the top of the heap:

func runOneTimer(pp *p, t *timer, now int64) {
    f := t.f
    arg := t.arg
    seq := t.seq

    if t.period > 0 {
        delta := t.when - now
        t.when += t.period * (1 + -delta/t.period)
        siftdownTimer(pp.timers, 0)
        if !atomic.Cas(&t.status, timerRunning, timerWaiting) {
            badTimer()
        }
        updateTimer0When(pp)
    } else {
        dodeltimer0(pp)
        if !atomic.Cas(&t.status, timerRunning, timerNoStatus) {
            badTimer()
        }
    }

    unlock(&pp.timersLock)
    f(arg, seq)
    lock(&pp.timersLock)
}

Depending on the timer's period field, the above functions do different things:

  • If the period field is greater than 0, it means it is a Ticker and needs to be triggered periodically:

    • Modify the next time the timer fires and update its position in the heap;
    • Update the state of the timer to timerWaiting;
    • Call the runtime.updateTimer0When function to set the handler's timer0When field.
  • If the period field is less than or equal to 0, it means it is a Timer, and it only needs to be triggered once:

    • Call the runtime.dodeltimer0 function to delete the timer;
    • Update the status of the timer to timerNoStatus.

After updating the timer, lock the mutex, call the timer's callback method f, and pass in the corresponding parameters. Complete the entire process.


与昊
222 声望634 粉丝

IT民工,主要从事web方向,喜欢研究技术和投资之道