Why Go's Concurrency Model Stands Apart
Go was designed from day one with concurrency as a first-class concern. Not as an afterthought library, not as a framework bolt-on — it's baked into the language syntax and runtime.
The result is a concurrency model that feels genuinely different from thread-based languages. The mantra: "Don't communicate by sharing memory; share memory by communicating."
This guide assumes basic Go familiarity. We'll go deep on goroutines, channels, and the patterns that experienced Go developers use in production.
Goroutines: Not Threads
Goroutines look like threads, but they're fundamentally different:
package main
import (
"fmt"
"time"
)
func doWork(id int) {
fmt.Printf("Worker %d starting\n", id)
time.Sleep(100 * time.Millisecond) // Simulates I/O
fmt.Printf("Worker %d done\n", id)
}
func main() {
// Launch 1000 goroutines — no problem
for i := 0; i < 1000; i++ {
go doWork(i)
}
time.Sleep(500 * time.Millisecond) // Wait for all to finish (bad pattern — see WaitGroup)
fmt.Println("All done")
}
What makes goroutines different from threads:
| OS Threads | Goroutines | |
|---|---|---|
| Stack size | 1-8MB (fixed or growing) | 2-8KB (grows as needed) |
| Creation cost | ~1ms, system call | ~1μs, runtime-only |
| Typical limit | ~1,000-10,000 | Millions |
| Scheduling | OS kernel (preemptive) | Go runtime (cooperative + preemptive) |
The Go runtime multiplexes goroutines onto OS threads (M:N scheduling). When a goroutine blocks on I/O, the runtime parks it and runs another — without you writing any callback or async/await.
Channels: Communication Primitives
A channel is a typed conduit through which goroutines communicate:
// Creating channels
ch := make(chan int) // Unbuffered — synchronous
ch := make(chan int, 100) // Buffered — up to 100 items without blocking
// Sending and receiving
ch <- 42 // Send (blocks if unbuffered and no receiver ready)
value := <-ch // Receive (blocks until a value is available)
// Close a channel (sender signals no more values)
close(ch)
// Range over channel (exits when channel is closed)
for value := range ch {
fmt.Println(value)
}
// Check if channel is closed
value, ok := <-ch
if !ok {
fmt.Println("Channel closed")
}
Unbuffered vs Buffered
// Unbuffered: sender and receiver must both be ready
func unbufferedExample() {
ch := make(chan int)
go func() {
fmt.Println("Sending...")
ch <- 42 // Blocks until receiver is ready
fmt.Println("Sent!")
}()
time.Sleep(1 * time.Second) // Simulate delay
value := <-ch // Now both are ready — unblocks the sender
fmt.Println("Received:", value)
}
// Buffered: sender can proceed without waiting for receiver
func bufferedExample() {
ch := make(chan int, 3) // Can hold 3 values
ch <- 1 // Returns immediately (buffer not full)
ch <- 2
ch <- 3
// ch <- 4 // Would block — buffer full
fmt.Println(<-ch) // 1
fmt.Println(<-ch) // 2
fmt.Println(<-ch) // 3
}
The select Statement
select is like a switch for channels — it handles multiple channel operations:
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go func() {
time.Sleep(1 * time.Second)
ch1 <- "one"
}()
go func() {
time.Sleep(2 * time.Second)
ch2 <- "two"
}()
// Wait for either channel to send
for i := 0; i < 2; i++ {
select {
case msg1 := <-ch1:
fmt.Println("Received from ch1:", msg1)
case msg2 := <-ch2:
fmt.Println("Received from ch2:", msg2)
}
}
}
Non-blocking operations with select:
// Non-blocking receive
select {
case msg := <-ch:
fmt.Println("Received:", msg)
default:
fmt.Println("No message ready")
}
// Non-blocking send
select {
case ch <- value:
fmt.Println("Sent successfully")
default:
fmt.Println("Channel full or no receiver")
}
Timeout pattern:
func fetchWithTimeout(url string, timeout time.Duration) (string, error) {
resultCh := make(chan string, 1)
go func() {
result := fetchURL(url) // Potentially slow operation
resultCh <- result
}()
select {
case result := <-resultCh:
return result, nil
case <-time.After(timeout):
return "", fmt.Errorf("request timed out after %v", timeout)
}
}
Context for Cancellation
context.Context is the standard Go mechanism for cancellation and deadlines:
import (
"context"
"fmt"
"time"
)
func slowOperation(ctx context.Context) (string, error) {
select {
case <-time.After(5 * time.Second): // Simulates slow work
return "result", nil
case <-ctx.Done():
return "", ctx.Err() // context.Canceled or context.DeadlineExceeded
}
}
func main() {
// With timeout
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel() // Always call cancel to release resources
result, err := slowOperation(ctx)
if err != nil {
fmt.Println("Error:", err) // "context deadline exceeded"
return
}
fmt.Println("Result:", result)
}
// Manual cancellation
func withCancel() {
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(1 * time.Second)
cancel() // Trigger cancellation
}()
<-ctx.Done()
fmt.Println("Cancelled:", ctx.Err())
}
Propagating context through a request:
// HTTP handler passes context to all downstream operations
func userHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() // Request context — cancelled when client disconnects
user, err := db.GetUser(ctx, userID)
if err != nil {
if errors.Is(err, context.Canceled) {
// Client disconnected — no need to respond
return
}
http.Error(w, err.Error(), 500)
return
}
json.NewEncoder(w).Encode(user)
}
// Database function respects context
func (db *DB) GetUser(ctx context.Context, id int) (*User, error) {
var user User
err := db.pool.QueryRowContext(ctx,
"SELECT * FROM users WHERE id = $1", id,
).Scan(&user.ID, &user.Name, &user.Email)
return &user, err
}
Concurrency Patterns
Worker Pool
func workerPool(jobs <-chan Job, results chan<- Result, numWorkers int) {
var wg sync.WaitGroup
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
for job := range jobs { // Receive jobs until channel closes
result := processJob(job)
results <- result
}
}(i)
}
// Close results when all workers are done
go func() {
wg.Wait()
close(results)
}()
}
func main() {
jobs := make(chan Job, 100)
results := make(chan Result, 100)
// Start worker pool
workerPool(jobs, results, 10) // 10 concurrent workers
// Send jobs
go func() {
for _, job := range getJobs() {
jobs <- job
}
close(jobs) // Signal no more jobs
}()
// Collect results
for result := range results {
fmt.Println(result)
}
}
Pipeline Pattern
// Pipelines: chain of stages connected by channels
func generate(nums ...int) <-chan int {
out := make(chan int)
go func() {
for _, n := range nums {
out <- n
}
close(out)
}()
return out
}
func square(in <-chan int) <-chan int {
out := make(chan int)
go func() {
for n := range in {
out <- n * n
}
close(out)
}()
return out
}
func filter(in <-chan int, predicate func(int) bool) <-chan int {
out := make(chan int)
go func() {
for n := range in {
if predicate(n) {
out <- n
}
}
close(out)
}()
return out
}
func main() {
// Pipeline: generate → square → filter even → print
numbers := generate(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
squared := square(numbers)
evenSquares := filter(squared, func(n int) bool { return n%2 == 0 })
for n := range evenSquares {
fmt.Println(n) // 4, 16, 36, 64, 100
}
}
Fan-Out, Fan-In
// Fan-out: one input channel, multiple processing goroutines
// Fan-in: merge multiple channels into one
func fanOut(in <-chan Work, numWorkers int) []<-chan Result {
outputs := make([]<-chan Result, numWorkers)
for i := 0; i < numWorkers; i++ {
outputs[i] = worker(in) // Each worker reads from same input
}
return outputs
}
func fanIn(channels ...<-chan Result) <-chan Result {
var wg sync.WaitGroup
merged := make(chan Result, len(channels))
output := func(ch <-chan Result) {
defer wg.Done()
for result := range ch {
merged <- result
}
}
wg.Add(len(channels))
for _, ch := range channels {
go output(ch)
}
go func() {
wg.Wait()
close(merged)
}()
return merged
}
Sync Primitives
Sometimes shared state is the right tool:
import "sync"
// Mutex for protecting shared data
type SafeCounter struct {
mu sync.Mutex
count int
}
func (c *SafeCounter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
c.count++
}
func (c *SafeCounter) Value() int {
c.mu.RLock() // RWMutex for read-heavy scenarios
defer c.mu.RUnlock()
return c.count
}
// sync.Once — run initialization exactly once
var (
instance *Database
once sync.Once
)
func GetDB() *Database {
once.Do(func() {
instance = connectToDatabase() // Called exactly once
})
return instance
}
// sync.Map — concurrent map (read-heavy workloads)
var cache sync.Map
cache.Store("key", "value")
value, ok := cache.Load("key")
cache.LoadOrStore("key", "default") // Atomic check-and-set
cache.Delete("key")
// WaitGroup — wait for goroutine completion
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
doWork(n)
}(i)
}
wg.Wait() // Blocks until all goroutines call Done()
Race Condition Detection
Go has a built-in race detector:
go run -race main.go
go test -race ./...
// This has a race condition:
counter := 0
for i := 0; i < 1000; i++ {
go func() {
counter++ // Race! Multiple goroutines write concurrently
}()
}
// Fix 1: Use sync/atomic
import "sync/atomic"
var counter int64
go func() {
atomic.AddInt64(&counter, 1) // Atomic increment
}()
// Fix 2: Use a channel
counterCh := make(chan struct{}, 1)
go func() {
counterCh <- struct{}{}
counter++
<-counterCh
}()
// Fix 3: Use mutex (shown above)
Go's concurrency model rewards thinking in terms of communication and ownership rather than locks. When you're fighting the model — using lots of mutexes, struggling with goroutine lifecycle — it's often a sign to step back and redesign around channels.
→ Compute hash values for verification and security with the Hash Text tool.