1. Goroutine and the go statement: the basic model
If you previously programmed only “in a single line,” concurrency looks like an ad promise: “press a button — and everything gets faster.” In practice, goroutines are needed not only (and not so much) for speed, but for convenient code organization: while one part of the program is waiting on disk or network I/O, another can do useful work. Concurrency is about multiple tasks that make progress in an interleaved way, while parallelism is about truly simultaneous execution on CPU cores.
In Go, concurrency is built into the language. The very fact that the go statement exists is like a sign on a door: “welcome to a world where the order of events is not a contract.” And that’s fine: we just need to learn to write code that does not depend on a random execution order.
To make the motivation very concrete, imagine our training app (a hypothetical console task tracker): we want it to print a hint “don’t forget to save changes” a couple of seconds after startup, while the main code keeps working and printing the menu. That’s exactly where a goroutine fits: the “reminder” lives separately, and the main execution flow is not blocked.
What a goroutine is: a “very lightweight thread,” but managed by the runtime
The word goroutine is often explained as a “green thread” or a “lightweight thread.” That’s a decent analogy, but it’s important not to confuse it with operating system threads. In Go, a goroutine is an execution unit scheduled and started by the Go runtime, not by you via OS APIs. That’s why goroutines are cheaper to create and destroy than full OS threads, and that’s why Go can run thousands of goroutines without feeling like your computer is about to take off and fly out the window.
A useful model (without excessive internal details) is this: you have many goroutines, and the runtime distributes them across a limited number of OS threads, which actually run on CPU cores. How many can run “truly at the same time” depends on GOMAXPROCS (the maximum number of OS threads that Go uses to execute goroutines simultaneously).
A small diagram so you don’t have to keep this as an abstraction in your head:
flowchart TD A[Your program] --> B[Many goroutines: G1, G2, G3...] B --> C[Go runtime scheduler] C --> D[Several OS threads] D --> E[CPU cores]
We need this diagram for one reason: when you write concurrent code, you do not directly control “who runs when.” You define the structure (what can be done independently), and the runtime decides the schedule.
Launch syntax: go f() — it’s a statement, not magic
The most important rule in today’s lecture sounds boring, but it saves hours of your life: the go statement applies only to a function call. That is, after go there must be something like functionName(...) or func(){...}().
In Go, this is called a go statement: a language statement that starts executing a function in a separate goroutine.
Here is the minimal example:
package main
import "fmt"
func hello() {
fmt.Println("hello from goroutine") // hello from goroutine
}
func main() {
go hello()
fmt.Println("main continues") // main continues
}
If you run this code as-is, you will often see only "main continues". And that’s not “because Go is broken,” but because the program may terminate before the goroutine even has time to do anything. The process lifecycle matters more than your expectations (we’ll cover this in detail in the next section).
Now an example of what you cannot do:
package main
func main() {
// go 1 + 2 // not allowed: after go there must be a function call
// x := go f() // not allowed: go is not an expression; it does not "return" anything
}
This is a common beginner mistake: you want to “create a thread” as a value, store it in a variable, and then “control it.” That’s not how Go works: a goroutine starts, lives, finishes — but it is not an object you hold in your hands as a “handle.”
3. Goroutine lifecycle and the main rule about main
A goroutine’s lifecycle is simpler than it looks, but it has one “brutal” feature. A goroutine is born when you execute go someFunc(...). Then the runtime scheduler decides when to give it CPU time. It runs until the function ends (or until return). And here is the key rule: when main finishes, the process ends. And along with the process, all goroutines disappear, even if they were “still running” and were in the middle of something important.
Let’s look at this as clearly as possible:
package main
import (
"fmt"
"time"
)
func main() {
go func() {
time.Sleep(50 * time.Millisecond)
fmt.Println("goroutine finished") // most likely you won't see this
}()
fmt.Println("main finished") // main finished
}
Why does "goroutine finished" usually not appear? Because main prints its message and exits the program without waiting for the background goroutine.
To see the message, you can “prop up” main with an artificial delay:
package main
import (
"fmt"
"time"
)
func main() {
go func() {
time.Sleep(20 * time.Millisecond)
fmt.Println("goroutine finished") // goroutine finished
}()
time.Sleep(50 * time.Millisecond) // demonstration, not a protocol
fmt.Println("main finished") // main finished
}
It’s important to say this honestly: time.Sleep(...) in main is not “proper waiting,” it’s a demo hack. It’s convenient to see the effect and feel concurrency with your hands. But building program correctness on it is like fixing a car with duct tape: sometimes it will get you there, but you shouldn’t rely on it.
4. Arguments, pointers, and results: what actually happens at launch
When arguments are evaluated: compute first, then start
This section seems like a small detail, but it constantly comes up in real code. In Go, call arguments are evaluated before the goroutine starts. That means the values you pass to the function are computed first, and only then is the goroutine created, receiving already-prepared arguments.
Example:
package main
import (
"fmt"
"time"
)
func printVal(v int) {
fmt.Println("v =", v)
}
func main() {
x := 1
go printVal(x) // argument x is evaluated here, while x == 1
x = 2
time.Sleep(10 * time.Millisecond)
}
The output will be:
v = 1
And that’s logical: we passed the value 1 into printVal(...).
Now let’s compare this with passing a pointer (you already know &x and *p from the pointers topic). Here we pass not a “snapshot of the number,” but the “address where the number is stored,” and then the goroutine will see the current value at read time:
package main
import (
"fmt"
"time"
)
func printPtr(p *int) {
fmt.Println("*p =", *p)
}
func main() {
x := 1
go printPtr(&x) // pass the address
x = 2
time.Sleep(10 * time.Millisecond)
}
The output will most often be:
*p = 2
The phrase “most often” is not accidental here: concurrency adds nondeterminism. In one run, the goroutine may manage to read x before the assignment x = 2, and in another run — after it. And that’s not about syntax anymore, but about a fundamental idea: the execution order between goroutines is not guaranteed unless you explicitly coordinate it.
You can’t “return a result” from a goroutine with a normal return
At some point, almost every beginner tries to do this: “I’ll start go compute(), and then somehow get the result.”
The problem is that a goroutine is started by the go statement, and that statement has no “place” to put returned values. Even if a function returns (int, error), those values will simply be ignored.
This can be shown with a simple example:
package main
import "fmt"
func compute() int {
return 42
}
func main() {
go compute() // the result is simply lost
fmt.Println("running...") // running...
}
The compiler does not complain because the function call is valid. But the meaning of the result disappears like a sock after laundry: it was there — and now it isn’t.
At today’s level, the correct takeaway is: goroutines are great for background tasks that produce side effects (printing, logging, writing a file, a network request inside a handler, etc.) or modify state according to a predefined protocol. How to “transfer results” between goroutines is a separate big topic, and we won’t jump ahead.
5. Practice: a background reminder in a mini-app
Let’s take a small step toward practice and add background reminders to a hypothetical console task app. Let’s say we have a “task of the day,” and after a short time the program will remind us about it without blocking the main flow.
package main
import (
"fmt"
"time"
)
func remind(task string) {
time.Sleep(30 * time.Millisecond)
fmt.Println("reminder:", task) // reminder: drink water
}
func main() {
task := "drink water"
go remind(task)
fmt.Println("menu: [1] list [2] add [0] exit") // menu: [1] list [2] add [0] exit
time.Sleep(50 * time.Millisecond) // demonstration
}
What matters here, besides the obvious go remind(task)?
We passed the string task by value. Strings in Go behave like “immutable data” from the perspective of the person reading the code, so this example is safer for demonstration: we don’t have shared mutable memory that two goroutines modify at the same time.
And once again: the Sleep at the end is only so we can see the reminder output. In a real program, you almost never want to “wait by guessing.”
6. panic inside a goroutine and local recover
This point is extremely important because concurrency makes errors more “spectacular.” If a panic happens inside a goroutine, by default it crashes the whole process when the panic reaches the top of that goroutine’s stack.
That is, “it crashed in the background” is not “oh well, it’s background.” No — it’s “the application terminated, thanks everyone.”
Example:
package main
import (
"fmt"
"time"
)
func main() {
go func() {
panic("boom")
}()
time.Sleep(10 * time.Millisecond)
fmt.Println("still alive?") // most likely you won't see this
}
To prevent a goroutine from crashing the whole process, recover must be done in the same goroutine and in a deferred function (defer). recover is not a “global crash handler”; it works only locally.
Here is an example of “self-protection” inside a goroutine:
package main
import (
"fmt"
"time"
)
func main() {
go func() {
defer func() {
if r := recover(); r != nil {
fmt.Println("recovered:", r) // recovered: boom
}
}()
panic("boom")
}()
time.Sleep(10 * time.Millisecond)
fmt.Println("program still alive") // program still alive
}
From an engineering culture perspective, it looks like this: if you start background work that can potentially panic (for example, due to a logic error, an unexpected nil, etc.), then either you are confident a panic cannot happen, or you put a protective recover at the boundary of that background task. But turning recover into “try/catch for every sneeze” is also not necessary: it worsens diagnostics and masks bugs.
7. Typical mistakes when starting goroutines
Mistake #1: expecting a “deterministic” execution order.
When you write two lines go f() and go g(), your brain really wants to read it as “first f, then g.” But concurrent code gives no such contract: the scheduler may start g before f, may interleave their execution, or may not even manage to start one of them before the program ends. A reliable approach is to treat the order as undefined and not build correctness on it.
Mistake #2: believing that a goroutine will “keep the program alive.”
Beginners often think: “I started a goroutine, so the program will wait until it finishes.” In Go, that’s false: if main finishes, the process ends entirely, and all goroutines “disappear.” This causes bugs like “sometimes it prints, sometimes it doesn’t.” The cure is not increasing Sleep, but an explicit protocol for waiting for background work to finish.
Mistake #3: trying to get a result from a goroutine via return.
It’s very natural to want to “return a number from a background function.” But the go statement does not give you a variable to store the returned value. So the pattern “run a computation in parallel and then take the result” requires other tools and discipline. At today’s step it’s useful to remember a simple rule: goroutines are great for background tasks and structuring work, but they do not hand you a “result in your hand” automatically.
Mistake #4: passing a pointer and expecting a stable value.
When you pass &x into a goroutine, you share the address of a variable that other code may modify. Because of this, the goroutine can see both the old value and the new one — depending on how the runtime schedules them. Sometimes that’s exactly what you need, but more often for a beginner it’s a source of mysterious “flaky” bugs. If you need a snapshot of the value at launch time — pass the value, not a pointer.
Mistake #5: a background panic without recover that unexpectedly crashes the whole process.
A panic in a goroutine does not “quiet down” by default and does not turn into an error somewhere in the logs — it ends the life of the app when it reaches the top of that goroutine’s stack. If you start background work and panics are possible there, think about the boundary of responsibility: either fix the cause of the panic, or place local protection defer + recover where it is justified.
GO TO FULL VERSION