🚧 We are still building the lighthouse. Hang tight!

projectlighthouse logoprojectlighthouse

Sign in with your Google or GitHub account to get started

More on Composite Types: Pointers and Channels

Deep dive into pointers for efficient memory management and channels for safe concurrent communication. Includes comprehensive memory diagrams and concurrency patterns.

Arrays, slices, maps, and structs let you organize data. But programs need more than data organization—they need to share data between functions efficiently, modify data in place, and coordinate work across concurrent operations. This requires understanding two critical compound concepts: pointers and channels.

Pointers provide direct memory addresses, enabling efficient data sharing and in-place modification. Channels provide typed conduits for safe communication between concurrent goroutines. Both are fundamental to how Go programs manage memory and concurrency.

Understanding Pointers

Every variable lives at a memory address. When you declare x := 42, the value 42 is stored somewhere in memory, and x refers to that location. A pointer stores that memory address instead of the value itself.

x := 42
p := &x  // p holds the address of x

The & operator takes the address of a variable. The type of p is *int—a pointer to an int. The * in the type declaration means "pointer to."

Access the value at an address using the * operator:

x := 42
p := &x
fmt.Println(*p)  // 42 - dereference the pointer

*p = 100  // Modify the value at the address
fmt.Println(x)  // 100 - x changed

Dereferencing with * reads or writes the value at the memory address. Modifying *p modifies x because they refer to the same memory location.

Memory Layout: Values vs Pointers

┌─────────────────────────────────────┐
│ Stack Memory                        │
├─────────────────────────────────────┤
│                                     │
│  x: 42                             │
│  [address: 0x1040]                 │
│                                     │
│  p: 0x1040 ───────┐               │
│  [address: 0x1048]  │               │
│                     │               │
│                     └──────────────┐│
│                                    ││
└────────────────────────────────────┘│
                                      │
    p points to x                     ▼
    (stores x's address)          [0x1040: 42]

The variable x contains the value 42. The variable p contains the address where x lives (0x1040 in this example). When you dereference p with *p, you access the value at address 0x1040.

Why Pointers Matter

Passing values to functions copies them:

func increment(n int) {
    n = n + 1
}

x := 42
increment(x)
fmt.Println(x)  // 42 - unchanged

The function receives a copy of x. Modifications affect the copy, not the original. For small values like integers, copying is fast. For large structs, copying is expensive.

Pointers enable functions to modify the original:

func increment(n *int) {
    *n = *n + 1
}

x := 42
increment(&x)
fmt.Println(x)  // 43 - modified

The function receives the address of x. Dereferencing *n accesses and modifies the original value.

Pointers with Structs

Structs are often passed by pointer to avoid copying and enable modification:

type User struct {
    Name  string
    Email string
    Age   int
}

func UpdateEmail(u *User, newEmail string) {
    u.Email = newEmail  // Automatically dereferenced
}

user := User{Name: "Alice", Email: "[email protected]", Age: 30}
UpdateEmail(&user, "[email protected]")
fmt.Println(user.Email)  // [email protected]

When accessing struct fields through a pointer, Go automatically dereferences. You write u.Email instead of (*u).Email. This syntactic sugar makes pointer usage cleaner.

Nil Pointers

A pointer that doesn't point to anything is nil:

var p *int
if p == nil {
    fmt.Println("p is nil")
}

Dereferencing a nil pointer crashes the program:

var p *int
fmt.Println(*p)  // panic: runtime error: invalid memory address

Always check for nil before dereferencing pointers that might not be initialized:

func PrintName(u *User) {
    if u == nil {
        fmt.Println("No user")
        return
    }
    fmt.Println(u.Name)
}

Pointer Receivers on Methods

Methods with pointer receivers can modify the receiver:

type Counter struct {
    count int
}

func (c *Counter) Increment() {
    c.count++
}

func (c *Counter) Value() int {
    return c.count
}

counter := Counter{}
counter.Increment()
counter.Increment()
fmt.Println(counter.Value())  // 2

The Increment method uses a pointer receiver *Counter, allowing it to modify the counter. Go automatically takes the address when calling methods on values:

counter := Counter{}
counter.Increment()  // Go converts to (&counter).Increment()

This convenience means you rarely write & explicitly when calling pointer receiver methods.

When to Use Pointers

Use pointers when:

1. The function needs to modify the argument:

func Reset(c *Counter) {
    c.count = 0
}

2. The type is large, and copying is expensive:

type LargeStruct struct {
    data [1000000]int
    // Many fields...
}

func Process(ls *LargeStruct) {
    // Avoid copying 1 million integers
}

3. Consistency—some methods need pointers, so all use pointers:

type Database struct {
    conn *sql.DB
}

// All methods use pointer receivers for consistency
func (db *Database) Query(sql string) (*Rows, error) { ... }
func (db *Database) Close() error { ... }

Don't use pointers when:

1. The type is small (integers, booleans, small structs):

type Point struct {
    X, Y int
}

// Value receiver is fine—two integers copy quickly
func (p Point) Distance() float64 {
    return math.Sqrt(float64(p.X*p.X + p.Y*p.Y))
}

2. The value shouldn't be modified:

func PrintUser(u User) {
    fmt.Println(u.Name)
}

3. The type is already a reference (slices, maps, channels):

func AddItem(items []int, item int) []int {
    return append(items, item)
}

Slices, maps, and channels are reference types—they contain pointers internally. Passing them by value is efficient.

Channels: Safe Concurrent Communication

Programs often need to perform multiple operations simultaneously. A web server handles thousands of requests concurrently. A data pipeline processes files in parallel. Go provides goroutines for concurrent execution and channels for safe communication between them.

A channel is a typed conduit that goroutines use to send and receive values:

ch := make(chan int)

This creates a channel that transmits integers. Channels are typed—a chan int sends and receives integers, a chan string sends and receives strings.

Sending and Receiving

Send values into a channel with <-:

ch <- 42  // Send 42 into the channel

Receive values from a channel with <-:

value := <-ch  // Receive from the channel
fmt.Println(value)

Channels block until both sender and receiver are ready. If a goroutine sends to a channel but no goroutine is receiving, the sender blocks. If a goroutine receives from a channel but no goroutine is sending, the receiver blocks. This synchronization prevents race conditions.

Goroutines and Channels Together

Goroutines execute functions concurrently. Launch a goroutine with the go keyword:

go doSomething()

Combine goroutines with channels for safe communication:

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

    go func() {
        result := compute()
        ch <- result  // Send result to main goroutine
    }()

    value := <-ch  // Wait for and receive result
    fmt.Println("Result:", value)
}

