Managing execution order in Go concurrent programming

Problems discussed in this article

Concurrent Go programming with goroutine, especially about:

  • execution order
  • data race

Context

I’m now reading “Head First Object-Oriented Analysis and Design1”. Although the programming language used in the original book is Java, I use Go in my hands-on.

The scene I want to program is “Fido gets in and out through dog door with automatic-closing feature.”

Although the full sequence diagram can be seen here, the expected output is as follows:

door opens...
Fido goes out
door closes...
door opens...
Fido gets back
door closes...

Precondition

Simplified dog door

We have two go sources in the working directory.

  .
  ├── door.go
  └── main_test.go

Although dog door in the original book in controlled by “bark recognizer”, I omit it to simplify the situation. I defined our simplified dog door as follows:

// door.go
package main

import (
        "fmt"
        "time"
)

type DogDoor struct {
        isOpen bool
}

func (d *DogDoor) Open() {
        fmt.Println("door opens...")

        d.isOpen = true // updates own state

        time.Sleep(3 * time.Second) // automatic close after 3 sec of its opening

        d.Close()
}

func (d *DogDoor) Close() {
        fmt.Println("door closes...")

        d.isOpen = false // updates own state
}

We use this struct in main_test.go as follows:

// main_test.go
package main

import (
        "fmt"
        "testing"
        "time"
)

func TestDogDoor(t *testing.T) {
        d := &DogDoor{}
        d.Open()
        fmt.Println("fido goes out")

        d.Open()
        fmt.Println("fido gets back")
}

When we run this test, we will get undesired output:

$  go test -v ./...
=== RUN   TestDogDoor
door opens...
door closes...
Fido goes out
door opens...
door closes...
Fido gets back
--- PASS: TestDogDoor (6.00s)
PASS
ok      github.com/Rindrics/dogs-door

