Go Concurrency Explained: Goroutines & Channels
1. What is a Thread?
A thread is the smallest unit of execution within a process. Threads allow a program to perform multiple tasks concurrently by dividing the process’s workload among multiple threads. Each thread within a process shares the same memory space but operates independently, meaning it can run simultaneously with other threads, enabling parallel execution on multi-core processors.
2. Why Do We Need to Manage Threads Effectively?
Managing threads effectively is crucial because improper thread management can lead to several problems:
– Race Conditions: When multiple threads access shared data simultaneously without proper synchronization, it can lead to inconsistent or incorrect results.
– Deadlocks: Threads waiting indefinitely for resources held by each other can cause deadlocks, where none of the threads can proceed.
– Resource Contention: Overuse of threads can lead to contention for CPU, memory, and other resources, degrading system performance.
– Complexity: Managing threads increases the complexity of the code, making it harder to debug, maintain, and ensure correctness.
– Scalability: Without effective management, scaling the number of threads can lead to performance bottlenecks and increased system resource usage.
3. Goroutines
Goroutines are lightweight threads managed by the Go runtime. You can start a new goroutine by prefixing a function call with the go keyword.
Goroutines are efficient because they consume significantly less memory and are managed by the Go runtime, which optimizes their scheduling to maximize performance and scalability.
Let’s look into the example below:
package main import ( "fmt" "sync" "time" ) func performTask(id int, wg *sync.WaitGroup) { defer wg.Done() // Signals that this goroutine is done when it finishes fmt.Printf("Task %d starting\n", id) time.Sleep(time.Second) // Simulates work by sleeping for 1 second fmt.Printf("Task %d finished\n", id) } func main() { var wg sync.WaitGroup // Launch 5 goroutines for i := 1; i <= 5; i++ { wg.Add(1) // Increments the WaitGroup counter go performTask(i, &wg) // Starts a performTask goroutine } // Wait for all goroutines to finish wg.Wait() fmt.Println("All tasks completed") }
The performTask function simulates some work by printing a start message, sleeping for 1 second, and then printing a finished message.
sync.WaitGroup is a type provided by Go’s sync package. It’s a synchronization primitive that allows you to wait for a collection of goroutines to finish executing.
wg *sync.WaitGroup: This is a pointer to a sync.WaitGroup. By passing the pointer, the function can modify the WaitGroup in the calling context.
wg.Done() is a method of sync.WaitGroup that decrements the internal counter of the WaitGroup by one. This counter was previously incremented by calling wg.Add(1) when the goroutine was launched.
wg.Done() is crucial to signal that a goroutine has completed its work. Without it, the sync.WaitGroup counter will not be decremented (not reach zero), causing the program to wait indefinitely.
Using defer wg.Done() ensures that Done() is called even if an unexpected exit or error occurs in the goroutine.
In main function, the line var wg sync.WaitGroup declares a variable wg of type sync.WaitGroup
A loop is used to start 5 performTask goroutines. Each task is given a unique ID and a reference to the WaitGroup. The wg.Add(1) call increments the WaitGroup counter each time a new goroutine is started. The go performTask(i, &wg) call starts the performTask function in a new goroutine.
After launching all the goroutines, the wg.Wait() call blocks the main function until all the goroutines signal that they are done by calling wg.Done().
The output will show that all Task start almost simultaneously, each Task runs for about a second, and then they all finish, demonstrating concurrent execution.
4. Channels
Channels are Go’s way of communicating between goroutines. They allow goroutines to synchronize execution and communicate by passing values.
Channels can be unbuffered (synchronous) or buffered (asynchronous).
Unbuffered Channels
– Synchronous: Sender and receiver must be ready at the same time.
– Behavior: A send operation waits for a receive operation and vice versa.
Buffered Channels
– Asynchronous: Sender and receiver don’t need to be ready at the same time.
– Behavior: Values can be sent to the channel and stored in a buffer until the receiver is ready to get them.
package main import ( "fmt" "sync" "time" ) func performTask(id int, wg *sync.WaitGroup, ch chan<- int) { defer wg.Done() // Signals that this goroutine is done when it finishes fmt.Printf("Task %d starting\n", id) time.Sleep(time.Second) // Simulates work by sleeping for 1 second fmt.Printf("Task %d finished\n", id) ch <- id // Send the task ID to the channel } func main() { var wg sync.WaitGroup ch := make(chan int, 5) // Create a buffered channel with a capacity of 5 // Launch 5 goroutines for i := 1; i <= 5; i++ { wg.Add(1) // Increment the WaitGroup counter go performTask(i, &wg, ch) // Start a performTask goroutine } // Wait for all goroutines to finish go func() { wg.Wait() close(ch) // Close the channel after all goroutines have completed }() // Receive and print results from the channel for id := range ch { fmt.Printf("Task %d result received\n", id) } fmt.Println("All tasks completed") }
Let’s analyze the code.
1. How many Goroutines are there?
performTask Goroutines:
Each of these 5 goroutines runs the performTask function. They perform work (simulate by sleeping) and then send their task ID to the channel.
main Goroutine:
This is the initial goroutine running the main function. It is responsible for starting the 5 performTask goroutines and handling the channel communication.
Waiting Goroutine:
An additional goroutine is created in the main function using go func() { … }
This anonymous goroutine waits for all performTask goroutines to complete (wg.Wait()) and then closes the channel.
2. How does the code work?
Channel Creation:
ch := make(chan int, 5) creates a buffered channel with a capacity of 5. The buffer allows up to 5 values to be stored in the channel before blocking.
Sending Data to the Channel:
In the performTask function, ch <- id sends the task ID to the channel when the task is finished. This allows communication between the goroutine and the main function.
Closing the Channel:
After all goroutines have finished, a new goroutine is started to wait for all the tasks to complete (wg.Wait()). Once the tasks are done, it closes the channel using close(ch). Closing the channel is necessary to signal the end of data transmission.
Receiving Data from the Channel:
In the main function, a for loop iterates over range ch, which receives data from the channel. When the channel is closed, the loop terminates, as the range on a closed channel receives zero values and then stops.
In summary, goroutines and channels provide a powerful and flexible mechanism for handling concurrent tasks in Go, allowing goroutines to communicate and synchronize efficiently.
カテゴリー: