Goroutine Made Easy :)

In this article I would like to cover what you need to know about goroutines… With almost zero jargon. In Short: Why do we need Concurrency? I have already covered this…

In this article I would like to cover what you need to know about goroutines… With almost zero jargon.

In Short: Why do we need Concurrency?
I have already covered this in this article on channels in go.

Imagine you run a cafe. You have one employee… let’s call him Raju.

A customer walks in. Raju takes the order, walks to the machine, makes the coffee and brings it back. Only then does Raju walk to the next customer… isn’t it?

Now Imagine you hire more Raju(s). Each customer gets their own Raju. While one Raju is waiting for the expresso machine, another Raju is taking orders, another is delivering the food. The cafe runs 10x faster with the same kitchen.

That’s concurrency. And in Go, each “Raju” is called a goroutine.

Threads, Goroutines (The difference)

Before Go, the standard way to do the concurrent work was OS threads. Think of threads like hiring full-time employees… each one has their own desk, their own computer, their own salary. They’re powerful, but expensive. Spinning up a thousand OS threads will bring your system to its knees.

Go introduced goroutines; think of them like freelance micro-workers. They’re incedibly cheap to create, use tiny amounts of memory (starting at just ~2kb of stack space vs ~1mb for an OS thread), and Go manages them itself without asking the OS.

In practice, you can spin up hundreds of thousands of goroutines on a reguklar laptop without breaking a sweat.

The go keyword

Starting a goroutine in Go is the simplest thing in the language. You take any function call and put go in front of it.

//Normal function call
sendEmail("Loll!")

// goroutine runs concurrently does not block
go sendEmail("Lol!")

func sendEmail(message string){
  go func(){
    time.Sleep(time.Millisecond*250)
    fmt.Printf("Email recieved: %s\n", message)
  }
  fmt.Printf("Email send: %s\n", message)
}

That’s it. Two letters.

Anonymous Goroutines

Look at the previous example again:

go func(){
    time.Sleep(time.Millisecond*250)
    fmt.Printf("Email recieved: %s\n", message)
}

this is an anonymous goroutine… a function with no name that’s defined and immedietly called. You’ll see this pattern everywhere in Go.

func(){...} defines an anonymous function

() at the end calls it immedietly

go at the start run it as a goroutine

Note that the anonymous goroutine here captures message from the outer function. This is called a closure… the goroutine carries the variable with it.

The Closure Trap

Closures are powerful but come with a famous pitfal. Let’s say you want to launch a goroutine for each item in the loop:

//don't do this
for i:=0;i<5;i++{
  go func(){
    fmt.Println(i) // which i does this print.??
  }()
}

Output might be: 0 1 2 3 4 or 5 5 5 5 5

Why? Because goroutine captures the variable i not it’s value at the moment of launch. By the time the goroutines actually run, the loop has already finished and i is 5.

The fix: pass the value as argument

for i:=0;i<5;i++{
  go func(n int){
    fmt.Println(n)
  }(i)
}

By passing i as the argument n, you create a copy of the valye at that moment. Each goroutine gets its own private n.

The main Goroutine is the Boss

Your entire go program is itself a goroutine… The main goroutine.

When the main goroutine finishes, the program exits. It doesn’t wait for other goroutines to finish.

func main(){
  go func(){
    time.Sleep(1*time.Second)
    fmt.Prinln("I never get printed")
  }()
  fmt.Println("main is done")
}

//Output:
//main is done

The goroutine is abandoned. It never runs.

While time.Sleep is a hack we used in above example to give our goroutine time to execute before main finishes in actual scenerio we usually use sync.WaitGroup ro channels to properly wait for goroutines to complete.

How the Go Scheduler Works (Simplified view)

I don’t know about you but I wondered: If I launch 10,000 goroutines, does Go create 10,000 OS threads?

No. That would be insane.

Go uses a model called M:N scheduling… which means M goroutines run on N OS threads, where N is typically the number of CPU cores you have.

Think of it like a restaurant again:

  • Goroutines = orders waiting to be cooked
  • OS threads = the actual stoves/burners
  • Go scheduler = the head chec who decides which order goes on which burner

If one goroutine is blocked (waiting for a network response, sleeping, waiting on a channel), the scheduler picks up another goroutine and runs it on the same thread. NO CPU TIME IS WASTED.

This is why you can have millions of goroutines… most of them are just waiting for something, and they cost almost nothing while they wait.

You can set the number of OS threads with runtime.GoMAXPROCS(n), though in modern Go it defaults to the number of CPU cores… which is almost always what you want it to be.

Goroutines don’t return values

yeah… I almost forgot to talk about this… here’s a limitation that trips up the noobs. A goroutine launhed with go cannot return a value.

result := go someFunciton()

go func()int{
  return 42
}

// these doesn't compile

Why? Because the goroutine runs concurrently. By the time it produces a value, your original code has moved on. There’s nothing waiting to catch a return value.

SO how do you get data out of a goroutine??

You use a channel… a pipe that the goroutine can push values into, and your main code can pull values from.

