One of Go's strength is its ability to perform concurrently (not parallelism). To go concurrent executions, we use the keywords: go
for initiate the goroutine and channel
for passing values between each processes.
Before we go straight into codes, we will go through some basics about multiple-processing (be it concurrency or parallelism). parallelism). From the very start, it is about managing simultaneous processes much like you're a platoon leader managing your company soldiers. There are many ways to do it.
The first thing to keep in mind when designing a multi-processing processes it to always keep a clear tracking with data flow path. Your tracking should always includes:
Due to the multiple processes exchanging information, you want to avoid simultaneous situations where one process is reading while the other is writing at the same time. This is known as anti-"race-conditions" rules. By keeping track WHO, WHEN, WHAT, and last resort policy, you know whether the multi-processes is buggy upfront.
Also, keep in mind that race condition bugs is one of the hardest bug to deal with since it requires runtime execution. Depending on complexity, it is usually hard to detect from codes.
The second thing is about contact switching, where 2 or more processes are exchanging information during their respective runs. Your tracking should always includes:
This is to avoid a dead-lock situation. A dead-lock scenario is when 2 processes waiting for one another but there is no condition to initiate from either sides. Hence, your overall process flow is locked at this step, waiting forever for the 2 processes to complete.
This type of bug is slightly easier than race condition bugs and can be easily cleared by running a mapping on the overall process flows. Here is an analogy example:
Multi-processing has various design models to work with. Among the known models are:
In practical design, one can use multiple models depending on stack location and its deployment. The details for each models are beyond this page.
goroutine
itself is using cooperative threading.go
, it creates a new thread but does not guarantee its starting timing.channel
for inter-process communicationsIn this page, we will look into how to initiate a thread and contact switching between them.
The very basic for multi-threading is:
Hence, the example would be:
package main
import (
"fmt"
)
func task(ch chan string) {
ch <- "Hello World"
close(ch)
}
func main() {
ch := make(chan string)
go task(ch)
fmt.Printf("doing something else\n")
s := <-ch
fmt.Printf("%v\n", s)
}
// Output:
// doing something else
// Hello World
Here is the phenomenons:
main()
creates a single channel for delegate to communicate back to itself.ch := make(chan string)
main()
then proceed to delegate an execution into the thread, called task(...)
using go
keyword.go task(ch)
task(...)
is executed in a separated thread.func task(ch chan string)
main()
proceed to do something else immediately after the delegation.fmt.Printf("doing something else\n")
main()
needs to wait for the delegate to signal its ends. the main()
waits for the return signal.s := <-ch
task(...)
completed its work and send the result back to main()
via the channel.ch <- "Hello World"
task(...)
closes the channel since there is nothing left to send.close(ch)
main()
receives the input from the channel and save it into variable s
.s := <-ch
main()
continues the remaining work and exit the program maturely.fmt.Printf("%v\n", s)
Some important points to remember:
s := <-ch
)ch <- "Hello World"
)main()
uses s := <-ch
to wait for delegates to signal back.Although Go does not enforce one to close the channel, the threading design model does. Just because you are given the privilege not to close the channel does not mean you can kick the can down to the drain, expecting the garbage collector to figure out the messes. If there are large data inside the channel, you might be hogging the memory until the collector cleanses the channel memory.
To close a channel, simply use the keyword close(channel)
(e.g. close(ch)
shown above).
Figuring out closing a channel is actually not hard. There are 4 axioms specified by Dave Cheney:
nil
channel blocks forevernil
channel blocks foreverThere is no way to check a channel is closed or not without reading a value from it. Also, a channel is designed for single direction. Performing half-duplex or duplex communications requires a higher level of communications understanding like daisy-chain mechanism etc, which is outside of the scope for this page.
Based on the axioms, it is always the best practice to close the channel from sender.
However, for "single receiver - multiple sender" case, it works differently.
That being said, we have a 4 types of scenarios for setting up the channels:
Generally speaking, if you have multiple receivers, it signals you that you have a smelly communications design problem in you threading model. As analogous to our daily life, you don't accept multiple messages at a time over a single person: you get confused and not paying attention to it.
Therefore, #2 and #4 are not feasible. If you have such designs, you need to rethink your threading models and map out your delegation properly.
Hence, we always have single receiver, single/multiple senders:
Now, let's expand the from the single receiver - multiple sender. There are various ways to do it.
Here, you assign which thread responsible for which channel from the start. Here is an example:
package main
import (
"fmt"
"time"
)
func fibonacci(c, quit chan int) {
x, y := 1, 1
for {
select {
case c <- x:
x, y = y, x+y
case <-quit:
fmt.Println("fibo: quit")
close(c)
return
case <-time.After(5 * time.Second):
fmt.Println("fibo: run timeout")
default:
fmt.Println("fibo: run default")
}
}
}
func subroutine(c, quit chan int) {
for i := 0; i < 10; i++ {
fmt.Println(<-c)
}
quit <- 0
close(quit)
}
func main() {
c := make(chan int)
quit := make(chan int)
squit := make(chan int)
go subroutine(c, squit)
go fibonacci(c, quit)
<-squit
quit <- 0
close(quit)
}
Here is the phenomenons:
main()
creates 3 channels, the sender -> receiver channels (c
, squit
) and the receiver -> sender channel (quit
).c := make(chan int)
quit := make(chan int)
squit := make(chan int)
main()
proceed to run all delegates, both subroutines(...)
and fibonacci(...)
go subroutine(c, squit)
go fibonacci(c, quit)
main()
waits for the squit
signal from subroutines(...)
.<-squit
fibonacci(...)
calculates the value and send to sender->receiver channel c
.case c <- x:
x, y = y, x+y
subroutine(...)
reads the data from sender->receiver channel c and printout for 10 times.for i := 0; i < 10; i++ {
fmt.Println(<-c)
}
subroutine(...)
, tasked to close the channel squit
, send quit signal to sender->receiver channel squit
and then close the channel.squit <- 0
close(quit)
main()
, tasked to close the channel quit
, receives squit
signal, and then send quit
signal to close fibonacci(...)
.quit <- 0
fibonacci(...)
, tasked to close sender->receiver channel c
, receives quit
signal and executes its exit protocol if main()
is still alive.case <-quit:
fmt.Println("fibo: quit")
close(c)
return
main()
exit program, killing both fibonnacci(...)
and subroutine(...)
.Some important points to remember:
This is rarely used but workable, similar to sentinel looping approach. You have 1 thread (usually main()) that is responsible for all channels lifespan and communications while having the rest of the threads to do the work.
Another method is to have a clear plan that maps the concurrency execution and data access. This way, it can achieves all the objectives mentioned earlier. Here is a simple plan for the Philosopher & Chopstick problem.
There are 5 philosophers, 5 sets of chopsticks between them. Only 2 philosophers are allowed to eat at a time, and they can only eat a maximum of 3 times, reaching fullness.
Plan 1
Plan 2
Plan 3
Plan 4
If you notice, in Plan 1, it serves as an overview of the program, testing whether I’m understanding the problem correctly or otherwise. It is cleared that we have at least 2 main process structures (main
as “host”, p
as philosopher process). The arrows (with some errors) indicate data exchanges between main
and any p
processes.
However, it is still unclear how would I approach the synchronization, so we need to improve the plan.
In Plan 2, a more improved version, we can now clearly see that host
is actually controlling who can eat, offers a request
function to either return nil
to deny eating or a chopstick
to allow eating.
Also, p
will check his/her fullness and inform back to host
that he/she will no longer eats. However, we did not take that into account in both Plan 1 and Plan 2, so we need to improve it again.
In Plan 3, it is very clear now with roles and synchronization. The table is synchronized using mutex locking in order to offer request
function. It has clarity on when to lock and unlock during checking.
Also, main
setup a channel approach (c1
) after table for synchronizing with all philosophers regarding of their fullness before serving them. Hence, we understood that step 6 in main
is a for...select
mechanism for their waiting. Hence, at step 5, we are clear that each philosopher will take in c1
as a parameter in order to inform host of his/her fullness.
On p
side, it is clear that philosopher knows whether he/she can eat by checking the return value of the request
function. Without chopstick, repeat the request
again. Since we’re using c1
channel, it made it easy to infrom the host before completing the process.
With Plan 3, you can now know how-to and what-to code. If you notice:
However, we are still unclear with
contact switching
between main
, table
, and p
. (WHO is doing WHAT at WHEN)Hence, we refactor the plan again.
In Plan 4, it has very clear contact switching, and explanation on WHO is doing WHAT at WHEN.
We can now clearly see that table
is responsible to setup and managing chopsticks
, offering contacting switching functions like Request
and Return
to p
. The mutex locking happens only at table
.
p
only needs to setup his/her fullness
limit, use Request
to get a chopstick from table, start eating, stop eating etc and Return
to free the chopstick back to table and check for its fullness
. If full
, then he/she informs host
by sending his/her name
to the fullness c1
channel.
main
(host) only needs to know the headcount
, setup table
, the fullness channel c1
, serve the philosophers (p1
, p2
, …), then process the waiting (for..select
) by checking the fullness report is the same has headcount
.
At any point, any time, you can use this map to explain WHO is doing WHAT at WHEN. This is the best indicator that you’re ready to code. You can continue to refactor your plan. However, Plan 4 is good enough for coding.
Here's an example codes based on Plan 4.
package main
import (
"fmt"
"sync"
)
const (
maxEating = 2
fullnessLimit = 2
)
// Chopsticks is an object structure that serves as a tool to eat
type Chopsticks struct {
mutex sync.RWMutex
ID int
isUsed bool
}
// NewChopsticks creates a Chopsticks object with the given id label.
func NewChopsticks(id int) *Chopsticks {
return &Chopsticks{
mutex: sync.RWMutex{},
ID: id,
isUsed: false,
}
}
// Use is to set the Chopsticks status to "In-Use"
func (c *Chopsticks) Use() {
c.mutex.Lock()
defer c.mutex.Unlock()
c.isUsed = true
}
// Free is to set the Chopsticks status to "Free"
func (c *Chopsticks) Free() {
c.mutex.Lock()
defer c.mutex.Unlock()
c.isUsed = false
}
// IsInUse is to check the Chopsticks status, currently "In-Use" or "Free".
func (c *Chopsticks) IsInUse() bool {
c.mutex.Lock()
defer c.mutex.Unlock()
return c.isUsed
}
// Table is the structure serving foods and chopsticks
type Table struct {
mutex sync.RWMutex
isEating uint
chopsticks []*Chopsticks
}
// NewTable creates the table object with person quantity.
func NewTable(quantity uint) *Table {
t := &Table{
mutex: sync.RWMutex{},
isEating: 0,
chopsticks: []*Chopsticks{},
}
for i := 0; i < int(quantity); i++ {
t.chopsticks = append(t.chopsticks, NewChopsticks(i))
}
return t
}
// RequestChopsticks is to allows a customer to eat using an available
// Chopsticks.
func (t *Table) RequestChopsticks() *Chopsticks {
t.mutex.Lock()
defer t.mutex.Unlock()
// table is full
if t.isEating >= maxEating {
return nil
}
// permit to eat. Scan for available chopsticks
c := t.seekChopsticks()
c.Use()
t.isEating++
return c
}
func (t *Table) seekChopsticks() *Chopsticks {
// NOTE: here, you can use random. I will use FIFO instead.
for _, c := range t.chopsticks {
if !c.IsInUse() {
return c
}
}
return nil
}
// ReturnChopsticks is to allow a customer to place back chopsticks when
// he/she is done eating
func (t *Table) ReturnChopsticks(c *Chopsticks) {
t.mutex.Lock()
defer t.mutex.Unlock()
t.isEating--
c.Free()
}
func philosopher(id int, fullness chan int, table *Table) {
eatCount := fullnessLimit
for {
chopsticks := table.RequestChopsticks()
if chopsticks == nil {
continue
}
// start eating
fmt.Printf("P%d: START eating with chopstick %d.\n",
id,
chopsticks.ID)
// eating
eatCount--
// stop eating
fmt.Printf("P%d: FINISH eating with chopstick %d.\n",
id,
chopsticks.ID)
table.ReturnChopsticks(chopsticks)
// check fullness
if eatCount == 0 {
fullness <- id
return
}
}
}
func main() {
headcount := 5
t := NewTable(uint(headcount))
c1 := make(chan int)
fullness := 0
for i := 0; i < headcount; i++ {
go philosopher(i, c1, t)
}
// Wait for fullness
for {
select {
case person := <-c1:
fullness++
fmt.Printf("Philosopher %d is full\n", person)
if fullness == headcount {
fmt.Printf("All are full.\n[ ENDED ]\n")
return
}
}
}
}
// Output (varies):
// P4: START eating with chopstick 0.
// P4: FINISH eating with chopstick 0.
// P4: START eating with chopstick 0.
// P4: FINISH eating with chopstick 0.
// Philosopher 4 is full
// P0: START eating with chopstick 0.
// P0: FINISH eating with chopstick 0.
// P0: START eating with chopstick 0.
// P0: FINISH eating with chopstick 0.
// Philosopher 0 is full
// P1: START eating with chopstick 0.
// P1: FINISH eating with chopstick 0.
// P1: START eating with chopstick 0.
// P1: FINISH eating with chopstick 0.
// Philosopher 1 is full
// P2: START eating with chopstick 1.
// P2: FINISH eating with chopstick 1.
// P2: START eating with chopstick 1.
// P2: FINISH eating with chopstick 1.
// Philosopher 2 is full
// P3: START eating with chopstick 0.
// P3: FINISH eating with chopstick 0.
// P3: START eating with chopstick 0.
// P3: FINISH eating with chopstick 0.
// Philosopher 3 is full
// All are full.
// [ ENDED ]
That's all about concurrency in Go. Quite simple right?