Go: Go Channel Communication

Channels are the core primitives of Go's concurrency model—"Don't communicate by sharing memory; share memory by communicating." Channels make data transfer between goroutines as safe and elegant as piping.

If goroutines are the "people" of Go concurrency, then channels are the "phone lines" connecting them. In this lesson, you'll master all the core uses of channels and learn about common pitfalls.

1. You will learn



2. A True Story of a Backend Engineer

(1) Pain Point: Shared memory + mutex code is ugly and prone to deadlocks

Alice is a واجهة خلفية engineer on the payments team, and she needs to pass transaction data between multiple goroutines:

"I used sync.Mutex to protect a shared []Transaction slice, with 5 goroutines writing to it and 3 goroutines reading from it. Out of 100 lines of code, 30 are Lock/Unlock statements. When the reviewer saw it, they immediately said, 'I can't review this code—I wouldn't dare touch a single line of it.'"

Her code looks like this:

GO
// Shared memory approach: lock on every read/write, hard to maintain
type TransactionPool struct {
    mu     sync.Mutex
    items  []Transaction
}

func (p *TransactionPool) Add(t Transaction) {
    p.mu.Lock()
    defer p.mu.Unlock()
    p.items = append(p.items, t)
}

func (p *TransactionPool) Get() Transaction {
    p.mu.Lock()
    defer p.mu.Unlock()
    if len(p.items) == 0 {
        return Transaction{}
    }
    item := p.items[0]
    p.items = p.items[1:]
    return item
}

(2) Solution in Go: Passing data via a channel

GO
// channel_approach.go
package main

import "fmt"

type Transaction struct {
    ID     سلسلة
    Amount float64
}

func main() {
    // Create an unbuffered channel
    ch := make(chan Transaction)

    // Producer goroutine
    go func() {
        tx := Transaction{ID: "TXN-001", Amount: 99.99}
        ch <- tx  // Send (blocks until receiver is ready)
        fmt.Println("Producer: send complete")
    }()

    // Consumer
    tx := <-ch  // Receive (blocks until sender is ready)
    fmt.Printf("Consumer: received %s ($%.2f)\n", tx.ID, tx.Amount)
}

Output:

TEXT 📖 Display only
Consumer: received TXN-001 ($99.99)
Producer: send complete

(3) Performance: Channel vs. Shared Memory

Dimension Shared Memory + Mutex Channel
Code size 30 lines of قالب code 1 line ch <- v
Thread Safety Manual Lock/Unlock Language-Native Guarantees
Coupling High (coupled via shared variables) Low (depends only on the chan interface)
Testability Requires a mock Mutex Test directly using a channel
Risk of Deadlock High (Lock Ordering Issues) Some deadlocks can be detected at compile time
💡 Tip: Go proverb: "Don't communicate by sharing memory; share memory by communicating." — Don't share data; instead, pass data from one goroutine to another via channels, ensuring that each piece of data has only one owner.



3. Creating Channels and Basic Operations

(1) Create a channel

GO
package main

import "fmt"

func main() {
    // Unbuffered channel (synchronous)
    ch1 := make(chan int)

    // Buffered channel (asynchronous, capacity 3)
    ch2 := make(chan string, 3)

    // nil channel (cannot be used directly)
    var ch3 chan float64

    fmt.Printf("ch1: %T, unbuffered\n", ch1)
    fmt.Printf("ch2: %T, buffer=%d\n", ch2, cap(ch2))
    fmt.Printf("ch3: %T, nil=%v\n", ch3, ch3 == nil)
}

(2) Sending and Receiving

GO
ch := make(chan int)

// Send: ch <- value
ch <- 42  // Unbuffered: blocks until someone receives

// Receive: value := <-ch
value := <-ch  // Blocks until someone sends

// Discard received value
<-ch  // Receive only, ignore value (used for synchronization signals)

▶ Example: Synchronization behavior of a unbuffered channel

GO
package main

import (
    "fmt"
    "time"
)

