A key component of Go’s language architecture is concurrency, which enables programs to work on multiple tasks at once. Go offers strong concurrency support by default, mostly through goroutines and channels. Parallelism is the simultaneous execution of several entities, while concurrency is a form of arranging software components for independent execution. Even in the absence of actual parallelism, a well-designed concurrent program can enhance its maintainability and structure.
Multiple Goroutines in Golang
A goroutine is a function that can execute in parallel to other functions. There is at least one goroutine, or main function, at the beginning of every Go program.
Key aspects of multiple goroutines
Lightweight and Easy to Create: Unlike conventional operating system threads, goroutines are light enough to be easily produced in large quantities (thousands or even hundreds of thousands) with negligible overhead. Launching a new goroutine is as easy as using the go keyword and a function invocation. Either an anonymous (inline) function or a regular named function may be used.
Immediate Return: The program doesn’t wait for the goroutine to finish when a function is called as a goroutine; instead, it moves straight on to the following line of code.
Go Scheduler Management: The internal scheduling mechanism of the Go runtime, which multiplexes several goroutines into a smaller number of OS threads (an m:n scheduling model), is responsible for managing goroutines. The developer is mainly unaware of how complicated this mapping and scheduling are.
Non-Deterministic Execution Order: Understanding that you cannot predict the sequence in which goroutines will be performed is essential. Depending on the operating system’s scheduler, the Go scheduler, and the system load, their order may vary from run to run.
Program Termination: All of the program’s current goroutines are instantly terminated if the main goroutine, also known as the main function, completes its execution. Mechanisms to guarantee that other goroutines finish their work before main exits are necessary because of this.Sleep is not a workable answer.
Multiple Goroutines Code Example
Using a loop to launch several goroutines is illustrated in this example. Observe how time is being used, sleep in main keeps the program running long enough for goroutines to run
package main
import (
"fmt"
"math/rand"
"time"
)
// f is a function that prints numbers from 0 to 9, with a random delay
func f(n int) {
for i := 0; i < 10; i++ {
fmt.Println(n, ":", i)
amt := time.Duration(rand.Intn(250)) // Random delay between 0 and 249 ms
time.Sleep(time.Millisecond * amt)
}
}
func main() {
rand.Seed(time.Now().UnixNano()) // Seed the random number generator for varied delays
for i := 0; i < 5; i++ {
go f(i) // Launch 5 new goroutines, each running f with a different 'n'
}
// This is a simple, but not ideal, way to wait for goroutines to finish.
// Without it, main would exit immediately, terminating all goroutines.
time.Sleep(4 * time.Second)
fmt.Println("Main function exited.")
}
Output
4 : 0
0 : 0
1 : 0
2 : 0
3 : 0
3 : 1
1 : 1
3 : 2
2 : 1
0 : 1
4 : 1
3 : 3
1 : 2
0 : 2
2 : 2
3 : 4
3 : 5
4 : 2
2 : 3
1 : 3
1 : 4
0 : 3
0 : 4
1 : 5
4 : 3
3 : 6
0 : 5
2 : 4
3 : 7
4 : 4
1 : 6
4 : 5
3 : 8
2 : 5
1 : 7
0 : 6
4 : 6
4 : 7
1 : 8
4 : 8
2 : 6
3 : 9
4 : 9
0 : 7
2 : 7
1 : 9
0 : 8
2 : 8
2 : 9
0 : 9
Main function exited.
Explanation: The f function is executed by a new goroutine that is launched with each go f(i) command. the moment.In order to demonstrate the concurrent, non-deterministic execution of various goroutines, sleep calls within f (and the rand.Intn for variable delays) guarantee that the output from each goroutine interleaves. the moment.In order to prevent all other goroutines from printing their messages before the main goroutine exits too rapidly, sleep(4 * time.Second) in main is required.
Communication Need and Synchronization
Although starting numerous goroutines is simple, a crucial problem occurs when these running processes must communicate or exchange data. A race condition is a problem that can result in unanticipated or inaccurate outcomes when two or more goroutines attempt to access or alter a shared property at the same time.
Take a look at this instance, in which several goroutines increase a shared counter variable independently:
package main
import (
"fmt"
"time"
)
var counter = 0 // Shared variable
func incr() {
counter++ // Accessing and modifying the shared 'counter'
fmt.Println(counter)
}
func main() {
for i := 0; i < 20; i++ {
go incr() // Launch 20 goroutines
}
time.Sleep(time.Millisecond * 10) // Wait briefly for goroutines to execute
}
Output
3
2
7
5
4
1
8
9
10
11
12
13
14
15
16
17
18
19
20
6
Explanation: This code tries to increment the counter by using several incr goroutines at the same time. Because goroutine scheduling is non-deterministic, it is possible for the counter++ operation which entails reading, incrementing, and writing to be stopped. As a result, the behaviour is unclear and the ultimate number of the counter or the printed sequence might not be the anticipated 1 through 20 in order.
In order to tackle these issues, Go highlights a philosophy: “Don’t communicate by sharing memory, share memory by communicating”.
This principle guides the use of Go’s primary concurrency primitives:
Channels: Direct data flow between goroutines is made safe and synchronized using channels. An action is blocking until the other side is ready (for example, a sender waits for a receiver, and vice versa) and guarantees that only one recipient will get a value transmitted across a channel. Because of its built-in synchronisation, concurrent programming is safer and helps prevent race scenarios by limiting data access.
sync
Package Primitives: Although channels are the preferred means of orchestration and communication (“Channels orchestrate; mutexes serialise”), Go’s sync package also offers more conventional synchronisation primitives like sync.Mutex and sync.WaitGroup.
sync.Mutex
(Mutual Exclusion Lock): You can use a mutex to lock a portion of code so that only one goroutine can run it at once. Deadlocks may result from inappropriate use, however this is helpful in preventing concurrent access to shared resources.sync.WaitGroup
: This is how you wait for a group of goroutines to be finished. after launching a goroutine, you can increment (Add) the counter that a waitgroup maintains, and after a goroutine is finished, you can decrement (Done). After that, the Wait method ensures that all tracked goroutines have completed before the main program resumes by blocking until the counter hits zero.
Communication and Synchronization Code Example
This is a synced version of the counter example.To guarantee that the main goroutine waits for all incremental goroutines to finish, use WaitGroup:
package main
import (
"fmt"
"sync" // Import the sync package
)
var counter = 0 // Shared variable
var mutex sync.Mutex // Declare a mutex to protect the shared variable
func incr(wg *sync.WaitGroup) { // Accept a WaitGroup pointer
defer wg.Done() // Decrement the WaitGroup counter when the goroutine finishes
mutex.Lock() // Lock the mutex before accessing the shared variable
counter++
fmt.Println(counter)
mutex.Unlock() // Unlock the mutex after accessing the shared variable
}
func main() {
var wg sync.WaitGroup // Declare a WaitGroup variable
for i := 0; i < 20; i++ {
wg.Add(1) // Increment the WaitGroup counter
go incr(&wg) // Launch the goroutine
}
wg.Wait() // Block until the WaitGroup counter is zero
fmt.Println("Final counter value:", counter)
fmt.Println("Main function exited after all goroutines finished.")
}
Output
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Final counter value: 20
Main function exited after all goroutines finished.
Explanation: In this example, the main goroutine and the incr goroutines are coordinated using sync.WaitGroup.
var wg sync.WaitGroup
creates a variable calledWaitGroup
.wg.Add(1)
is called before eachgo incr(&wg)
to increment the internal counter of theWaitGroup
, signaling that a new goroutine has been launched.defer WG.Done()
inincr
makes sure that when the incr function (and consequently the goroutine) finishes,wg.Done()
is executed, decrementing the counter. After the surrounding function is finished, a function call is scheduled to be executed using thedefer
keyword.- Until the internal counter of the WaitGroup drops to zero, indicating that all
incr
goroutines have calledDone()
and completed their execution,wg.Wait()
inmain
blocks themain
goroutine.
Although the order of counter
increments may still vary due to the race condition on the counter
itself (which would ideally be protected by a mutex or managed via a channel in a production scenario to prevent data races), this approach allows all goroutines to finish their tasks and makes the program’s output more predictable regarding completion.
You can also read Parsing And Comparing Time And Dates In Go: A Deep Dive