func compute() int {
    // Expensive computation
    time.Sleep(2 * time.Second)
    return 42
}

The goroutine runs compute concurrently. When finished, it sends the result through the channel. The main goroutine blocks on <-ch until the result arrives. No locks, no shared memory, no race conditions—channels coordinate safely.

Channel Synchronization

Channels naturally synchronize goroutines:

func main() {
    done := make(chan bool)

    go func() {
        fmt.Println("Working...")
        time.Sleep(time.Second)
        fmt.Println("Done")
        done <- true  // Signal completion
    }()

    <-done  // Wait for signal
    fmt.Println("Main exiting")
}

The main goroutine blocks on <-done until the worker goroutine sends true. This pattern ensures the main function doesn't exit before work completes.

Buffered Channels

Unbuffered channels require both sender and receiver to be ready simultaneously. Buffered channels allow sending without an immediate receiver:

ch := make(chan int, 3)  // Buffer holds 3 values

Send to a buffered channel succeeds immediately if the buffer isn't full:

ch := make(chan int, 2)
ch <- 1  // Doesn't block - buffer has space
ch <- 2  // Doesn't block - buffer has space
ch <- 3  // Blocks - buffer is full, waits for receiver

Receiving from a buffered channel succeeds immediately if the buffer isn't empty:

fmt.Println(<-ch)  // 1
fmt.Println(<-ch)  // 2
fmt.Println(<-ch)  // Blocks - buffer is empty, waits for sender

Buffer size controls how many values can be in-flight without synchronization.

Buffered Channel Memory Layout

┌──────────────────────────────────────┐
│ Buffered Channel (capacity: 3)      │
├──────────────────────────────────────┤
│                                      │
│  Buffer: [42] [17] [ ]              │
│           ↑    ↑    ↑                │
│           │    │    └─ empty slot    │
│           │    └─ value 2            │
│           └─ value 1                 │
│                                      │
│  Head: 0  (next read position)      │
│  Tail: 2  (next write position)     │
│  Count: 2 (current values)          │
│                                      │
└──────────────────────────────────────┘

Buffered channels use a circular buffer. Values enter at the tail, exit at the head. When the buffer fills, senders block. When empty, receivers block.

Closing Channels

A sender can close a channel to signal no more values will be sent:

ch := make(chan int, 3)
ch <- 1
ch <- 2
ch <- 3
close(ch)

for value := range ch {
    fmt.Println(value)  // Prints 1, 2, 3
}

Ranging over a channel receives values until the channel closes. Attempting to send on a closed channel panics. Receiving from a closed channel returns the zero value immediately:

ch := make(chan int)
close(ch)
value := <-ch  // 0 (zero value for int)

