Goroutines #
Goroutines are one of the strongest reasons Go excels at concurrent systems. Not because Go is the only language with concurrency, but because goroutines make it extremely easy and cheap: a goroutine only needs ~2KB of stack when first created (it can grow dynamically), so you can run hundreds of thousands of goroutines in a single process without running out of memory. OS threads, by contrast, need ~1-8MB of fixed stack. This model — called M:N scheduling (M goroutines scheduled onto N OS threads by the Go runtime) — is what makes Go so efficient for I/O-bound workloads like HTTP servers handling thousands of concurrent connections.
This scheduling model is managed by the Go runtime scheduler, which intelligently distributes many goroutines across operating system threads. The M:N scheduling model can be illustrated in the following diagram:
flowchart TD
subgraph GoRuntime["Go Runtime (M:N Scheduler)"]
direction TB
G1["Goroutine 1 (G)"]
G2["Goroutine 2 (G)"]
G3["Goroutine 3 (G)"]
P1["Logical Processor 1 (P)"]
P2["Logical Processor 2 (P)"]
G1 & G2 --> P1
G3 --> P2
end
subgraph OS["Operating System (OS)"]
M1["OS Thread 1 (M)"]
M2["OS Thread 2 (M)"]
end
P1 --> M1
P2 --> M2How to Create a Goroutine #
Just add the go keyword before a function call:
import (
"fmt"
"time"
)
func sayHello(name string) {
fmt.Printf("Hello, %s!\n", name)
}
func main() {
go sayHello("Budi") // run as a goroutine
go sayHello("Sari") // second goroutine
go func() { // goroutine with an anonymous function
fmt.Println("Anonymous goroutine is running")
}()
// PROBLEM: main() doesn't wait for goroutines to finish!
// If main() exits, all goroutines are terminated immediately
time.Sleep(100 * time.Millisecond) // a temporary solution, not idiomatic
}
Why time.Sleep Isn’t the Right Solution
#
Using time.Sleep to wait for goroutines is an anti-pattern — you don’t know how long a goroutine needs. The correct solution is sync.WaitGroup or channels.
sync.WaitGroup — Waiting for Many Goroutines
#
A WaitGroup is a counter that lets one goroutine wait for a group of other goroutines to finish:
import "sync"
func processData(id int, wg *sync.WaitGroup) {
defer wg.Done() // make sure Done() is always called, even on panic
fmt.Printf("Worker %d started\n", id)
// ... do the work
fmt.Printf("Worker %d finished\n", id)
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 5; i++ {
wg.Add(1) // increment the counter before go
go processData(i, &wg)
}
wg.Wait() // block until the counter reaches 0
fmt.Println("All workers finished")
}
wg.Add(1)must be called beforego, not inside the goroutine. If called inside the goroutine,wg.Wait()may be called beforeAdd(), so the program exits immediately without waiting.// ANTI-PATTERN: Add called inside the goroutine go func() { wg.Add(1) // ✗ too late — Wait() may have already passed defer wg.Done() // ... }() // CORRECT: Add called before go wg.Add(1) go func() { defer wg.Done() // ✓ // ... }()
Channels — Communication Between Goroutines #
Channels are the mechanism for goroutines to communicate safely — “don’t communicate by sharing memory; share memory by communicating.”
Unbuffered Channels #
An unbuffered channel makes the sender block until the receiver is ready, and vice versa. This guarantees synchronization:
ch := make(chan int) // unbuffered
// Sender — a goroutine
go func() {
fmt.Println("Sending value...")
ch <- 42 // blocks until someone receives
fmt.Println("Value sent")
}()
// Receiver — the main goroutine
value := <-ch // blocks until someone sends
fmt.Println("Received:", value)
Buffered Channels #
A buffered channel has an internal capacity. The sender only blocks if the buffer is full:
ch := make(chan int, 3) // buffered, capacity 3
ch <- 1 // doesn't block, goes into the buffer
ch <- 2 // doesn't block
ch <- 3 // doesn't block
// ch <- 4 // blocks! buffer full
fmt.Println(<-ch) // 1
fmt.Println(<-ch) // 2
fmt.Println(<-ch) // 3
Closing and Ranging over Channels #
ch := make(chan int, 5)
// Send several values
go func() {
for i := 0; i < 5; i++ {
ch <- i
}
close(ch) // signal that no more values will be sent
}()
// Range automatically stops when the channel is closed
for v := range ch {
fmt.Println(v) // 0 1 2 3 4
}
// Check whether the channel is still open
v, ok := <-ch
if !ok {
fmt.Println("Channel is closed, value:", v) // v is the zero value
}
Never close a channel from the receiver side, and don’t close an already-closed channel — both cause panics. Convention: only the sender may close a channel.
Directional Channels — Channels with a Direction #
You can restrict a channel to send-only or receive-only for clarity of intent:
func producer(ch chan<- int) { // send-only channel
for i := 0; i < 5; i++ {
ch <- i
}
close(ch)
// <-ch // ← compile error: receive from send-only channel
}
func consumer(ch <-chan int) { // receive-only channel
for v := range ch {
fmt.Println("Consumed:", v)
}
// ch <- 1 // ← compile error: send to receive-only channel
}
func main() {
ch := make(chan int, 5)
go producer(ch)
consumer(ch)
}
select — Channel Multiplexing
#
select lets one goroutine wait on several channel operations at once, executing the first case that’s ready:
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go func() {
time.Sleep(1 * time.Second)
ch1 <- "one"
}()
go func() {
time.Sleep(500 * time.Millisecond)
ch2 <- "two"
}()
// select picks the first case that's ready
for i := 0; i < 2; i++ {
select {
case msg1 := <-ch1:
fmt.Println("From ch1:", msg1)
case msg2 := <-ch2:
fmt.Println("From ch2:", msg2)
}
}
// Output: From ch2: two (first), then From ch1: one
}
Select with a Timeout #
func fetchData(url string) (string, error) {
resultCh := make(chan string, 1)
go func() {
// simulate an HTTP request
time.Sleep(2 * time.Second)
resultCh <- "data from " + url
}()
select {
case result := <-resultCh:
return result, nil
case <-time.After(1 * time.Second):
return "", fmt.Errorf("timeout: request to %s took too long", url)
}
}
Non-Blocking Select with Default #
ch := make(chan int, 1)
// Try sending without blocking
select {
case ch <- 42:
fmt.Println("Successfully sent")
default:
fmt.Println("Channel full, skip")
}
// Try receiving without blocking
select {
case v := <-ch:
fmt.Println("Received:", v)
default:
fmt.Println("No value, skip")
}
sync.Mutex — Mutual Exclusion
#
When goroutines need to access shared state (not via channels), use a Mutex to prevent race conditions:
import "sync"
type SafeCounter struct {
mu sync.Mutex
count int
}
func (c *SafeCounter) Increment() {
c.mu.Lock()
defer c.mu.Unlock() // unlock is guaranteed to be called
c.count++
}
func (c *SafeCounter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.count
}
func main() {
counter := &SafeCounter{}
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter.Increment()
}()
}
wg.Wait()
fmt.Println("Final:", counter.Value()) // always 1000
}
sync.RWMutex — Read-Write Lock
#
For workloads with more reads than writes, RWMutex is more efficient because it allows many readers at once:
type Cache struct {
mu sync.RWMutex
store map[string]string
}
func (c *Cache) Set(key, value string) {
c.mu.Lock() // write lock — exclusive
defer c.mu.Unlock()
c.store[key] = value
}
func (c *Cache) Get(key string) (string, bool) {
c.mu.RLock() // read lock — can be concurrent with other readers
defer c.mu.RUnlock()
v, ok := c.store[key]
return v, ok
}
sync/atomic — Atomic Operations
#
For simple operations on numeric types, atomic is lighter than a mutex because it doesn’t need a lock:
import "sync/atomic"
var counter int64
// Atomic increment — thread-safe without a mutex
atomic.AddInt64(&counter, 1)
// Atomic load — safely read the current value
val := atomic.LoadInt64(&counter)
// Atomic store
atomic.StoreInt64(&counter, 0)
// CompareAndSwap — change only if the current value matches
swapped := atomic.CompareAndSwapInt64(&counter, 0, 100)
fmt.Println("Swapped:", swapped)
// Since Go 1.19 — atomic.Value for any type
var v atomic.Value
v.Store("hello")
fmt.Println(v.Load()) // "hello"
Race Conditions and the Race Detector #
A race condition happens when two goroutines access the same variable simultaneously and at least one writes — without proper synchronization:
// ANTI-PATTERN: race condition
var count int
go func() { count++ }() // goroutine 1 reads+writes
go func() { count++ }() // goroutine 2 reads+writes concurrently
// The final value of count is unpredictable!
Go provides a very useful race detector:
go run -race main.go
go test -race ./...
go build -race -o myapp .
The race detector prints a detailed report when a race is detected:
==================
WARNING: DATA RACE
Write at 0x00c0000b4010 by goroutine 7:
main.main.func2()
/home/user/main.go:12 +0x38
Previous write at 0x00c0000b4010 by goroutine 6:
main.main.func1()
/home/user/main.go:11 +0x38
==================
Context — Cancellation and Timeouts #
context.Context is Go’s standard way of propagating cancellation signals, deadlines, and values across goroutines:
import "context"
func fetchUser(ctx context.Context, id int) (*User, error) {
// Create a cancellable request
req, _ := http.NewRequestWithContext(ctx, "GET",
fmt.Sprintf("/users/%d", id), nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("fetchUser: %w", err)
}
defer resp.Body.Close()
// ...
}
func main() {
// A context with a 5-second timeout
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() // always call cancel to free resources
user, err := fetchUser(ctx, 42)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
fmt.Println("Request timeout")
}
return
}
fmt.Println(user)
}
// Propagating cancellation to child goroutines
func processAll(ctx context.Context, items []Item) error {
for _, item := range items {
// Check whether the context has been cancelled
select {
case <-ctx.Done():
return ctx.Err() // context.Canceled or context.DeadlineExceeded
default:
}
if err := process(ctx, item); err != nil {
return err
}
}
return nil
}
Goroutine Leaks — Goroutines That Never Stop #
A goroutine leak happens when a goroutine is created but never stops — usually because it’s waiting on a channel that never receives a value or gets closed:
// ANTI-PATTERN: goroutine leak
func doWork() <-chan int {
ch := make(chan int)
go func() {
for {
ch <- rand.Int() // this goroutine runs forever!
}
}()
return ch
}
// CORRECT: use a done channel or context to stop
func doWorkWithStop(ctx context.Context) <-chan int {
ch := make(chan int)
go func() {
defer close(ch)
for {
select {
case ch <- rand.Int():
case <-ctx.Done():
return // stop when the context is cancelled
}
}
}()
return ch
}
Idiomatic Patterns #
Worker Pools #
func workerPool(numWorkers int, jobs <-chan int, results chan<- int) {
var wg sync.WaitGroup
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for job := range jobs {
results <- job * job // process the job
}
}(i)
}
go func() {
wg.Wait()
close(results)
}()
}
func main() {
jobs := make(chan int, 100)
results := make(chan int, 100)
go workerPool(5, jobs, results)
for i := 1; i <= 20; i++ {
jobs <- i
}
close(jobs)
for r := range results {
fmt.Println(r)
}
}
Pipelines #
// Stage 1: generate numbers
func generate(nums ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for _, n := range nums {
out <- n
}
}()
return out
}
// Stage 2: square each number
func square(in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in {
out <- n * n
}
}()
return out
}
func main() {
// Chain the pipeline
c := generate(2, 3, 4, 5)
out := square(square(c)) // square twice
for v := range out {
fmt.Println(v) // 16, 81, 256, 625
}
}
Complete Example Program #
package main
import (
"context"
"fmt"
"math/rand"
"sync"
"sync/atomic"
"time"
)
// Job represents work that needs processing
type Job struct {
ID int
Value int
}
// Result represents a processing outcome
type Result struct {
JobID int
Output int
Worker int
}
// Stats tracks statistics with atomic operations
type Stats struct {
processed int64
errors int64
totalTime int64
}
func (s *Stats) RecordSuccess(elapsed time.Duration) {
atomic.AddInt64(&s.processed, 1)
atomic.AddInt64(&s.totalTime, int64(elapsed))
}
func (s *Stats) RecordError() {
atomic.AddInt64(&s.errors, 1)
}
func (s *Stats) Report() {
processed := atomic.LoadInt64(&s.processed)
errors := atomic.LoadInt64(&s.errors)
totalTime := atomic.LoadInt64(&s.totalTime)
fmt.Printf("\n=== Statistics ===\n")
fmt.Printf("Successfully processed : %d\n", processed)
fmt.Printf("Errors : %d\n", errors)
if processed > 0 {
avg := time.Duration(totalTime / processed)
fmt.Printf("Average time : %v\n", avg)
}
}
// worker processes jobs from jobCh and sends results to resultCh
func worker(
ctx context.Context,
id int,
jobCh <-chan Job,
resultCh chan<- Result,
stats *Stats,
wg *sync.WaitGroup,
) {
defer wg.Done()
for {
select {
case <-ctx.Done():
fmt.Printf("Worker %d: context cancelled, stopping\n", id)
return
case job, ok := <-jobCh:
if !ok {
fmt.Printf("Worker %d: jobCh closed, done\n", id)
return
}
start := time.Now()
// Simulate work with a random duration
delay := time.Duration(rand.Intn(100)) * time.Millisecond
select {
case <-time.After(delay):
// work finished
case <-ctx.Done():
stats.RecordError()
return
}
// Simulate an error 10% of the time
if rand.Float32() < 0.1 {
stats.RecordError()
fmt.Printf("Worker %d: error processing job #%d\n", id, job.ID)
continue
}
elapsed := time.Since(start)
stats.RecordSuccess(elapsed)
result := Result{
JobID: job.ID,
Output: job.Value * job.Value,
Worker: id,
}
select {
case resultCh <- result:
case <-ctx.Done():
return
}
}
}
}
func main() {
const (
numWorkers = 5
numJobs = 30
timeout = 3 * time.Second
)
// A context with a timeout
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
jobCh := make(chan Job, numJobs)
resultCh := make(chan Result, numJobs)
stats := &Stats{}
// Start the worker pool
var wg sync.WaitGroup
for i := 1; i <= numWorkers; i++ {
wg.Add(1)
go worker(ctx, i, jobCh, resultCh, stats, &wg)
}
// Close resultCh after all workers finish
go func() {
wg.Wait()
close(resultCh)
}()
// Send all jobs
for i := 1; i <= numJobs; i++ {
select {
case jobCh <- Job{ID: i, Value: i}:
case <-ctx.Done():
fmt.Println("Timeout while sending jobs!")
break
}
}
close(jobCh)
// Collect the results
fmt.Printf("Starting %d jobs with %d workers...\n\n", numJobs, numWorkers)
received := 0
for result := range resultCh {
received++
fmt.Printf("Job #%-3d → %d² = %d (Worker %d)\n",
result.JobID, result.JobID, result.Output, result.Worker)
}
stats.Report()
fmt.Printf("Total results received: %d out of %d jobs\n", received, numJobs)
}
Summary #
- Goroutines are very lightweight (~2KB initial stack) — you can run hundreds of thousands in a single process.
wg.Add(1)beforego, not inside the goroutine — the signal to the WaitGroup must be given before the goroutine runs.- Unbuffered channels make the sender and receiver wait for each other — strict synchronization.
- Buffered channels let the sender not block as long as the buffer isn’t full — decoupling producers and consumers.
- Only the sender may
closea channel — closing from the receiver or closing an already-closed channel causes a panic.selectmultiplexes channels; usedefaultfor non-blocking,time.Afterfor timeouts.sync.Mutexfor shared state that’s frequently written;sync.RWMutexfor read-heavy workloads.sync/atomicis lighter than a mutex for simple operations on numeric types.- Always run with
-raceduring development and testing to detect race conditions.- Context propagates cancellation and deadlines across the entire call chain and goroutines.
- Goroutine leaks happen when goroutines never stop — always provide a stop mechanism via context or a done channel.