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.
-
Brett McLaughlin, Gary Pollice, David West (2006) O’Reilly Media, Inc. ↩︎