Golang Multithreading In-depth Crash Course — Part 1: Program, Process and Threads

Ben Meehan
8 min readFeb 2, 2024

--

Hey there! 👋 Welcome to a very in-depth Golang Multithreading Crash Course. Ever wondered how your computer effortlessly handles multiple tasks at once? That’s where programs, processes, and threads come into play. In this course I will be covering multithreading from start to finish with diagrams where ever possible and with real world case studies at the end. Hope you learn something new along the way :)

This first part covers some multithreading basics starting all the way from programs, processes, threads and how it all works in Golang.

Pre Requisites:

  1. Basics of Golang

Table of Contents:

  1. What is a program?
  2. Executing a program: Process
  3. Parallelizing a process: Threads
  4. Types of threads
  5. Multithreading in Golang
  6. Concurrency vs Parallelism

What is a Program?

In order to do some task, a computer must undertake a sequence of operations. Programs are basically these sequence of operations that need to happen.

We write programs in programming languages like Python or Java or Go and so on. These languages allow us to write out a program in plain english that we and our friends can understand. But this cannot be understood by the computer since all it knows is binary (0’s and 1's).

Converting our code to a machine understandable code is done by a special software called Compiler/Interpreter. It takes our code and turns it into a bunch of 0’s and 1's.

compiling a program

Now this is the code that you execute or share with others. For example, when you download Google Chrome, you are actually downloading the compiled machine code for Google Chrome.

Executing a Program: Process

Process is what a program becomes when you execute it. When you tell the computer to execute a program, the code is copied from the Disk to the RAM and a few resources are assigned to it, like a piece of your RAM, CPU and I/O.

Formally, In computing, a process is an instance of a computer program that is being executed by one or many threads. It is essentially a program in execution, and each process has its own memory space, resources, and state.

process

A process has a state, which reflects its current condition in the execution lifecycle. Common states include “running,” “waiting,” “ready,” and “terminated.

You can create as many processes from a program as you like. Each process operates independently of other processes. They are isolated from each other in terms of memory space, ensuring that one process cannot directly access the data or variables of another process. There are Inter-process communication mechanisms which allow processes to exchange data and synchronize their activities.

Parallelizing a Process: Threads

Let’s say a function in our code has made a request to download an image from a server hosted in US-East from India. It will take a while to complete. But in this time, instead of doing nothing, what if we can execute some other part of our code instead?

This is where threads come in to play. By breaking down a program into threads, specific tasks can be executed concurrently.

Formally, a thread is the smallest unit of execution within a process. It represents an independent flow of control, allowing a program to perform multiple tasks concurrently.

threads

Threads are often referred to as “lightweight processes” because they consume fewer resources compared to full processes. This is because threads within the same process share resources of the process, while still having their own stack memory and registers.

Threads can also exist in different states, such as “running,” “ready,” “blocked,” or “terminated” and also communicate with each other.

Types of Threads:

There are three types of threads that we need to be aware of.

User Level Thread:

In this type, the threads are created and maintained by the application process itself. The operating system underneath has no idea about these threads. For the operating system it all appears as one giant thread.

user threads

ULT is considered lightweight because the overhead associated with their creation and management is minimal. Switching between user-level threads typically doesn’t involve kernel intervention.

But ULT may not fully utilize multiple processor cores because the operating system sees only one thread per process, regardless of how many user-level threads are created within that process.

Kernel Level Thread:

In this type, the application process tells the operating system to create and maintain the threads. The operating system is full aware of the threads and manages their resources.

kernel threads

Kernel-Level Threads can take full advantage of multiple processor cores since the operating system is aware of and can schedule each thread independently.

KLT may be less portable across different operating systems due to variations in the implementations of kernel-level thread management.

Hybrid Threads:

Finally, there is a type which mixes both. One or more user level threads are mapped to a kernel level thread. This is the approach used in languages like Java and Go.

hybrid threads

Hybrid Threads attempt to strike a balance between scalability (utilizing multiple cores) and portability (across different operating systems).

Multithreading in Go:

Go was designed with multithreading in mind. It is really easy to create new threads in go. In go we use something called as go routines. It is similar to a thread but is much more lightweight.

The go runtime scheduler manages these go routines. Like I explained before, It uses the hybrid thread type approach. The go scheduler combines multiple go routines into a multiple kernel level threads.

To create a go routine, simply add the “go” keyword in front of a function. Here is an example of a multithreaded go program that prints the numbers up to 5.

package main

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

func printNumbers(prefix string, wg *sync.WaitGroup) {
defer wg.Done() // Decrease the counter when the goroutine completes

for i := 1; i <= 5; i++ {
time.Sleep(time.Millisecond * 100)
fmt.Printf("%s %d\n", prefix, i)
}
}

func main() {
var wg sync.WaitGroup

// Launch two goroutines to print numbers concurrently
wg.Add(2) // Set the counter to the number of goroutines

go printNumbers("Goroutine 1:", &wg)
go printNumbers("Goroutine 2:", &wg)

// Wait for all goroutines to finish
wg.Wait()

fmt.Println("Main Goroutine: All goroutines have completed.")
}
  • The printNumbers function prints numbers with a given prefix and simulates some work using time.Sleep.
  • The main function launches two goroutines concurrently using the go keyword.
  • The sync.WaitGroup is used to wait for both goroutines to finish before proceeding.
  • The main function waits for the completion of both goroutines using wg.Wait().

We will cover wait groups in detail in the future. But for now, just know that it is used to wait for all the threads to complete before exiting the program.

Sample Output:

Goroutine 2: 1
Goroutine 1: 1
Goroutine 1: 2
Goroutine 2: 2
Goroutine 2: 3
Goroutine 1: 3
Goroutine 1: 4
Goroutine 2: 4
Goroutine 2: 5
Goroutine 1: 5
Main Goroutine: All goroutines have completed.

As you can see both threads executed concurrently. While one thread was sleeping, the other thread executed without waiting.

The main function also executes in a separate main go routine. It is the parent of all other go routines. If the main go routine dies, all the other go routines are also killed.

It is important to note that unlike in other languages like Java, Go routines don’t have any identity and hence go routines have no notion of identity that is accessible to the programmer.

Concurrency is not Parallelism:

Lastly, I just want to talk about this phrase as it is used everywhere in the multithreading world.

Concurrency and Parallelism are basically two ways multiple threads can execute.

Concurrency:

Concurrency is a concept where multiple tasks are making progress simultaneously, but not necessarily executing at the exact same time. Concurrency doesn’t imply that tasks are executed simultaneously, but rather that progress is made on different tasks in overlapping time intervals.

concurrency

Concurrency is often used to improve the responsiveness of a system, particularly in cases where tasks may be waiting for external events, such as user input, I/O operations, or network communication. By allowing tasks to overlap in time, a program can remain responsive and make efficient use of system resources.

Parallelism:

Parallelism, on the other hand, involves executing multiple tasks at the exact same time, typically on multiple processors or cores. It’s a way to achieve concurrent execution by distributing the workload among multiple processing units.

parallelism

The goal of parallelism is to improve the overall performance and speed of a program by dividing the computation into smaller, independent parts that can be processed simultaneously.

— — — — — — —

Concurrency can exist without parallelism. For example, in a single-core processor, a program may be designed to handle multiple tasks concurrently through techniques like multitasking or asynchronous programming. While these tasks are making progress simultaneously, they are not executing in parallel, as there’s only one processor handling them sequentially. This is what happens in languages like JavaScript and Python.

In contrast, parallelism requires multiple processors or cores to execute tasks at the same time.

A go program can be both concurrent and parallel to handle multiple tasks concurrently and takes advantage of multiple processors or cores to execute those tasks simultaneously. This is done by the go scheduler which we will explore in detail in the next part!

“Things hardly ever work on the first try. We’ll make another, a better one.”
― Anthony Doerr, All the Light We Cannot See

--

--

Ben Meehan

Software Engineer at Razorpay. Sharing knowledge and experiences.