func main() {
    ch := make(chan string)

    go func() {
        fmt.Println("goroutine: ready to send...")
        ch <- "hello"  // Blocks until main receives
        fmt.Println("goroutine: send complete")
    }()

    time.Sleep(1 * time.Second)  // Simulate delay
    fmt.Println("main: ready to receive...")
    msg := <-ch
    fmt.Printf("main: received %q\n", msg)
}
▶ Try it Yourself

Output:

TEXT 📖 Display only
goroutine: ready to send...
main: ready to receive...
goroutine: send complete
main: received "hello"
🔥 Common Mistake (Synchronous Bufferless Channels): The sender and receiver must both be ready at the same time; otherwise, one side will be blocked. Bufferless channels = synchronous—the sender waits for the receiver, and the receiver waits for the sender; both continue simultaneously only after they "shake hands."



4. Unbuffered vs. Buffered Channels

(1) Asynchronous behavior of buffered channels

GO
package main

import (
    "fmt"
    "time"
)

func main() {
    ch := make(chan int, 3)  // Capacity 3

    go func() {
        for i := 1; i <= 5; i++ {
            ch <- i
            fmt.Printf("Sent %d (len=%d)\n", i, len(ch))
        }
        close(ch)
    }()

    time.Sleep(500 * time.Millisecond)

    for v := range ch {
        fmt.Printf("Received %d\n", v)
        time.Sleep(200 * time.Millisecond)
    }
}

Output:

TEXT 📖 Display only
Sent 1 (len=0)
Sent 2 (len=1)
Sent 3 (len=2)
Sent 4 (len=3)
Sent 5 (len=4)
Received 1
Received 2
Received 3
Received 4
Received 5

▶ Example: Block when the buffer is full

GO
package main

import (
    "fmt"
    "time"
)

func main() {
    ch := make(chan int, 2)  // Capacity 2

    ch <- 1  // ✅ Does not block (has space)
    ch <- 2  // ✅ Does not block (has space)
    // ch <- 3  // ❌ Blocks! Buffer is full, waiting for receiver

    go func() {
        time.Sleep(100 * time.Millisecond)
        fmt.Println("goroutine: starting to receive")
        <-ch  // Frees up one slot
    }()

    fmt.Println("main: sending 3")
    ch <- 3  // Now does not block (goroutine has received one)
    fmt.Println("main: send complete")
    close(ch)

    for v := range ch {
        fmt.Printf("v=%d\n", v)
    }
}
▶ Try it Yourself

(3) Comparison of Bufferless vs. Buffered

Feature Unbuffered make(chan T) Buffered make(chan T, n)
Send Operation Block until the receiver is ready Do not block if there is space in the مخزن مؤقت
Receive Behavior Block until the sender is ready Do not block when data is in the مخزن مؤقت
Synch/Async Synch (Handshake) Async (Queue)
Capacity 0 n
Typical Scenarios Synchronization Signals, Coordination Between Goroutines Task Queues, Pipelines


5. close and range iterate

(1) close: Close the channel

GO
package main

import "fmt"

func main() {
    ch := make(chan int, 3)
    ch <- 1
    ch <- 2
    close(ch)  // Cannot send after closing

    // Can still receive remaining data after closing
    v1 := <-ch  // 1
    v2 := <-ch  // 2
    v3 := <-ch  // 0 (zero value, ok=false)
    fmt.Println(v1, v2, v3)
}

▶ Example: comma-ok checks whether the channel is closed

GO
package main

import "fmt"

func main() {
    ch := make(chan int, 2)
    ch <- 10
    ch <- 20
    close(ch)

    // comma-ok: ok=false means channel is closed and has no data
    v, ok := <-ch
    fmt.Printf("v=%d, ok=%v\n", v, ok)  // v=10, ok=true

    v, ok = <-ch
    fmt.Printf("v=%d, ok=%v\n", v, ok)  // v=20, ok=true

    v, ok = <-ch
    fmt.Printf("v=%d, ok=%v\n", v, ok)  // v=0, ok=false (closed and empty)
}
▶ Try it Yourself

(3) Iterate through the channel using a for-range loop

GO
package main

import "fmt"

