Concurrency with Goroutines
Introduction
Concurrency is one of Go's headline features. A goroutine is a lightweight thread managed by the Go runtime. Starting a goroutine is as simple as putting the keyword go before a function call. Go can run hundreds of thousands of goroutines simultaneously — far more than traditional OS threads.
Python's approach to concurrency is constrained by the Global Interpreter Lock (GIL), which prevents true parallel execution of Python bytecode across multiple CPU cores. Python offers threading (limited by the GIL), multiprocessing (bypasses the GIL but heavy), and asyncio (single-threaded cooperative concurrency). Go has none of these limitations — goroutines are truly parallel.
Communication between goroutines in Go is done through channels — typed conduits through which goroutines can send and receive values. Go's philosophy is: "Don't communicate by sharing memory; share memory by communicating."
GitHub Copilot tip: Concurrency is one of the harder topics to get right. Use Copilot Chat freely here — ask it to explain any code, suggest fixes, or demonstrate patterns like worker pools and pipelines.
Concepts
Goroutines
// Start a goroutine — runs concurrently with the rest of the program
go myFunction()
// Anonymous goroutine
go func() {
fmt.Println("running in a goroutine")
}()
sync.WaitGroup — wait for goroutines to finish
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
// ... do work ...
}()
wg.Wait() // blocks until all Done() calls match Add() calls
Channels
// Create a channel
ch := make(chan int)
// Send (blocks until received)
ch <- 42
// Receive (blocks until sent)
value := <-ch
// Buffered channel — doesn't block until buffer is full
bch := make(chan string, 10)
sync.Mutex — protect shared data
var mu sync.Mutex
var counter int
mu.Lock()
counter++
mu.Unlock()
Examples
package main
import (
"fmt"
"sync"
"time"
)
func fetchData(id int, wg *sync.WaitGroup, results chan<- string) {
defer wg.Done()
time.Sleep(time.Millisecond * 100) // simulate work
results <- fmt.Sprintf("result from worker %d", id)
}
func main() {
results := make(chan string, 5)
var wg sync.WaitGroup
for i := 1; i <= 5; i++ {
wg.Add(1)
go fetchData(i, &wg, results)
}
// Close channel when all goroutines finish
go func() {
wg.Wait()
close(results)
}()
// Read results as they arrive
for result := range results {
fmt.Println(result)
}
}
Challenges
1. Your first goroutine
- Write a program that launches three goroutines, each printing a different message.
- Use
sync.WaitGroupto wait for all three to complete before the program exits.
Ask Copilot: What happens if I don't use WaitGroup and the program's main function exits before the goroutines finish?
💡 Show Answer
package main
import (
"fmt"
"sync"
)
func printMessage(msg string, wg *sync.WaitGroup) {
defer wg.Done()
fmt.Println(msg)
}
func main() {
var wg sync.WaitGroup
messages := []string{"Hello from goroutine 1", "Hello from goroutine 2", "Hello from goroutine 3"}
for _, msg := range messages {
wg.Add(1)
go printMessage(msg, &wg)
}
wg.Wait()
fmt.Println("All goroutines finished")
}
If main returns before the goroutines finish, the entire program exits and the goroutines are killed, even if they haven't completed. WaitGroup prevents this.
2. Goroutine order is non-deterministic
- Run the program from challenge 1 five times.
- Observe whether the messages always appear in the same order.
- Ask Copilot: Why is the output order of goroutines not guaranteed?
💡 Show Answer
The order changes between runs because goroutines are scheduled by the Go runtime, which distributes them across available CPU cores. The OS decides which goroutine runs at any given moment — there is no guarantee of order. This is fundamental to concurrent programming and is true in Python's threading module too.
If you need ordering guarantees, use channels to coordinate goroutines explicitly.
3. Send results via a channel
- Write a program that launches five goroutines, each computing the square of a number (1–5).
- Each goroutine sends its result to a channel.
- The main goroutine receives all five results and prints them.
💡 Show Answer
package main
import "fmt"
func main() {
ch := make(chan int, 5) // buffered so goroutines don't block
for i := 1; i <= 5; i++ {
go func(n int) {
ch <- n * n
}(i)
}
for i := 0; i < 5; i++ {
fmt.Println(<-ch)
}
}
4. Protect shared state with a Mutex
- Write a program that launches 100 goroutines, each incrementing a shared counter once.
- First, do it without a mutex and run it multiple times — observe inconsistent results.
- Then add a
sync.Mutexand confirm the counter always reaches 100.
Ask Copilot: What is a race condition and how does the Go race detector help?
💡 Show Answer
package main
import (
"fmt"
"sync"
)
func main() {
var mu sync.Mutex
var counter int
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
counter++
mu.Unlock()
}()
}
wg.Wait()
fmt.Println("Counter:", counter) // Always 100 with the mutex
}
Run without the mutex with go run -race main.go to see the race condition detected automatically. The Go race detector is a built-in tool with no Python equivalent.
5. Pipeline pattern
- Implement a simple pipeline: one goroutine generates numbers 1–10 and sends them to a channel; a second goroutine receives them, squares each one, and sends the results to a second channel; the main goroutine prints the final results.
💡 Show Answer
package main
import "fmt"
func generate(nums ...int) <-chan int {
out := make(chan int)
go func() {
for _, n := range nums {
out <- n
}
close(out)
}()
return out
}
func square(in <-chan int) <-chan int {
out := make(chan int)
go func() {
for n := range in {
out <- n * n
}
close(out)
}()
return out
}
func main() {
nums := generate(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
squares := square(nums)
for result := range squares {
fmt.Println(result)
}
}
6. Compare to Python asyncio
- Ask Copilot: How does Go's goroutine model compare to Python's asyncio? When would you use each?
- Read the explanation and note the key differences.
💡 Show Answer
| Feature | Go goroutines | Python asyncio |
|---|---|---|
| Model | True parallelism (multi-core) | Cooperative multitasking (single thread) |
| Syntax | go func() | async def / await |
| Blocking calls | Safe — Go can park the goroutine | Blocks the entire event loop unless awaited |
| Memory | ~2KB per goroutine (grows as needed) | Larger per coroutine |
| Best for | I/O + CPU-bound work | I/O-bound work (network, file) |
Choose asyncio when you are integrating with an async Python ecosystem (FastAPI, aiohttp). Choose Go goroutines when you need true CPU parallelism, need to handle very large numbers of concurrent connections, or want simpler concurrency code without async/await propagating through every layer of your codebase.
7. select statement
- Ask Copilot to show you an example of Go's
selectstatement for reading from multiple channels. - Use
/explainto understand the output.
💡 Show Answer
package main
import (
"fmt"
"time"
)
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go func() {
time.Sleep(1 * time.Second)
ch1 <- "one"
}()
go func() {
time.Sleep(2 * time.Second)
ch2 <- "two"
}()
for i := 0; i < 2; i++ {
select {
case msg := <-ch1:
fmt.Println("Received from ch1:", msg)
case msg := <-ch2:
fmt.Println("Received from ch2:", msg)
}
}
}
select is like a switch for channels — it waits until one of its cases can proceed, then executes that case. This is the idiomatic Go way to multiplex work across multiple channels without blocking on any one of them.