goresourcessleepblocking

Behavior of sleep and select in go


I'm trying to understand a bit more about what happens under the surface during various blocking/waiting types of operations in Go. Take the following example:

otherChan = make(chan int)
t = time.NewTicker(time.Second)
for {
    doThings()

    // OPTION A: Sleep
    time.Sleep(time.Second)

    // OPTION B: Blocking ticker
    <- t.C

    // OPTION C: Select multiple
    select {
        case <- otherChan:
        case <- t.C:
    }
}

From a low level view (system calls, cpu scheduling) what is the difference between these while waiting?

My understanding is that time.Sleep leaves the CPU free to perform other tasks until the specified time has elapsed. Does the blocking ticker <- t.C do the same? Is the processor polling the channel or is there an interrupt involved? Does having multiple channels in a select change anything?

In other words, assuming that otherChan never had anything put into it, would these three options execute in an identical way, or would one be less resource intensive than the others?


Solution

  • That's a very interesting question, so I did cd into my Go source to start looking.

    time.Sleep

    time.Sleep is defined like this:

    // src/time/sleep.go
    
    // Sleep pauses the current goroutine for at least the duration d.
    // A negative or zero duration causes Sleep to return immediately.
    func Sleep(d Duration)
    

    No body, no definition in an OS-specific time_unix.go!?! A little search and the answer is because time.Sleep is actually defined in the runtime:

    // src/runtime/time.go
    
    // timeSleep puts the current goroutine to sleep for at least ns nanoseconds.
    //go:linkname timeSleep time.Sleep
    func timeSleep(ns int64) {
      // ...
    }
    

    Which in retrospect makes a lot of sense, as it has to interact with the goroutine scheduler. It ends up calling goparkunlock, which "puts the goroutine into a waiting state". time.Sleep creates a runtime.timer with a callback function that is called when the timer expires - that callback function wakes up the goroutine by calling goready. See next section for more details on the runtime.timer.

    time.NewTicker

    time.NewTicker creates a *Ticker (and time.Tick is a helper function that does the same thing but directly returns *Ticker.C, the ticker's receive channel, instead of *Ticker, so you could've written your code with it instead) has similar hooks into the runtime: a ticker is a struct that holds a runtimeTimer and a channel on which to signal the ticks.

    runtimeTimer is defined in the time package but it must be kept in sync with timer in src/runtime/time.go, so it is effectively a runtime.timer. Remember that in time.Sleep, the timer had a callback function to wake up the sleeping goroutine? In the case of *Ticker, the timer's callback function sends the current time on the ticker's channel.

    Then, the real waiting/scheduling happens on the receive from the channel, which is essentially the same as the select statement unless otherChan sends something before the tick, so let's look at what happens on a blocking receive.

    <- chan

    Channels are implemented (now in Go!) in src/runtime/chan.go, by the hchan struct. Channel operations have matching functions, and a receive is implemented by chanrecv:

    // chanrecv receives on channel c and writes the received data to ep.
    // ep may be nil, in which case received data is ignored.
    // If block == false and no elements are available, returns (false, false).
    // Otherwise, if c is closed, zeros *ep and returns (true, false).
    // Otherwise, fills in *ep with an element and returns (true, true).
    func chanrecv(t *chantype, c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
      // ...
    }
    

    This part has a lot of different cases, but in your example, it is a blocking receive from an asynchronous channel (time.NewTicker creates a channel with a buffer of 1), but anyway it ends up calling... goparkunlock, again to allow other goroutines to proceed while this one is stuck waiting.

    So...

    In all cases, the goroutine ends up being parked (which is not really shocking - it can't make progress, so it has to leave its thread available for a different goroutine if there's any available). A glance at the code seems to suggest that the channel has a bit more overhead than a straight-up time.Sleep. However, it allows far more powerful patterns, such as the last one in your example: the goroutine can be woken up by another channel, whichever comes first.

    To answer your other questions, regarding polling, the timers are managed by a goroutine that sleeps until the next timer in its queue, so it's working only when it knows a timer has to be triggered. When the next timer has expired, it wakes up the goroutine that called time.Sleep (or sends the value on the ticker's channel, it does whatever the callback function does).

    There's no polling in channels, the receive is unlocked when a send is made on the channel, in chansend of the chan.go file:

    // wake up a waiting receiver
    sg := c.recvq.dequeue()
    if sg != nil {
        recvg := sg.g
        unlock(&c.lock)
        if sg.releasetime != 0 {
            sg.releasetime = cputicks()
        }
        goready(recvg, 3)
    } else {
        unlock(&c.lock)
    }
    

    That was an interesting dive into Go's source code, very interesting question! Hope I answered at least part of it!