func main() {
    ch := make(chan string, 3)
    ch <- "Alice"
    ch <- "Bob"
    ch <- "Charlie"
    close(ch)

    // for range automatically exits when the channel is closed
    for name := range ch {
        fmt.Printf("Hello, %s!\n", name)
    }

    // Equivalent to:
    // for {
    //     name, ok := <-ch
    //     if !ok { break }
    //     fmt.Println(name)
    // }
}
🔥 Common Mistake: Sending data to a closed channel will cause a panic. Closing a channel that is already closed will also cause a panic. The receiver does not need to worry about closing the channel—for range handles this automatically. The sender is always responsible for calling close.



6. Directional Restrictions

(1) The دالة parameter specifies the channel direction

GO
package main

import "fmt"

// Write-only channel (can only send)
func producer(out chan<- int) {
    for i := 1; i <= 3; i++ {
        out <- i  // ✅ Can only send
    }
    close(out)
}

// Read-only channel (can only receive)
func consumer(in <-chan int) {
    for v := range in {
        fmt.Printf("Received: %d\n", v)  // ✅ Can only receive
    }
}

func main() {
    ch := make(chan int, 3)
    go producer(ch)   // Automatically converts to chan<- int
    consumer(ch)       // Automatically converts to <-chan int
}

▶ Example: Directional Constraints in Practice (Pipeline Pattern)

GO
package main

import (
    "fmt"
    "strings"
)

// Stage 1: Write-only
func stage1(names []سلسلة, out chan<- سلسلة) {
    for _, name := range names {
        out <- strings.ToUpper(name)
    }
    close(out)
}

// Stage 2: Read-write (read from in, process, write to out)
func stage2(in <-chan سلسلة, out chan<- سلسلة) {
    for name := range in {
        out <- "Hello, " + name + "!"
    }
    close(out)
}

// Stage 3: Read-only
func stage3(in <-chan سلسلة) {
    for msg := range in {
        fmt.Println(msg)
    }
}

func main() {
    ch1 := make(chan سلسلة, 3)
    ch2 := make(chan سلسلة, 3)

    names := []سلسلة{"Alice", "Bob", "Charlie"}

    go stage1(names, ch1)
    go stage2(ch1, ch2)
    stage3(ch2)
}
▶ Try it Yourself

Output:

TEXT 📖 Display only
Hello, ALICE!
Hello, BOB!
Hello, CHARLIE!

(3) Usage with directional restrictions

declare Permissions Purpose
ch chan T Read/Write Variable Declaration
ch chan<- T Write-only Producer دالة argument
ch <-chan T Read-only Consumer دالة argument
💡 Tip: Directionality is checked only at compile time—passing a bidirectional channel to a write-only parameter won't cause an خطأ (due to automatic implicit conversion), but attempting to receive data on a write-only channel will result in a compile-time خطأ.



7. Introduction to Select Multiplexing

(1) SELECT Basics

GO
package main

import (
    "fmt"
    "time"
)

func main() {
    ch1 := make(chan string)
    ch2 := make(chan string)

    go func() {
        time.Sleep(100 * time.Millisecond)
        ch1 <- "from ch1"
    }()

    go func() {
        time.Sleep(200 * time.Millisecond)
        ch2 <- "from ch2"
    }()

    select {
    case msg := <-ch1:
        fmt.Println(msg)
    case msg := <-ch2:
        fmt.Println(msg)
    case <-time.After(300 * time.Millisecond):
        fmt.Println("Timeout")
    }
}
💡 Tip: select is similar to switch but is used for channels—whichever channel in a case becomes ready first is executed. If multiple channels become ready at the same time, one is chosen at random. select is the ultimate tool for concurrent programming in Go; we'll cover it in depth in Lesson 15.

▶ Example: Implementing timeout control in SELECT statements

GO
package main

import (
    "fmt"
    "time"
)

func longOperation(result chan<- سلسلة) {
    time.Sleep(3 * time.Second)
    result <- "Done"
}

func main() {
    result := make(chan سلسلة)

    go longOperation(result)

    select {
    case res := <-result:
        fmt.Println(res)
    case <-time.After(1 * time.Second):
        fmt.Println("Operation timed out!")
    }
}
▶ Try it Yourself

Output:

TEXT 📖 Display only
Operation timed out!


