Go has two core concurrency primitives: goroutines and channels. Goroutines run code concurrently. Channels let goroutines communicate. Once you understand these two, you understand 90% of Go’s concurrency story.

Goroutines

A goroutine is a function that runs concurrently with the rest of your program. To start one, put go in front of a function call. That’s it.

package main

import (
    "fmt"
    "time"
)

func sayHello() {
    fmt.Println("Hello from goroutine!")
}

func main() {
    go sayHello()

    time.Sleep(100 * time.Millisecond)
    fmt.Println("main done")
}
Hello from goroutine!
main done

What just happened:

  1. go sayHello() started a new goroutine running sayHello. The main function did not wait for it.
  2. time.Sleep paused main long enough for the goroutine to finish.
  3. Without that sleep, main would have ended immediately and the goroutine would never get to print anything.

When main returns, the program exits — even if other goroutines are still running. This catches every Go beginner once. We’ll see proper ways to wait shortly.

Goroutines are cheap

Goroutines are not OS threads. Go runs many goroutines on a small pool of OS threads, multiplexing them efficiently. A goroutine starts at about 2 KB of stack — you can have hundreds of thousands of them simultaneously.

package main

import (
    "fmt"
    "time"
)

func work(id int) {
    fmt.Printf("worker %d started\n", id)
    time.Sleep(time.Millisecond * 50)
    fmt.Printf("worker %d done\n", id)
}

func main() {
    for i := 1; i <= 5; i++ {
        go work(i)
    }
    time.Sleep(time.Millisecond * 100)
    fmt.Println("main done")
}
worker 5 started
worker 1 started
worker 2 started
worker 3 started
worker 4 started
worker 5 done
worker 1 done
worker 4 done
worker 3 done
worker 2 done
main done

Notice the order is unpredictable — that’s intentional. Goroutines run concurrently and the scheduler decides who runs when.

The problem: timing isn’t a coordination tool

time.Sleep is fine for examples, but it’s not how real code waits for goroutines. We need proper synchronization. That’s what channels are for.

Channels

A channel is a typed conduit through which one goroutine can send values to another. Think of it like a pipe — one goroutine writes in, another reads out.

package main

import "fmt"

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

    go func() {
        ch <- "hello from goroutine"
    }()

    msg := <-ch
    fmt.Println(msg)
}
hello from goroutine

Reading the syntax:

  • make(chan string) — creates a channel that carries string values
  • ch <- "..." — sends a value into the channel
  • <-ch — receives a value from the channel

The crucial property: channel operations block until the other side is ready. The goroutine blocks on send until main is ready to receive. main blocks on receive until the goroutine sends. This blocking is the synchronization — no time.Sleep needed.

A practical example

A program that runs three slow operations in parallel and gathers their results:

package main

import (
    "fmt"
    "time"
)

func fetchData(source string, ch chan<- string) {
    time.Sleep(time.Millisecond * 200)   // pretend we're hitting a slow API
    ch <- source + ": done"
}

func main() {
    start := time.Now()
    ch := make(chan string, 3)

    go fetchData("api-1", ch)
    go fetchData("api-2", ch)
    go fetchData("api-3", ch)

    for i := 0; i < 3; i++ {
        fmt.Println(<-ch)
    }

    fmt.Printf("Took %v\n", time.Since(start))
}
api-2: done
api-1: done
api-3: done
Took 200.123ms

Three “fetches” that each take 200ms ran in roughly 200ms total, not 600ms. That’s concurrency saving real time.

The channel type chan<- string (with the arrow on the channel side) means send-only. Used as a function parameter, it’s a hint that this function can only push to the channel, not receive. Same idea for <-chan string (receive-only).

Buffered channels

The channel above used make(chan string, 3) — the 3 is a buffer size. A buffered channel can hold N values without blocking the sender:

  • make(chan string) — unbuffered. Send blocks until a receiver is ready.
  • make(chan string, 3) — buffered. Send blocks only when the buffer is full.

Use buffering when you want to decouple send and receive timing. But default to unbuffered — they make synchronization more obvious.

Closing a channel and range

A sender can close a channel to signal “no more values coming”:

package main

import "fmt"

func produce(ch chan<- int) {
    for i := 1; i <= 5; i++ {
        ch <- i
    }
    close(ch)
}

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

    for n := range ch {
        fmt.Println(n)
    }
    fmt.Println("done")
}
1
2
3
4
5
done

for n := range ch reads from the channel until it’s closed. The receiver stops automatically when the channel is empty and closed.

Only the sender should close. Closing from the receiver, or closing twice, panics. If multiple senders share a channel, you need extra coordination.

select — waiting on multiple channels

select is like switch, but for channel operations. It picks the first case that’s ready, or blocks until one is.

package main

import (
    "fmt"
    "time"
)

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

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

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

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

select is the foundation for timeouts, cancellation, and many other concurrent patterns. With time.After:

// inside main()
select {
case msg := <-ch1:
    fmt.Println(msg)
case <-time.After(time.Second * 2):
    fmt.Println("timeout!")
}

If ch1 doesn’t send within 2 seconds, the timeout case fires and execution moves on.

Pipelines

A pipeline is a chain of stages where each stage takes input from one channel and produces output to another. It’s a clean pattern for stream processing.

package main

import "fmt"

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 main() {
    nums := generate(1, 2, 3, 4, 5)
    squared := square(nums)

    for n := range squared {
        fmt.Println(n)
    }
}
1
4
9
16
25

Each stage runs concurrently in its own goroutine. The first stage feeds the second through a channel, and the second feeds you. Add more stages by chaining functions — every stage is independent and easy to reason about on its own.

Pipelines are how serious Go programs handle data streams: log processing, image processing, web scraping, ETL.

What’s next

Goroutines and channels work for most concurrency problems. But sometimes you need to share state between goroutines safely without sending it through a channel. That’s what synchronization primitivessync.Mutex, sync.RWMutex, and sync.Once — are for. That’s the next lesson.

Toggle theme (T)