The moment main exits, everything dies
You write a function to fetch a list of users from an API. You prefix the call with go to run it in the background. You run the program. It prints nothing. It exits immediately. The background task never finishes. This happens because the main function reached its closing brace, and the Go runtime considers the process complete. Every goroutine attached to that process gets terminated instantly. Starting a goroutine is trivial. Keeping it alive long enough to do useful work requires a deliberate synchronization strategy.
What a goroutine actually is
A goroutine is not an operating system thread. OS threads are heavy. They consume megabytes of stack memory upfront, and context switching between them requires kernel intervention. Goroutines are managed entirely by the Go runtime. Think of the runtime as a highly efficient dispatcher. It maintains a pool of OS threads and schedules thousands of goroutines across them. When a goroutine blocks on I/O, the runtime detaches it from the thread and runs something else. When the I/O completes, the runtime reattaches it. This M:N multiplexing model means your program can handle tens of thousands of concurrent tasks on a handful of OS threads.
Goroutine stacks also start at a few kilobytes and grow or shrink automatically as the call stack demands. The runtime copies the stack to a larger allocation when it needs more space, and shrinks it back down when memory is no longer needed. This design makes spawning a goroutine cost a few hundred bytes of memory and a fraction of a microsecond. You can safely launch thousands without crashing your process. The tradeoff is that the runtime controls their lifecycle, not you. You cannot manually pause, resume, or inspect a goroutine from outside. You coordinate them through channels, wait groups, or context cancellation.
The bare minimum
Here is the simplest way to spawn background work and wait for it to finish. The sync.WaitGroup tracks how many tasks are still running.
package main
import (
"fmt"
"sync"
"time"
)
// worker simulates a background task and signals completion.
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done() // Decrement counter on return to guarantee cleanup even if panic occurs
time.Sleep(time.Second) // Simulate blocking I/O that yields the OS thread to the runtime
fmt.Printf("Worker %d finished\n", id)
}
func main() {
var wg sync.WaitGroup
// Launch three independent tasks
for i := 1; i <= 3; i++ {
wg.Add(1) // Increment counter before spawning to prevent the main goroutine from exiting early
go worker(i, &wg)
}
// Block the main goroutine until the counter reaches zero
wg.Wait()
fmt.Println("All workers completed.")
}
Walk through the execution. main creates a WaitGroup with a counter of zero. The loop runs three times. Each iteration calls wg.Add(1) to increment the counter, then calls go worker(i, &wg). The go keyword hands the function call to the runtime scheduler. The loop continues immediately without waiting. Once the loop finishes, main calls wg.Wait(). This call blocks the current goroutine until the internal counter drops back to zero. Each worker runs concurrently, sleeps for a second, prints its ID, and returns. The defer wg.Done() statement ensures the counter decrements exactly once per worker, even if the function panics. When all three workers return, the counter hits zero, wg.Wait() unblocks, and main prints the final message. The runtime then shuts down cleanly.
Passing data back without shared state
Waiting for completion is only half the problem. You usually need the results. Shared variables require mutexes, which introduce complexity and potential deadlocks. Channels provide a type-safe way to pass values between goroutines while establishing a happens-before memory ordering guarantee. Here is a pattern for collecting results without blocking the sender.
package main
import (
"fmt"
"time"
)
// calculate performs a simple computation and sends the result.
func calculate(id int, resultChan chan<- int) {
time.Sleep(time.Millisecond * 100) // Simulate variable processing time across workers
resultChan <- id * 2 // Send result; blocks if channel buffer is full
}
func main() {
// Buffered to 3 so senders never block waiting for the receiver loop
ch := make(chan int, 3)
for i := 1; i <= 3; i++ {
go calculate(i, ch)
}
// Drain the channel exactly once per spawned task
for i := 0; i < 3; i++ {
fmt.Println(<-ch)
}
}
The channel capacity matters here. An unbuffered channel requires a receiver to be ready before the sender can proceed. If you spawn three goroutines that all try to send to an unbuffered channel before main starts receiving, they will block indefinitely. A buffer of three guarantees each sender can deposit its result and exit immediately. The receiver loop pulls values in an undefined order, which is fine for independent tasks. If you need ordered results, you would attach an index to the payload or use a slice with a mutex. The convention in Go is to keep channel operations explicit and linear. You pass channels as parameters, you read from them with the <- operator, and you close them only from the sending side when no more values will arrive.
Where things go wrong
Goroutines are forgiving, but they punish careless design. The most common failure mode is a goroutine leak. This happens when a background task waits on a channel that never receives a value, or blocks on a mutex that never unlocks. The process keeps running, memory usage creeps up, and eventually the system runs out of resources. Always design a cancellation path. The standard library provides context.Context for this exact purpose. By convention, context.Context is always the first parameter of a function, conventionally named ctx. Functions that accept a context should monitor ctx.Done() and exit early if the parent scope cancels the operation.
Compiler errors will catch structural mistakes early. If you forget to import sync, the compiler rejects the program with undefined: sync. If you import a package but never reference it, you get imported and not used. Go enforces strict variable usage to prevent dead code. If you attempt to read from a closed channel, the program panics at runtime with panic: receive on closed channel. If you send to a closed channel, you get panic: send on closed channel. The compiler will not catch these. They require careful lifecycle management.
Loop variable capture used to be a frequent trap. In older Go versions, reusing the loop variable i inside a goroutine closure meant every goroutine would read the final value of i after the loop finished. Go 1.22 changed the loop semantics so each iteration gets its own variable, eliminating the bug. You no longer need to write i := i inside the loop body.
Formatting and naming conventions reduce cognitive load. Run gofmt on every file. It standardizes indentation, spacing, and brace placement. The community treats formatting as a solved problem. You do not debate indentation width. You also follow strict visibility rules. Names starting with a capital letter are exported to other packages. Names starting lowercase are private to the current package. There are no public or private keywords. The compiler enforces this boundary. When defining methods, the receiver name should be one or two letters matching the type, like (b *Buffer) Write(...). You avoid this or self. When a function returns multiple values and you only need one, use the underscore _ to discard the rest explicitly. result, _ := fetch() tells the reader you considered the second return value and intentionally ignored it. You use it sparingly with errors because swallowing errors silently is a fast track to production outages.
The community also follows a simple design mantra: accept interfaces, return structs. When you pass a parameter to a goroutine, pass the most general interface that satisfies the requirement. When you return data from a function, return a concrete struct. This keeps your API flexible and your implementations decoupled. Another small but important rule: never pass a *string. Strings are already cheap to pass by value because they are just a pointer and a length. Dereferencing a pointer to a string adds indirection without saving memory.
Goroutines are cheap. Channels are not magic.
Picking the right synchronization tool
Picking the right coordination pattern depends on what the background task actually does.
Use a goroutine with sync.WaitGroup when you need to fan out independent tasks and wait for all of them to finish without collecting results. Use a goroutine with a buffered channel when you need to gather outputs from multiple workers and the order of arrival does not matter. Use a goroutine with context.Context when the task must support cancellation or timeouts from a parent scope. Use a single goroutine plus a pipeline of channels when one stage of processing feeds directly into the next. Use plain sequential code when you do not need concurrency. The simplest thing that works is usually the right thing.