Understanding Goroutines in Go

When building concurrent applications in Go, one of the first concepts you’ll encounter is the goroutine: a lightweight thread managed by the Go runtime.

But what exactly is a goroutine? How is it different from a traditional thread? When should you use one and how do you manage its lifecycle? In this post, we’ll dive into the core ideas behind goroutines, how they work and what you should watch out for.

What is a Goroutine?

A goroutine is a function or method executing concurrently with other goroutines in the same address space. They are incredibly cheap to create (in terms of memory and scheduling) and are the building blocks of concurrency in Go.

Unlike OS threads, goroutines are multiplexed onto a smaller number of system threads by the Go runtime. This makes them extremely scalable: it’s common to run thousands or even millions of goroutines without exhausting system resources.

The Main Goroutine

Every Go application starts with one goroutine: the main goroutine. This is the one executing your main() function. Any other goroutine must be explicitly started from this (or from another goroutine).

func main() {
    fmt.Println("This is the main goroutine.")
}

If your program only contains this, then it’s a single-goroutine program. But as soon as you introduce concurrency, you’re in goroutine land.

Launching a New Goroutine

You can start a new goroutine by prefixing a function or method call with the go keyword:

go doWork()

This call returns immediately and doWork() runs concurrently in a separate goroutine.

For example:

package main

import (
	"fmt"
)

func main() {
	fmt.Println("This is the main goroutine.")

	go doWork()

	fmt.Println("Main goroutine finished.")
}

func doWork() {
	fmt.Println("Doing some work...")
}

Let’s execute this code and take a look at the output:

This is the main goroutine.
Main goroutine finished.

Wait… What?!? Where is the "Doing some work…" output?

Let’s break it down: when you call go doWork(), that line schedules the function to run as a goroutine. But it doesn’t wait for it to finish, the main goroutine keeps running. And in this case, it immediately reaches the end of main().

Here’s the catch: when the main goroutine exits, the entire program terminates. That includes any goroutines that haven’t finished yet. So if the scheduler hasn’t had time to start doWork() before main() exits, the program just ends and your goroutine never gets a chance to run.

This introduces one of the most important principles when working with goroutines:

When main() ends, the whole process ends, regardless of how many goroutines are still running.

To prevent this, we often use tools like sync.WaitGroup, channels or just a temporary time.Sleep() to keep main() alive long enough for background goroutines to do their work. Let’s change our code and check it again:

package main

import (
	"fmt"
	"time"
)

func main() {
	fmt.Println("This is the main goroutine.")

	go doWork()

	// Put this sleep here, just for 10 milliseconds:
	time.Sleep(10 * time.Millisecond)

	fmt.Println("Main goroutine finished.")
}

func doWork() {
	fmt.Println("Doing some work...")
}

As you can see, we added this line:

	time.Sleep(10 * time.Millisecond)

This causes the main goroutine to block itself in that line for just 10 milliseconds and then continue its flow. You can see how fast things are going now. Let’s check the output:

This is the main goroutine.
Doing some work...
Main goroutine finished.

There it its! Note that sleeping for just 10 ms in the main goroutine allows the new goroutine to have chance to execute.

Goroutine Lifecycle

A goroutine starts when you invoke it with go. From that point on:

  • It runs independently of its parent goroutine.
  • It executes until the function returns.
  • Once it finishes execution, it is automatically cleaned up by the runtime.
  • There is no implicit way to stop or interrupt a running goroutine from outside (more on that later).

Goroutines are not bound to threads. The Go scheduler moves them between OS threads as needed, depending on availability, blocking operations and system load.

Communication and Coordination

While this post focuses solely on goroutines, it’s worth mentioning that goroutines usually need to coordinate. Since they share memory, access to shared variables must be synchronized to avoid race conditions.

In Go, the idiomatic way to communicate between goroutines is by sharing channels, not memory.

We’ll cover that in a separate post. For now, remember this rule of thumb:

Don’t communicate by sharing memory. Share memory by communicating. Rob Pike

This principle is at the heart of Go’s concurrency model. Instead of multiple goroutines modifying shared state directly, they should send messages through channels that encapsulate that state or intent.

That said, many developers (especially those coming from traditional multithreaded programming) are tempted to share variables across goroutines using a sync.Mutex or sync.RWMutex. While Go does support this and mutexes are useful tools in certain low-level scenarios, using them as your default approach often leads to fragile, tightly-coupled designs.

Here’s the problem: just adding a mutex doesn’t mean your code is safe or well-structured. You’re still exposing shared state and now you’ve also introduced the possibility of:

  • Deadlocks.
  • Priority inversion.
  • Forgotten Unlock() calls.
  • Difficult-to-reproduce race conditions.

More importantly, it goes against Go’s philosophy. By using channels, you decouple the data from the access and make your intentions explicit: “this goroutine owns the data, others can request or send info through a well-defined channel.”

If you absolutely must share state, keep it private to one goroutine and expose it through operations sent via a channel. Or, at the very least, encapsulate access cleanly behind a safe abstraction.

We’ll explore channels in detail in a dedicated post, but keep this in mind for now: using mutexes without understanding the lifecycle and interaction of goroutines can be dangerous. Channels aren’t just safer, they’re more expressive and in line with Go’s concurrency model.

Stopping a Goroutine

Once a goroutine starts running, it cannot be forcefully killed from the outside. Go deliberately avoids providing a “kill goroutine” API to keep things predictable.

If you want to stop a goroutine early, you need to design it to stop, usually by:

Here’s a quick example introducing the select statement:

package main

import (
	"fmt"
	"time"
)

func main() {
	stop := make(chan struct{})
	go worker(stop)

	time.Sleep(2 * time.Second)

	close(stop)

	time.Sleep(time.Second)
}

func worker(stop <-chan struct{}) {
	for {
		select {
		case <-stop:
			fmt.Println("Stopping goroutine")
			return

		default:
			fmt.Println("Working...")
			time.Sleep(500 * time.Millisecond)
		}
	}
}

And its output:

Working...
Working...
Working...
Working...
Stopping goroutine

Process finished with the exit code 0

This lets your goroutine exit gracefully, rather than getting cut off in the middle of execution.

Best Practices when working with Goroutines

  • Always be aware of the lifecycle of the goroutine. Make sure they don’t leak or hang indefinitely.
  • Avoid spawning goroutines in tight loops unless you are sure of their termination.
  • Always consider how you’ll coordinate and stop them.
  • Prefer explicit termination logic using channels or contexts.
  • Don’t ignore returned errors or panics inside goroutines: if something goes wrong inside, it might go unnoticed.

Conclusion

Goroutines are one of the most powerful features in Go, but with great power comes great responsibility. Knowing how to spawn, manage and terminate them correctly is essential to building reliable concurrent programs.

In upcoming posts, we’ll explore how goroutines interact with channels, how to manage their lifecycle using context.Context and how to design resilient concurrent systems using these tools together.

Stay tuned! Concurrency in Go gets more interesting from here on.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top