Sign In
Sign In

Multithreading in Golang

Multithreading in Golang
Hostman Team
Technical writer
Go
30.09.2024
Reading time: 11 min

Single-threaded applications in Golang look like ordinary, sequentially executing code. In this case, all invoked functions are executed one after the other, passing the return value from the completed function as an argument to the next one. There are no shared data, issues with concurrent access (reading and writing), or synchronization.

Multithreaded Go applications parallelize the logic into several parts, speeding up program execution. In this case, the tasks are performed simultaneously.

In this article, we will create the logic for a simple single-threaded Go application and then modify the code to turn it into a multithreaded one.

Simple Application

Let's create a basic scenario where we have multiple mines, and inside, mining for ore takes place. In the code below, we have two caves, each containing a unique set of resources. Each cave has a mining progress state that indicates the number of digs performed inside the mine:

package main

import (
    "fmt"  // for console output
    "time" // for creating timeouts
)

func mining(name string, progress *int, mine *[]string) { 
    // using pointers to track the mining progress and mine contents
    if *progress < len(*mine) { 
        // checking if the mining progress is less than the mine size
        time.Sleep(2 * time.Second) // pause execution for 2 seconds, simulating the mining process
        fmt.Printf("In mine «%s», found: «%s»\n", name, (*mine)[*progress]) 
        // print the found resource and mine name to the console (notice how we dereference the pointer to the array)
        *progress++ // increment the mine’s progress
        mining(name, progress, mine) // repeat the mining process
    }
}

func main() {
    mine1 := []string{"stone", "iron", "gold", "stone", "gold"} // Mine #1
    mine1Progress := 0 // Mining progress for mine #1

    mine2 := []string{"stone", "stone", "iron", "stone"} // Mine #2
    mine2Progress := 0 // Mining progress for mine #2

    mining("Stonefield", &mine1Progress, &mine1) // start mining Mine #1
    mining("Rockvale", &mine2Progress, &mine2) // start mining Mine #2
}

In the example above, the mines are worked on one after another until completely exhausted. Therefore, the console output will strictly follow this sequence:

In mine «Stonefield», found: «stone»
In mine «Stonefield», found: «iron»
In mine «Stonefield», found: «gold»
In mine «Stonefield», found: «stone»
In mine «Stonefield», found: «gold»
In mine «Rockvale», found: «stone»
In mine «Rockvale», found: «stone»
In mine «Rockvale», found: «iron»
In mine «Rockvale», found: «stone»

Notice that Stonefield is completely mined first, followed by Rockvale. This sequential (single-threaded) mining process seems quite slow and inefficient.

You could assume that the reason is a lack of necessary equipment. If there is only one mining drill, you can't mine both caves simultaneously,  only one after the other.

In theory, we could optimize mining so that multiple drills work at the same time, turning resource extraction into a multithreaded process. Let's try doing that.

Goroutines

You can parallelize the execution of several tasks using what is called "goroutines" in Golang.

A "goroutine" is essentially a function that doesn't block the execution of the code that follows it when it starts running.

Calling such a parallel function is simple – you just need to add the keyword go before the function call.

func main() {
    // these functions will execute sequentially

    action()
    action()
    action()

    // these functions will start executing simultaneously right after they are called

    go anotherAction() // "go" is specified, so the code will continue without waiting for the function's results
    go anotherAction() 
    go anotherAction()
}

Now we can slightly modify our mining application:

package main

import (
    "fmt"
    "time"
)

func mining(name string, progress *int, mine *[]string) {
    if *progress < len(*mine) {
        time.Sleep(2 * time.Second)
        fmt.Printf("In mine «%s», found: «%s»\n", name, (*mine)[*progress])
        *progress++
        mining(name, progress, mine)
    }
}

