Skip to main content

mtfelisb

Introduction to Goroutines

How to invalidate the cache? There is a better name? Long-lived questions in computer science that every programmer had faced or will at some point in time. Concurrent code has also some well-known pitfalls, the necessity of synchronization and communication between processes created a model used by languages like Java and C++, where the programmer must share memory to communicate leading to another set of problems like deadlocks, starvation, and livelocks. This introductory article aims to explain which strategies and patterns are used by Golang in order to achieve concurrency through goroutines.


Sequential Programming

Given the same input, a sequential program will execute the same instructions and produce always the same results. This is determinism. The same order the programmer read the code is the same order it will be executed. The code below illustrates it. The main function will call the slowOp and after two seconds it will print slowOp finished, then print main finished. Every time that it runs.


package main

import (
	"fmt"
	"time"
)

func slowOp() {
	time.Sleep(2 * time.Second)
	fmt.Println("slowOp finished")
}

func main() {
	slowOp()
	fmt.Println("main finished")
}

By adding the keyword go before the invocation of slowOp, the function is going to be scheduled by the Go’s Scheduler as a goroutine. This produces now a non-deterministic result since there is nothing telling the main goroutine to wait on.


Goroutine

A goroutine could be compared to an OS thread, but a lightweight one. In fact, many goroutines can be executed within the same kernel thread. The Golang’s Scheduler uses a cooperative scheduler strategy called M:N Scheduler, where a number M stands for goroutines to a number N of OS Threads. This strategy enables a fast context switching and better utilization of resources since all cores can be used.


WaitGroup

Golang style of programming doesn’t enforce the use of low-level synchronization primitives as the norm but still has these available in the sync package, offering tools such as WaitGroup and Mutex.

Using a WaitGroup, the code works now how it’s expected to, by explicitly saying to wait for one task to be performed before finishes the main goroutine.


package main

import (
	"fmt"
	"sync"
	"time"
)

func slowOp(wg *sync.WaitGroup) {
	defer wg.Done()
	time.Sleep(2 * time.Second)
	fmt.Println("slowOp finished")
}

func main() {
	var wg sync.WaitGroup
	wg.Add(1)

	go slowOp(&wg)
	fmt.Println("main finished")

	wg.Wait()
}

Concurrent Programming

In the opposite way of sequential code, the outcome of a concurrent code is non-deterministic, since there is no guarantee of which order the goroutines will be scheduled and executed. These characteristics lead to synchronization problems. However, there are well-known techniques to write concurrent code safely, and they have tradeoffs.


Mutex

The traditional strategy to synchronize access to the state is by using Mutex. Before the read instruction, the programmer must invoke the exclusive access to the state, calling Lock, and then release the exclusiveness by calling Unlock, manually.


package main

import (
	"fmt"
	"sync"
)

type safeCounter struct {
	v   int
	mux sync.Mutex
}

func main() {
	var wg sync.WaitGroup
	c := safeCounter{v: 0}

	for i := 0; i < 500; i++ {
		wg.Add(1)

		go func(c *safeCounter) {
			c.mux.Lock()
			defer c.mux.Unlock()
			c.v++
			wg.Done()
		}(&c)
	}

	wg.Wait()

	fmt.Printf("%d\n", c.v) // 500
}

Go’s Philosophy

Don’t communicate by sharing memory, share memory by communicating.


Go follows the CSP (Communicating Sequential Processes) technique, created by the British computer scientist Charles Antony Richard Hoare. Fun fact: he also created the sorting algorithm quicksort. The paper, published in 1978, concluded that input, output, and concurrency should be treated as programming primitives. Through examples, he shows how complex problems could be solved in a simpler manner. The classical way to show synchronization problems, the Dining Philosopher, originate by Edsger Dijkstra, in 1965, was explained by Hoare using his technique.


Channels

To communicate and synchronize goroutines, the channels offer a useful and simple manner to accomplish that. When a channel is created using the make function, the reference of this data structure can be passed as an argument to another function, then, the reference made by both receiver and caller is the same. A channel can either receive a message or send it and also can be buffered or unbuffered. The buffered one will have a queue of messages inside of it, while unbuffered, the default, doesn’t have. This makes the sender block until the receiver has, int fact, received the message. The code below shows how to send a message to a channel, and how to naturally have a concurrent code written in Golang.


package main

import "fmt"

func sum(a int, b int, ch chan int) {
	ch <- a + b // send the result to the channel
}

func main() {
	ch := make(chan int)

	go sum(1, 5, ch)
	go sum(10, 50, ch)

	x, y := <-ch, <-ch // receive both messages

	fmt.Printf("x: %d; y: %d; sum: %d\n", x, y, x+y) // x: 60; y: 6; sum: 66
}

Conclusion

This introductory article presented how Go approaches concurrency, still having the primitives, but following the Hoare’s techniques as a philosophy. The programmer doesn’t need to focus on how to manage threads, thread pool size, or trust in third-party libraries to handle the complexities of concurrent code. The main focus is the business rules to be applied.




There are advanced topics that deserve solo articles, such as Channels, Garbage Collection, Memory Management, Memory Allocation, and Golang’s Scheduler just to name a few. Given the complexity of these topics, the research needs to reach a deep level, so… I’ll be back.


References