Multithreading and Synchronisation in Go

Multithreading and Synchronisation in Go

A thread is the smallest unit of execution within a process, and it represents a single sequence of instructions that can be scheduled to run independently by the operating system.

Multithreading is a programming and execution model that allows multiple threads to exist within the context of a single process.

Within Go, lightweight threads, known as goroutines, are often used. These differ from traditional threads in the following ways:

  • Goroutines are managed by the Go runtime whereas threads are managed by the operating system

  • Goroutines can be thought of as an abstraction over threads making them computationally cheaper than threads

  • Goroutines run in the same address space, so access to shared memory must be synchronized

Goroutines

Goroutines can be created by just using the keyword go in front of the function being called.

package main

import "time"

func Order(food string) {
    for i := 0; i < 5; i++ {
        time.Sleep(100 * time.Millisecond);
        println("Order received: " + food);
    }
}

func main(){
    go Order("Ice cream");
    Order("Pizza");
}

If we remove the keyword go then we can predict a defined order .i.e Ice cream will be printed first followed by Pizza

With the keyword go however, Ice cream and Pizza can be printed in any order before or after each other depending upon the execution times

Communication between Goroutines

Channels

Channels are a typed conduit through which you can send and receive values with the channel operator, <-.

package main

import "time"

func Receive(ch chan int){
    for i := 0; i < 5; i++ {
        v := <- ch;
        println(v);
    }
}

func Send(ch chan int){
    for i := 0; i < 5; i++ {
        time.Sleep(100 * time.Millisecond);
        ch <- i;
    }
}


func main(){
    ch := make(chan int);
    go Send(ch);
    Receive(ch);
}

The above code will output numbers 0-4 in intervals of 100 ms. The integers are passed between goroutines using channel ch

v := <- ch // Receives data and stores it in `v`
ch <- i // Sends `i`

By default, sends and receives are blocking in nature until the other side is ready. This allows goroutines to synchronise without explicit locks or condition variables.

Buffered Channels

Channels can also be buffered by initialising it with buffer length.

package main

import "time"

func Receive(ch chan int){
    for i := 0; i < 5; i++ {
        v := <- ch;
        println(v);
    }
}

func Send(ch chan int){
    for i := 0; i < 5; i++ {
        time.Sleep(100 * time.Millisecond);
        ch <- i;
    }
}


func main(){
    ch := make(chan int, 10);
    go Send(ch);
    Receive(ch);
}

make(chan int, 10) initialises a buffer of length 10. Sends to a buffered channel are blocking in nature only when the buffer is full. Receives are blocking in nature when the buffer is empty.

Synchronisation of Goroutines

Mutex

Go's standard library provides mutual exclusion with sync.Mutex and its two methods:

  • Lock

  • Unlock

We can define a block of code to be executed in mutual exclusion by surrounding it with a call to Lock and Unlock

package main

import (
    "sync"
    "time"
)

type Counter struct {
    m sync.Mutex
    count int
}

func (c *Counter) increment() {
    c.m.Lock();
    c.count++;
    c.m.Unlock();
}

func (c *Counter) decrement() {
    c.m.Lock();
    c.count--;
    c.m.Unlock();
}

func main(){
    counter := Counter{count: 0};
    for i := 0; i<500; i++ {
        go counter.increment();
    }

    for i := 0; i<400; i++ {
        go counter.decrement();
    }

    time.Sleep(1*time.Second);
    println(counter.count);
}

Both functions increment and decrement acquire a lock on the counter before modifying its value. Hence, we get a predictable output of 100.

Wait Groups

A WaitGroup waits for a collection of goroutines to finish.

There are 3 key methods that are provided by sync.WaitGroup :

  • Add sets the number of goroutines to wait for

  • Done decrements the WaitGroup counter by one

  • Wait is blocking in nature until the WaitGroup counter is zero.

package main

import (
    "sync"
    "time"
)

func timedloop() {
    time.Sleep(3*time.Millisecond);
    println("Done: timedloop");
}

func main() {
    var wg sync.WaitGroup;
    for i := 0; i<3; i++ {
        wg.Add(1);
        go func ()  {
            defer wg.Done();
            timedloop();
        }()
    }

    wg.Wait();
    println("Done");
}

WaitGroup ensures that the main goroutine has to wait until all the other goroutines are finished.

The output comes out as follows:

Done: timedloop
Done: timedloop
Done: timedloop
Done