We want everything fast. Groceries in minutes. Replies in seconds. A date in a few swipes.
We don’t like to wait anymore. So we build software that keeps up. Software that tries to deliver your output as quickly as possible.
But speed doesn’t always mean throwing more resources at the problem. Often, it means using what we have more efficiently.
That’s where concurrency comes in. It’s one of the many tools that help us do more with less.
In this blog, I’ll try to explain concurrency, goroutines, and the complete Go toolkit for working with concurrent code again (the best explanation will always be this 13-year-old video by Rob Pike). This time by throwing a party. ( the second best explanation I think I’ve found on r/golang subreddit)
What is Concurrency?
In ELI5 terms, concurrency is about dealing with multiple things at once. Like your or my brain, we’re always DEALING with multiple things.

In software development, it means your system can handle multiple functions or processes at one time. In Go, this doesn't mean threads or processes like in other languages.
Go allows you to implement concurrency with the help of goroutines and gives you a toolkit for working with concurrent code.
- Goroutines – Start background tasks
- Channels – Send and receive values between goroutines
- WaitGroups – Wait for a set of goroutines to finish
- Semaphores – Limit how many things run at once
- Context – Cancel or time out operations
Let’s understand the toolkit in more depth.
Goroutines: Call your Friends
Instead of rewriting the same definition of goroutines again, let’s see that Reddit post I was talking about.

Remember, the Airbnb house is your Go program.
The friends are your goroutines.
Each kitchen is a CPU core (or logical thread).
The party is the deadline (a goal your program is racing toward).
Each dish (task) is cooked in its own kitchen. You start a goroutine for each meal.
In Go, you start a goroutine using the keyword go
:
go bakeLemonCake(...)
go bakeStrawberryCupcakes(...)
go grillChicken(...)
go cookGoatStew(...)
You don’t wait for one to finish before starting another. That’s the core of Go’s concurrency model.
Channels: Pass the Sugar
None of the friends knows what the right amount of sugar is that goes in the desserts. So once the lemon cake group figures it out, they send the details about the levels over to another group.
sugarLevelChan <- sugarLevel // Sender
data := <-sugarLevelChan // Receiver
Remember, they are not sharing sugar directly. They’re sending information about the sugar level. That’s what channels are for.
Go shares memories by communicating.
Buffered Channels
What if the cupcake team is still busy, but the lemon cake team has already measured the sugar? Instead of waiting for a reply, they leave a sticky note with the value.
In Go code, this translates to:
bufferedChan := make(chan int, 2)
bufferedChan <- 1
bufferedChan <- 2
fmt.Println(<-bufferedChan)
fmt.Println(<-bufferedChan)
That’s a buffered channel. A little message queue. The buffer holds values temporarily.
WaitGroups
You want to serve food only after all dishes are ready. So you ask everyone to send a thumbs-up when they’re done. That’s the function of Waitgroups.
var wg sync.WaitGroup
wg.Add(4)
go func() { bakeLemonCake(); wg.Done() }()
go func() { bakeStrawberryCupcakes(); wg.Done() }()
go func() { grillChicken(); wg.Done() }()
go func() { cookGoatStew(); wg.Done() }()
wg.Wait()
Add()
tasks, then Wait()
for them to complete.
Semaphores: Manage with Only Two Ovens
The house has only two ovens shared by all four kitchens.
Even though each team has its own kitchen, they have to take turns using the oven. But only two teams can use the ovens at once.
You need a way to limit that.
That’s where semaphores come in.
In Go, you'd use a channel as a counting semaphore:
// useOven demonstrates the use of a semaphore (ovenSlots) to limit
// concurrent oven usage
func useOven(dish string, ovenSlots chan struct{}) {
ovenSlots <- struct{}{} // acquire (semaphore pattern)
fmt.Println("Using oven for", dish)
time.Sleep(2 * time.Second) // simulate oven time
<-ovenSlots // release (semaphore pattern)
fmt.Println("Done with", dish)
}
Each team calls useOven()
to wait for an available slot.
// bakeLemonCake demonstrates sending data through a channel and using a WaitGroup
func bakeLemonCake(sugarLevelChan chan<- int, wg *sync.WaitGroup, ovenSlots chan struct{}) {
defer wg.Done()
fmt.Println("Baking lemon cake: Deciding sugar level...")
time.Sleep(1 * time.Second) // Simulate time to decide
sugarLevel := 5
fmt.Printf("Baking lemon cake: Sugar level decided: %d\\n", sugarLevel)
sugarLevelChan <- sugarLevel // Send sugar level to channel
useOven("lemon cake", ovenSlots)
fmt.Println("Baking lemon cake: Done!")
}
Even if four teams start concurrently, only two can use the ovens at a time.
The others wait.
Select Statement: Serve the Dish that’s done first
Now you’re waiting to hear back from the chicken team or the stew team. Whoever finishes first gets served.
// Wait for either chicken or stew to finish first, or timeout after 5 seconds
select {
case dish := <-chickenDone:
fmt.Println(dish, "team finished first and gets served!")
case dish := <-stewDone:
fmt.Println(dish, "team finished first and gets served!")
case <-time.After(5 * time.Second):
fmt.Println("Timeout: No dish finished in time!")
}
You respond to whichever group pings you first.
You don’t wait in line. You respond to the one that’s ready first. That’s what the select statement
helps with.
Party time 🎉
Congratulations. You’ve just thrown a concurrent party in Go. You called your friends (goroutines), passed messages (channels), took turns using the oven (semaphores), and waited for everyone to finish before serving (WaitGroups).
We learned five core ideas behind Go concurrency through this example:
- Goroutines allow multiple tasks to run in parallel with minimal cost.
- Channels enable structured communication between those tasks.
- WaitGroups help coordinate task completion.
- Semaphores (via buffered channels) control access to limited resources.
- Composition: You can combine all of the above to write real, practical concurrent systems.
You can find the complete code on GitHub.
None of these are abstract patterns. They’re heavily used when writing backend systems, CLI tools, or concurrent services.
Wrapping up the Party
Go doesn’t hide concurrency behind frameworks. It gives you simple, composable tools. With just goroutines, channels, and a few sync primitives, you can model everything from a kitchen party to a production-grade microservice.
And that's the real power of Go: it lets you reason about concurrency.
Don't reach for a bigger machine the next time you face a performance bottleneck.
Reach for a goroutine. And maybe a spatula.
For a more complex implementation of Go’s concurrency, check out Treblle’s Go SDK and contribute to the SDK if you can.