Goroutines Fundamentals

Updated

September 8, 2026

Goroutines Fundamentals

Overview

A goroutine is a function executing independently under the Go runtime. The keyword go starts that execution; it does not create an OS thread by itself. Thousands of goroutines can multiplex onto far fewer threads.

Sequential vs concurrent

func say(id int, phrase string) {
    for _, word := range strings.Fields(phrase) {
        fmt.Printf("Worker #%d: %s\n", id, word)
        time.Sleep(time.Duration(rand.Intn(50)) * time.Millisecond)
    }
}

// Sequential: second talker starts only after the first finishes.
func sequential() {
    say(1, "go is awesome")
    say(2, "cats are cute")
}

// Concurrent: both run; main must still wait somehow.
func concurrent() {
    go say(1, "go is awesome")
    go say(2, "cats are cute")
    time.Sleep(300 * time.Millisecond) // fragile — prefer WaitGroup
}
sequence (top → bottom):
  actors: Main, say#1, say#2
  Main --> say#1  : call
  say#1 --> Main  : return
  Main --> say#2  : call
  say#2 --> Main  : return
  Main --> say#1  : go
  Main --> say#2  : go
  Main --> Main  : must wait or exit
  note: sequential
  note: concurrent

Main is a goroutine

When main returns, the process ends. Other goroutines are abandoned—even if they had more work.

Without wait:

  main ──go──► G1
       ──go──► G2
       ──return──► process exit  (G1/G2 may print nothing)

WaitGroup: correct waiting

func concurrentWait() {
    var wg sync.WaitGroup
    wg.Add(2)
    go func() {
        defer wg.Done()
        say(1, "go is awesome")
    }()
    go func() {
        defer wg.Done()
        say(2, "cats are cute")
    }()
    wg.Wait()
}

Separate concurrency from business logic. Prefer wrapping say so say itself does not know about WaitGroup.

WaitGroup.Go (Go 1.25+)

var wg sync.WaitGroup
wg.Go(func() { say(1, "go is awesome") })
wg.Go(func() { say(2, "cats are cute") })
wg.Wait()

Under the hood: Add(1) + go + defer Done().

First channel: producer and consumer

┌─────────────┐    ┌─────────────┐
│ goroutine B │    │ goroutine A │
│   send X    │───►│   recv X    │
└─────────────┘    └─────────────┘
func main() {
    messages := make(chan string)
    go func() { messages <- "ping" }()
    msg := <-messages
    fmt.Println(msg)
}

Unbuffered send blocks until a receiver is ready (and vice versa). Channels transfer data and synchronize.

sequence (top → bottom):
  actors: sender, channel, receiver
  sender --> channel  : send "ping" (blocks)
  receiver --> channel  : receive
  channel --> receiver  : "ping"
  channel --> sender  : send completes

Producer-consumer skeleton

func countDigitsInWords(phrase string) map[string]int {
    words := strings.Fields(phrase)
    type pair struct {
        word  string
        count int
    }
    out := make(chan pair)
    go func() {
        defer close(out)
        for _, w := range words {
            out <- pair{w, countDigits(w)}
        }
    }()
    stats := map[string]int{}
    for p := range out {
        stats[p.word] = p.count
    }
    return stats
}

Rules of thumb

Do Don’t
Wait with WaitGroup / channel / context time.Sleep to “hope” work finished
Keep pure functions free of sync types Pass WaitGroup into every helper
Start with unbuffered channels Buffer “just in case” without a reason

Runnable example

go mod init example && go run .
package main

import (
    "fmt"
    "sync"
    "time"
)

func main() {
    var wg sync.WaitGroup
    ch := make(chan string)
    wg.Go(func() {
        time.Sleep(20 * time.Millisecond)
        ch <- "ready"
    })
    fmt.Println(<-ch)
    wg.Wait()
    fmt.Println("done")
}

What to notice: Receive synchronizes with send; WaitGroup covers the worker lifetime if you need both result and join.

Try next: Remove the receive and observe deadlock; remove WaitGroup and observe whether exit is still safe (with only channel sync it can be).