Goroutines and Channels
Goroutines
A goroutine is a lightweight thread of execution managed by Go's runtime. It enables concurrent programming by allowing multiple functions to run independently and simultaneously.
- Goroutines are much lighter than traditional threads.
- They share the same address space, making them efficient.
- Goroutines are started using the
go
keyword followed by a function call.
Lightweight
Golang’s goroutines are lightweight because they are designed to be more efficient than traditional operating system (OS) threads. This efficiency comes from several optimizations at the runtime level that allow millions of goroutines to run concurrently with minimal overhead.
- Start with a small stack (about
2 KB
)- OS threads typically start with
2 MB
. - This smaller stack means less memory is allocated upfront.
- OS threads typically start with
- Managed by the Go runtime scheduler in user space, not by the operating system.
- Go uses an M:N scheduling model, meaning M goroutines are multiplexed onto N OS threads.
- The scheduler decides when to pause and resume goroutines.
- Faster context switching.
- Because goroutine is user level, only a few registers and the stack pointer need to be saved.
- OS Threads require full CPU context to switch which make it slower.
- Efficient communication using channels.
Because golang use garbage collector there is a brief pause for GC to do full scanning. For some cases this brief pause will create big impact if we running low latency application with high traffic.
Example
package main
import (
"fmt"
"time"
)
func printMessage(msg string) {
for i := 0; i < 5; i++ {
fmt.Println(msg)
time.Sleep(100 * time.Millisecond)
}
}
func main() {
go printMessage("Goroutine 1") // Runs in a separate goroutine
go printMessage("Goroutine 2") // Another goroutine
printMessage("Main Function") // Runs in the main thread
}
- The printMessage function is executed concurrently by multiple goroutines.
- The main function itself runs as a goroutine.
Channels
A channel is a mechanism that goroutines use to communicate with each other and synchronize their execution. Channels enable the safe exchange of data between goroutines without the need for explicit locking.
- Channels are typed; you define the type of data they transmit.
- Use the
make
function to create a channel. - Use
<-
for sending (channel <- value
) and receiving (value := <-channel
) data. - Channels can be unbuffered (synchronous) or buffered (asynchronous).
Example
package main
import (
"fmt"
"time"
)
func sendMessage(ch chan string) {
time.Sleep(5 * time.Second)
ch <- "Hello from Goroutine" // Send data into the channel
}
func main() {
ch := make(chan string) // Create a channel of string type
go sendMessage(ch) // Start a goroutine
message := <-ch // Receive data from the channel
fmt.Println(message) // Output: Hello from Goroutine
}
- The sendMessage goroutine sends data to the channel.
- The main function waits to receive the data before proceeding.
Interaction Between Goroutines and Channels
- Sending Data: One goroutine can send data to a channel.
- Receiving Data: Another goroutine can block until it receives the data.
- Synchronization: Unbuffered channels act as synchronization points, ensuring one goroutine waits for the other.
Example
package main
import "fmt"
// Worker function to calculate sum
func calculateSum(numbers []int, result chan int) {
sum := 0
for _, num := range numbers {
sum += num
}
result <- sum // Send result to the channel
}
func main() {
numbers := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
// Create a channel to communicate results
result := make(chan int)
// Split work across two goroutines
go calculateSum(numbers[:len(numbers)/2], result) // First half
go calculateSum(numbers[len(numbers)/2:], result) // Second half
// Receive results from both goroutines
sum1 := <-result
sum2 := <-result
fmt.Println("Total Sum:", sum1+sum2) // Combine the results
}
- Two goroutines calculate partial sums concurrently.
- Results are sent back through the result channel.
- The main function waits to receive both
results
before combining them.
Buffered vs. Unbuffered Channels
Unbuffered Channels
- Default type.
- Sender blocks until a receiver is ready.
- Receiver blocks until a sender sends data.
- Ensures synchronization.
ch := make(chan int) // Unbuffered channel
Buffered Channels
- Allow multiple values to be queued in the channel.
- Sender only blocks when the buffer is full.
- Receiver only blocks when the buffer is empty.
ch := make(chan int, 5) // Buffered channel with capacity 5
Channel to Prevent Race Condition
Lets create a simplified example of data race in Golang. In here we want increase a counter
variable, but this variable will be accessed by many go routines.
var counter int
func increment() {
for range 1_000 {
counter++ // Race condition here
}
}
func main() {
for range 10 {
go increment() // 10 goroutines incrementing concurrently
}
time.Sleep(1 * time.Second)
fmt.Println("Final Counter:", counter)
}
➜ go run main.go
Final Counter: 6897
➜ go run main.go
Final Counter: 4626
As we can see above that this program returning incorrect counter
value. This is because each goroutine modifies counter
without synchronization, leading to data races and an incorrect value.
This can be fixed by using channel.
func increment(ch chan int) {
for range 1_000 {
ch <- 1 // Send increment request
}
}
func main() {
ch := make(chan int)
counter := 0
go func() {
for value := range ch {
counter += value // Safely update counter
}
}()
for range 10 {
go increment(ch)
}
time.Sleep(1 * time.Second)
close(ch)
fmt.Println("Final Counter:", counter)
}
➜ go run main.go
Final Counter: 10000
➜ go run main.go
Final Counter: 10000
By adding channel we make sure that only one goroutine (receiver) update the counter. This will ensure safe access to the counter
variable and return consistent correct value. This method is inline with Golangs's slogan below.
Do not communicate by sharing memory; instead, share memory by communicating.
Please do note that the example above is oversimplified. In the real word scenario you should use sync.WaitGroup
to wait all the goroutine to finish instead os using time.Sleep
.
Summary
- Goroutines provide concurrency, while channels facilitate communication and synchronization.
- Channels prevent race conditions by allowing data to flow safely between goroutines.
- Use unbuffered channels for synchronization and buffered channels for decoupled communication.