ES7-style Async/Await Implementation in Golang

Written by sun0day | Published 2022/08/03
Tech Story Tags: golang | backend | software-development | software-engineering | software-developer | go | programming | go-programming-language

TLDRIn Golang, we use goroutines to execute asynchronous tasks while we use a data structure called `Channel` to communicate with each other. Javascript V8 engine uses the main thread to execute synchronous code while Golang uses a main goroutine. Javascript threads communicate with main thread via a constantly polling event loop. Golang provides a more granular and flexible way to control asynchronous task execution. Introducing Async/Await pattern in golang can help Goer to write more neat, readable, and robust code.via the TL;DR App

Preface

I was learning Golang recently. What interests me in Golang is the goroutine + channel concurrency model. In Golang, we use goroutines to execute asynchronous tasks while these goroutines communicate with each other via the data structure called Channel.

As a frontend developer, I found that the goroutine + channel model in Golang is much like the event loop in Javascript.

  • Javascript V8 engine uses the main thread to execute synchronous code while Golang uses the main goroutine.
  • Javascript V8 engine maintains a thread pool to execute asynchronous code while Golang uses multiple goroutines.
  • Javascript threads communicate with the main thread via a constantly polling event loop while Golang goroutines communicate with each other via Channel.

So I am wondering if there is a way that we could control asynchronous tasks in Golang via Async/Await pattern just like what we do in Javascript ES7. The answer is yes!

For more details → https://github.com/sun0day/async

Async/Await

Before we continue, let’s give a flashback about the common asynchronous model. There are two kinds of common asynchronous models: the blocking asynchronous models & non-blocking asynchronous models. The difference between a blocking asynchronous model and a non-blocking asynchronous model is whether the synchronous code execution should wait for the asynchronous task response to continue.

In Javascript ES7, we use async reserved word to tag a function as an asynchronous task. Once you call a async function, it will be sent to another sub-thread to execute. Then the rest synchronous code will be executed immediately no matter whether the async function is done. The async funciton calling flow above is non-blocking asynchronous. We can also transform the async function calling flow to be blocking asynchronous using await reserved word.

async function nonBlock() {...}

async function block() {...}

async function main() {
  nonBlock() // will not block main 

  await block() // will block main
}

Golang provides a more granular and flexible way to control asynchronous task execution. But flexibility also means we have to write more code to control asynchronous task execution. And what’s worse is that we have to deal with exceptions such as goroutine leak, deadlock, panic, etc. Introducing Async/Await pattern in golang can help Goer to write more neat, readable, and robust code.

Async Implementation in Golang

In Golang we can convert a synchronous func to be asynchronous using a func named Async. The Async type definition is:

func Async[V any](f func () V) func() *AsyncTask[V] {...}

Async accepts a func as a parameter and returns a new func which will create a struct named AsyncTask. You should tell Async what the return type of f is via the generic parameter V. AsyncTask represents the asynchronous task created by Async. It has two properties: data & err.

type AsyncTask[V any] struct {
  data chan V
  err chan any
}

We can observe f() result through AsyncTask.data and the possible error through AsyncTask.err. Then let's take a look at the internals of Async.

func Async[V any](f func () V) func() *AsyncTask[V] {
  exec := func() *AsyncTask[V] {
    data, err := handle[V](f)
    return &AsyncTask[V]{data: data, err: err}
  }

  return exec
}

A func named exec will create a AsyncTask pointer, inside exec, handle handles the synchronous f result and possible error asynchronously. Both data Channel and err Channel capacity are set to 1 to prevent goroutine leaks. Once f() is done, the sub goroutine created by handle can exit immediately regardless of whether there are other goroutines receiving data and err.

func handle[V any](f func () V) (chan V, chan any) {
  data := make(chan V, 1)
  err := make(chan any, 1)

  go func() {
    var result V
    defer func() {
      if e:= recover(); e == nil {
        data <- result
      } else {
        err <- e
      }
      close(data)
      close(err)
    }()
    
    result = f()
  }()

  return data, err
}

The example below shows how Async encapsulates f and how f works asynchronously.

func main() {
  a := 1
  b := 2
  af := Async[int](func() int {
    c := a + b
    fmt.Println("f() result is", c)
    return c
  })

  fmt.Println("sync start, goroutine=", runtime.NumGoroutine())
  af()
  fmt.Println("sync end, goroutine=", runtime.NumGoroutine())
  
  time.Sleep(1 * time.Second)
  fmt.Println("async end, goroutine=", runtime.NumGoroutine())
}
/* stdout:
sync start, goroutine=1
sync end, goroutine=2
f() result: 3
async end, goroutine=1
*/

Await Implementation in Golang

Await implementation in Golang is simple. What Await does is waiting for AsyncTask.data or AsyncTask.err sending data.

func Await[V any](t *AsyncTask[V]) (V, any) {
  var data V
  var err any

  select {
  case err := <-t.err:
    return data, err
  case data := <-t.data:
    return data, err
  }
}

The example below shows how Await waits for Async response.

func main() {
  a := 1
  b := 2
  af1 := Async[int](func() int {
    c := a + b
    return c
  })
  af2 := Async[int](func() int {
    panic("f() error")
  })
  
  fmt.Printf("sync start, goroutine=%d\n", runtime.NumGoroutine())
  data, _ := Await[int](af1())
  _, err := Await[int](af2())
  fmt.Printf("sync end, goroutine=%d, af1() result=%d, af2() result='%s'\n", runtime.NumGoroutine(), data, err)
}

/* stdout
sync start, goroutine=1
sync end, goroutine=1, af1() result=3, af2() result='f() error'
*/

Conclusion

This article shows how to implement ES7-style Async/Await API in Golang via goroutine and Channel. In addition to the above AsyncTask definition, we still need to consider its execution result and state. Going a step further, we can also implement other asynchronous streaming APIs based on Async and Await, such as all, race, etc.


The full source code can be found here.


Written by sun0day | Full-stack developer. Member of vueuse & nextui Peace & Code
Published by HackerNoon on 2022/08/03