8. Common Deadlock Scenarios

GO
// Deadlock 1: Unbuffered channel with send but no receive
func deadlock1() {
    ch := make(chan int)
    ch <- 42  // fatal error: all goroutines are asleep - deadlock!
}

// Deadlock 2: main goroutine waiting on itself
func deadlock2() {
    ch := make(chan int)
    <-ch  // fatal error: deadlock
}

// Deadlock 3: Multiple goroutines waiting on each other
func deadlock3() {
    ch1 := make(chan int)
    ch2 := make(chan int)

    go func() {
        <-ch1  // Wait for ch1
        ch2 <- 1
    }()

    <-ch2  // Wait for ch2
    ch1 <- 1  // Never reaches this line
}

(1) Quick Reference: 4 Types of Deadlock Scenarios

Scenario Cause Solution
Send-only, no receive Non-buffered channel blocking Ensure there are consumers
Receive only, no send Channel is empty and there is no sender Ensure there is a producer
Goroutines waiting on each other A waits on B's channel, B waits on A's channel Redesigning the dependency order
Operations on nil channels Permanently blocking on/from a nil channel Initializing a channel


9. Complete Example: Producer-Consumer Pipeline

GO
// pipeline.go
package main

import (
    "fmt"
    "math/rand"
    "sync"
    "time"
)

// Work unit
type Job struct {
    ID      int
    Payload سلسلة
}

type Result struct {
    Job      Job
    Output   سلسلة
    Err      خطأ
    Duration time.Duration
}

// Producer: generates jobs (write-only channel)
func producer(jobs chan<- Job, count int) {
    for i := 1; i <= count; i++ {
        jobs <- Job{
            ID:      i,
            Payload: fmt.Sprintf("task-%d", i),
        }
        time.Sleep(time.Duration(rand.Intn(50)) * time.Millisecond)
    }
    close(jobs)
}

// Consumer: processes jobs (read jobs, write results)
func consumer(id int, jobs <-chan Job, results chan<- Result, wg *sync.WaitGroup) {
    defer wg.Done()
    for job := range jobs {
        start := time.Now()

        // Simulate processing
        time.Sleep(time.Duration(50+rand.Intn(100)) * time.Millisecond)
        output := fmt.Sprintf("consumer-%d processed %s", id, job.Payload)

        results <- Result{
            Job:      job,
            Output:   output,
            Duration: time.Since(start),
        }
    }
}

// Collector: collects and prints results (read-only channel)
func collector(results <-chan Result, done chan<- struct{}) {
    var total time.Duration
    count := 0

    for r := range results {
        count++
        total += r.Duration
        حالة := "OK"
        if r.Err != nil {
            حالة = "ERR"
        }
        fmt.Printf("[%s] Job#%d: %s (%v)\n",
            حالة, r.Job.ID, r.Output, r.Duration)
    }

    if count > 0 {
        fmt.Printf("\nTotal %d tasks, avg %.2f ms\n",
            count, float64(total.Milliseconds())/float64(count))
    }
    close(done)
}

func main() {
    const (
        jobCount     = 20
        workerCount  = 3
    )

    jobs := make(chan Job, 10)
    results := make(chan Result, 10)
    done := make(chan struct{})

    // 1 producer
    go producer(jobs, jobCount)

    // N consumers
    var wg sync.WaitGroup
    for w := 1; w <= workerCount; w++ {
        wg.Add(1)
        go consumer(w, jobs, results, &wg)
    }

    // Wait for all consumers to finish, then close results
    go func() {
        wg.Wait()
        close(results)
    }()

    // Collector (main goroutine)
    collector(results, done)
    <-done
}

Expected Output:

TEXT 📖 Display only
[OK] Job#1: consumer-2 processed task-1 (85ms)
[OK] Job#2: consumer-1 processed task-2 (92ms)
[OK] Job#3: consumer-3 processed task-3 (78ms)
...
[OK] Job#20: consumer-1 processed task-20 (112ms)

