A visual introduction to golang  concurrency and goroutines

A visual introduction to golang concurrency and goroutines

Add to bookmarks

Sat 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:

goroutine 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?

scheduled goroutine 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:

image2 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:

runtime direction

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:

runtime direction

  • 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 read main 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:

runtime direction

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!