Understanding Concurrency in Golang
One of Go’s standout features is its built-in support for concurrency, made possible through goroutines and channels. These two primitives make it easy to write concurrent programs that are readable, efficient, and safe.
What is a Goroutine?
A goroutine is a lightweight thread managed by the Go runtime.
Starting a goroutine is as simple as using the go keyword:
1 | go doSomething() |
That’s it — now doSomething() runs concurrently with the rest of your program.
Unlike traditional threads, goroutines:
- Are extremely lightweight (a few KB of stack to start)
- Are multiplexed onto system threads by the Go scheduler
- Scale efficiently — you can easily run thousands of them
Example:
1 | func sayHello() { |
Without the time.Sleep, the program might exit before the goroutine finishes — because the main function exits immediately. This highlights the need for synchronization, which brings us to…
What is a Channel?
A channel is a typed conduit through which goroutines can communicate.
Think of channels as pipes that connect goroutines. One goroutine sends data, and another receives it.
1 | ch := make(chan int) // create a channel of int |
Basic Usage
1 | func worker(ch chan string) { |
When you send or receive on a channel, the operation blocks until the other side is ready. This is known as synchronous communication.
Channel Direction
You can restrict the direction of channel usage to make APIs clearer:
1 | func send(ch chan<- int) { |
This helps prevent bugs by making the contract explicit — a great feature for building reusable components.
Buffered Channels
By default, channels are unbuffered: they block until both sender and receiver are ready.
You can also create buffered channels:
1 | ch := make(chan int, 2) |
Buffered channels are useful when you want to decouple sender and receiver speed.
Select: Waiting on Multiple Channels
Go provides the select statement to wait on multiple channel operations:
1 | select { |
This is especially powerful for building responsive systems, implementing timeouts, or handling multiple sources of data.
Common Pitfalls and Best Practices
Deadlocks
A deadlock happens when all goroutines are waiting and none can proceed. Example:
1 | func main() { |
Always ensure sends and receives match up, or use buffering carefully.
Leaky Goroutines
Forgetting to cancel or return from goroutines can lead to goroutine leaks, which consume memory and resources.
Use context cancellation or done channels:
1 | func worker(ctx context.Context) { |
Avoid Sharing Memories by Default
Go’s philosophy is:
“Do not communicate by sharing memory; share memory by communicating.”
Instead of locking shared state with mutexes, try to use channels to synchronize access.
Real-World Example: Fan-Out Pattern
Let’s say you want to parallelize some work:
1 | func worker(id int, jobs <-chan int, results chan<- int) { |
This is a classic fan-out/fan-in pattern — multiple workers process jobs in parallel and send results back.
Conclusion
Goroutines and channels are the foundation of Go’s concurrency model. With just a few keywords (go, chan, select), you can build complex, concurrent systems that are still readable and safe.
If you’re new to concurrency, Go is a fantastic place to start. Just remember:
- Use channels to coordinate work
- Avoid shared memory unless absolutely necessary
- Watch out for goroutine leaks and deadlocks
- Leverage context for cancellation
Further Reading