In this lesson we cover the Go goroutines tutorial fundamentals — lightweight concurrent functions, channels for safe communication, and the patterns that make Go a leading language for servers and cloud infrastructure. If you completed Go Interfaces and Errors, you understand how Go handles failures. Now you will learn how it handles parallelism — running many tasks at once without the complexity of manual thread management.
Prerequisites: Lessons 1–10 completed, Go 1.22 or newer installed. Estimated time: 55–65 minutes.
1. Goroutines — Lightweight Concurrent Functions
A goroutine is a function running concurrently with other goroutines, managed by the Go runtime. Start one with the go keyword — no thread pools, no boilerplate:
package main
import (
"fmt"
"time"
)
func say(message string) {
for i := 0; i < 3; i++ {
fmt.Println(message)
time.Sleep(100 * time.Millisecond)
}
}
func main() {
go say("from goroutine") // runs concurrently
say("from main") // main goroutine
time.Sleep(500 * time.Millisecond) // wait for goroutine to finish
fmt.Println("done")
}
Goroutines are cheap — you can run thousands or millions on a single machine. The Go scheduler multiplexes them onto a small number of OS threads. Unlike OS threads, goroutines start with a small stack (a few KB) that grows as needed. This is why Go services handle high concurrency with modest hardware.
2. Channels — Communicating Between Goroutines
Go’s motto is: “Do not communicate by sharing memory; share memory by communicating.” Channels are typed conduits that let goroutines send and receive values safely:
package main
import "fmt"
func worker(id int, jobs <-chan int, results chan<- int) {
for job := range jobs {
fmt.Printf("worker %d processing job %d\n", id, job)
results <- job * 2
}
}
func main() {
jobs := make(chan int, 5)
results := make(chan int, 5)
// start 3 workers
for w := 1; w <= 3; w++ {
go worker(w, jobs, results)
}
// send 5 jobs
for j := 1; j <= 5; j++ {
jobs <- j
}
close(jobs)
// collect results
for r := 1; r <= 5; r++ {
fmt.Println("result:", <-results)
}
}
Channel directions matter: <-chan T is receive-only; chan<- T is send-only. This prevents accidental misuse at compile time. Always close channels when no more values will be sent — ranging over a channel (for v := range ch) exits when the channel is closed and drained.
3. Goroutines + Channels Architecture
The diagram below shows a typical producer–worker–aggregator flow. The main goroutine sends jobs through a channel; worker goroutines process them in parallel; results flow back through a second channel.
┌─────────────┐ jobs chan ┌──────────────┐
│ main() │ ─────────────────► │ worker #1 │
│ (producer) │ └──────┬───────┘
└──────┬──────┘ jobs chan ┌──────┴───────┐ results chan ┌─────────────┐
│ ─────────────────► │ worker #2 │ ──────────────────► │ collector │
│ └──────┬───────┘ │ (main) │
│ jobs chan ┌──────────┴───┐ results chan └─────────────┘
└──────────────────────► │ worker #3 │ ───────────────────────────────┘
└──────────────┘
Flow: main sends jobs → workers process concurrently → results return via channel
This pattern appears everywhere in Go — HTTP handlers spawning background tasks, log processors, ETL pipelines, and microservice workers. Channels coordinate goroutines without mutexes in the common case, though shared state still needs protection when multiple goroutines write to the same variable.
4. Buffered vs Unbuffered Channels
An unbuffered channel blocks the sender until a receiver is ready, and vice versa — a direct handoff. A buffered channel (make(chan T, n)) holds up to n values before blocking:
package main
import "fmt"
func main() {
// unbuffered — synchronous handoff
ch := make(chan string)
go func() { ch <- "hello" }()
fmt.Println(<-ch)
// buffered — sender does not block until buffer is full
buf := make(chan int, 3)
buf <- 1
buf <- 2
buf <- 3
fmt.Println(len(buf)) // 3
}
Choose buffer size based on your workload. A buffer of zero (unbuffered) provides the strongest synchronization guarantee. A larger buffer decouples producers and consumers at the cost of memory and potential latency before backpressure kicks in.
5. select — Multiplexing Channels
The select statement waits on multiple channel operations — like a switch for channels. It is essential for timeouts, cancellation, and merging streams:
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan string)
go func() {
time.Sleep(2 * time.Second)
ch <- "data ready"
}()
select {
case msg := <-ch:
fmt.Println("received:", msg)
case <-time.After(1 * time.Second):
fmt.Println("timed out waiting for data")
}
}
time.After returns a channel that fires after a duration — a simple timeout pattern. In production code, use context.Context for cancellation (deadlines, client disconnects). The select with ctx.Done() is the standard way to stop goroutines gracefully when a request is cancelled.
6. sync Package — Mutexes and WaitGroups
Channels are not always the right tool. When goroutines share mutable state, use a mutex from the sync package. sync.WaitGroup waits for a group of goroutines to finish:
package main
import (
"fmt"
"sync"
)
func main() {
var (
mu sync.Mutex
wg sync.WaitGroup
total int
)
for i := 1; i <= 100; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
mu.Lock()
total += n
mu.Unlock()
}(i)
}
wg.Wait()
fmt.Println("sum 1..100 =", total) // 5050
}
Pass loop variables into goroutines as function arguments (go func(n int) { ... }(i)) — capturing the loop variable directly is a classic bug where every goroutine sees the final value. The sync package also provides sync.Once, sync.Map, and sync.Pool for specialized concurrency needs.
7. Worker Pool Pattern
A worker pool limits concurrency by running a fixed number of goroutines that pull jobs from a channel. This prevents overwhelming databases, APIs, or CPU when work arrives faster than you can process it:
package main
import (
"fmt"
"sync"
"time"
)
func fetchURL(url string) string {
time.Sleep(50 * time.Millisecond) // simulate network call
return "response from " + url
}
func main() {
urls := []string{"/api/books", "/api/users", "/api/orders", "/api/health"}
jobs := make(chan string, len(urls))
results := make(chan string, len(urls))
const numWorkers = 2
var wg sync.WaitGroup
for w := 0; w < numWorkers; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for url := range jobs {
results <- fetchURL(url)
}
}()
}
for _, u := range urls {
jobs <- u
}
close(jobs)
go func() {
wg.Wait()
close(results)
}()
for r := range results {
fmt.Println(r)
}
}
This pattern scales from CLI tools to production microservices. In Lesson 12 you will build an HTTP server that handles each request in its own goroutine — the same concurrency model, applied to REST APIs.
8. Next Steps
You now understand Go goroutines and channels — starting concurrent functions, sending values through channels, using select for timeouts, protecting shared state with mutexes, and building worker pools. In the final lesson you bring everything together in a complete HTTP REST API — structs, errors, JSON, and concurrent request handling with net/http.
Continue the series: Lesson 10 – Interfaces and Errors · Lesson 12 – HTTP REST API (Capstone)
Frequently Asked Questions
What is the difference between a goroutine and an OS thread?
Goroutines are managed by the Go runtime and are much lighter than OS threads. Thousands of goroutines can run on a handful of threads, with the scheduler handling multiplexing.
When should I use channels vs mutexes?
Use channels to pass ownership of data between goroutines. Use mutexes when multiple goroutines need to read and write shared state. Many programs use both.
How do I avoid goroutine leaks?
Ensure every goroutine has a way to exit — cancelled context, closed channel, or completed work. Goroutines blocked forever on channel operations leak memory.
Want live Go or backend classes? Join Alkademy for instructor-led programming courses with hands-on projects.
[…] Previous: Lesson 11: Go Goroutines and Channels – Concurrency Made Simple […]