正在加载,请稍候…

Go Concurrency: Goroutines, Channels, and Patterns That Actually Scale

Master Go concurrency: goroutines vs threads, channels (buffered/unbuffered), select statement, sync primitives, common patterns (fan-out, pipeline, worker pools), and avoiding race conditions.

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.