Total 20 tasks, avg 87.45 ms
100%
sequenceDiagram
    participant P as Producer
    participant C as Channel
    participant W as Consumer

    Note over P: Sender is responsible for close
    P->>C: ch <- job (send)
    activate C
    Note over C: Unbuffered: sync wait<br/>Buffered: enqueue
    C->>W: job := <-ch (receive)
    deactivate C
    Note over W: Process task
    W-->>P: Continue loop
    Note over P: close(ch)
    Note over C: Remaining data readable
    Note over W: for range auto-exit
🔥 Common Mistake: This example has 3 channels (jobs/results/done) and 3 types of directional constraints (write-only, read-only, read-write). Directional constraints ensure at compile time that you cannot receive data in the producer—this is a design feature of Go's type system to ensure concurrency safety.


❓ FAQ

Q What is the difference between buffered and unbuffered channels?
A With unbuffered channels, sending and receiving must be ready at the same time (synchronous handshake); otherwise, the channel blocks. A buffered channel does not block when sending if the buffer is not full, and does not block when receiving if the buffer is not empty. Unbuffered channels are used for synchronous coordination, while buffered channels are used for asynchronous queues.
Q Can you still send messages after a channel is closed?
A No. Sending a message to a closed channel will cause a panic. After closing, you can continue to receive any remaining data; once all data has been read, a zero value is returned. In v, ok := <-ch, an ok value of false indicates that the channel is closed and has no data left.
Q What is the purpose of the directional constraints chan<- and <-chan?
A They restrict the direction of channels in function signatures—ensuring at compile time that producers do not accidentally receive and consumers do not accidentally send. The Go compiler will report an error at compile time rather than causing a panic at runtime.
Q What is the underlying implementation of a channel?
A Under the hood, a channel consists of a ring buffer plus two waiting queues (the sender queue and the receiver queue). When data is sent, if there are waiting recipients in the receiver queue, it is passed directly; otherwise, it is placed in the buffer or the sender queue. See the hchan struct in the Go source code runtime/chan.go.
Q How does select use channels?
A select listens to multiple channels simultaneously; it executes the case for whichever channel becomes ready first. If multiple channels become ready at the same time, it selects one at random. If none of the channels are ready and a default case is specified, the default case is executed; otherwise, the process blocks and waits. select is key to timeout control and non-blocking operations.
Q How can you avoid channel deadlocks?
A Four rules: Unbuffered channels must have a corresponding receiver; pay attention to the order of dependencies between goroutines; use select with a default clause to implement non-blocking operations; use close to notify the receiver that the channel has been closed.
Q Can a channel be used as a semaphore?
A Yes. A buffered channel can act as a semaphore: initialize sem := make(chan struct{}, 10), send sem <- struct{}{} before the operation, and receive <-sem upon completion. It blocks when the buffer is full—providing natural rate limiting.
Q When should you use a channel, and when should you use a mutex?
A Data transfer → channel; data protection → mutex. Specifically: Transferring ownership (data from one goroutine to another) → channel; protecting shared state (multiple goroutines accessing the same variable) → Mutex. The two are not substitutes for each other; they are complementary.

📖 Summary


📝 Exercises

  1. Basic Problem (Difficulty ⭐): Write a program that starts a goroutine to send the ten digits 0 through 9 to an unbuffered channel, while the main goroutine receives and prints them. Observe the alternating order of sending and receiving.

  2. Advanced Problem (Difficulty ⭐⭐): Implement a fan-out pattern: A producer sends 100 tasks to a channel; launch 5 consumer goroutines to read and process tasks from the same channel, with each consumer printing its own ID and the task number. You must use a buffered channel, close, and for range.

  3. Challenge (Difficulty ⭐⭐⭐): Implement a three-stage pipeline: Stage 1 generates random integers ([]int); Stage 2 filters out even numbers; Stage 3 calculates the sum of squares. Each stage is an independent goroutine connected via channels. Requirements: (1) Use directional constraints between stages; (2) Support dynamic adjustment of the number of stages; (3) Implement an elegant exit using select.

Web-Tutorial.com

Web-Tutorial Tech Team

A team of developers maintaining programming tutorials. Each tutorial is written and reviewed by developers with expertise in that field. We work to keep our content accurate and reliable — if you spot an issue, please let us know.

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