This is because our ((*Dogdoor).Open() is sequential.

To let Fido through while the door is open, we have to make (*DogDoor).Open() concurrent.

Improvements

Use goroutine

To make program concurrent, we can use goroutine. First, I simply updated the code to call `*DogDoor.Open()` inside goroutine:

// main_test.go
package main

import (
        "fmt"
        "testing"
)

func TestDogDoor(t *testing.T) {
        d := &DogDoor{}
        go d.Open()    // open door by goroutine
        fmt.Println("Fido goes out")

        go d.Open()    // open door by goroutine
        fmt.Println("Fido gets back")
}

It is important to note that two goroutines are trying to rewrite the same data (d.isOpen) in this code. Because such concurrent programs risk unintended consequences, it is a good idea to enable the data race detector with the -race flag:

$ go test -v -race ./...
=== RUN   TestDogDoor
Fido goes out
Fido gets back
door opens...
--- PASS: TestDogDoor (0.00s)
door opens...
PASS
==================
WARNING: DATA RACE
Write at 0x00c0000202bf by goroutine 9:
  github.com/Rindrics/dogs-door.(*DogDoor).Open()
      /Users/Rindrics/dev/dogs-door/door.go:14 +0x7c
  github.com/Rindrics/dogs-door.TestDogDoor.func2()
      /Users/Rindrics/dev/dogs-door/door_test.go:13 +0x39

Previous write at 0x00c0000202bf by goroutine 8:
  github.com/Rindrics/dogs-door.(*DogDoor).Open()
      /Users/Rindrics/dev/dogs-door/door.go:14 +0x7c
  github.com/Rindrics/dogs-door.TestDogDoor.func1()
      /Users/Rindrics/dev/dogs-door/door_test.go:10 +0x39

Goroutine 9 (running) created at:
  github.com/Rindrics/dogs-door.TestDogDoor()
      /Users/Rindrics/dev/dogs-door/door_test.go:13 +0x164
  testing.tRunner()
      /usr/local/Cellar/go/1.19/libexec/src/testing/testing.go:1446 +0x216
  testing.(*T).Run.func1()
      /usr/local/Cellar/go/1.19/libexec/src/testing/testing.go:1493 +0x47

Goroutine 8 (running) created at:
  github.com/Rindrics/dogs-door.TestDogDoor()
      /Users/Rindrics/dev/dogs-door/door_test.go:10 +0xae
  testing.tRunner()
      /usr/local/Cellar/go/1.19/libexec/src/testing/testing.go:1446 +0x216
  testing.(*T).Run.func1()
      /usr/local/Cellar/go/1.19/libexec/src/testing/testing.go:1493 +0x47
==================
Found 1 data race(s)
FAIL	github.com/Rindrics/dogs-door	0.366s
FAIL

The warnings we’ve got tells us the program caused a “data race”, one of the most difficult bugs to debug.

One of the options to resolve this is using “channel”, as explained in the official document.

Do it by using channel

To update the property of the same DogDoor instance, the process executed in the second goroutine must be performed AFTER that by the first one is completed.

Using channels, we can ensure the execution order of processes in the following manner:

// door.go
package main

import (
        "fmt"
        "time"
)

type DogDoor struct {
        isOpen bool
}

func (d *DogDoor) Open(done chan bool) {
        fmt.Println("door opens...")
        d.isOpen = true

        time.Sleep(3 * time.Second)

        d.Close(done)
}

func (d *DogDoor) Close(done chan bool) {
        fmt.Println("door closes...")
        d.isOpen = false
        done <- true // tell channel that
                     //   the whole process is completed
}
// main_test.go
package main

import (
        "fmt"
        "testing"
)

func TestDogDoor(t *testing.T) {
        done := make(chan bool)      // create channel
        d := &DogDoor{}
        go d.Open(done)              // pass the channel
        fmt.Println("Fido goes out")
        <-done                       // ensure completion

        go d.Open(done)
        fmt.Println("Fido gets back")
        <-done
}

Run the test again:

$ go test -v -race ./hoge/...
=== RUN   TestDogDoor
Fido goes out
door opens...
door closes...
Fido gets back
door opens...
door closes...
--- PASS: TestDogDoor (6.00s)
PASS
ok    github.com/Rindrics/dogs-door/hoge	6.275s

Although we managed to resolve the data race, we’re facing another problem; the output order seems weird.

Fido passes through the door BEFORE it opens! Apparently Fido is allowed to pass through the door only when d.isOpen is true.

If we try to peek at the value of d.isOpen directly from outside the goroutine, however, we will reencounter the data race.

// main_test.go
package main

import (
        "fmt"
        "testing"
        "time"
)

func TestDogDoor(t *testing.T) {
        done := make(chan bool)
        d := &DogDoor{}
        go d.Open(done)
        for !d.isOpen {
                fmt.Println("(door is still closed)")
                time.Sleep(time.Second)
        }
        fmt.Println("Fido goes out")
        <-done

        go d.Open(done)
        for !d.isOpen {
                fmt.Println("(door is still closed)")
                time.Sleep(time.Second)
        }
        fmt.Println("Fido gets back")
        <-done
}

To avoid this, we need channel here as well.

// main_test.go
package main

import (
        "fmt"
        "testing"
        "time"
)

func TestDogDoor(t *testing.T) {
        done := make(chan bool)
        isOpen := make(chan bool)
        d := &DogDoor{}
        go d.Open(isOpen, done)
        for {
                if <-isOpen {
                        fmt.Println("Fido goes out")
                        break
                }
                fmt.Println("still not open")
                time.Sleep(time.Second)
        }
        <-done

        go d.Open(isOpen, done)
        for {
                if <-isOpen {
                        fmt.Println("Fido gets back")
                        break
                }
                fmt.Println("still not open")
                time.Sleep(time.Second)
        }
        <-done
}
// door.go
func (d *DogDoor) Open(isOpen, done chan bool) {
        fmt.Println("door opens...")
        d.isOpen = true
        isOpen <- d.isOpen

        time.Sleep(3 * time.Second)

        d.Close(done)
}

Now our test runs perfect!

$ go test -v -race ./hoge/...
=== RUN   TestDogDoor
door opens...
Fido goes out
door closes...
door opens...
Fido gets back
door closes...
--- PASS: TestDogDoor (6.00s)
PASS
ok      github.com/Rindrics/dogs-door/hoge	6.364s

Conclusion

Channels are a reassuring ally that allows us safely execute multiple goroutines with data updates.


  1. Brett McLaughlin, Gary Pollice, David West (2006) O’Reilly Media, Inc. ↩︎


comments powered by Disqus