Goroutines are Go's core concurrency primitive, and their cost model is what makes Go's "just spawn a goroutine" style practical.
Starting one
func sayHello() {
fmt.Println("hello")
}
go sayHello() // runs concurrently, doesn't block the caller
time.Sleep(time.Millisecond) // without this, main might exit before sayHello runs
main exiting ends the whole program immediately, even if goroutines are still running. Real code coordinates completion with a sync.WaitGroup or a channel, not time.Sleep.
Why goroutines are cheap
| OS thread | Goroutine | |
|---|---|---|
| Initial stack size | Usually 1-8MB, fixed | ~2KB, grows/shrinks dynamically |
| Typical max count | A few thousand | Hundreds of thousands to millions |
| Scheduled by | The OS kernel | The Go runtime (in user space) |
| Context switch cost | Relatively expensive (kernel involved) | Much cheaper (userspace scheduler) |
The M:N scheduler
The Go runtime maps many goroutines (G) onto a smaller number of OS threads (M), coordinated through logical processors (P), roughly one per CPU core by default. When a goroutine blocks on a channel operation or a network call, the runtime parks it. It lets the OS thread run a different goroutine instead of sitting idle. This is why a Go program with 100,000 goroutines might only use a handful of OS threads under the hood.
Practical implication
Spawning a goroutine per incoming HTTP request, or per item in a batch job, is a completely normal Go pattern precisely because goroutines are this cheap. The same pattern with OS threads would exhaust system resources long before you got to that kind of concurrency.
Related Resources
Channels are how goroutines talk to each other safely, following Go's philosophy: share memory by communicating, not by locking.
Unbuffered: a rendezvous point
ch := make(chan int)
go func() {
ch <- 42 // blocks until someone receives
}()
v := <-ch // blocks until someone sends
fmt.Println(v) // 42
The send and receive happen at the same logical moment. This makes an unbuffered channel useful as a synchronization signal, not just a value carrier — receiving from it tells you the sender has reached that specific point.
Buffered: decoupled, up to a point
ch := make(chan int, 2)
ch <- 1 // doesn't block, buffer has room
ch <- 2 // doesn't block, buffer now full
ch <- 3 // blocks — buffer is full, waits for a receive
Closing and detecting closure
ch := make(chan int, 3)
ch <- 1
ch <- 2
close(ch)
v, ok := <-ch // 1, true
v, ok = <-ch // 2, true
v, ok = <-ch // 0, false — channel closed and drained
for v := range ch also stops automatically once a channel is closed and drained, making it the idiomatic way to consume every value from a channel until its producer is done.
A key rule
Only the sender should close a channel, never the receiver, and never close a channel twice — both cause a panic. Sending on a closed channel also panics, so coordinate shutdown carefully in real producer/consumer code.
Related Resources
select is Go's multiplexer for channel operations, and it shows up constantly in real concurrent code.
Waiting on the first of several channels
select {
case v := <-ch1:
fmt.Println("from ch1:", v)
case v := <-ch2:
fmt.Println("from ch2:", v)
}
Whichever channel has a value ready first wins. If both are ready at the same instant, Go picks randomly between them.
Implementing a timeout
select {
case result := <-workCh:
fmt.Println("got result:", result)
case <-time.After(2 * time.Second):
fmt.Println("timed out")
}
Non-blocking check with default
select {
case v := <-ch:
fmt.Println("received:", v)
default:
fmt.Println("no value ready, moving on")
}
Without default, this select would block until ch had a value. With it, the select returns immediately either way.
Cancellation with context
func worker(ctx context.Context, workCh <-chan int) {
for {
select {
case <-ctx.Done():
return // caller cancelled, stop working
case v := <-workCh:
process(v)
}
}
}
This pattern, select between real work and a cancellation signal, is the standard way to make a long-running goroutine stoppable from the outside.
Related Resources
A goroutine leak doesn't crash your program immediately. It just quietly accumulates blocked goroutines until memory or other resources run out.
A classic leak
func startWorker(dataCh chan int) {
go func() {
for {
v := <-dataCh // blocks forever if nobody ever sends again, and nobody closes it
process(v)
}
}()
}
If the caller stops sending to dataCh and never closes it, this goroutine blocks on the receive forever. It's never garbage collected, since it's still technically runnable, just permanently parked.
Fixing it with context cancellation
func startWorker(ctx context.Context, dataCh chan int) {
go func() {
for {
select {
case <-ctx.Done():
return // clean exit when the caller cancels
case v := <-dataCh:
process(v)
}
}
}()
}
Detecting leaks
import "go.uber.org/goleak"
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m) // fails the test suite if goroutines are still running after tests finish
}
goleak snapshots running goroutines before and after a test and fails if new ones are still alive at the end, which catches leaks that are otherwise invisible until they show up as a slow memory creep in production.
The general rule
Every goroutine you start should have a clear, reachable path to termination. If you can't answer "what makes this goroutine return" when you write the go statement, that's a strong signal you're about to leak one.
Related Resources
Go gives you two legitimate concurrency tools, mutexes and channels, and picking the right one for the job matters.
Basic mutex usage
type Counter struct {
mu sync.Mutex
count int
}
func (c *Counter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
c.count++
}
func (c *Counter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.count
}
defer c.mu.Unlock() right after Lock() is the idiomatic pattern — it guarantees the lock releases even if the function panics or has multiple return paths.
RWMutex for read-heavy workloads
type Cache struct {
mu sync.RWMutex
data map[string]string
}
func (c *Cache) Get(key string) string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.data[key]
}
func (c *Cache) Set(key, value string) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = value
}
Many goroutines can call Get concurrently, since RLock doesn't exclude other readers. Set still needs the exclusive Lock, since a write must not race with any read or another write.
Mutex vs. channel: a rule of thumb
| Situation | Prefer |
|---|---|
| Multiple goroutines read/write the same in-memory state (counter, cache, map) | Mutex |
| Handing ownership of a piece of data from one goroutine to another | Channel |
| Signaling "this event happened" or coordinating a pipeline | Channel |
| Protecting a small, hot piece of state where channel overhead would be wasteful | Mutex |
Go's proverb "share memory by communicating" is a design philosophy, not a hard rule. Real Go codebases use both, choosing whichever tool more directly expresses the actual problem.