Check if a channel is closed:

value, ok := <-ch
if !ok {
    fmt.Println("Channel closed")
}

The second return value indicates whether the channel is still open.

Select: Multiplexing Channels

The select statement lets a goroutine wait on multiple channels:

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"
    }()

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

The select blocks until one channel is ready, then executes that case. If multiple channels are ready, it chooses randomly. This enables handling multiple concurrent operations.

Timeout with select:

select {
case result := <-ch:
    fmt.Println("Got result:", result)
case <-time.After(3 * time.Second):
    fmt.Println("Timeout")
}

If ch doesn't send within 3 seconds, the timeout case executes.

Real-World Pattern: Worker Pool

Channels enable common concurrency patterns like worker pools:

func worker(id int, jobs <-chan int, results chan<- int) {
    for job := range jobs {
        fmt.Printf("Worker %d processing job %d\n", id, job)
        time.Sleep(time.Second)
        results <- job * 2
    }
}

func main() {
    jobs := make(chan int, 10)
    results := make(chan int, 10)

    // Start 3 workers
    for w := 1; w <= 3; w++ {
        go worker(w, jobs, results)
    }

    // Send 5 jobs
    for j := 1; j <= 5; j++ {
        jobs <- j
    }
    close(jobs)

    // Collect results
    for a := 1; a <= 5; a++ {
        <-results
    }
}

Three workers share a job queue. Jobs distribute automatically across available workers. Results funnel back through the results channel. This pattern scales—add more workers by incrementing the loop counter.

Channel Directions

Function signatures can restrict channel directions:

func send(ch chan<- int) {
    ch <- 42  // Can only send
}

func receive(ch <-chan int) {
    value := <-ch  // Can only receive
}

chan<- int is a send-only channel. <-chan int is a receive-only channel. This improves type safety—functions can't accidentally misuse channels.

Channels Are Reference Types

Like slices and maps, channels are reference types. Passing a channel to a function doesn't copy the channel, it passes a reference:

func send(ch chan int) {
    ch <- 42
}

func main() {
    ch := make(chan int)
    go send(ch)  // Pass channel to goroutine
    fmt.Println(<-ch)  // Receive from same channel
}

Both main and send operate on the same underlying channel. No pointer needed.

When to Use Channels

Use channels when:

1. Goroutines need to communicate safely:

go func() {
    result := compute()
    resultChan <- result
}()

2. You need to signal events between goroutines:

done := make(chan struct{})
go func() {
    doWork()
    close(done)
}()
<-done  // Wait for work to complete

3. Coordinating multiple concurrent operations:

for i := 0; i < 10; i++ {
    go processItem(i, results)
}

Don't use channels when:

1. A mutex would be simpler for protecting shared state:

var mu sync.Mutex
var counter int

mu.Lock()
counter++
mu.Unlock()

2. No communication is needed between goroutines:

3. The overhead of channel operations exceeds the benefit:

Combining Pointers and Channels

Pointers and channels often work together. Send pointers through channels to avoid copying large structs:

type Task struct {
    ID   int
    Data [10000]int
}

func worker(tasks <-chan *Task) {
    for task := range tasks {
        process(task)
    }
}

func main() {
    tasks := make(chan *Task, 10)

    go worker(tasks)

    task := &Task{ID: 1}
    tasks <- task  // Send pointer, not entire struct
}

Sending *Task instead of Task avoids copying 10,000 integers. The pointer transmission is fast, and the worker operates on the original task.

Key Takeaways

Pointers store memory addresses. Use & to get an address, * to dereference. Pointers enable efficient data sharing and in-place modification.

Pointer receivers let methods modify their receiver. Use pointer receivers for large types or when modification is needed.

Nil pointers cause panics. Always check for nil before dereferencing pointers that might be uninitialized.

Channels provide safe concurrent communication. Goroutines send and receive values through typed channels without locks or shared memory.

Channels block until both sides are ready. This synchronization prevents race conditions and coordinates goroutine execution.

Buffered channels allow asynchronous sends. Buffer size determines how many values can be in-flight without blocking.

Close channels to signal completion. Receivers can range over channels or check the second return value to detect closure.

Select multiplexes multiple channels. Wait on multiple operations simultaneously, with timeout support.

Channels are reference types. Pass them to functions without pointers—they reference the same underlying channel.

Pointers and channels are fundamental to Go's approach to memory management and concurrency. Pointers enable efficient data handling without excessive copying. Channels enable safe concurrent programming without explicit locks. Together, they provide the tools to build fast, correct, concurrent programs.


Ready to learn how to organize behavior with functions and methods?

Notes & Highlights

© 2025 projectlighthouse. All rights reserved.