Skip to content

Go1.23 Timer Unbuffered Channel Changes

When I wrote this blog, go has just released go1.25rc2 and go1.23 is quite a long time ago release. In go1.23 release note, go team shares that Go 1.23 makes two significant changes to the implementation of time.Timer and time.Ticker. This blog focuses on the second change:

the timer channel associated with a Timer or Ticker is now unbuffered, with capacity 0. The main effect of this change is that Go now guarantees that for any call to a Reset or Stop method, no stale values prepared before that call will be sent or received after the call.

It will talk about why buffered channel is a problem, why unbuffered channel is used, the cases affected and how go implements this change.

Background

To understand the problem, it's helpful to understand how golang timer works, and we focus on (*time.Timer).C:

The Timer type represents a single event. When the Timer expires, the current time will be sent on C, unless the Timer was created by AfterFunc. A Timer must be created with NewTimer or AfterFunc.

Here, I use go1.22.7 and go1.23.6 as a reference.

The expiration logic of timer is controlled by runtime both go1.22 and go1.23, so you can see src/runtime/time.go file inside the source code.

In go1.22.7, when creating a new time.Timer by time.NewTimer. The channel is a buffered channel with cap 1, and it calls startTimer(time/sleep.go:L96) which the function is linked to `runtime/time.

// go1.22.7 time/sleep.go:L86
func NewTimer(d Duration) *Timer {
    c := make(chan Time, 1)
    t := &Timer{
        C: c,
        r: runtimeTimer{
            when: when(d),
            f:    sendTime,
            arg:  c,
        },
    }
    startTimer(&t.r)
    return t
}

The function call addtimerlinks to the function with the same name(runtime/time.go:L257). There are a lot of complex logics inside runtime, but what we need to know is that each P maintains its own timers in a list, and here the old timers are cleaned and the new one is added.

The logic of sending time is defined in sendTime(time/sleep.go:L143) and is maintained inside field (*runtimeTimer).f.

// go1.22.7 time/sleep.go:L143
func sendTime(c any, seq uintptr) {
    select {
    case c.(chan Time) <- Now():
    default:
    }
}

The logic of checking whether a timer expires is quite complex, so we ignore this part and only check how the function sendTime is called. It's called in function runOneTimer(runtime/time.go:L885), which is called by runtimer from checkTimers.

Ignoring the other complex scheduling logics, the logic of sending time into channel is straightforward, when it expires, the handler is triggered.

Problem

Go1.22 uses a buffered channel with cap 1, it means that when scheduler finds the timer expires, it will send out the time to the channel and return, regardless the reader exists. As a result, when we reuse a timer by Reset, the timer is reset but its channel may contain the stale data from last time.

  1. timer expires, channel receives a data without any reader.
  2. reset the timer, and read from timer's channel.

The operation above expects to read the data after the reset timer timeout, however, due to the stale data stored inside the channel before reset, reading from channel gets the outdated data.

For example, the code snippet below prints 2.625µs rather than 1s. Besides, the <-t.C doesn't work as comment describes as there is a stale data stored inside the channel.

// go1.22 outputs:
// 2.625µs
func main() {
    t := time.NewTimer(1 * time.Second)
    time.Sleep(2 * time.Second)

    t.Reset(1 * time.Second)
    resetT := time.Now()
    <-t.C // wait until the reset timer expires
    fmt.Println(time.Since(resetT))
}

This is definitely a problem, as go team described, it makes difficult to use Reset and Stop correctly.

New Behavior in go1.23

In go1.23, go team has used a buffered channel for (*Timer).C, which means len(timer.C) and cap(timer.C) will be 0 in go1.23. Besides, the code snippet in problem section will work correctly.

// go1.23 outputs:
// 1.001099709s
func main() {
    t := time.NewTimer(1 * time.Second)
    time.Sleep(2 * time.Second)

    t.Reset(1 * time.Second)
    resetT := time.Now()
    <-t.C // wait until the reset timer expires
    fmt.Println(time.Since(resetT))
}
func main() {
    t := time.NewTimer(time.Second)
    fmt.Println(len(t.C), cap(t.C))
    time.Sleep(2 * time.Second)
    fmt.Println(len(t.C), cap(t.C))
    tm := <-t.C // both go1.23 and go1.22 output the same
    fmt.Println(tm)

    // go1.23 outputs:
    // 0 0
    // 0 0
    //
    // go1.22 outputs:
    // 0 1
    // 1 1
}

This is reasonable, because if there isn't a reader for channel (*Timer).C, the sendTime function call will use the default branch and does nothing when the timer expires.

// Output: default
func main() {
    ch := make(chan int)
    select {
    case ch <- 1:
    default:
        fmt.Println("default")
    }
}

As a result, when reseting the timer, there won't be a stale data inside the channel.

Implementation Details

I want to learn about the implementation because when I click the implementation in src/time/sleep.go of go1.23.6, I found the channel is still allocated as a buffered channel. I know something must

func NewTimer(d Duration) *Timer {
    c := make(chan Time, 1)
    t := (*Timer)(newTimer(when(d), 0, sendTime, c, syncTimer(c)))
    t.C = c
    return t
}

Reading the source code tries to understand two points:

  1. Who changes the buffered channel into unbuffered channel as docs say?
  2. Why in NewTimer the channel is still allocated as a buffered channel? Why not allocate unbuffered channel here directly?

Who Changes the Buffered Channel?

In short, the title is wrong because the buffered channel is still a buffered channel, but go runtime has more logics to make it works as an unbuffered channel. This section tracks my investigation.

If we find time package doesn't allocate the unbuffered channel, the only answer is that runtime does so. Functio syncTimer(src/time/sleep.go:L22) also comments:

// syncTimer returns c as an unsafe.Pointer, for passing to newTimer. // If the GODEBUG asynctimerchan has disabled the async timer chan // code, then syncTimer always returns nil, to disable the special // channel code paths in the runtime.

time.NewTimer calls newTimer, which is linked to the newTimer defined in runtime(src/runtime/time.go:L325). Note that their signatures are slightly different:

// defined in src/time/sleep.go
func newTimer(when, period int64, f func(any, uintptr, int64), arg any, cp unsafe.Pointer) *Timer

// defined in src/runtime/time.go
func newTimer(when, period int64, f func(arg any, seq uintptr, delay int64), arg any, c *hchan) *timeTimer

But we can conclude they are the same thing, so the unsafe pointer cp is hchan, and the call argument is a chan time.Timer.

func NewTimer(d Duration) *Timer {
    c := make(chan Time, 1)
    t := (*Timer)(newTimer(when(d), 0, sendTime, c, syncTimer(c)))
    t.C = c
    return t
}

func syncTimer(c chan Time) unsafe.Pointer

So the hchan is a representation of chan time.Timer. We can verify this idea as hchan is defined in chan.go and the doc comment says:

// This file contains the implementation of Go channels.

If you're quite familiar with go channel, you can skip this as it tries to find out the question without knowing all details of channel implementation.

To understand how go changes the buffered channel into unbuffered one, learning how hchan maintains the buffer concept is compulsory. Function makechan(src/runtime/chan.go:L73) provides a good entry for us.

const (
    maxAlign  = 8
    hchanSize = unsafe.Sizeof(hchan{}) + uintptr(-int(unsafe.Sizeof(hchan{}))&(maxAlign-1))
    debugChan = false
)

func makechan(t *chantype, size int) *hchan {
    ...
    mem, overflow := math.MulUintptr(elem.Size_, uintptr(size))
    ...
    var c *hchan
    switch {
    case mem == 0:
        // Queue or element size is zero.
        c = (*hchan)(mallocgc(hchanSize, nil, true))
        // Race detector uses this location for synchronization.
        c.buf = c.raceaddr()
    case !elem.Pointers():
        // Elements do not contain pointers.
        // Allocate hchan and buf in one call.
        c = (*hchan)(mallocgc(hchanSize+mem, nil, true))
        c.buf = add(unsafe.Pointer(c), hchanSize)
    default:
        // Elements contain pointers.
        c = new(hchan)
        c.buf = mallocgc(mem, elem, true)
    }

    c.elemsize = uint16(elem.Size_)
    c.elemtype = elem
    c.dataqsiz = uint(size)
    lockInit(&c.lock, lockRankHchan)
    ...
}

This code snippet tells us:

  • the allocated memory will be used by hchan and its buffer.
  • the buffer size is maintained by c.dataqsiz.

Here, I got blocked because I don't know how to find the logic "who changes the buffered channel", so generally I have 2 directions:

  1. think about whether my assumption is true. the buffered channel may not be changed, but behaves like a unbuffered one with some modifications. we are inside runtime so it might happen.
  2. my assumption is correct, but i haven't found it yet.

I hold the assumption of point1 because std time package passes a unsafe.Pointer to runtime, so it's totally possible. As we know the cap(t.C) also changes, we may have a look how cap is implemented for channel. We search cap in src/runtime/chan.go and find chancap(src/runtime/chan.go:L803). Surprisingly, it handles a special condition check!

func chancap(c *hchan) int {
    if c == nil {
        return 0
    }
    if c.timer != nil {
        async := debug.asynctimerchan.Load() != 0
        if async {
            return int(c.dataqsiz)
        }
        // timer channels have a buffered implementation
        // but present to users as unbuffered, so that we can
        // undo sends without users noticing.
        return 0
    }
    return int(c.dataqsiz)
}

It's likely that even though src/time/sleep.go allocates a buffered channel and passes its unsafe pointer into runtime, runtime doesn't change it, but adds some logics to make it behaves like an unbuffered channel.

As a result, the assumption is wrong, runtime doesn't change a buffered channel into a unbuffered channel.

Hence, let's think about the differences between buffered/unbuffered channels:

  • Unbuffered channel cap is 0, but buffered is not.
  • Unbuffered channel requires a reader to receive otherwise writing will block. Buffered channel could write without blocking if the buffer is not full.

The first item is satisfied by adding a specific logic, then let's say the second item. Understanding how channel sends data is complex, so I change the direction. In function modify(src/runtime/time.go:L499), we find if the async is disabled(go1.23 new feature is enabled), it locks the t.sendLock. The second if !async && t.isChan if stmt also notes it's used for stop any future sends with stale values., which is the feature we are interested.

func (t *timer) modify(when, period int64, f func(arg any, seq uintptr, delay int64), arg any, seq uintptr) bool {
    ...
    async := debug.asynctimerchan.Load() != 0

    if !async && t.isChan {
        lock(&t.sendLock)
    }

    ...

    if !async && t.isChan {
        // Stop any future sends with stale values.
        // See timer.unlockAndRun.
        t.seq++

        // If there is currently a send in progress,
        // incrementing seq is going to prevent that
        // send from actually happening. That means
        // that we should return true: the timer was
        // stopped, even though t.when may be zero.
        if oldPeriod == 0 && t.isSending.Load() > 0 {
            pending = true
        }
    }
    ...
}

Following the comment, we see the implementation of unlockAndRun, and it well explains 'how the data is not sent out'. Until now, I have realized my speculation is wrong. At beginning, I mainly focus on the idea that 'how golang changes a buffered channel to an unbuffered channel'. I hold this opinion because the golang release note says: the timer channel associated with a Timer or Ticker is now unbuffered, with capacity 0. Because I don't know any internal details about go channel, I concluded go has changed the buffered channel to an unbuffered one, which can reuse the existing mechanism of unbuffered channel. My speculation is the go runtime changes buffered channel to an unbuffered channel and call f blindly, which means the select inside f determines whether the data will be sent out.

I got the wrong idea because I haven't considered what's the consequence if go runtime changes the buffered channel to an unbuffered one directly. The motivation is to eliminate the stale data. If go runtime changes it to an unbuffered channel, the data won't be sent if there isn't a reader at that time, which is definitely wrong behavior. This simple justification could assert my speculation is wrong. Unfortunately, I don't check this before I check the source code.

// unlockAndRun unlocks and runs the timer t (which must be locked).
// If t is in a timer set (t.ts != nil), the caller must also have locked the timer set,
// and this call will temporarily unlock the timer set while running the timer function.
// unlockAndRun returns with t unlocked and t.ts (re-)locked.
//
//go:systemstack
func (t *timer) unlockAndRun(now int64) {
    ...
    f := t.f
    ...
    async := debug.asynctimerchan.Load() != 0
    if !async && t.isChan && t.period == 0 {
        // Tell Stop/Reset that we are sending a value.
        if t.isSending.Add(1) < 0 {
            throw("too many concurrent timer firings")
        }
    }
    ...
    if !async && t.isChan {
        // For a timer channel, we want to make sure that no stale sends
        // happen after a t.stop or t.modify, but we cannot hold t.mu
        // during the actual send (which f does) due to lock ordering.
        // It can happen that we are holding t's lock above, we decide
        // it's time to send a time value (by calling f), grab the parameters,
        // unlock above, and then a t.stop or t.modify changes the timer
        // and returns. At that point, the send needs not to happen after all.
        // The way we arrange for it not to happen is that t.stop and t.modify
        // both increment t.seq while holding both t.mu and t.sendLock.
        // We copied the seq value above while holding t.mu.
        // Now we can acquire t.sendLock (which will be held across the send)
        // and double-check that t.seq is still the seq value we saw above.
        // If not, the timer has been updated and we should skip the send.
        // We skip the send by reassigning f to a no-op function.
        //
        // The isSending field tells t.stop or t.modify that we have
        // started to send the value. That lets them correctly return
        // true meaning that no value was sent.
        lock(&t.sendLock)

        if t.period == 0 {
            // We are committed to possibly sending a value
            // based on seq, so no need to keep telling
            // stop/modify that we are sending.
            if t.isSending.Add(-1) < 0 {
                throw("mismatched isSending updates")
            }
        }

        if t.seq != seq {
            f = func(any, uintptr, int64) {}
        }
    }

    f(arg, seq, delay)
    ...
}

How Go Prevents Stale Data