Futures in Go, no package required
Futures are mechanisms for decoupling a value from how it was computed. Goroutines and channels allow implementing futures trivially. Does this approach cover all aspects of a future?
Back to the futures
Recently I came across a short comment on Reddit:
peterbourgon (7.00/0.00): Futures in Go, no package required:
c := make(chan int) // future go func() { c <- f() }() // async value := <-c // await
I got curious. Is this sufficient to model a future as known in other languages? Or would advanced use cases still require a futures
package for properly modeling futures semantics?
Futures in a nutshell
According to Wikipedia, futures “describe an object that acts as a proxy for a result that is initially unknown, usually because the computation of its value is not yet complete.” Sounds like a natural fit for Go's built-in concurrency. Let's pick Peter Bourgon's minimal code apart.
Part 1: Define the communication channel.
c := make(chan int)
Channel c
is our future, a proxy for a result that may or may not be ready at the point of reading. Channel semantics cause the reader to block and wait until a value is available.
Part 2: Compute the value asynchronously.
For this we set up and execute a goroutine. The main goroutine can continue doing other things until it needs the computed value from c.
Deviating from the original code, let's define the channel as a parameter to the goroutine. The actual call then receives the channel we defined above as an argument. We can also pass input parameters as needed.
go func(input int, result chan<- int) {
result <- input * 2 // Horribly complex and long-winded calculation
}(1, c)
Part 3: retrieve the computed value.
Finally, at some point, the main goroutine requests the value. If the channel already contains a result, it is retrieved right away; otherwise, the statement blocks until the result is there.
value := <-c
A closer look
The above code is easy enough, right?
However, this code contains some implicit assumptions about how to compute and read a future.
Assumption #1: It is ok that the spawned goroutine blocks after having calculated the result.
Assumption #2: The reader reads the result only once.
Assumption #3: The spawned goroutine provides a result within a reasonable time.
All of these limiting assumptions can be addressed. And the best part is, the additional code required here is also trivial and makes use of well-known standard Go features.
Let's have a look at each of them.
Let the spawned goroutine do more after computing the future
Assumption #1 allows us to create a channel with zero length. The write operation c <- f()
then blocks until a reader is ready to receive the value from the channel. This is perfectly fine if the goroutine's only job is to calculate the future's result. In most cases, this is exactly what we need.
If, for some reason, calculation of the future is embedded in a broader context that is supposed to continue to run concurrently, simply use a channel of length 1 to store the result until the reader is ready to retrieve it:
c := make(chan int, 1)
Now the spawned goroutine can pass the result to the channel and continue immediately, maybe computing other futures that depend on the one just delivered. Or doing cleanup or whatever.
However, here lies a catch: On a single-core CPU where the concurrent execution cannot be effectively parallelized, the computing goroutine may block the reading goroutine while it continues computing things.
Read the computed future more than once
Assumption #2 is also just fine in most cases. However, sometimes you might have multiple goroutines that shall receive the computed value.
Again, this is trivial in Go. We only need to make the computing goroutine send the result to the channel over and over again.
go func(input int, result chan<- int) {
value := compute(input)
for {
result <- value
}
}(256, c)
On the consuming side, noting needs to change. We can repeatedly reading from the channel and get the same value back, once it has been computed.
value1 := <-c
value2 := <-c // Read again, get the same value again
And in case you wonder – no, there is no busy-looping happening here. The loop blocks on every attempt to write to the channel until a reader retrieves a value from the channel.
Limit the time to wait for the future
In some cases it is better to not rely on the goroutine to provide a value in time. For example, the algorithm to compute the future might be of exponential time complexity, and the caller might have passed an input that causes the result to take ages to compute. Or the future might be calculated by calling a remote function over a slow and/or unreliable network.
Obviously, we need to be able to set an upper limit for the time to wait for the result. Again, this is quite easy: Go provides contexts to equip goroutines with a timeout.
This time we need to change the caller/reader side. The computing goroutine can remain unchanged.
To be able to watch for both the result of the future and a timeout, we define a get()
function to retrieve the value. Inside the function, a select
statement observes both the result channel and a timer that we start by calling time.After()
. This method returns a channel upon calling, and sends the current time through that channel when the time is up. We do not need that time, so the result is discarded.
When the timer triggers, we need to indicate failure to the caller. For this, we can add a second return parameter that turns true if a timeout occurs.
get := func(s int) (result int, timedout bool) {
select {
case result = <-c3:
return result, false
case <-time.After(time.Duration(s) * time.Second):
return 0, true
}
}
To retrieve the future, call get()
, pass the desired timeout (in seconds), and test the boolean:
value, timedOut := get(1)
if timedOut {
...
}
More “futureness”
Futures in other languages usually have a couple more methods as the authors strived to cover every imaginable use case. You do not need those at all costs. If you do, here are suggestions for mapping these methods to Go features.
Note that I have not added the code snippets from this section to the main code listing below. I feel that adding too much fine-grained control can easily lead to over-engineered code and tight coupling between goroutines. However, as there might be use cases, I discuss them here briefly and leave the implementation as an exercise for the reader.
Cancel the computing goroutine
In some situations, a future may become obsolete before it has been fully computed. To save resources, the computing goroutine should be canceled then.
Here, the context
package comes in handy. A context is an object that provides canceling, deadline, and timeout functionalities to goroutines. For canceling a goroutine, create a Background context and add the Cancel option.
ctx, cancel := context.WithCancel(context.Background())
Pass the ctx
object to the goroutine. The second return value, cancel
, is a function. When the goroutine is not needed, call this function to request the goroutine to cancel itself.
How does this work?
The context object contains a done
channel. This channel delivers no values as long as it is open. Hence reading from the channel blocks the reader as long as the channel is open. Calling cancel()
closes this channel. A closed channel starts delivering the zero value of its element type, and so any reader unblocks and can invoke code for cleaning up and exiting the goroutine.
To implement this inside the computing goroutine, execute a select statement in a loop. Make the select watch for the done
channel to get closed. As long as the done
channel is open, any read operation blocks because the done
channel does not deliver anything. Hence the select statement skips this case block and evaluates other case
blocks instead.
go func(ctx context.Context) {
// compute the future
for {
select {
case <-ctx.Done():
// cleanup code here
return
case default:
// compute the future
}
}
}(ctx)
This concept can be extended to multiple goroutines that compute the same future. When the fastest computation finishes, all other computing goroutines can be canceled via the done
channel.
Have the computing goroutine time out
The above mechanism can as well be used for having the computing goroutine time out. This is an improved version of the above approach, where we only unblocked the reader after the time out. With a context, we can cancel the computing goroutine itself and thus stop it from further consuming CPU time or other resources. The WithTimeout()
method of a Context
object creates the same done
channel as the WithCancel()
method, and even returns a cancel
function that can be called, but in addition, the done
channel is also closed when the passed-in time has elapsed.
ctx, cancel := context.WithTimeout(context.Background(), 10 * time.Second)
More control?
There are even more ways of interacting with the computation of a future. For example, jQuery's Deferred Object provides methods for chaining, notifications, progress notifications, state inspection, and other bells and whistles. If you have a closer look at them, I am sure you will find ways of implementing these methods via channels, waitGroups, or contexts.
However, as said above, don't over-engineer your code. If you feel the need to have your goroutines micro-manage each other using a truckload of methods for inspection and manipulation, this might be a good opportunity to re-think your overall concurrency design. Chances are that you will find a cleaner way that is more manageable and in the end also easier to reason about.
And back to my initial question: Would you be better off with a futures package? I see two possible reasons for using a package rather than native Go features: convenience, and domain-specific semantics. A convenience package can provide methods and objects that might help you switching from some other language to Go. And if you develop code for a specific problem domain, then a domain-specific API can “talk” to you in the language of that problem domain and avoid having to jump between different levels of abstraction. Neither of the two reasons is really life-saving, so it is a matter of personal (or team) preference whether to use a package or to stick to native Go features. Tip: start with the latter and only switch to a convenience or domain-specific package if they help managing complexity in your specific situation.
The code
The code below contains all of the above in one single, executable file. Feel free to use it for your own experiments.
package main
import (
"fmt"
"time"
)
func main() {
c := make(chan int)
fmt.Println("\nA simple future")
fmt.Println("---------------\n")
go func(input int, result chan<- int) {
fmt.Println("Calculating")
time.Sleep(1 * time.Second)
fmt.Println("done")
result <- input * 2
}(1, c)
var value int
fmt.Println("Waiting")
value = <-c
fmt.Println("got", value)
Read the future multiple times
fmt.Println("\nReading the future multiple times")
fmt.Println("-----------------------------\n")
c2 := make(chan int)
go func(input int, res chan<- int) {
fmt.Println("Calculating")
time.Sleep(1 * time.Second)
for {
fmt.Println("Writing result")
res <- input * 4
}
}(1, c2)
fmt.Println("Waiting")
fmt.Println("got", <-c2)
fmt.Println("got", <-c2)
Read with a timeout
fmt.Println("\nReading with a timeout")
fmt.Println("-----------------------------\n")
c3 := make(chan int)
go func(input int, res chan<- int) {
fmt.Println("Calculating")
time.Sleep(2 * time.Second)
for {
fmt.Println("Writing result")
res <- input * 8
}
}(1, c3)
get := func(s int) (result int, timedout bool) {
select {
case result = <-c3:
return result, false
case <-time.After(time.Duration(s) * time.Second):
return 0, true
}
}
fmt.Println("Waiting")
result, timedOut := get(1)
if timedOut {
fmt.Println("Timed out")
return
}
fmt.Println(result)
}
How to get and run the code
Step 1: clone the repository.
git clone github.com/appliedgo/futures
Step 2: cd
to the source code directory and run the code.
go run futures.go
Or run the code directly in the Go Playground.
Happy coding!
Changelog
2023-10-17 Small improvements to the code for “Read the future more than once”
The basics