Build your own Future in Go

Alexey Soshin
Level Up Coding
Published in
6 min readAug 17, 2020

--

Photo by gdtography on Unsplash

One of the main features of Go programming language is its eponymous go statement. In my opinion, though, the go statement is one of its main drawbacks too. And that’s not only me.

Unlike expressions, statements don’t bear any result. In Go, it’s super-easy to start a new goroutine. But how do you get its results? How do you know if it may have errored? How do you wait for it to complete? How do you cancel it, if you don’t need its results anymore?

Those familiar with Go will say: well, obviously, use channels. But channel in Go is still a low level construct. First of all, to have a goroutine that would yield either a result or an error, and that is also cancellable, you’ll need three of them. You may think Context would help for the third requirement. But Context still exposes channels: Done() is just <-chan struct{}

What’s the problem with that? The more channels you have — the more problems. Channels can deadlock. Channels can panic. You have all those low-level concerns you need to deal with, even before you begin writing your business logic.

And it’s not enough to write it just once. You’ll have to repeat it over an over again. Because most probably, two different goroutines would return two different types of results. Which means, two different channel types. Which means that, without generics, you either replicate your code many times, or resort to using interface{} and runtime casts, which completely breaks the idea of type-safety. You have to choose between two bad solutions, simply because go is a statement, and not an expression.

Well, that was true until Go language introduced generics as an experimental feature. I’ve written about them briefly once already, and this time, I’d like to demonstrate how generics may help us solve one of the biggest flaws of Go design.

We’ll implement a deferred value design pattern, which in different languages and frameworks is called Future, Promise and a bunch of other names. I’ll call it Future, like Java does.

Deferred values are either eager or lazy. Meaning they either start executing as soon as they were created, or only when something triggers them. Since go statement is eager by its nature, I’ll favor eager execution.

Our Future will have the following methods:

  • Get() that blocks current goroutine until result of the Future is obtained
  • Cancel() that stops execution of our Future

In Go terms, it will be an interface with two methods:

type Future[type T] interface {
Get() Result[T]
Cancel()
}

Note that I’ll be using square brackets to denote generic types. They are not documented in the proposal, but Go2Playground supports them, fact that I learned from this article. I find this Scala-like syntax less confusing than round brackets.

Result is another interface, that wraps Successof type S, or aFailure:

type Result[type S] interface {
Success() S
Failure() error
}

To back Result, we’ll need a struct to hold its data:

type result[type S] struct {
success S
failure error
}

And looking at the struct, implementing both of the Result interface methods should be trivial:

func (this *result(S)) Success() S {
return this.success
}

func (this *result(S)) Failure() error {
return this.failure
}

We could simply make the struct itself public, and avoid using the interface, saving a few lines of code, but interfaces provide much cleaner API.

In the future, it would be also convenient to print the content of our result, so we’ll implement Stringer interface for that:

func (this *result(S)) String() string {
if this.failure != nil {
return fmt.Sprintf("%v", this.failure)
} else {
return fmt.Sprintf("%v", this.success)
}
}

So far, it should be pretty simple. Let’s now discuss what data our struct backing the Future will need.

Having this struct:

type future[type T] struct {
...
}

What do we need to know about the state of the Future?

First of all, we want to hold the result somewhere:

type future[type T] struct {
result *result[T]
...
}

It would be also useful to know if the Future has already completed:

type future[type T] struct {
...
completed bool
...
}

And if it didn’t complete yet, we need a way to wait for it. A common approach for that in Go is to use a channel:

type future[type T] struct {
...
wait chan bool
...
}

Our last requirement is to be able to cancel the Future. For that, we’ll use Context, that returns a function that we need to invoke in order to cancel it:

type future[type T] struct {
...
cancel func()
}

But it also would be useful to have reference to Context itself:

type future[type T] struct {
...
ctx context.Context
cancel func()
}

And that’s it, that’s all the data our Future will need for now.

