Skip to main content

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.WaitGroup to 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.Mutex and 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
FeatureGo goroutinesPython asyncio
ModelTrue parallelism (multi-core)Cooperative multitasking (single thread)
Syntaxgo func()async def / await
Blocking callsSafe — Go can park the goroutineBlocks the entire event loop unless awaited
Memory~2KB per goroutine (grows as needed)Larger per coroutine
Best forI/O + CPU-bound workI/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 select statement for reading from multiple channels.
  • Use /explain to 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.