Concurrency: Comparing Golang's Channels to C#'s Async/Await

golang dotnet concurrency

Comparing concurrency in Go (golang) with C#’s Async/Await.

I’ve recently started to experiment with Google’s Go and, coming from a C# background, it was interesting to see how concurrency is managed. In this post I will compare the two using an analogy of making coffee.

Async/Await

C# uses the async and await keywords to handle concurrency. You mark an asynchronous method with the async keyword, and if you want to wait for that method to return something (i.e. call it synchronously) you can use await. But first let’s look at a synchronous example.

Console.WriteLine("Start making coffee...");

Console.WriteLine("Boiling the kettle");
Task.Delay(3000).Wait();
Console.WriteLine("Kettle has been boiled");

Console.WriteLine("Adding instant coffee to the mug...");
Task.Delay(1000).Wait();

Console.WriteLine("Adding sugar to the mug...");
Task.Delay(1000).Wait();

Console.WriteLine("Pouring boiling water...");
Console.WriteLine("Stirring coffee...");

In this example, the code would take 5 seconds to run. Each step is finished before continuing to the next. However, there’s no reason why you can’t prepare your mug with the coffee and sugar whilst the kettle is still boiling. This can be handled using async/await as follows.

Console.WriteLine("Start making coffee...");
async Task BoilKettle() {
    Console.WriteLine("Boiling the kettle");
    await Task.Delay(3000);
}
var boilTask = BoilKettle();

Console.WriteLine("Adding instant coffee to the mug...");
Task.Delay(1000).Wait();

Console.WriteLine("Adding sugar to the mug...");
Task.Delay(1000).Wait();

await boilTask;
Console.WriteLine("Kettle has been boiled");

Console.WriteLine("Pouring boiling water...");
Console.WriteLine("Stirring coffee...");

The kettle boiling code is now contained in a function and marked with the async keyword. It’s called asynchronously on the next line and so the coffee and sugar can be added to the mug whilst the kettle is still boiling. It now takes 3 seconds to make the coffee instead of 5.

Asynchronous functions return a Task. After the coffee and sugar have been added to the mug, the Task is awaited using the await keyword. This ensures the function has finished before continuing.

By the way, although it’s not the focus of this post, to return an actual value from an asynchronous function, for example a string, you can use generics and set the return type to Task<string>. Then, when you await the result of the method you’ll get the string value back and not the task. Although it’s not valid code, Task is effectively equivalent to Task<void>.

Channels in Go

In Go, there is no async/await. Instead, communication across concurrent threads is done via channels.

package main

import (
    "fmt"
    "time"
)

func main() {
    fmt.Println("Start making coffee...")
    kettle_boiled := make(chan bool)
    boil_kettle := func() {
        fmt.Println("Boiling the kettle...")
        time.Sleep(3 * time.Second)

        kettle_boiled <- true
    }
    go boil_kettle()

    // There's an extra sleep here since go was running the following
    // print statement before even starting the goroutine
    time.Sleep(time.Second)
    fmt.Println("Adding instant coffee to the mug...")
    time.Sleep(time.Second)

    fmt.Println("Adding sugar to the mug...")
    time.Sleep(time.Second)

    <-kettle_boiled
    fmt.Println("Kettle has been boiled")

    fmt.Println("Pouring boiling water...")
    fmt.Println("Stirring coffee...")
}

In this example, the kettle boiling code is, again, wrapped in a function, and this function is called asynchronously by preceding it with the go keyword. It doesn’t need to be marked as asynchronous like in C#.

However, getting a response from boil_kettle is a little different. There’s no Task returned as with C#. Instead, a channel is created.

<-kettle_boiled blocks the thread and waits for data to be sent into the channel before continuing. This channel will have a value sent to it to signal that the boil_kettle function has finished executing.

It’s worth mentioning that the value sent to the channel is thrown away with <-kettle_boiled. The channel is just used to signal that the boil_kettle function has finished. Channels always send and receive a type so if boil_kettle needed to return something meaningful it could and the receiving code could read it like my_value := <-kettle_boiled.

Conclusion

At this stage, I can’t say I prefer either implementation. I’m more familiar with async/await having used it a lot more, but Go’s channels certainly make a lot of sense and don’t seem alien to me.

Although it’s not a direct 1-to-1 mapping, Go’s go keyword makes a function run asynchronously, much like marking a function singature with async will make a function run asynchronously when called in C#. Waiting for a value to arrive on a channel with <-my_channel in Go, is the equivalent of using the await keyword in C# to wait for a Task to complete.

comments powered by Disqus