Go concurrency is often described as easy parallelism, but the honest version is narrower. Go makes it easy to start concurrent work and to move values between concurrent units. It still leaves the hard questions to the developer, such as what to do when a goroutine sends to a channel nobody is reading.
Go concurrency in one paragraph
Go concurrency means running many goroutines, which are lightweight threads the Go runtime schedules inside your process, and connecting them with typed channels instead of locking shared memory. It matters because a goroutine starts at roughly 2 KB of stack, so a program can hold far more goroutines than operating-system threads before it runs out of memory.
The practical effect is that concurrency stops being something you avoid. Locks, semaphores and mutexes make parallel code expensive to write and hard to review, so most teams quietly serialise work. When the primitives are cheap, running two things at once is often the shorter path.
Go is a compiled, statically typed language, and the toolchain is available from the official Go site. The concurrency model is part of the language and its standard library, not a separate framework you install.
How goroutines differ from OS threads
A goroutine is a green thread managed by the Go runtime rather than requested from the operating system kernel. Both kinds can execute simultaneously on multiple CPU cores, but a goroutine costs an order of magnitude less memory and its creation does not require a system call.
The numbers explain the difference. An operating-system thread typically reserves around 1 to 2 MB of stack even when the work inside it needs far less. At 2 MB each, 100 threads consume roughly 200 MB before any of them do useful work. A goroutine starts around 2 KB and grows as needed.
That ratio is why a Go program can keep hundreds of thousands of goroutines alive at once. The Go runtime multiplexes them onto a smaller pool of OS threads, the way an operating system multiplexes processes onto cores.
One caution: the 2 KB figure is a starting stack size, not a memory ceiling. Goroutines that call deeply recursive functions or hold large local values grow their stacks, so the number of live goroutines is not a fixed memory guarantee.
Why channels replace shared-memory locks
Go channels let concurrent units hand values to each other instead of reading and writing the same memory location. That removes the need to acquire a lock around shared state, because only one goroutine owns the value at any moment.
The classic bug channels avoid is the race condition. Two threads both read the same counter holding 1, both compute 2, and both write back 2, so one increment disappears. With a channel the value is sent from one goroutine to another, and there is no window where two of them hold the same piece of state.
Goroutines communicate through typed channels as a first-class language construct rather than a library convention. A send blocks until a receiver is ready, and a receive blocks until a value arrives, so synchronisation is built into the transfer.
Unbuffered and blocking behaviour
An unbuffered channel synchronises the sender and the receiver: whoever arrives first waits for the other. That makes channel operations a natural coordination point in Go concurrency, not just a data pipe.
The behaviour is easy to observe. When a goroutine sends on a channel that nothing is reading, the program does not crash. It waits. In a running example, a main goroutine that receives from a channel before any sender exists simply blocks until the value arrives.
This is also the first real trap. A goroutine that blocks forever on a send leaks: it stays alive, holds its stack, and never finishes. Nothing in the language warns you, so the number of goroutines in a running service is worth watching.
A worker pool built with Go channels and goroutines
A worker pool is the standard shape for bounded parallelism in Go: one producer pushes jobs into a channel, several workers range over that channel and process what they receive. Adding a worker is one more goroutine, and removing one is a matter of how many you start.
The structure is short. A worker function takes an identifier plus a channel of integers and loops over it, doing its work for each value. The producer starts each worker with the go keyword and then sends jobs into the channel.
The worker pool also shows where Go concurrency is genuinely simple. Ten workers handling one queue take a handful of lines, and the only synchronisation is the channel itself. There is no lock to forget and no shared counter to protect.
Fan-out, fan-in and racing duplicate requests
Fan-out means starting several goroutines for the same logical job and keeping the first useful answer. A common pattern fires two or more network requests simultaneously, returns whichever responds first, and abandons the rest.
Timeouts belong alongside that race. A context with a deadline lets the program cancel work that has already taken too long, so a slow provider cannot hold the response open while faster ones have already answered.
The pattern is real but the winning sender is not stable. In a live demonstration hitting two Brazilian postal-code APIs, the faster source changed from call to call because it depends on which provider's network path is available at that moment. Treat the winner as something you log, not something you promise.
What the Go HTTP server does per request
Go's standard HTTP server handles each incoming request in its own goroutine. The behaviour is built into net/http, so the concurrency model applies to web service code you did not write yourself.
That design keeps the handler code linear while the runtime parallelises requests across cores. Keeping up with a large number of simultaneous connections becomes a matter of how much work each handler does, not how many connections the server can accept.
The limits are still yours, not the language's. A handler that waits on a database or an upstream API consumes a goroutine for the whole wait, and the runtime does not make slow downstream calls fast. Connection pool sizes, timeouts and backpressure stay application responsibilities.
The standard library is also what makes this economical. Building an HTTP endpoint in Go takes an import and a handler, which is why the community leans on the standard library before reaching for a router.
Where the difficulty actually moves
Go relocates the hard part of concurrent programming rather than deleting it. Starting work and moving data between concurrent units become simple; reasoning about lifetimes, cancellation and goroutine leaks stays exactly as hard as it was.
The trade-off appears in code review. A Go codebase with thousands of goroutines can be correct and still leak if any of them can block forever on a channel with no reader. Locks are gone, but the failure mode has moved rather than disappeared.
Generic advice about concurrency applies here too. Concurrency is not parallelism: interleaving tasks on one core is still concurrency, and only multiple cores make work physically parallel. Choosing Go changes the cost of writing concurrent code, not the definition of the word.
Observability and production hardening
Runtime metrics are the discipline that keeps a goroutine-heavy service honest. The Go runtime can report the current goroutine count, and a number that climbs steadily under steady traffic points at a leak rather than a capacity problem.
Cancellation should be treated as a default. Contexts propagate deadlines through layers, channels let each worker see the cancellation, and a fan-out race without a timeout can wait indefinitely on the slowest provider instead of answering.
Neither feature is enough on its own. A worker pool without a queue limit will still accept more work than it can finish, and a race without a deadline can still leave the caller waiting. Both belong in the initial design, not in an incident review.
Frequently asked questions about Go concurrency
- Is a goroutine the same as an operating-system thread? No. A goroutine is a lightweight thread scheduled by the Go runtime, starting at roughly 2 KB of stack, while an OS thread is requested from the kernel and typically reserves around 1 to 2 MB. The runtime multiplexes many goroutines onto a smaller set of OS threads.
- Do Go channels always use blocking sends and receives? An unbuffered channel blocks the sender until a receiver is ready and blocks the receiver until a value arrives. Buffered channels allow a limited number of sends before blocking, but they do not remove the need to reason about what happens when the buffer is full.
- Can Go concurrency still produce a race condition? Yes. Channels remove races on values that travel through them, but goroutines that read and write the same variable directly still need mutexes or another synchronisation method. The language does not prevent shared-memory mistakes, it gives you a better default.
- How many goroutines can a Go program run? The practical ceiling depends on the memory each goroutine's stack actually grows to and on the work being scheduled, not on a fixed number in the language specification. Hundreds of thousands of live goroutines are plausible on a normal machine when each one stays small.
- Does using goroutines make a program faster? Only when the work is genuinely independent and the machine has spare cores or waiting on I/O. Wrapping sequential, CPU-bound work in goroutines adds scheduling overhead and synchronisation cost without saving time.
- When should a channel be replaced with a mutex? Channels fit ownership transfer and coordination between concurrent units, while mutexes fit protecting a small piece of state that many goroutines read and write. Protecting one counter with a full channel pipeline is more code than the problem needs.
- Why does my Go program hang and never exit? The usual cause is a goroutine blocked forever on a channel send or receive with no counterpart, or a
mainfunction that waits on a channel nothing will ever write to. The runtime reports a deadlock in some of these cases and simply waits in others.
- Is
net/httpconcurrency something I configure? No. The standard HTTP server starts a goroutine per request automatically, so the concurrency is already there. What you configure are timeouts, connection limits and the cost of each handler's downstream calls.
- Which learning path covers Go concurrency in depth? Structured programmes that pair the language fundamentals with production patterns tend to work better than syntax drills. Free community events such as the Full Cycle immersion week cover Go services alongside Next.js, websockets and Docker in a single project.
Turning one hour of teaching into written material
The interesting thing about this material is where the difficulty sits. Starting goroutines is easy, a worker pool is a few lines, and the actual work is in cancellation, timeouts and not leaking a goroutine that will never be read from. That split is what makes a live coding session hard to capture in text.
If you have an hour of explanation like this sitting in a video, Skala Blog turns it into an article. Paste the YouTube URL, transcribe the video, and generate a draft you can edit into something a reader can search and follow without the video.
Sessions like this also sit alongside structured tracks such as Crazystack, including the Crazystack Typescript content and the Bootcamp do Dev Doido run by Gustavo Dev Doido, for readers who want a full path rather than a single language tour. Free community weeks such as the Full Cycle immersion cover Go services together with Next.js, websockets and Docker in one project.