A visual introduction to golang concurrency and goroutines
Add to bookmarksSat Jun 01 2019
Golang is a language well known for its amazing concurrency (not to be mistaken for parralellism) system using goroutines. Golang achieves this with goroutines and channels.
Prerequisites
- Basic understanding of the go syntax
What are Goroutines?
You can think of goroutines as threads but with a few key differences :
- Goroutines are lighter with an average size of 8kB
- Goroutines communicate with channels whereas thread communication is more complex
- Goroutine scheduling is managed by the go at runtime
- Goroutines do not have any identity
- Goroutines are run on real OS threads, the number of which is controlled by
GOMAXPROCS
Goroutine states
For this tutorial, it is helpful to think of goroutines as always being in one of the following states:
-Waiting: the goroutine is not blocked by a blocking statement but is not yet being executed by the scheduler. - Running: The goroutine is being executed by the scheduler - Blocking: The goroutine is being blocked by a blocking operation e.g channel, sleep, network operation. The scheduler moves on to the next available goroutine - Executed: The goroutine has been fully executed and exited. E.G main() function closing
Firing a goroutine
Looking at this code:
func main(){
s := "main"
go side()
for _,item := range s{
fmt.Println(string(item))
}
}
func side(){
s := "goroutine"
for _,item := range s{
fmt.Println(item)
}
}
It outputs
//Output
m
a
i
n
What happens here?
The
side()
function is being called to run as a goroutine but we do not receive output from it yet, why? The goroutine()
is still on ready and the scheduler does not run it throughout the app's runtime.
If we make a change like this:
...
func main(){
s := "main"
go side()
for _,item := range s{
fmt.Println(string(item))
}
time.Sleep(time.Millisecond)
}
...
This is what happens:
The call to
time.Sleep
(a blocking statement) in the main goroutine put's that goroutine in a blocked state. That allows the go scheduler run any other scheduled goroutines that are ready to be run, in this case the goroutine() is executed.
From this, you see that go does not run a goroutine until it is scheduled to. And how do you get a goroutine to run? By using a getting all other goroutines lined up before it to run or blocking it.
Multiple goroutines.
Considering this code:
func main(){
go sayWord("goroutine1")
go sayWord("goroutine2")
sayWord("Main")
time.Sleep(time.Millisecond)
fmt.Println("End of main")
}
func sayWord(word string){
for i := 0;i< 2; i++ {
fmt.Println(word)
}
}
//Output
//Main
//Main
//goroutine1
//goroutine1
//goroutine2
//goroutine2
//End of main
This is what's going on:
Channels Intro
Think of channels as pipelines between goroutines. A channel can be sent data ( one type) and read from.
Channels are represented as green arrows in this tutorial.
Declaring a channel
A channel can be made with the statement:
pipe := make(chan string)
We can now send and receive data on it with:
//Send hello to it
pipe <- "Hello"
go goroutine(pipe)
//Receive hello from it
func goroutine(pipe chan string){
hello := <- pipe
}
Unidirectional Channel
You can also set a channel as an input only or output only channel depending on the direction of the arrow when you initialize it:
func goroutine(in <-chan string, out chan<- string){
received := <-in
out <- "processed"
}
Channels are blocking by default.
When data is written to a channel channel <- "Hi"
, that goroutine is blocked until some other goroutine reads from that channel.
NOTE: If no other goroutine reads from it, a Deadlock error occurs and the program crashes.
Similarly, if you are trying to read data from a channel when there is none, that goroutine is blocked until some other goroutine sends data to that channel.
Consider this code:
func main() {
pipe := make(chan string)
go sayWord(pipe)
fmt.Println("start of main")
pipe <- "Hello"
fmt.Println("End of main")
}
func sayWord(in <-chan string) {
fmt.Println(<-in)
fmt.Println("Data received in sayWord")
}
//Output:
//start of main
//Hello
//Data received in sayWord
//End of main
This is what goes on:
- The
main
goroutine runs until data is sent into the unbuffered (more on this) pipe channel. A blocking operation - The
sayword
is run by the scheduler, once the data has been readmain
is moved to ready state main
completes its execution
Buffered channels
A buffered channel is created by passing a second parameter into the make() command, indicating the size of the buffer.
The difference between a buffered channel and an unbuffered channel includes:
- Pushing data to a buffered channel does not block the goroutine until it is filled
- Reading data from an unbuffered channel is non-blocking until it is empty.
You can think of an unbuffered channel as
make(chan T, 0)
Sample code
func main() {
pipe := make(chan int,3)
go consoleLog(pipe)
for i := 0; i < 3; i ++{
pipe <- i
}
fmt.Println("Done")
}
func consoleLog(in <-chan int) {
//Infinite loop to always read channel when running
for{
fmt.Println(strconv.Itoa(<-in))
}
}
//Output
//Done
As you can see the goroutine is never run. This is because the loop in the main
function only adds 3 items to a 3 capacity buffer, allowing the main
goroutine run without blocking. If we were to add 4 items to it:
func main() {
pipe := make(chan int,3)
go consoleLog(pipe)
for i := 0; i < 4; i ++{
pipe <- i
}
fmt.Println("Done")
}
...
//Output
//0
//1
//2
//3
//Done
- The
main
goroutine blocks on the fourth push to the channel - Control is given to the
consoleLog
goroutine which runs an infinite loop that empties out the channel. - The
consoleLog
goroutine blocks when it tries to read from an empty channel - Control is passed back to
main
which executes the rest of its code and exits the program
Range
The range
keyword can be used in place of the infinite loop in the code above. Like so:
func sayWord(in <-chan int) {
//Infinite loop to always read channel when running
for item := range in{
fmt.Println(strconv.Itoa(item))
}
}
The results would be the same.
Select
A select statement is like a switch
statement for channels. The things to note about the select statement is:
- It runs the first non-blocking case
- If more than one case is non-blocking, it runs one of it randomly
- If all cases are blocking, the whole block becomes blocking until one of the cases unblocks. Unless a default case is given (More on this)
Looking at the code:
func main() {
pipe := make(chan string, 2)
second := make(chan string, 2)
go goroutines(second, time.Second*3,"First goroutine")
go goroutines(pipe, time.Second*2,"Second goroutine")
select {
case res := <-pipe:
fmt.Println(res)
case res := <-second:
fmt.Println(res)
}
}
func goroutines(out chan<- string, wait time.Duration, name string) {
time.Sleep(wait)
out <- name
}
//Output
//Second goroutine
A breakdown of what happens:
Default case
If we were to update the code above with something like this
func main() {
pipe := make(chan string, 2)
second := make(chan string, 2)
go goroutines(second, time.Second*3,"First goroutine")
go goroutines(pipe, time.Second*2,"Second goroutine")
select {
case res := <-pipe:
fmt.Println(res)
case res := <-second:
fmt.Println(res)
default:
fmt.Println("None are ready")
}
}
The output would be None are ready
.
The select statement blocks (if all cases are blocking), if there is no available default case. If there is a default case, it runs that immediately.
Conclusion.
Hopefully, this article helped get a good grasp of goroutines, goroutine communication and how they are scheduled. I will be writing a follow-up article on the inner workings of channels to better understand how to use them co-ordinate your goroutines better.
Till then, try and play around with goroutines and explore different result, or you could even try and explore parallelism by setting the GOMAXPROCS
to the number of cores available in your CPU (Caution advised).
CHEERS!