type future[type T] struct {
result *result[T]
complete bool
wait chan bool
ctx context.Context
cancel func()
}

Let’s now implement both of Future methods.

Since we’re using Context, cancelling our Future becomes trivial:

func (this *future[T]) Cancel() {
this.cancel()
}

Let’s now discuss what cases our Get() should handle.

  1. The Future have already completed its work. Then we should simply return the result, whether it’s a success or failure
  2. The Future didn’t complete its work yet. Then we should wait, blocking the calling goroutine, and when result is ready, we should return it
  3. The Future was cancelled in the meantime. We should return an error indicating that

Having mapped those three cases, we arrive at the following method:

Case of the already completed Future is pretty simple. We just return the cached result.

In case it didn’t complete yet, we use the wait channel to wait for it.

There may be also a case where our Future was cancelled by cancelling the context. We’ll know that by checking ctx.Done() channel.

And that’s it for implementing different use cases of handling the result.

Next, let’s see how we construct our Future.

Our Future needs to execute an arbitrary piece of code. The code itself may return either a result of a generic type, or an error. Our constructor will simply return a Future of the same generic type.

func NewFuture[type T](f func() (T, error)) Future[T] {
...
}

Note how generic allow us now to define powerful relations between our input and output types. Our Future is guaranteed to return the same type as an arbitrary function we provide the constructor. No more need to use interface{} and cast unsafely.

Next, we want to initialize our Future:

fut := &future[T]{
wait: make(chan bool),
}
fut.ctx, fut.cancel = context.WithCancel(context.Background())
...

return fut

We create a Context, in order for our Future to be cancellable, and a channel, so we could wait for it to complete in a concurrent manner.

You may want to consider passing Context to the constructor of the Future, instead of creating it yourself. I omit this for brevity of the example.

Finally, we need to do something with the arbitrary piece of code we’re deferring:

go func() {
success, failure := f()

fut.result = &result[T]{success, failure}
fut.completed = true
fut.wait <- true
close(fut.wait)
}()

Here we’re executing the function in a new goroutine, getting its results, and marking our Future as completed.

Channel should be used only once, so it’s a good idea to close it.

Depending on your use-case, you may want to consider using a worker pool instead of spawning goroutine for every future.

Let’s now see how it works.

First, we would like to see that our Future is able to return a result:

f1 := NewFuture(func() (string, error) {
time.Sleep(1000)
return "F1", nil
})

fmt.Printf("ready with %v \n", f1.Get())
// Need to wait...
// ready with F1

So far, looks good.

What if we try to get the result again, though?

fmt.Printf("trying again with %v \n", f1.Get()) 
// trying again with F1

Note that it doesn’t print “Need to wait” now, because the result is already memoized.

How does our Future behaves if the function returns an error?

f2 := NewFuture(func() (string, error) {
time.Sleep(1000)
return "F2", fmt.Errorf("something went wrong")
})

fmt.Printf("ready with %v \n", f2.Get())
// Need to wait...
// ready with something went wrong

Nice, seems like errors are also handled correctly.

Finally, what about cancellations?

f3 := NewFuture(func() (string, error) {
time.Sleep(100)
fmt.Println("I'm done!")
return "F3", nil
})
f3.Cancel()

fmt.Printf("ready with %v \n", f3.Get())
// Need to wait...
// ready with context canceled

Note that “I’m done!” is never printed, because we discarded results of this Future.

Conclusions

Generics coming to Go may help solve a lot of issues that go being a statement, and not an expression, causes.

Thanks to them, we can use deferred values as our concurrency primitives, as many other language do. That means we now can:

  • Easily access goroutine results and errors
  • Write type-safe code, that is still reusable and generic
  • Stop messing with low-level concurrency primitives such as channels
  • Can stop using go statement altogether

Footnotes

Full code example can be found here: https://github.com/AlexeySoshin/go2future

--

--

Solutions Architect @Depop, author of “Kotlin Design Patterns and Best Practices” book and “Pragmatic System Design” course