In Go, context
is a package that allows you to pass request-scoped values, cancel signals, and deadlines across API boundaries to all the goroutines involved in handling a request.
It's primarily used in servers to manage the lifecycle of a request, especially to control the cancellation of goroutines that are no longer needed, which can help in saving resources and managing server load more efficiently.
A context.Context
is an interface with several methods, but the most commonly used are Done()
, Err()
, Deadline()
, and Value(key interface{})
.
When a context is canceled or times out, the Done()
channel is closed, signaling all goroutines watching this channel to stop their work and terminate. This mechanism is crucial for writing reliable, scalable, and concurrent code in Go.
Exercise 1: Basic Usage of Context for Cancellation
Objective: Learn to create a context and use it to cancel a goroutine.
Create a context with cancellation.
Start a goroutine that does some work (e.g., a loop) and listens to the context's
Done()
channel.Cancel the context after a short delay and observe the goroutine terminating.
Solution:
package main
import (
"context"
"fmt"
"time"
)
func doWork(ctx context.Context) {
for {
select {
case <-ctx.Done():
fmt.Println("Goroutine exiting...")
return
default:
// Simulate work
fmt.Println("Working...")
time.Sleep(500 * time.Millisecond)
}
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
go doWork(ctx)
// Cancel the context after 2 seconds
time.Sleep(2 * time.Second)
cancel()
// Wait a bit for the goroutine to finish
time.Sleep(1 * time.Second)
}
Exercise 2: Context with Timeout
Objective: Understand how to use context with timeouts to automatically cancel goroutines.
Create a context with a timeout.
Launch a goroutine that attempts to perform a task longer than the timeout duration.
See how the goroutine gets canceled automatically when the timeout expires.
Solution:
package main
import (
"context"
"fmt"
"time"
)
func doWorkWithTimeout(ctx context.Context) {
for {
select {
case <-ctx.Done():
fmt.Println("Goroutine canceled due to timeout")
return
default:
// Simulating work that takes longer than the context's deadline
fmt.Println("Working...")
time.Sleep(1 * time.Second) // This sleep represents work being done
}
}
}
func main() {
// Create a context that is canceled automatically after 3 seconds
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel() // It's good practice to call cancel even if the context expires
go doWorkWithTimeout(ctx)
// Wait enough time to observe the automatic cancellation
time.Sleep(5 * time.Second)
}
Exercise 3: Passing Context to Goroutines
Objective: Learn to pass a context to multiple goroutines and manage their lifecycle.
Create a parent context with cancellation.
Start multiple goroutines, each performing different tasks, and pass the same context to all.
Cancel the context and observe how all goroutines terminate.
Solution:
package main
import (
"context"
"fmt"
"math/rand"
"time"
)
// simulateWork simulates work by sleeping for a random amount of time
func simulateWork(ctx context.Context, workerID int) {
for {
select {
case <-ctx.Done():
fmt.Printf("Worker %d: Stopping due to cancellation\n", workerID)
return
default:
// Simulate doing some work
workTime := time.Duration(rand.Intn(1000)) * time.Millisecond
fmt.Printf("Worker %d: Working for %v\n", workerID, workTime)
time.Sleep(workTime)
}
}
}
func main() {
// Create a parent context with cancellation
ctx, cancel := context.WithCancel(context.Background())
// Start multiple goroutines, passing the same context to all
for i := 1; i <= 5; i++ {
go simulateWork(ctx, i)
}
// Wait for a short time before cancelling all goroutines
time.Sleep(3 * time.Second)
fmt.Println("Main: Cancelling context")
cancel()
// Wait a bit for all goroutines to receive the cancellation signal
time.Sleep(1 * time.Second)
}
Exercise 4: Cancelling Context Based on OS Signals
Objective: Learn how to use context cancellation in response to operating system signals such as SIGINT (Ctrl+C) and SIGTERM. This is particularly useful for gracefully shutting down applications or cleaning up resources before exiting in response to termination requests from the user or the system.
Create a context with cancellation. This context will be used to propagate the cancellation signal to your goroutines.
Set up signal handling for SIGINT and SIGTERM. Use the
os/signal
package to listen for these signals in your main function. When either signal is received, cancel the context.Launch a goroutine that performs a task indefinitely or for a long duration. Pass the context created in step 1 to this goroutine.
Observe the goroutine's behavior in response to the signal. When you send a SIGINT (Ctrl+C) or SIGTERM to the process, the context should be canceled, and the goroutine should stop its execution gracefully.
Solution:
package main
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"time"
)
// worker simulates a task that runs until the context is cancelled
func worker(ctx context.Context, id int) {
for {
select {
case <-ctx.Done():
fmt.Printf("Worker %d: Stopping\n", id)
return
default:
fmt.Printf("Worker %d: Running...\n", id)
// Simulate work
time.Sleep(1 * time.Second)
}
}
}
func main() {
// Create a context that can be cancelled
ctx, cancel := context.WithCancel(context.Background())
// Setup signal handling to catch SIGTERM and SIGINT (Ctrl+C)
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT)
go worker(ctx, 1)
<-sigChan // Wait for signal
fmt.Println("Signal received, canceling context...")
cancel()
// Wait for the worker to finish
<-ctx.Done()
fmt.Println("Main: Exiting")
}
Exercise 5: Combining Context and WaitGroup
Objective: Understand how to use context
for cancellation and WaitGroup
for synchronization together in a single application. This is useful for situations where you need to manage the lifecycle of multiple concurrent operations and also ensure that all operations have completed before proceeding.
Create a context with cancellation. This context will be used to signal goroutines when they should stop their work.
Initialize a WaitGroup. The
WaitGroup
will be used to wait for all goroutines to finish their work before the main function exits.Launch multiple goroutines, each performing some work. Pass the same context to all goroutines for cancellation and use the
WaitGroup
to wait for all of them to complete. Each goroutine should:Increment the
WaitGroup
counter at the start.Listen for the cancellation signal from the context to exit early if needed.
Decrement the
WaitGroup
counter upon completing its work or when exiting due to cancellation.
Cancel the context after a delay. This simulates a scenario where an operation needs to be canceled due to external conditions (like a timeout or user intervention).
Wait for all goroutines to finish using the WaitGroup. This ensures that the main function only exits after all goroutines have cleaned up properly.
Solution:
import (
"context"
"fmt"
"sync"
"time"
)
func worker(ctx context.Context, id int, wg *sync.WaitGroup) {
defer wg.Done() // Decrement the counter when the goroutine completes
for {
select {
case <-ctx.Done():
// Context was cancelled, exit the goroutine
fmt.Printf("Worker %d: Stopping due to cancellation\n", id)
return
default:
// Simulate doing some work
fmt.Printf("Worker %d: Working...\n", id)
time.Sleep(1 * time.Second)
}
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
var wg sync.WaitGroup
// Start multiple goroutines
for i := 1; i <= 3; i++ {
wg.Add(1) // Increment the WaitGroup counter
go worker(ctx, i, &wg)
}
// Cancel the context after a delay to simulate a cancellation scenario
time.Sleep(3 * time.Second)
cancel()
// Wait for all goroutines to finish
wg.Wait()
fmt.Println("Main: All workers have stopped")
}
How It Works:
The
context
provides a mechanism to cancel all goroutines at once, simulating a scenario where an operation is no longer needed or a timeout occurs.The
WaitGroup
ensures that the main function waits for all goroutines to finish their work or acknowledge the cancellation before exiting. This is crucial for avoiding premature termination of the program, ensuring that resources are properly released, and any necessary cleanup is performed.
Exercise 6: Detecting Leaks with Context and Without WaitGroup
Objective: Illustrate how the absence of WaitGroup
can lead to goroutines not completing their work as expected, potentially causing resource leaks or tasks to be left in an indeterminate state when the main program exits prematurely.
Scenario:
We'll simulate a scenario where several long-running tasks are initiated, but due to improper synchronization (i.e., not using a WaitGroup
), the main program exits before these tasks have a chance to complete. This will be contrasted against proper usage, where tasks are allowed to complete or are gracefully canceled.
Steps without Proper Synchronization:
Create a context with cancellation, to simulate a controlled shutdown mechanism for your goroutines.
Launch several goroutines to perform long-running tasks without using a
WaitGroup
to synchronize their completion.Cancel the context after a brief delay, simulating a shutdown command that attempts to terminate all running tasks.
Immediately exit the main program, without waiting for the goroutines to acknowledge the cancellation or complete their work.
Problematic code:
package main
import (
"context"
"fmt"
"time"
)
func task(ctx context.Context, id int) {
fmt.Printf("Task %d started\n", id)
select {
case <-time.After(2 * time.Second): // Simulates a long-running operation
fmt.Printf("Task %d completed\n", id)
case <-ctx.Done():
fmt.Printf("Task %d canceled\n", id)
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
for i := 1; i <= 5; i++ {
go task(ctx, i)
}
// Simulate an early shutdown scenario
time.Sleep(1 * time.Second)
cancel()
// Main program exits immediately, potentially before all tasks can respond to the cancel signal
fmt.Println("Main program exiting")
}
Observe the logs, not all goroutines could even respond to the cancellation signal:
$ go run main.go
Task 2 started
Task 3 started
Task 1 started
Task 5 started
Task 4 started
Main program exiting
Task 5 canceled
$ go run main.go
Task 2 started
Task 3 started
Task 5 started
Task 1 started
Task 4 started
Main program exiting
Correct code:
package main
import (
"context"
"fmt"
"sync"
"time"
)
func task(ctx context.Context, id int, wg *sync.WaitGroup) {
defer wg.Done() // Ensure this is called to signal task completion
fmt.Printf("Task %d started\n", id)
select {
case <-time.After(2 * time.Second): // Simulates a long-running operation
fmt.Printf("Task %d completed\n", id)
case <-ctx.Done():
fmt.Printf("Task %d canceled due to context cancellation\n", id)
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
var wg sync.WaitGroup
for i := 1; i <= 5; i++ {
wg.Add(1) // Signal that a new task is starting
go task(ctx, i, &wg)
}
// Simulate a scenario where the program needs to shut down early
time.Sleep(1 * time.Second)
fmt.Println("Main program initiating shutdown...")
cancel()
wg.Wait() // Wait for all tasks to either complete or handle the cancellation
fmt.Println("Main program exited gracefully")
}
All goroutines exited gracefully:
$ go run main.go
Task 1 started
Task 3 started
Task 4 started
Task 2 started
Task 5 started
Main program initiating shutdown...
Task 1 canceled due to context cancellation
Task 5 canceled due to context cancellation
Task 2 canceled due to context cancellation
Task 4 canceled due to context cancellation
Task 3 canceled due to context cancellation
Main program exited gracefully
And that’s all !