Skip to Content
Course content

29: Goroutines Explained

Click on the "Edit" button in the top corner of the screen to edit your slide content.

When I first started with Go, I fell into a trap that almost every developer does: I assumed a goroutine was just a fancy name for a thread. It sounds like a pedantic distinction, but thinking of them as 1:1 mappings to OS threads will lead you to write inefficient code and misunderstand how Go actually scales.

"Goroutines are just lightweight OS threads"

If goroutines were simply OS threads, your computer would buckle under the pressure long before you hit a million of them. Standard OS threads are expensive; they have a fixed-size stack (often 1MB or 2MB) and the context switch—the process of the CPU swapping from one thread to another—requires a trip to the kernel, which is slow.

Try to imagine creating 100,000 OS threads on your laptop. You'd likely run out of memory or crash the system because you'd be asking for 100GB+ of RAM just for the stacks. But in Go, I can spin up 100,000 goroutines on a cheap VPS and it won't even make the fan spin up. Here is why that's possible:

package main

import (
	"fmt"
	"runtime"
)

func main() {
	// I'm launching 100,000 goroutines here.
	// If these were OS threads, this program would likely crash.
	for i := 0; i < 100000; i++ {
		go func(id int) {
			_ = id // simulate some work
		}(i)
	}
	fmt.Printf("Running on %d OS threads\n", runtime.GOMAXPROCS(0))
}

Multiplexing via the Go Runtime

The reality is that Go uses an M:N scheduler. This means it multiplexes M goroutines onto N OS threads. The Go runtime handles the heavy lifting; it manages a pool of OS threads and schedules goroutines to run on them. When a goroutine blocks—say, it's waiting for a network response—the scheduler simply moves it off the OS thread and puts another runnable goroutine in its place. The OS thread never actually sleeps; it just switches tasks.

Another huge win is the stack. While an OS thread has a rigid size, a goroutine starts with a tiny 2KB stack that grows and shrinks dynamically. I've always found this to be the "secret sauce" of Go's concurrency: you don't have to budget your memory for every single concurrent task you launch.

Triggering Execution with the go Keyword

Actually using them is deceptively simple. You just put the word go before a function call. But there is a catch: the main function doesn't care about your goroutines. If main reaches its closing brace, the program terminates immediately, killing every background goroutine regardless of whether they finished their work.

Let's look at a real-world scenario: checking the status of multiple websites. If we did this sequentially, we'd wait for Site A to respond before even asking Site B. With goroutines, we fire off all the requests at once.

package main

import (
	"fmt"
	"net/http"
	"time"
)

func checkSite(url string) {
	resp, err := http.Get(url)
	if err != nil {
		fmt.Printf("[!] %s is down\n", url)
		return
	}
	fmt.Printf("[✓] %s returned %d\n", url, resp.StatusCode)
}

func main() {
	sites := []string{
		"https://google.com",
		"https://golang.org",
		"https://github.com",
		"https://nonexistent-site-123.com",
	}

	for _, site := range sites {
		// We launch each check in its own goroutine
		go checkSite(site)
	}

	// I'm adding a sleep here just so the program doesn't exit 
	// before the goroutines finish. In a real app, 
	// you'd use a WaitGroup or Channels (which we'll cover soon).
	time.Sleep(2 * time.Second)
	fmt.Println("Finished checking sites.")
}

Notice that the order of the output will be random. Since the goroutines are scheduled independently, the fastest website to respond will likely print its result first, regardless of its position in the slice. This non-deterministic behavior is the price we pay for the massive performance gain.




📋 Practical Task

Exercise: Concurrent Image Processing Simulator

You are building a system that simulates processing high-resolution images. Each "process" takes a different amount of time based on the image size.

Your Goal: Modify the provided code so that the image processing happens concurrently. Currently, the program processes images one by one, which is too slow. You must use the go keyword to ensure all images start processing at the same time.

package main

import (
	"fmt"
	"time"
)

func processImage(name string, duration time.Duration) {
	fmt.Printf("Starting %s...\n", name)
	time.Sleep(duration) // Simulate heavy processing
	fmt.Printf("Finished %s after %v\n", name, duration)
}

func main() {
	images := map[string]time.Duration{
		"vacation.jpg": 500 * time.Millisecond,
		"portrait.png": 200 * time.Millisecond,
		"landscape.webp": 800 * time.Millisecond,
		"thumbnail.jpg": 100 * time.Millisecond,
	}

	for name, duration := range images {
		// TODO: Make this call concurrent
		processImage(name, duration)
	}

	// Keep the main function alive long enough to see the results
	time.Sleep(1 * time.Second)
	fmt.Println("All images processed.")
}

Success Criteria:

  • The program should output "Starting..." for all images before it starts outputting "Finished..." for the longer tasks.
  • The total execution time should be roughly equal to the longest single task (800ms) rather than the sum of all tasks (1.6s).
Rating
0 0

There are no comments for now.

to be the first to leave a comment.