Musing on Go, Thunks, and delayed execution.

Michael Francis
4 min readSep 10, 2022
Photo by Gabrielle Claro on Unsplash

This post is an adventure in delayed code execution in Go and some musings on shifting more functionality to the libraries vs. through builtins.

In a prior post, I talked about missing tuples in Go and how they could have helped clean up the error handling.

In this post, I talk about something a little more esoteric. Go has no direct mechanism to capture a thunk of code and evaluate that chuck of code lazily.

It turns out that the above statement is not entirely true. There are a couple of places where the go builtins behave like a thunk semantic is supported. Unfortunately, this is not exposed to the end developer. The first of these we will talk about is the defer functionality.

func bar() {
fmt.Println( “from bar” )
}
func wow() {
fmt.Println( “from wow” )
}
func main() {
defer bar()
defer wow()
fmt.Println( “from foo” )
}

which will output

from foo
from wow
from bar

Note the reverse order of printing for bar and wow.

What is actually happening? The code writer builds up a stack of uninvoked function calls that are then guaranteed to execute in reverse order at the exit from the defined function.

The second example is the go routine. With a go routine, the uninvoked function is passed to the runtime to be executed on a different thread / go routine.

func main() {
go func() { … }()
}

Launches the function instance on a seperate logical ‘thread’, see the following.

One way of looking at both of these idioms, is that they are ‘special’ channels. (To my knowlegde the run-time does not treat them as such.)

Following this mental model, the run-time drains these channels, and the functions’ execution happens outside the user’s control. What does that mean in practice? If hypothetically you could define a channel on a ‘thunk’ then you could implement similar behaviors in the language instead of using built-ins.

Since functions are first-class value types in go we can get pretty close.

func main() {
delay := make( chan func(),1 )
// Push an unexecuted function onto the channel
delay <- func() {….}
// Evaluate the thunk
(<- delay)()
}

The behavior of this is not the same as the defer method. The execution of the delayed functions is in reverse order, and there is no way to enforce running the channel drain when the function terminates. Additionally we are not defering an executed function, but an unexecuted one.

This above pattern works for one of classic use cases of defer, such as closing a resource. It is interesting to note that we are capturing a function on a struct instance here.

type closer int 
func (_ closer) Close() {
fmt.Printf( “Closed” )
}
func main() {
delay := make( chan func(),2 )
delay <- closer( 0 ).Close
delay <- func() {….}
// Evaluate the thunk
for f := range delay {
f()
}
}

This code exhibits an ‘interesting’ behavior; since the delay channel is not closed, we get an error about deadlocks. We can fix this by closing the channel from itself…

func main() {
delay := make( chan func(),3 )
delay <- closer( 0 ).Close
delay <- func() {….}
delay <- func() { close( delay ) }
// Evaluate the thunk
for f := range delay {
f()
}
}

Items to note, you must supply the size for the channel greater than the number of delayed function calls; otherwise, the code will block.

Squinting, you can ‘see’ that the defer keyword could be a predefined channel drained on exit from the function’s scope.

Modifying the above, using defer itself since there is no direct way to hook the runtime.

func main() {
delay := make( Chan func(),3 )
defer func() {
for f := range delay {
f()
}
}()
delay <- closer( 0 ).Close
delay <- func() {….}
delay <- func() { close( delay ) }
}

If I then wished to run these in reverse, I would drain the channel into a slice and then execute in reverse order.

Now squinting a little more … the go keyword can also be thought of as a channel onto which un-executed functions are pushed. The runtime drains this channel and executes the functions. Obviously we are cheating here using the go keyword but I hope you can see the intent.

package main

import (
"fmt"
"time"
)

func run() chan func() {
gs := make(chan func())
// Cheating here
go func() {
for g := range gs {
go g()
}
}()
return gs
}
var _go = run()

func main() {
_go <- func() { fmt.Println("A") }
_go <- func() { fmt.Println("B") }
time.Sleep(2 * time.Second)
}

We can push functions to run onto a channel and then these get scheduled to run in the background.

Food for thought, but this does suggest that some built-ins could be moved out and into the runtime library. With lower-level routines provided to support the more lower level functionality. I’m sure many reasons exist for not making these changes, but it makes for an interesting experiment.

Thoughts? Comments?

--

--