Right, so you’ve heard the hype. “Concurrency made easy!” “It’s like threads but they’re lightweight!” And for once, the hype is mostly right. But let’s be clear: easy doesn’t mean magic. You still have to know what you’re doing, or you’ll build a spectacularly concurrent system that does absolutely nothing correctly.

The absolute bedrock of concurrency in Go is the goroutine. Think of it as the smallest unit of work that the Go scheduler can manage. The syntax for starting one is so stupidly simple it feels like you’re getting away with something. You just prefix a function call with the keyword go, and boom, you’re off to the races. The function you call then runs concurrently alongside the rest of your code.

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

func main() {
    go sayHello() // <-- Here's the magic spell

    fmt.Println("Hello from the main goroutine!")
    time.Sleep(10 * time.Millisecond) // We'll get to this abomination in a second
}

Run that. You’ll probably see the main greeting first, then the goroutine’s. Or maybe the other way around. This non-determinism is your first, and most important, lesson: the moment you fire off a goroutine, you surrender control over the order of execution. The Go scheduler, which maps these cheap goroutines onto real OS threads, decides what runs when. This is the core of its power, but also the source of most beginner mistakes.

The go Keyword is a Fire-and-Forget Launch

Notice what I did in that first example? I committed a cardinal sin: I used time.Sleep in main to wait for the goroutine. This is the concurrency equivalent of throwing a paper airplane into a storm and hoping it lands where you want. You must have a coordination mechanism. The go statement itself provides none. It simply hands the function off to the scheduler and immediately returns to the next line of code. The calling function doesn’t wait; it doesn’t get a return value; it doesn’t know if the goroutine lived, died, or ascended to a higher plane of existence.

This is why that first example is pedagogically useful but practically terrible. Never, ever rely on timing delays for synchronization. We’ll use proper channels and waitgroups for that in a minute.

Anonymous Functions: The Inline Workhorse

Ninety percent of the goroutines you’ll launch will be one-off jobs. For these, defining a named function is overkill. This is where anonymous functions shine. The syntax is a little weird the first time you see it, but you’ll get used to it.

func main() {
    message := "Hello from an anonymous function!"

    go func() {
        fmt.Println(message) // This closes over the 'message' variable
    }() // These parentheses are what actually call the function

    time.Sleep(10 * time.Millisecond) // Still a hack, just for demonstration
}

Crucially, the anonymous function here is a closure. It captures the variables from the surrounding scope (like message). This is incredibly powerful but also a classic foot-gun.

The Pitfall: Loop Variable Capture

This right here? This is the thing that gets everyone. Even seasoned engineers have been bitten by this after a long day. It’s so common it’s practically a rite of passage. Let’s set the trap.

func main() {
    for i := 0; i < 5; i++ {
        go func() {
            fmt.Println(i) // What do you think this will print?
        }()
    }
    time.Sleep(time.Second)
}

You might expect 0, 1, 2, 3, 4 in some random order. What you’ll almost certainly get is 5, 5, 5, 5, 5. Why? Because the scheduler is lazy. By the time those goroutines actually get scheduled to run, the for loop has long since finished and i has reached its terminal value of 5. All five goroutines share the same variable i in memory.

The fix is to break the closure by passing the value as a parameter. Parameters are evaluated at the time the goroutine is launched, not when it runs.

func main() {
    for i := 0; i < 5; i++ {
        go func(x int) { // 'x' is a new variable scoped to this goroutine
            fmt.Println(x) // This will be the value of i at the time of the 'go' statement
        }(i) // Pass the current value of i into the function
    }
    time.Sleep(time.Second)
}

This is a non-negotiable best practice. If you use a loop variable inside a goroutine, you must pass it as an argument. No exceptions.

Coordinating Without Shame: sync.WaitGroup

Let’s finally ditch that embarrassing time.Sleep. The proper way to wait for a squad of goroutines to finish their work is with a sync.WaitGroup. Think of it as a thread-safe counter with a built-in wait room.

func main() {
    var wg sync.WaitGroup

    for i := 0; i < 5; i++ {
        wg.Add(1) // You tell the waitgroup: "Hey, I'm about to launch one more thing to wait for"
        go func(x int) {
            defer wg.Done() // You promise to tell the waitgroup "I'm done" when you finish, no matter how you finish (even if you panic)
            fmt.Println(x)
        }(i)
    }

    wg.Wait() // This blocks until the WaitGroup's counter goes back to zero.
    fmt.Println("All goroutines are finished. Main can now exit with dignity.")
}

This is clean, deterministic, and doesn’t rely on guessing how long something might take. You Add for each goroutine before you launch it (doing it inside the goroutine is a race condition), you Done when you’re finished (using defer is idiomatic and safe), and you Wait for them all. This is how you write professional, robust concurrent code.