DEV Community

loading...
Cover image for Go concurrency and sychronization - Part 1: First approach

Go concurrency and sychronization - Part 1: First approach

davidkroell profile image David Kröll ・4 min read

This intention of this series is to teach something about concurrency and synchronization in the Golang ecosystem. As always, I'd like to explain it with a simple to understand, but still tough example.

Assume we'd like to create a counter, which just counts from zero to infinity. Very quick implementation which would satisfy our needs already.

func main() {
    i := 0

    for {
        i++
        fmt.Println(i)
    }
}
Enter fullscreen mode Exit fullscreen mode

So, how to scale this solution? Yes of course by using concurrent computation - but in Go we do not talk about threads, we talk about Goroutines. You have to imagine, that there is more work to do than just counting numbers. It could be anything, but we'd like the results in a specific order. This is the challenging part of this example. We would like to have the exact same output but provided via different goroutines.

// current and desired state would be
0, 1, 2, 3, 4, 5, 6, 7, ...
// but when just introducing concurrency,
// we could maybe end up like this
0, 2, 1, 3, 5, 4, 6, 7, ...
Enter fullscreen mode Exit fullscreen mode

When we just spawn two Goroutines and let them count (in two-steps now) we may expect the output like above. We can't tell the order here, so the Goroutines have to know it somehow.

Goroutine communication

There are different approaches available for doing this. We may share some parts of memory (a variable) and let the other oroutine wait until it's time to print the number. The other solution would be to tell the other Goroutine directly, that the number is printed and it is now time to continue.

These are of course two core principles and it would go far beyond the scope of this post now to explain them in detail, but I guess you may already know them.

Since we are talking about Go and Go is all about keeping the programming paradigms and rules in mind, I am going to use approach 2.

Share memory by Communicating - A Golang core principle read more here

I've summed up the above architectural explanations in a graphic.

Goroutine architectural workflow

The tricky part here is the communication between the two printing Goroutines, since the ordering has very high priority (if not this post would be useless).

The solution

When we talk about communication, we always talk about channels.
Below is already the whole solution to solve the problem.

func main() {

    // initialize all channels
    printOdd := make(chan struct{})
    printEven := make(chan struct{})
    closer := make(chan struct{})

    // spawn Goroutine A
    go func() {
        start := 0

        // infinte looping
        for {

            // block until some data arrives from either channel
            select {
            case <-printEven:
                // simulate the calculation
                time.Sleep(time.Second)
                // print
                fmt.Println(start)
                start = start + 2

                // notify Goroutine B to print an even number now
                printOdd <- struct{}{}
            case <-closer:
                return
            }
        }
    }()

    // spawn Goroutine B
    go func() {
        start := 1

        for {
            select {
            case <-printOdd:
                time.Sleep(time.Second)
                fmt.Println(start)
                start = start + 2
                printEven <- struct{}{}

            case <-closer:
                return
            }
        }
    }()

    reader := bufio.NewReader(os.Stdin)
    fmt.Println("Press enter to cancel")
    fmt.Println("---------------------")

    // trigger the ping-pong
    printEven <- struct{}{}

    // wait for console input to quit
    reader.ReadString('\n')
    fmt.Println("finished")

    // we would like to let all other goroutines return, but in fact they starve away
    // when the main goroutine returns
    // closing this channel here is totally useless
    close(closer)
}
Enter fullscreen mode Exit fullscreen mode

The empty struct type is used because of memory optimization,
since we don't want to share any other data, just notify the other goroutine.

The danger in this solution is that we cannot clean up our worker goroutines (A and B).
When the main Goroutine returns all other goroutines are killed, as well. In our example it does not matter much.
But there are of course use-cases where we want to clean up something. Think of closing some files in use, closing network connections and so on.

Introducing cleanup

When we'd like to make a clean up possible for your worker Goroutines, we could use one of the standard libraries sync.WaitGroup.

You may view the original documentation here: https://pkg.go.dev/sync/#WaitGroup

Now we are adding waitgroups to enable cleanup for our worker Goroutines.

func main() {

    printOdd := make(chan struct{})
    printEven := make(chan struct{})
    closer := make(chan struct{})

    wg := sync.WaitGroup{}

    go func() {
        start := 0
        wg.Add(1)

        for {
            select {
            case <-printEven:
                time.Sleep(time.Second)
                fmt.Println(start)
                start = start + 2
                printOdd <- struct{}{}
            case <-closer:
                fmt.Println("finished odd printing")
                wg.Done()
                return
            }
        }
    }()

    go func() {
        start := 1
        wg.Add(1)

        for {
            select {
            case <-printOdd:
                time.Sleep(time.Second)
                fmt.Println(start)
                start = start + 2
                printEven <- struct{}{}

            case <-closer:
                fmt.Println("finished even printing")
                wg.Done()
                return
            }
        }
    }()

    reader := bufio.NewReader(os.Stdin)
    fmt.Println("Press enter to cancel")
    fmt.Println("---------------------")
    // trigger the ping-pong
    printEven <- struct{}{}

    reader.ReadString('\n')
    fmt.Println("finished")

    // we would like to let all other goroutines return
    close(closer)

    // panics, because a close on a channel may only be received once
    // and therefore the call to wg.Done() is only called once instead of twice
    wg.Wait()

    // output: fatal error: all goroutines are asleep - deadlock!
}
Enter fullscreen mode Exit fullscreen mode

There are several questions arising now. Why would one introduce a new channel (the closer) to make the other ones return? We may just use our other channels to achieve this. This will however introduce another tricky problem which we'll discuss in the follow-up post.

Edit: I mixed up even and odd - as @prateek_reddy pointed out in the comments

Discussion (3)

pic
Editor guide
Collapse
dbhaskaran profile image
Deepak Bhaskaran

Nice article, do checkout my site when you have time covid-dashboard.herokuapp.com/ :)

Collapse
prateek_reddy profile image
Prateek Reddy • Edited

Great article 👍 don't you think signalling printEven should print even numbers and likewise 🤔

Collapse
davidkroell profile image
David Kröll Author

Oh damn... thanks for you information