Documentation Index
Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt
Use this file to discover all available pages before exploring further.
Concurrency in Go
Go is famous for its concurrency model. Unlike many other languages that treat concurrency as an afterthought or a library add-on, Go builds concurrency directly into the language core. The core philosophy comes from Tony Hoare’s 1978 paper on Communicating Sequential Processes (CSP): instead of sharing memory and coordinating with locks, you share data by sending it through channels. As the Go proverb says: “Do not communicate by sharing memory; instead, share memory by communicating.”Goroutines
A goroutine is a lightweight thread managed by the Go runtime. Think of goroutines as lightweight workers in a factory: you can have thousands of them running simultaneously, each doing a small job. Unlike operating system threads (which are like hiring full-time employees with benefits, office space, and a 1-2MB stack), goroutines start with just 2KB of stack and are managed by Go’s own scheduler rather than the OS kernel.The go Keyword
To start a goroutine, simply use the go keyword before a function call.
How Goroutines Work
Goroutines are multiplexed onto a smaller number of OS threads using the GMP model (Goroutines, Machine threads, Processors). This M:N scheduling allows Go to efficiently run thousands of goroutines on just a handful of OS threads. Key Characteristics:- Lightweight: Start with a tiny 2KB stack that grows/shrinks dynamically.
- Fast Context Switching: Switching between goroutines is much faster than OS thread context switches.
- Work Stealing: Idle processors can steal work from busy ones for load balancing.
Channels
Channels are the pipes that connect concurrent goroutines. Think of a channel as a physical pipe between two rooms: one goroutine puts a message in one end, and another goroutine pulls it out the other end. The pipe can be either unbuffered (a direct handoff — the sender waits until the receiver is ready, like a relay race baton pass) or buffered (the pipe has room to hold a few messages, like a mailbox that can queue up letters).Creating Channels
Sending and Receiving
Unbuffered vs. Buffered Channels
- Unbuffered Channels: Sending blocks until the receiver is ready. Receiving blocks until the sender is ready. This provides synchronization.
- Buffered Channels: Sending only blocks if the buffer is full. Receiving only blocks if the buffer is empty.
Channel Internals
Under the hood, a channel is a structhchan that contains a circular buffer, send/receive indices, and wait queues for blocked goroutines.
The select Statement
The select statement lets a goroutine wait on multiple communication operations. A select blocks until one of its cases can run, then it executes that case. It chooses one at random if multiple are ready.
Synchronization Primitives (sync package)
While channels are great for passing data, sometimes you just need to coordinate state.
WaitGroup
sync.WaitGroup waits for a collection of goroutines to finish.
Mutex
sync.Mutex provides a mutual exclusion lock to prevent data races.
Common Pitfalls and Practical Tips
Goroutine Leaks
A goroutine leak is when a goroutine is blocked forever — waiting on a channel that nobody will ever send to or receive from. These are the memory leaks of Go: they accumulate silently and eventually exhaust memory.Race Conditions
Race conditions occur when multiple goroutines access shared data concurrently with at least one write. Go’s race detector (go test -race or go run -race) is one of the most valuable tools in the language — use it always during development and in CI.
Channel Direction Types
Using directional channel types in function signatures communicates intent and prevents bugs at compile time:Best Practices
- Share Memory by Communicating: Don’t communicate by sharing memory. Use channels to pass ownership of data.
- Detect Race Conditions: Run your tests with
go test -raceto detect data races. Make this part of your CI pipeline. - Avoid Leaking Goroutines: Ensure every goroutine you start has a way to exit. Use
context.Contextfor cancellation. - Close Channels from the Sender: Only the sender should close a channel, never the receiver. Closing a channel signals “no more values.” Sending to a closed channel panics.
- Use WaitGroup for Fan-Out: When spawning multiple goroutines, use
sync.WaitGroupto wait for all of them to complete before proceeding.
Summary
- Goroutines are lightweight threads managed by the Go runtime.
- Channels allow safe communication and synchronization between goroutines.
- Select allows waiting on multiple channel operations.
- Sync Package provides low-level primitives like
MutexandWaitGroupfor finer control.
Interview Deep-Dive
You launch 10,000 goroutines that each make an HTTP request. Describe what happens at the runtime scheduler level and what could go wrong in production.
You launch 10,000 goroutines that each make an HTTP request. Describe what happens at the runtime scheduler level and what could go wrong in production.
Strong Answer:
- At the scheduler level, 10,000 goroutines are created with roughly 2KB of stack each (about 20MB total stack memory). They are distributed across P run queues (one per GOMAXPROCS, typically equal to CPU cores). As each goroutine issues an HTTP request, it eventually hits a network I/O operation. The runtime’s network poller (using epoll on Linux) puts the goroutine to sleep without consuming an OS thread — the M is freed to run other goroutines from the P’s queue.
- What could go wrong: First, 10,000 simultaneous outbound HTTP connections will likely exhaust file descriptors (default ulimit is often 1024), causing “too many open files” errors. Second, the target server may reject or rate-limit this many connections. Third, if the HTTP client uses the default transport,
MaxIdleConnsPerHostdefaults to 2, so connection reuse is minimal and you create far more TCP connections than necessary. Fourth, if the responses are large and not consumed quickly, you can exhaust memory. - The production-grade approach: use a worker pool or semaphore to limit concurrency to a reasonable number (say, 50-100 concurrent requests). Configure the HTTP transport with appropriate
MaxIdleConnsandMaxIdleConnsPerHost. Usecontext.WithTimeouton each request to prevent hanging connections. UseerrgroupwithSetLimitfor clean goroutine management and error propagation. - A key insight: just because goroutines are cheap to create does not mean the resources they consume (network connections, file descriptors, target server capacity) are cheap. The goroutine is lightweight, but the work it does may not be.
runtime.NumGoroutine() over time — export it as a Prometheus gauge. In a healthy service, the goroutine count should be relatively stable with some fluctuation during load. A steadily climbing count indicates a leak. To diagnose, hit the pprof endpoint at /debug/pprof/goroutine?debug=2 to get a full dump of all goroutine stacks. Look for goroutines blocked on channel operations or select statements with no timeout or cancellation. The stack trace tells you exactly where the goroutine is stuck. For automated detection in tests, use go.uber.org/goleak which checks that no unexpected goroutines remain after a test completes. In my experience, the most common causes are: forgetting to close a channel that a goroutine is reading from, missing context cancellation in long-running workers, and sending to an unbuffered channel with no receiver.Explain the difference between using a channel versus a mutex for protecting shared state. When would you choose one over the other?
Explain the difference between using a channel versus a mutex for protecting shared state. When would you choose one over the other?
The Go race detector reports a data race in your CI pipeline. Walk me through how you would diagnose and fix it, and explain why data races are particularly dangerous in Go.
The Go race detector reports a data race in your CI pipeline. Walk me through how you would diagnose and fix it, and explain why data races are particularly dangerous in Go.
Strong Answer:
- The race detector output tells you exactly which two goroutines are conflicting and which memory location they are accessing. It shows the stack traces for both the “previous write” and the “concurrent read” (or write). My first step is reading both stack traces to identify the shared variable and understand the access pattern.
- Common patterns: a struct field being read by one goroutine while another writes to it, a map being read and written concurrently (which actually causes a fatal crash, not just a race), or a slice being appended to from multiple goroutines.
- Fixes depend on the pattern. For a simple counter: use
atomic.Int64. For a shared map: protect withsync.RWMutex(use RLock for reads, Lock for writes). For shared state that is set once and read many times: usesync.Once. For data that flows between goroutines: restructure to use channels so only one goroutine owns the data at a time. - Why data races are particularly dangerous in Go: Go’s memory model provides very few guarantees about what one goroutine sees when another writes without synchronization. A data race is not just “you might read a stale value” — it is undefined behavior. The compiler and CPU may reorder memory operations, and without a synchronization point (channel send/receive, mutex lock/unlock, atomic operation), there is no happens-before relationship between the goroutines. This means the racy read might see a partially written value, an arbitrarily old value, or even a value that was never written. In practice, races cause intermittent, non-reproducible bugs that are nearly impossible to debug without the race detector.
-race on unit tests in CI always — the overhead is acceptable for test workloads. For integration tests or end-to-end tests that are already slow, you can run -race on a subset or in a separate CI stage that runs less frequently (nightly instead of per-commit). Some teams tag their most concurrency-sensitive tests and run only those with -race on every commit. The race detector only catches races on code paths that are actually exercised, so you also need good test coverage of concurrent paths. A practical pattern is to have a “race” CI stage that runs go test -race -count=5 ./... — the -count=5 runs tests multiple times to increase the chance of exposing timing-dependent races. Importantly, never ship a binary compiled with -race to production — the performance overhead is too high for any production workload.