Concurrency Model in Go: Goroutines and Channels

Concurrency Model in Go: Goroutines and Channels

Go's concurrency model is built upon Goroutines (lightweight threads) and Channels, which enable efficient and safe concurrent programming. The following sections explain their core concepts and usage step-by-step.


1. Goroutine: Lightweight Concurrency Unit

Description:
A Goroutine is a concurrent execution unit in Go. It is more lightweight than an OS thread (initial stack ~2KB, can grow dynamically) and is scheduled by the Go runtime, not the OS kernel.

Creation and Usage:

  • Start a Goroutine using the go keyword:
    go func() {
        // code to execute concurrently
    }()
    
  • Example: Main thread and Goroutine executing in parallel
    package main
    import (
        "fmt"
        "time"
    )
    
    func sayHello() {
        fmt.Println("Hello from Goroutine!")
    }
    
    func main() {
        go sayHello() // Launch Goroutine
        time.Sleep(100 * time.Millisecond) // Wait for Goroutine (temporary solution)
        fmt.Println("Main function ends.")
    }
    
    Note: Using Sleep to wait for a Goroutine is not a robust approach; in practice, use Channels or sync.WaitGroup for synchronization.

2. Channel: Communication Mechanism between Goroutines

Description:
A Channel is a type-safe conduit for passing data and synchronizing operations between Goroutines. It follows the principle: "Don't communicate by sharing memory; share memory by communicating."

Basic Usage:

  • Create a Channel:
    ch := make(chan int) // Unbuffered Channel
    bufferedCh := make(chan int, 3) // Buffered Channel (capacity of 3)
    
  • Send and Receive Data:
    ch <- 42       // Send data to Channel
    value := <-ch  // Receive data from Channel
    

Unbuffered vs. Buffered Channels:

  • Unbuffered Channel: Send and receive operations block until the other side is ready (synchronous communication).
  • Buffered Channel: The sender blocks when the buffer is full; the receiver blocks when the buffer is empty (asynchronous communication).

3. Synchronization Example: Using a Channel to Wait for a Goroutine

package main
import "fmt"

func worker(done chan bool) {
    fmt.Println("Working...")
    done <- true // Send completion signal
}

func main() {
    done := make(chan bool)
    go worker(done)
    <-done // Block until signal is received
    fmt.Println("Done.")
}

4. Common Pattern: Select Multiplexing

Description:
The select statement monitors multiple Channel operations, handling multiple communication tasks.

Example:

ch1, ch2 := make(chan string), make(chan string)

go func() { ch1 <- "from ch1" }()
go func() { ch2 <- "from ch2" }()

for i := 0; i < 2; i++ {
    select {
    case msg1 := <-ch1:
        fmt.Println(msg1)
    case msg2 := <-ch2:
        fmt.Println(msg2)
    }
}

Key Points:

  • select blocks until one of its cases can proceed.
  • If multiple cases are ready, one is chosen at random (prevents starvation).

5. Avoiding Race Conditions

Problem: Multiple Goroutines modifying shared data simultaneously can lead to inconsistent results.
Solutions:

  • Use Channels to transfer data ownership (recommended).
  • Or use sync.Mutex for locking:
    var mu sync.Mutex
    var counter int
    
    func safeIncrement() {
        mu.Lock()
        counter++ // Critical section
        mu.Unlock()
    }
    

6. Practical Recommendations

  1. Prefer Channels: Simplify synchronization logic, avoid explicit locks.
  2. Avoid Goroutine Leaks: Ensure every Goroutine can exit (e.g., via context.Context cancellation).
  3. Use Global Variables Cautiously: Prefer passing data through Channels.

By following these steps, you can understand how Goroutines and Channels collaborate to build efficient concurrent programs.