Concurrency in Go

Concurrency in Go

What is Concurrency?

Concurrency is about dealing with lots of things at once.

From the computer’s perspective , concurrency is about executing instructions from multiple processes without keeping the CPU idle. This is already happening at the OS level, with many processes running in an interleaved manner. Now in the sense of an application level, concurrency is the ability to run different parts of a application in an interleaved manner.

Difference Between Concurrency and Parallelism

So the difference between concurrency and parallelism, as captured by Robert Pike,

Concurrency is about dealing with lots of things at once. Parallelism is about doing lots of things at once.

I’d like to state this example as stated in this freecodecamp article, Concurrency can be thought of as multiple cars running in a multiple lane road overtaking each other at times, But parallelism is multiple cars running in their own different roads.

So now let’s look at this from a perspective of a low level CPU and an high level application program, Let’s now understand the concurrency using a diagram.

The above diagram indicates multiple flows of execution as depicted by different colours of execution in the diagram , black being the main flow of execution and the blue colour representing sub task of the main task that can be run independently of the main task.

  • Concurrency : Let’s say you have a single processor system, in that case the OS will interleave the execution of the flows as decided by the scheduler of the processor, i.e let's say the OS will start execution of blue flow, now since it’s a single processor it switches between the execution of instructions of the main flow and blue flow. Giving the overall picture of simultaneous execution.

  • Parallelism : Let’s say u have multiple CPU setup. In this case the main flow and the blue flow can be picked up by different CPU at the same time and executed simultaneously.

So the conclusion here is, concurrency gives an ILLUSION of simultaneous execution through context switching and other mechanism, Parallelism is TRUE simultaneous execution.

Now that we understand concurrency and parallelism, we observe that these concepts play a major role in application building as they will help improve the overall efficiency of our application. Majorly independent tasks can be run in a simultaneous fashion.

CSP Model

While concurrently running processes, there are different ways to handle concurrency.

  1. Use locks to safeguard the critical-section/shared-resource during operations and then release the lock after the operation has completed, making the critical-section/shared-resource available for other processes. This ensures that only one process has exclusive access to the shared-resource/critical-section at a given time.

  2. Communicate the messages via channels , i.e. no locks are required, we create a channel which is unidirectional/blocking in nature, meaning a writer to the channel will be blocked until a reader reads from the channel.This is CSP Model

Communicating Sequential Processes

CSP, or Communicating Sequential Processes, is a concurrency model that was developed by Tony Hoare in 1978. It describes concurrent systems as a collection of sequential processes.

In Hoare's CSP language, processes communicate by sending or receiving values from named unbuffered channels. Since the channels are unbuffered, the send operation blocks until the value has been transferred to a receiver, thus providing a mechanism for synchronisation.

The CSP model of concurrency communication supports Golang’s mantra of

Do not communicate by sharing memory; instead, share memory by communicating.

Go support for Concurrency

Go supports concurrency using go routines, channels.

Go Routines

Can be thought of as lightweight thread of executions, that can execute a piece of code/function in a concurrent manner with respect to main flow of execution.

Underneath the hood, Go routines are multiplexed onto a smaller number of operating system threads. This means that the Go runtime manages the scheduling and execution of Go routines internally, using a smaller pool of OS threads. As a result, the overhead of creating and managing OS threads is greatly reduced, contributing to the lightweight nature of Go routines.

package main

import (
    "fmt"
    "sync"
)

func add(a, b int, wg *sync.WaitGroup) {
    defer wg.Done()
    fmt.Println("Result of addition : ", a+b)
}

func subtract(a, b int, wg *sync.WaitGroup) {
    defer wg.Done()
    fmt.Println("Result of subtraction : ", a-b)
}

func multiply(a, b int, wg *sync.WaitGroup) {
    defer wg.Done()
    fmt.Println("Result of multiplication : ", a*b)
}

func divide(a, b int, wg *sync.WaitGroup) {
    defer wg.Done()
    if b != 0 {
        fmt.Println("Result of division :", a/b)
    } else {
        fmt.Println("Error : Cannot divide a number by 0")
    }
}

func main() {
    var wg sync.WaitGroup

// Add 4 methods to the WaitGroup
    wg.Add(4)

    go add(2, 2, &wg)
    go subtract(10, 5, &wg)
    go multiply(7, 3, &wg)
    go divide(6, 2, &wg)

// Wait for the execution of all the go routines added to the WaitGroup
    wg.Wait()

}

Output : 
Result of division : 3
Result of addition :  4
Result of subtraction :  5
Result of multiplication :  21
  • Code Explanation :

    • This piece of code defines 4 methods namely, add, subtract, multiply and divide, which implement their own logic.

    • In the main function we create a 4 different go routines by calling them using the keyword go

    • We use synchronisation utility WaitGroup which is provided by the builtin sync package, this is used to synchronise the working of the go routines and to indicate the main method execution to wait till all 4 go routines finish their respective execution.

    • Note : The output might not be the same for every time, as it depends on how context is switched between different go routines by the underlying OS.

Channels

Channels are a powerful feature for facilitating communication and synchronisation between goroutines. They provide a way for goroutines to send and receive values to and from each other, allowing for safe and efficient coordination of concurrent tasks.

Channels in Go are blocking by nature, i.e. sending to or receiving from a channel will block the sender or receiver until the other party is ready. Meaning if there is data in the channel, a sender wanting to send the data into the channel cannot go ahead with the send operation, until a receiver receives the already present data. This synchronises communication between gorountines.

package main

import (
    "fmt"
    "sync"
)

func producer(ch chan<- int, wg *sync.WaitGroup) {
    defer close(ch)
    defer wg.Done()
    for i := 0; i < 5; i++ {
        ch <- i // Send values to the channel
    }
    // Close the channel when done
}

func consumer(ch <-chan int, wg *sync.WaitGroup) {
    defer wg.Done()
    for v := range ch {
        fmt.Println("Received:", v) // Receive values from the channel
    }
}

func main() {
    var wg sync.WaitGroup
    ch := make(chan int) // Create an unbuffered channel

    wg.Add(2)

    // Start producer and consumer concurrently
    go producer(ch, &wg)
    go consumer(ch, &wg)

    wg.Wait()

    // Keep the main goroutine alive
    fmt.Println("Main goroutine exiting...")
}

Output : 
Received: 0
Received: 1
Received: 2
Received: 3
Received: 4
Main goroutine exiting...
  • Code Explaination

    • ch := make(chan int) is used to declare and initialise a channel of type integer.

    • The producer and consumer go routines are triggered , which produce and consume data from the channel respectively.

    • chan<- : Indicates that transmission into the channel, data sending operation

    • <-chan : Indicate data consumption from the channel, data receiving operation.

UnBuffered Channels

  • Channels by default are unbuffered in Go, meaning both the sender and the receiver should be ready for their respective operation.

  • When we send a value to an unbuffered channel, the sender will block until there is a receiver ready to receive the value.

  • Similarly, when we receive a value from an unbuffered channel, the receiver will block until there is a sender ready to send the value.

  • General syntax to create a Unbuffered channel

    • ch := make(chan <type>)

Buffered Channels

  • Buffered channels have some capacity, unlike unbuffered channels. Buffered channels can hold some values without blocking the sender. Once the buffer is full , a sender trying to send data to the channel is blocked until a receiver receives data from the channel, making the buffer space available.

  • General Syntax to create a Buffered channel

    • ch := make(chan <type>, size)

Resources

  1. https://go.dev/blog/codelab-share

  2. https://www.cs.princeton.edu/courses/archive/fall16/cos418/docs/P1-concurrency.pdf