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?
That's a very interesting question, so I did cd
into my Go source to start looking.
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
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.
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.
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!