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:
go sayHello()started a new goroutine runningsayHello. Themainfunction did not wait for it.time.Sleeppausedmainlong enough for the goroutine to finish.- Without that sleep,
mainwould have ended immediately and the goroutine would never get to print anything.
When
mainreturns, 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 carriesstringvaluesch <- "..."— 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 primitives — sync.Mutex, sync.RWMutex, and sync.Once — are for. That’s the next lesson.