SPONSORED ADS

Golang concurrency tutorial: A beginner's guide

Last Updated Apr 05, 2023

Introduction

Concurrency is the ability to perform multiple tasks simultaneously. In other words, it allows for multiple operations to be executed, rather than one at a time. This can improve the efficiency and speed of a system, as well as allow for better use of available resources. Concurrency is a powerful feature for building scalable and efficient software systems.

In golang, concurrency is achieved through goroutines and channels. Goroutines address the challenge of executing code concurrently in a program, while channels tackle the problem of secure communication between concurrently running code.

Let's dive in.

Goroutines

Goroutines are building blocks in Go programs. Every Go program has at least one goroutine, which is called the main goroutine. It starts automatically when the program runs. You'll probably use more goroutines as you work on your program to help solve problems. Keep in mind that goroutines are lightweight and don't require much overhead beyond the allocation of stack space. They start with small stacks, which keeps them inexpensive, and can grow by allocating and freeing heap storage as needed. So, what exactly are goroutines?

Well, a goroutine is a function that is running concurrently alongside other code. You can start one simply by prefix the go keyword before a function:

func main() {
    go talk()
    // continue doing other stuffs
}

func talk() {
    fmt.Println("Hi from nodeepshit blog!")
}

You could also create function, execute and apply concurrency at the same time using anonymous function like this

go func() {
    fmt.Println("Hi from anonymous function")
}()

Nice and helpful!

How Goroutines differ from threads ?

Goroutines and threads are workers in your computer that can do different tasks at the same time.

Goroutines are like tiny workers that are super fast and don't need a lot of space to do their work. They are like ants that can work together to get things done quickly.

Threads are like bigger workers that need more space and take longer to do their work. They are like elephants that can do big tasks, but they take up a lot of space and can be slower.

Goroutines and threads have different ways of talking to each other and taking turns doing their work. Threads use other ways like locks and conditions (for example python and its Global Interpreter Lock aka GIL) while Golang Goroutines use channels to talk to each other.

Channels

Channels are used to communicate between goroutines. It is like a special tube that lets different Goroutines talk to each other. The sender Goroutine puts a message into the tube, and the receiver Goroutine takes the message out of tube.

You could also think about channel is like a river that carries information. You can put information into a channel and it will flow downstream, where it can be read out.

Creating a channel is very simple.

package main

import "fmt"

func main() {
    // Create a channel to hold string values
    messages := make(chan string)

    // Start a Goroutine that sends a message into the channel
    go func() {
        messages <- "beep boop!"
    }()

    // Read the message from the channel and print it
    msg := <-messages
    fmt.Println(msg)
}

When used as function parameters and return types, channels can be convert to only allow data to flow in one direction. This means you can tell a channel that only allows sending or only allows receiving.

You don't need to think ahead, just create a two-way channel as usual. When you pass it to a function that needs a read-only channel, it will automatically convert. Inside that function, Go ensures that the channel is read-only, and any attempt to write to it will result in a compile error.

// normal two-way channel of string
msg := make(chan string)

// beep expect a read-only channel as parameter (the direction of arrow)
func beep(msg <-chan string) {
    s := <-msg
    fmt.Println("I read value from read-only channel", s)
}

// boop, in the other hand, require write-only channel
func boop(msg chan<- string) {
    msg <- "here is a string"
    fmt.Println("I send value to the write-only channel")
}

// When calling, it will automatically convert
boop(msg) 
beep(msg)

By restricting the direction of data flow, you can create safer and more efficient code that avoids race conditions.

There are more to talk about channel, like buffering, closing, tip trick etc.. However let's move on for now. We will need a full blog post to go in deep with channel, which I will surely do later.

Mutexes

Channels are not the only way for goroutines to communicate in Go. Goroutines can also communicate through other synchronization primitives such as ....mutexes.

We can use a mutex to safely access data across multiple goroutines. So, how it work ?

How Mutexes work

Mutexes work by providing a locking mechanism that allows only one goroutine to access a shared resource at a time. Under the hood, a Mutex consists of a lock and a queue of goroutines waiting for the lock. When a goroutine requests the lock and it is already held by another goroutine, the requesting goroutine is added to the queue. When the lock becomes available, the next goroutine in the queue is unblocked and allowed to acquire the lock.

It's important to always acquire the lock before accessing the shared resource and release the lock as soon as the access is finished. (easy with the help of defer)

package main

import (
    "fmt"
    "sync"
)

// Our count value
var counter int

// This is our mutex
var mutex sync.Mutex

func inc() {
    // ensure that concurrent access to counter 
    // is properly synchronized
    mutex.Lock()
    defer mutex.Unlock()
    
    counter++
}

func main() {
    // The WaitGroup is used to wait for all goroutines 
    // to finish before printing our final value
    var wg sync.WaitGroup
    
    // Kick off 100 goroutines, each call our inc func
    for i := 0; i < 100; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            inc()
        }()
    }
    wg.Wait()
    fmt.Println("Final value:", counter) // 100
}

Mutexes are typically used to synchronize access to shared resources that are accessed by multiple goroutines.

Channels, on the other hand, are typically used to communicate values or signals between goroutines.

However, in many cases, the choice between mutexes and channels may not be clear. For example, you can use a mutex to protect a shared data structure while also using channels to communicate signals between goroutines that access the data structure.

Select statements

Select statements are another synchronization construct in Go that allow you to multiplex multiple channels. Or in another words it is the glue that binds channels together. Think about cancellations, timeouts, waiting, and default values. (We will talk more about concurrency patterns with another blog post)

So how does select statements work ?

Under the hook, a select statement blocks until one of its cases can proceed, and then it executes that case.

Let's take an example.


done1 := make(chan interface{})
done2 := make(chan interface{})

// close timeout channel after waiting for 5 seconds
timeout := make(chan interface{})
go func() {
    time.Sleep(5*time.Second)
    close(timeout)
}()

// do something, close done1 when done.
go beep(done1)

// do other stuff, close done2 when done.
go boop(done2)

// which one come first win !
select {
case <-beepDone: 
    fmt.Println("ok beep done first")
case <-boopDone:
    fmt.Println("wow boop done first, beep lost")
case <-timeout:
    fmt.Println("ops!! timed out.")
}

In a select statement, the first channel that is ready to proceed wins. If no channel is ready, the select statement blocks until one of the channels becomes available. However, there may be cases where we need to execute some code while waiting for the channels to become available.

To handle this scenario, the select statement also supports a default clause. It executes almost instantaneously, allowing you to exit the select block without blocking.

select {
case <-c1:
case <-c2:
default:
    fmt.Printf("shit hit the fan !!!")
}

Well, for a beginner's guide this surely long enough.

To put it simply

Golan concurrency is achieved by breaking a program into multiple goroutines that can execute concurrently. These goroutines can exchange data or coordinate their work using channels. In more complex scenarios, multiple channels can be combined using the select statement to create a more robust communication system. Additionally, goroutines can share resources using mutexes, which provide a way to synchronize access to shared data.

By using this combination of goroutines, channels, and mutexes, Golang provides a powerful and flexible way to write efficient and scalable concurrent programs. This approach making it easier to write concurrent programs in golang compare to other language.

Hi there. Nodeepshit is a hobby website built to provide free information. There are no chargers to use the website.

If you enjoy our tutorials and examples, please consider supporting us with a cup of beer, we'll use the funds to create additional excellent tutorials.

If you don't want or unable to make a small donation please don't worry - carry on reading and enjoying the website as we explore more tutorials. Have a wonderful day!