Concurrency
Go’s concurrency model is built on goroutines and channels. The mantra: “Don’t communicate by sharing memory; share memory by communicating.”
Goroutines
A goroutine is a lightweight thread managed by the Go runtime. Each starts with ~2KB of stack (grows as needed). You can run millions of them.
func main() {
go sayHello("world") // launches a goroutine
time.Sleep(time.Second)
}
func sayHello(name string) {
fmt.Printf("Hello, %s!\n", name)
}
Any function call prefixed with go runs concurrently. The main goroutine does not wait for others to finish unless you explicitly synchronize.
func main() {
var wg sync.WaitGroup
for i := range 5 {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("worker", i)
}()
}
wg.Wait() // blocks until all goroutines call Done()
}
Tip: Since Go 1.22, loop variables are per-iteration. No need to capture
iin a closure parameter anymore.
Channels
Channels are typed conduits for sending and receiving values between goroutines.
// Create a channel
ch := make(chan string)
// Send (blocks until someone receives)
go func() {
ch <- "hello"
}()
// Receive (blocks until someone sends)
msg := <-ch
fmt.Println(msg) // "hello"
Buffered vs Unbuffered
| Type | Creation | Send blocks when | Receive blocks when |
|---|---|---|---|
| Unbuffered | make(chan T) | No receiver ready | No sender ready |
| Buffered | make(chan T, n) | Buffer full | Buffer empty |
// Buffered channel - send doesn't block until buffer is full
ch := make(chan int, 3)
ch <- 1
ch <- 2
ch <- 3
// ch <- 4 would block here (buffer full)
Directional Channels
Restrict channel usage in function signatures:
func producer(out chan<- int) { // send-only
for i := range 10 {
out <- i
}
close(out)
}
func consumer(in <-chan int) { // receive-only
for v := range in {
fmt.Println(v)
}
}
Select
select lets a goroutine wait on multiple channel operations. It blocks until one case is ready, picking randomly if multiple are ready simultaneously.
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go func() {
time.Sleep(100 * time.Millisecond)
ch1 <- "one"
}()
go func() {
time.Sleep(200 * time.Millisecond)
ch2 <- "two"
}()
select {
case msg := <-ch1:
fmt.Println("received", msg)
case msg := <-ch2:
fmt.Println("received", msg)
}
}
Use default for non-blocking operations:
select {
case msg := <-ch:
fmt.Println(msg)
default:
fmt.Println("no message ready")
}
Context
context.Context carries deadlines, cancellation signals, and request-scoped values across goroutines and API boundaries.
func fetchData(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
func main() {
// Cancel after 5 seconds
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
data, err := fetchData(ctx, "https://api.example.com/data")
if err != nil {
log.Fatal(err)
}
fmt.Println(string(data))
}
Context Rules
- Pass
ctxas the first parameter:func DoThing(ctx context.Context, ...) - Never store contexts in structs
- Always call
cancel()(usedefer) - Use
context.Background()at the top of your call chain - Use
context.TODO()when you’re unsure which context to use (temporary)
Gotcha: A cancelled context cancels all derived contexts. If you create
childCtxfromparentCtxand cancel the parent, the child is also cancelled.
Quick Reference
go f() // launch goroutine
ch := make(chan T) // unbuffered channel
ch := make(chan T, n) // buffered channel
ch <- v // send
v := <-ch // receive
close(ch) // close (only sender closes)
for v := range ch { } // receive until closed
select { case <-ch: ... } // multiplex channels
ctx, cancel := context.WithTimeout(ctx, d) // deadline
Next: Interfaces | Concurrency Patterns