Concurrency in GoLang

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

Overview

Imagine you are preparing a dinner that includes multiple dishes, such as boiling pasta, sautéing vegetables, and baking a cake. Each of these tasks can be considered a separate "thread" of execution. The pasta is boiling on the stove, the vegetables are cooking in a pan, and the cake is baking in the oven, all at the same time.

You, the cook, are managing all these tasks concurrently by switching your attention between them, checking on the pasta, adjusting the heat, stirring the vegetables, setting a timer for the cake, and so on. You are also using synchronization techniques, such as timing the pasta to be ready when the vegetables finish or keeping an eye on the cake so it doesn't burn. All these tasks, though independent and running at the same time, are eventually coordinated to finish at the same time, and all together make a complete meal ready to be served.

As explained in the above example, the way to do multiple tasks at the same time is called Concurrency. Concurrency is a key feature of the Go programming language, also known as Golang. It allows for the execution of multiple tasks simultaneously, making it a popular choice for building high-performance and scalable systems. In this blog post, we will discuss the basics of concurrency in Go and how it is implemented in the language.

What are Go-routines?

Go's concurrency model is based on goroutines, which are lightweight threads of execution. A goroutine is created by calling the built-in go keyword followed by a function call. For example, the following code creates a goroutine that prints "Hello, World!" to the console:

package main

func main() {
    go func() {
        fmt.Println("Hello, World!")
    }()
}

Advantages of using Go-routines

One of the key advantages of goroutines is that they are extremely lightweight and can be created and managed with minimal overhead. This makes it easy to create a large number of goroutines, which can run concurrently and take advantage of multiple CPU cores.

Go-routines can communicate and synchronize with each other using channels, which are built-in concurrency primitives in Go. Go-routines can be used for a wide range of concurrent programming tasks, from building high-performance servers to concurrent data processing.

Go-routines have built-in support for error handling, making it easy to handle and recover from errors in concurrent code.

The sync package

The "sync" package in Go is a collection of synchronization primitives that can be used to synchronize access to shared resources among goroutines. These primitives include:

  • Mutex (short for mutual exclusion): A Mutex is a type of lock that can be used to ensure that only one goroutine can access a shared resource at a time.

  • RWMutex (short for Read-Write Mutex): A RWMutex is a type of lock that allows multiple goroutines to read a shared resource simultaneously, but only one goroutine can write to it at a time.

  • WaitGroup: A WaitGroup is a synchronization primitive that can be used to wait for multiple goroutines to finish.

These primitives can be used to control the access to shared resources, and to coordinate the execution of goroutines. They provide a simple, efficient, and safe way to handle concurrency in Go.

Now, let's understand the usage of the sync package with an example:

package main

import (
    "fmt"
    "net/http"
    "sync"
)

func main() {
    // Declare an array of strings containing website URLs
    websiteList := []string{
        "https://www.google.com",
        "https://www.facebook.com",
        "https://www.amazon.com",
        "https://www.youtube.com",
    }

    // Create a WaitGroup variable
    wg := &sync.WaitGroup{}

    // Create a Mutex variable
    mut := &sync.Mutex{}

    // Create a slice of strings to store signals
    signals := make([]string, 0)

    // Iterate over the websiteList slice, creating a goroutine for each URL
    for _, site := range websiteList {
        // Launch a goroutine for each URL
        go getStatusCode(site, wg, mut, &signals)
        // Increment the WaitGroup counter
        wg.Add(1)
    }

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

    // Print the signals slice
    fmt.Println(signals)
}

func getStatusCode(endpoint string, wg *sync.WaitGroup, mut *sync.Mutex, signals *[]string) {
    // Decrement the WaitGroup counter when the function returns
    defer wg.Done()

    // Perform an HTTP GET request on the endpoint
    res, err := http.Get(endpoint)

    // Check for errors
    if err != nil {
        fmt.Println(err)
    } else {
        // Lock the mutex
        mut.Lock()
        // Append the endpoint to the signals slice
        *signals = append(*signals, endpoint)
        // Unlock the mutex
        mut.Unlock()

        // Print the status code of the response
        fmt.Printf("%d status code for %s\n", res.StatusCode, endpoint)
    }
}

This source code demonstrates the usage of concurrency and synchronization primitives provided by the Go standard library.

The code consists of two main parts: the main function and the getStatusCode function. The main function starts by defining an array of strings that contains a list of website URLs. Then it creates two variables, one of type sync.WaitGroup and another type sync.Mutex. It also creates a slice of strings that will be used to store signals.

Next, it uses a for loop to iterate over the websiteList array, creating a goroutine for each URL in the array. The goroutine calls the getStatusCode function, passing the URL, the WaitGroup variable, the Mutex variable and a pointer to the signals slice as arguments. The WaitGroup's Add method is also called to increment the WaitGroup's counter by one for each URL.

The getStatusCode function is responsible for performing an HTTP request to the provided URL and storing the endpoint URL in the signals slice, if the request was successful. The function starts by deferring the Done method of the WaitGroup, which decrements the WaitGroup's counter. Then, it uses the net/http package to perform an HTTP GET request on the endpoint URL. If the request is successful, it uses the Mutex variable to lock the access to the signals slice before appending the endpoint URL to it, this way only one goroutine can access the slice at a time. After this, it prints the status code of the response along with the endpoint URL.

Finally, the main function calls the Wait method on the WaitGroup variable to wait for all the goroutines to finish. It then prints the signals slice. This example demonstrates how concurrency can be used to perform multiple HTTP requests simultaneously in Go, using the WaitGroup primitive to coordinate the execution of the goroutines and the net/http package to perform the actual requests. It also demonstrates the usage of Mutex primitive to synchronize access to a shared resource (signals slice) among goroutines. This way, it ensures that only one goroutine can access the signals slice at a time and eliminates the race condition.

Channels for communication and synchronization

Goroutines communicate with each other using channels. A channel is a typed conduit through which you can send and receive values with the channel operator <-. For example, the following code creates a channel of type int and sends the value 42 through it:

package main

func main() {
    ch := make(chan int)
    go func() {
        ch <- 42
    }()
    fmt.Println(<-ch) // prints "42"
}

Channels can be used for synchronizing goroutines, as well. The select statement allows a goroutine to wait for multiple channels to become ready, and then execute the first one that is ready. This can be used, for example, to implement a timeout:

package main

func main() {
    ch := make(chan int)
    timeout := time.After(time.Second)
    go func() {
        time.Sleep(2 * time.Second)
        ch <- 42
    }()
    select {
    case x := <-ch:
        fmt.Println(x) // prints "42"
    case <-timeout:
        fmt.Println("timeout")
    }
}

Conclusion

Go's built-in support for concurrency and communication makes it easy to write concurrent programs that are also easy to reason about. This is especially true when compared to other concurrency models, such as threads and locks.

However, Go's concurrency model also has some limitations. For example, there is no built-in support for shared memory or thread-local storage. This means that if you want to share data between goroutines, you need to use channels or other synchronization primitives. Additionally, Go's garbage collector can cause pauses in program execution, which can be a problem for real-time systems.

In conclusion, Go's concurrency model is a powerful tool for building high-performance and scalable systems. Its lightweight goroutines and built-in support for communication make it easy to write concurrent programs that are also easy to reason about. However, it also has some limitations, such as the lack of built-in support for shared memory and thread-local storage, and the potential for garbage collection pauses.

10
Subscribe to my newsletter

Read articles from Swarnim Pratap Singh directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Swarnim Pratap Singh
Swarnim Pratap Singh

I'm a software developer and open-source contributor