func main() {
    mine1 := []string{"stone", "iron", "gold", "stone", "gold"}
    mine1Progress := 0

    mine2 := []string{"stone", "stone", "iron", "stone"}
    mine2Progress := 0

    go mining("Stonefield", &mine1Progress, &mine1) // added the "go" keyword
    go mining("Rockvale", &mine2Progress, &mine2)   // added "go" here as well

    for mine1Progress < len(mine1) && mine2Progress < len(mine2) { 
        // loop runs until mining progress in each mine matches its size
        fmt.Printf("Supply Center is waiting for miners to return...\n")
        time.Sleep(3 * time.Second) 
        // execute the code inside the loop every 3 seconds, printing a message from the "Supply Center"
    }
}

The console output from this code will differ, as the mining results will be interspersed:

Supply Center is waiting for miners to return...
In mine «Rockvale», found: «stone»
In mine «Stonefield», found: «stone»
Supply Center is waiting for miners to return...
In mine «Stonefield», found: «iron»
In mine «Rockvale», found: «stone»
Supply Center is waiting for miners to return...
In mine «Rockvale», found: «iron»
In mine «Stonefield», found: «gold»
In mine «Stonefield», found: «stone»
In mine «Rockvale», found: «stone»
Supply Center is waiting for miners to return...
In mine «Stonefield», found: «gold»

As you can see, mining in both caves is happening simultaneously, and the information about resource extraction is interspersed with messages from the "Supply Center," which is periodically produced by the main program loop.

However, to implement multithreading in real Golang applications, goroutines alone are not enough. Therefore, we will look at a few more concepts.

Channels

Channels are like "cables" that allow goroutines to communicate and exchange information with each other. This provides a special way to pass data between tasks running in different threads. Symbols like arrows (<-) are used to send and receive data in channels.

Here’s an example:

package main

import "fmt"

func main() {
    someChannel := make(chan string) // Create a channel

    go func() { // Create a self-invoking function to send a message to the channel
        fmt.Println("Waiting for 2 seconds...")
        time.Sleep(2 * time.Second)
        someChannel <- "A message" // Send data to the channel
    }()

    message := <-someChannel // The execution pauses here until a message is received from the channel
    fmt.Println(message)
}

Console output:

Waiting for 2 seconds...
A message

However, in this example, you can only send one message into the channel. To send multiple values, you need to specify the channel size explicitly:

package main

import (
    "fmt"
    "time"
)

func main() {
    someChannel := make(chan string, 2) // Create a buffered channel

    go func() {
        fmt.Println("Waiting for 2 seconds...")
        time.Sleep(2 * time.Second)
        someChannel <- "A message"
        fmt.Println("Waiting another 2 seconds...")
        time.Sleep(2 * time.Second)
        someChannel <- "Another message"
    }()

    message1 := <-someChannel
    fmt.Println(message1)

    message2 := <-someChannel
    fmt.Println(message2)
}

Console output:

Waiting for 2 seconds...
Waiting another 2 seconds...
A message
Another message

This is an example of blocking synchronization using goroutines and channels.

Channel Directions

Channels can be directional, meaning you can create a channel only for sending or only for receiving data, increasing type safety. For example, a channel can be both readable and writable, but you can pass it to functions with restrictions on how it can be used. One function may only be allowed to write to the channel, while another can only read from it:

package main

import "fmt"

// This function only sends data to the channel
func write(actions chan<- string, name string) {
    actions <- name
}

// This function only reads data from the channel
func read(actions <-chan string, execution *string) {
    *execution = <-actions
}

func main() {
    actions := make(chan string, 3) // Buffered channel with a size of 3
    var execution string

    write(actions, "Read a book")
    write(actions, "Clean the house")
    write(actions, "Cook dinner")

    read(actions, &execution)
    fmt.Printf("Current task: %s\n", execution)

    read(actions, &execution)
    fmt.Printf("Current task: %s\n", execution)

    read(actions, &execution)
    fmt.Printf("Current task: %s\n", execution)
}

Console output:

Current task: Read a book
Current task: Clean the house
Current task: Cook dinner

