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.
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.
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 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.
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
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")
}
}
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.
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.