Checkout this article https://medium.com/@abhay.tiwari.er/channels-in-go-almost-all-about-it-6d8a2687a0a2

ch := make(chan int)

go func(){
  ch<-42
}

result := <-ch
fmt.Println(result) // 42

Goroutines and Panic

Every goroutine has its own call stack… a kind of private record of which functions are currently running inside it.

When a goroutine panics, the panic unwinds only that goroutine’s stack. Deferred functions in that goroutine run. If nothing recovers from the panic, the entire program crashes… (Entire not just the goroutine that panicked.)

go func(){
  defer func(){
    if r:= recover(); r!= nil{
      fmt.Println("Recovered from panic:", r)
    }
  }()
  panic("Something went wrong")
}()

Some rules to remember:

  • recover only works inside a defer in the same goroutine that panicked
  • You can’t recover from a panic from a different goroutine
  • An uncovered panic in any goroutine kills the whole program

This is why long-running goroutines (like servers handling requests) often wrap thier body in a defer recover() to catch unexpected panics and log them rather than crashing everything.

But the general rule is: use error values for expected failures, and only use panic for truly unrecoverable situations.

Goroutine Leaks

A goroutine leak happens when a goroutine is started but can never finish. It’s stuck forever silently consuming memory.

The most common cause is a goroutine is waiting to send or recieve on a channel, but nobody every shows up on the other end.

//leak: this goroutine is stuck forever
func startLeak(){
  ch:= make(chan int)
  go func(){
    val:= <-ch // waiting... waiting... waiting...
    fmt.Println(val) 
  }()
//ch is never snet to, and falls out of scope
// the goroutine is stuck forever
}

Goroutine leaks are silent… they don’t crash your program immedietly, they just make it slowly eat more and more memory until things go wrong.

Avoiding Leaks:

  1. Always ensure every goroutine has a way to exit. Either through a channel being closed, a context.Context being cancelled, or a done signal
  2. Use context.WithCancel and context.WithTimeout for goroutines that should stop after a certain time
  3. In tests and production services, use tools liek goleak to detect leaked groutines

Concurrency is Not Parallelism

This distinction even I wasn’t aware of till the time I sat down and researched to write this article.

  • Concurrency: dealing with many things at once (structuring program to handle multiple tasks at once)
  • Parallelism: doing many things at once (actually running multiple taks simultaneously on multiple CPUs)

A single-core CPU can run goroutines concurrently… it happens by switching between them so fast that it appears simultaneous… but it can run one at a time. True parallelism requires multiple CPU cores.

Note: Go gives you both. Your goroutines run concurrently by default, and if you have multiple CPU cores available, Go automatically parallelises them.

“Concurrency is about structure. Parallelism is about execution.”
_Rob Pike (One of Go’s creators)

The Rules of Goroutines

Here’s everything distilled into rules you can actually remember:

  1. Starting a goroutine is one word go go myFunction() go func(){...}()
  2. Goroutines are cheap… you can use them liberally
  3. The main goroutine won’t wait… so synchronize explicitly. Use channels sync.WaitGroup , or time.Sleep to ensure goroutines complete before main exits
  4. Goroutines don’t return values… usechannels
  5. Avoid the closure loop trap… pass variables as arguments
  6. Panic in a goroutine kills the whole program… protect long running goroutines with defer recover() when appropriate
  7. Always give goroutines a way to exit

An Example to see the real action

Sequential Version (Slow):

func processItems(items []string) []string {
  results := make([]string, len(items))
  for i, item:= range items {
    results[i] = slowProcess(item) // each one waits for the previous
  }
  return results
}

If slowProcess takes 1 second and you have 10 items, this takes 10 seconds.

Concurrent Version (Fast):

func processItemsConcurrent(items []string) []string {
  results := make([]string, len(items))
  ch := make(chan struct{idx int; val string})
   
  for i, item:= range items {
    go func(idx int, s string){
      ch <- struct{idx int; val string}{idx, slowProcess(s)}
    }(i, item)
  }
  
  for range items {
    r:= <- ch
    results[r.idx] = r.val
  {
  return results
}

All 10 items now process concurrently. Total time ~1 second, not 10.

Quick Reference

//launch a named function as a goroutine
go myFunc()

//launch an anonymous goroutine
go func(){
    //do work
}()

//pass arguments safely (avoiding closure traps)
go func(x int)
  fmt.Println(x)
}(42)

//goroutines communicate via channels
ch := make(chan string)
go func() {
  ch <- "result"
}()
result := <-ch

//protect groutine from panic
go func(){
  defer func(){
    if r:= recover(); r!=nil{
      log.Println("Recovered:" , r)
    }
  }()
  riskyWork()
}()

Summary

That’s it… I think you now know almost everything about goroutines that you need to know before you starting working with it.

If you have not yet checked my previous article on channels I will highly recommend checking it now because channels are the go to way to get returns out of goroutines.

Thanks… Let me know if you have any feedback on the article or anything else that I should cover.