Non-blocking Channel Reads

You can use a select statement to avoid blocking when reading from a channel:

package main

import (
    "fmt"
    "time"
)

func main() {
    channel := make(chan string)

    go func() { // Self-invoking goroutine
        channel <- "Message received\n"
    }()

    // First select will hit the default section since the message hasn't arrived yet
    select {
    case message := <-channel:
        fmt.Println(message)
    default:
        fmt.Println("No messages")
    }

    time.Sleep(2 * time.Second) // Wait for 2 seconds

    // Second select will now receive the message from the channel
    select {
    case message := <-channel:
        fmt.Println(message)
    default:
        fmt.Println("No messages")
    }
}

Refined Application

Now that we know how to use goroutines and channels, let’s modify the previous mining application. In this scenario, we will have a "Supply Center" that launches the mining process for all available mines. Once the mining is done, each mine will notify the Supply Center that it's finished, and the Supply Center will then terminate the program.

In the following code, we create separate structures for the mines and the Supply Center:

package main

import (
    "fmt"
    "time"
)

type Mine struct {
    name      string    // Mine name
    resources []string  // Resources in the mine
    progress  int       // Mining progress
    finished  chan bool // Channel for signaling the completion of mining
}

type SupplyCenter struct {
    mines []*Mine // Array of pointers to all the existing mines
}

func dig(m *Mine) {
    if m.progress < len(m.resources) {
        time.Sleep(1 * time.Second)
        fmt.Printf("In mine \"%s\", found: \"%s\"\n", m.name, m.resources[m.progress])
        m.progress++
        dig(m)
    } else {
        m.finished <- true // Send a completion signal to the channel
    }
}

func main() {
    supply := SupplyCenter{[]*Mine{
        {"Stonefield", []string{"stone", "iron", "gold", "stone", "gold"}, 0, make(chan bool)},
        {"Rockvale", []string{"stone", "stone", "iron", "stone"}, 0, make(chan bool)},
        {"Ironridge", []string{"iron", "gold", "stone", "iron", "stone", "gold"}, 0, make(chan bool)},
    }}

    // Start the mining process for all created mines
    for _, mine := range supply.mines {
        go dig(mine)
    }

    // Wait for completion signals from all mines
    for _, mine := range supply.mines {
        <-mine.finished
    }

    // Once all mines are done, the program terminates
}

Sample output:

In mine "Rockvale", found: "stone"
In mine "Ironridge", found: "iron"
In mine "Stonefield", found: "stone"
In mine "Ironridge", found: "gold"
In mine "Stonefield", found: "iron"
In mine "Rockvale", found: "stone"
In mine "Ironridge", found: "stone"
In mine "Rockvale", found: "iron"
In mine "Stonefield", found: "gold"
In mine "Rockvale", found: "stone"
In mine "Stonefield", found: "stone"
In mine "Ironridge", found: "iron"
In mine "Ironridge", found: "stone"
In mine "Stonefield", found: "gold"
In mine "Ironridge", found: "gold"

You can verify that all resources were mined by counting the number of lines in the output. It will match the total number of resources in all the mines.

Conclusion

The examples in this tutorial are simplified, but they demonstrate the power of concurrency in Golang. Goroutines and channels provide flexible ways to manage concurrent tasks in real-world applications. It’s important to follow some basic principles to avoid complicating your program's logic:

  • Prefer channels over shared variables (or pointers) for synchronization between goroutines.

  • Choose appropriate language constructs to "wrap" concurrency primitives.

  • Avoid unnecessary blocking and ensure proper scheduling of procedures.

  • Use profiling tools (like the net/http/pprof package in Go) to identify bottlenecks and optimize performance when developing multithreaded applications.

Go
30.09.2024
Reading time: 11 min

Do you have questions,
comments, or concerns?

Our professionals are available to assist you at any moment,
whether you need help or are just unsure of where to start
Email us