1. Why we need concurrency
If up to this point programming felt like something along the lines of “wrote commands — the computer obediently did them,” concurrency slightly changes the rules of the game: now you write commands, and the order of some events becomes… not your personal business. And that’s not a bug, it’s an important feature: the world around your program doesn’t wait for you to finish one function before starting another.
Imagine a typical application scenario: we read a file, process data, print the result; in parallel we’d like to log progress, react to cancellation, serve incoming requests, or simply not “freeze” the UI. In such scenarios, concurrency is not an “accelerator,” but a way to organize work. Go is famous precisely because it provides a convenient concurrency model “out of the box.”
Concurrency and parallelism: what’s the difference
The words really do look similar, so people often mix them up and then get surprised by the results. Concurrency is about the structure of the program (how we organize multiple tasks), while parallelism is about hardware (how instructions are actually executed). You can have concurrency without parallelism, and you can have parallelism without “good” concurrency in the sense of a convenient code model.
Let’s lock in the definitions as practically as possible.
| Term | Simple definition | What you usually “get” |
|---|---|---|
| Concurrency (concurrency) | Several logical tasks make progress “interleaved over time” | A convenient structure: waiting for I/O, background activities, handling multiple requests |
| Parallelism (parallelism) | True simultaneous execution on multiple CPU cores | Potential speed-up of computations (but not guaranteed) |
A good analogy (just one, I promise, with no analogy fan club): concurrency is when a cook has soup simmering and a cutlet frying at the same time, switching between them. Parallelism is when you have two cooks and they really cook simultaneously. One cook can organize cooking concurrently (gets more done), but it’s still one person. And vice versa: two cooks can get in each other’s way if the plan is bad.
2. The Go runtime and the goroutine scheduler
Process, OS threads, and the “magic” of goroutines
When you start a program, the OS creates a process, and inside the process—threads and other infrastructure. If you write concurrent code “head-on” using OS threads, it usually gets heavy: threads are expensive, you can’t create too many, and synchronization quickly turns into a “guess where the deadlock is” quest.
Go pulls off a trick that in practice feels like “they shipped magic”: instead of forcing you to manage OS threads directly, Go gives you goroutines—very lightweight units of execution. But goroutines don’t live on their own: they are managed by the Go runtime.
The Go runtime is part of the platform that runs together with your code and is responsible for several big things. It schedules goroutines, runs garbage collection (GC), helps with network waiting, and generally makes your code look simple while running fast. Historically, Go performance improvements have also touched the goroutine scheduler; for example, the Go 1.1 release notes specifically mentioned optimizations including the scheduler, as well as the introduction of the race detector as an important tool for concurrent code.
A simplified G / M / P model
An important note: we are not diving into the runtime internals as if this were a “Go internals course.” But we do need a model so that you can think correctly about the behavior of a concurrent program and not build logic on accidental coincidences.
In Go, people often use a simplified three-letter model:
| Letter | How to expand it | Intuitive meaning |
|---|---|---|
| G | goroutine | a “task” that wants to run |
| M | machine (thread) | an OS thread that actually executes instructions |
| P | processor (scheduler context) | a “permit” to execute Go code; a mediator between G and M |
In plain terms, goroutines (G) are a queue of tasks. OS threads (M) are the real working hands. And P is the limiter and dispatcher: how many “work slots” for executing Go code are currently active.
Schematically, you can picture it like this:
flowchart LR
G1[Goroutine G] --> P1[P: scheduling]
G2[Goroutine G] --> P1
P1 --> M1[M: OS thread]
P2[P: scheduling] --> M2[M: OS thread]
About GOMAXPROCS
There is a parameter GOMAXPROCS that determines how many “P”s the runtime uses to execute Go code. In modern versions of Go, the default value usually matches the number of available CPUs (cores/logical processors), and that often means: parallelism is possible, but it still isn’t guaranteed “just because you want it.”
You can simply print the current value like this:
package main
import (
"fmt"
"runtime"
)
func main() {
fmt.Println(runtime.GOMAXPROCS(0)) // for example: 8
}
Here runtime.GOMAXPROCS(0) returns the current value without changing it. What matters to us right now is not tuning, but the idea: the runtime decides how much Go code can truly run at the same time.
4. Nondeterminism and the program lifecycle
Why execution order is not a contract
This is the core of today’s lecture. While you have a single execution thread, you can mentally read the program top to bottom and roughly understand what will happen. As soon as you add concurrency, you get a scheduler that can switch execution between tasks. That means some things (for example, print order) become nondeterministic.
First, a checkpoint example: regular sequential code. Here the order is rock-solid:
package main
import "fmt"
func main() {
fmt.Println("A") // A
fmt.Println("B") // B
}
Now let’s enable concurrency in the smallest possible way (yes, with just one word go). And suddenly your internal “future predictor” starts getting it wrong:
package main
import (
"fmt"
"time"
)
func main() {
go fmt.Println("A")
go fmt.Println("B")
time.Sleep(10 * time.Millisecond) // a hack for demonstration
}
The key point here (and this is a big bold marker): the “A/B” order is not guaranteed. Sometimes it will be A then B, sometimes B then A, sometimes you’ll feel like the computer is messing with you (it isn’t—it’s just doing its job honestly).
time.Sleep is only used here so the program doesn’t exit immediately. It’s not a “proper way to wait,” it’s a demonstration prop, like a cardboard guitar in a music lesson: it looks similar, but you can’t play a concert with it.
main finished — the process finished
There is a rule that beginners almost always learn “experimentally,” i.e., by surprise: if the main function returns, the entire process ends, and no unfinished goroutines will “politely wait” to finish their business. They simply stop along with the program.
Let’s look at an example:
package main
import (
"fmt"
"time"
)
func main() {
go func() {
time.Sleep(50 * time.Millisecond)
fmt.Println("goroutine finished") // may not print
}()
fmt.Println("main finished") // main finished
}
Here main prints "main finished" and exits. After that, the process is fully allowed to terminate before the goroutine wakes up from Sleep. Sometimes (depending on the scheduler and timing) you’ll get lucky and see both lines, but that will be luck—not a contract.
This rule is especially important for real applications (CLIs, servers, workers): if you start background work, you must explicitly think through who will wait for it and how.
5. Performance and waiting
Why concurrency doesn’t have to speed things up
A very human thought: “If I do it in parallel, it will be faster.” Sometimes yes. Sometimes no. Sometimes it will even get slower—and that’s normal too.
Concurrency often pays off in I/O scenarios: networks, disks, timers, requests. There, you’re not constantly CPU-bound, and the ability to “switch to something else” truly increases overall throughput.
But for CPU-bound tasks (when you’re just crunching numbers), speed-up depends on the number of cores, scheduling overhead, GC work, and whether goroutines started fighting hard for the same resource. In other words, concurrency is primarily about organization, not “turbo mode.”
If you want it short and honest: concurrency makes a program more capable of doing many things, and parallelism (if present) can make some computations faster, but only if you don’t get in your own way.
Why Sleep is not synchronization
Now comes a moment that beginners love, because it “works.” And then they start hating it, because “it doesn’t work on another computer.” We’re talking about trying to synchronize using time.Sleep.
Imagine we want to “wait” for a goroutine to do something. The most tempting approach looks like this:
package main
import (
"fmt"
"time"
)
func main() {
go func() {
fmt.Println("work done") // work done
}()
time.Sleep(1 * time.Millisecond) // "we hope it's enough"
}
The problem is that this is not a waiting protocol; it’s a casino bet. On your machine it may be “enough,” on CI it won’t, on a laptop in power-saving mode it will be “sometimes,” and at the exact moment when a browser with 40 tabs opens nearby—things get sad.
The correct takeaway from this lecture is: Sleep can help show the effect of concurrency in examples, but it must not be a way to make a program correct.
6. Where concurrency shows up in real applications
Even if you don’t write a single goroutine today, concurrency is already nearby, because modern programs rarely live in an ideal vacuum. As soon as you have a server, multiple request handling, background tasks, logging, timeouts, graceful shutdown—you start thinking in terms of tasks that live “at the same time.”
For example, when we talk about graceful server shutdown, we want the server to stop accepting new requests but wait for the current ones to finish. In the Go ecosystem, this is an important and long-supported scenario: in the Go 1.8 release notes, for example, graceful shutdown for HTTP servers is mentioned as a way to “minimize downtime” by waiting for in-flight requests.
Today’s lecture is needed precisely as a mental foundation: when you later see code that “starts work separately” and “waits for a group of tasks,” you won’t treat it like a magic ritual. You’ll understand: the runtime schedules execution, order isn’t guaranteed, and correctness requires explicit agreements.
7. Common mistakes and why they happen
Mistake #1: confusing concurrency with speed-up and getting disappointed.
A beginner runs multiple tasks “at the same time,” expects everything to become twice as fast, and gets the same runtime or even worse. This happens because concurrency is first and foremost about structure, and speed-up is only possible with parallelism and proper work partitioning without unnecessary contention.
Mistake #2: assuming execution order (or print order) is fixed.
As soon as concurrency appears, the order of events becomes nondeterministic. If you write code that “logically depends” on "A" printing first and "B" printing second, you’re building a house on sand. Sometimes it stands, sometimes it doesn’t—and the worst part is that it breaks “rarely,” i.e., at the most inconvenient moment.
Mistake #3: using time.Sleep as a mechanism to wait for completion.
Sleep is a pause, not an agreement. It doesn’t tell the runtime “wait for the goroutine,” it tells your thread “lie down.” On fast machines you “accidentally” land in the right time window; on slow ones you don’t. Reliable programs are not built this way: they use explicit protocols for waiting on work to finish.
Mistake #4: thinking goroutines will keep the program alive.
This is a common psychological trap: if a goroutine is doing something, the program should wait. But Go is different: when main finishes, the whole process ends, and unfinished goroutines stop. This isn’t the language being cruel—it’s a simple and useful lifecycle rule.
Mistake #5: trying to guess scheduler behavior.
Sometimes you want to think: “well, it will run this goroutine first because I created it first.” The scheduler never promised anything. Moreover, real behavior depends on the runtime version, load, number of cores, timing, GC, and lots of tiny details. The only stable strategy is not to base correctness on assumptions about order and timing without explicit coordination.
GO TO FULL VERSION