⚠️How does this series go?
1. Run each example: Don’t just look at the code. Enter it, run it, and watch its behavior.
2. Experiment and break things: Remove sleep and see what happens, change channel buffer size, modify goroutine count.
Breaking things teaches you how they work
3. Reasons for behavior: Before running the modified code, try to predict the results. When you see unexpected behavior, stop and think about why. Challenge interpretation.
4. Establish a mental model: Each visualization represents a concept. Try drawing your own diagram of the modified code.
This is Part 1 our “Mastering Go Concurrency” Series we will cover:
- How goroutine works and its life cycle
- Channel communication between Goroutines
- Buffered channels and their use cases
- Practical examples and visualizations
We’ll start with the basics and gradually develop an intuition for how to use them effectively.
This is going to be a little long, quite a long one, so be prepared.
We’ll be hands-on with the entire process.
The basics of Goroutines
Let’s start with a simple program that downloads multiple files.
package main
import (
"fmt"
"time"
)
func downloadFile(filename string) {
fmt.Printf("Starting download: %s\n", filename)
// Simulate file download with sleep
time.Sleep(2 * time.Second)
fmt.Printf("Finished download: %s\n", filename)
}
func main() {
fmt.Println("Starting downloads...")
startTime := time.Now()
downloadFile("file1.txt")
downloadFile("file2.txt")
downloadFile("file3.txt")
elapsedTime := time.Since(startTime)
fmt.Printf("All downloads completed! Time elapsed: %s\n", elapsedTime)
}
The program takes a total of 6 seconds because each 2-second download must complete before the next download can begin. Let’s imagine this:
We can lower this time, let’s modify our program to use follow a routine:
Notice:
go
keyword before function call
package main
import (
"fmt"
"time"
)
func downloadFile(filename string) {
fmt.Printf("Starting download: %s\n", filename)
// Simulate file download with sleep
time.Sleep(2 * time.Second)
fmt.Printf("Finished download: %s\n", filename)
}
func main() {
fmt.Println("Starting downloads...")
// Launch downloads concurrently
go downloadFile("file1.txt")
go downloadFile("file2.txt")
go downloadFile("file3.txt")
fmt.Println("All downloads completed!")
}
Wait what? Nothing printed? Why?
Let’s imagine this to understand what might happen.
From the above visualization, we understand that the main function exists before the goroutine completes. One observation is that the life cycle of all goroutines depends on the main function.
notes:
main
The function itself is a goroutine 😉
To solve this problem, we need a way to make the main goroutine wait for other goroutines to complete. There are several ways to do this:
- Wait a few seconds (hacky way)
- use
WaitGroup
(Correct method, next step) - use channel (We’ll cover this below)
let us wait a few seconds so that the go routine completes.
package main
import (
"fmt"
"time"
)
func downloadFile(filename string) {
fmt.Printf("Starting download: %s\n", filename)
// Simulate file download with sleep
time.Sleep(2 * time.Second)
fmt.Printf("Finished download: %s\n", filename)
}
func main() {
fmt.Println("Starting downloads...")
startTime := time.Now() // Record start time
go downloadFile("file1.txt")
go downloadFile("file2.txt")
go downloadFile("file3.txt")
// Wait for goroutines to finish
time.Sleep(3 * time.Second)
elapsedTime := time.Since(startTime)
fmt.Printf("All downloads completed! Time elapsed: %s\n", elapsedTime)
}
The problem is, we may not know how long it will take coroutine May be required. In some cases, we each have a constant time, but in real scenarios, we know that the download time will vary.
Coming sync.WaitGroup
one sync.WaitGroup
The concurrency control mechanism in Go is used to wait for a collection of goroutines to complete execution.
Let’s see it in action and visualize it:
package main
import (
"fmt"
"sync"
"time"
)
func downloadFile(filename string, wg *sync.WaitGroup) {
// Tell WaitGroup we're done when this function exits
defer wg.Done()
fmt.Printf("Starting download: %s\n", filename)
time.Sleep(2 * time.Second)
fmt.Printf("Finished download: %s\n", filename)
}
func main() {
fmt.Println("Starting downloads...")
var wg sync.WaitGroup
// Tell WaitGroup we're about to launch 3 goroutines
wg.Add(3)
go downloadFile("file1.txt", &wg)
go downloadFile("file2.txt", &wg)
go downloadFile("file3.txt", &wg)
// Wait for all downloads to complete
wg.Wait()
fmt.Println("All downloads completed!")
}
Let’s imagine this and understand how it works sync.WaitGroup
:
Counter mechanism:
-
WaitGroup
Maintain an internal counter -
wg.Add(n)
Counter incrementsn
-
wg.Done()
Decrement the counter1
-
wg.Wait()
Block until the counter is reached0
Synchronization process:
- Main coroutine call
Add(3)
Before starting the goroutine - Every goroutine will call
Done()
when it’s done - The main Goroutine is blocked on
Wait()
until the counter hits0
- When the counter reaches
0
the program continues and exits cleanly
Common pitfalls to avoid
// DON'T do this - Add after launching goroutine
go downloadFile("file1.txt", &wg)
wg.Add(1) // Wrong order!
// DON'T do this - Wrong count
wg.Add(2) // Wrong number!
go downloadFile("file1.txt", &wg)
go downloadFile("file2.txt", &wg)
go downloadFile("file3.txt", &wg)
// DON'T do this - Forgetting Done()
func downloadFile(filename string, wg *sync.WaitGroup) {
// Missing Done() - WaitGroup will never reach zero!
fmt.Printf("Downloading: %s\n", filename)
}
channel
In this way we have a good understanding of how goroutines work. No, how do two Go routines communicate? This is where channels come into play.
channel Concurrency primitives in Go are used for communication between goroutines. They provide a way for goroutines to safely share data.
Treat channels as pipes: One goroutine can send data to the channel, and another goroutine can receive the data.
Here are some properties:
- Channels are blocking by nature.
- one Send to channel Operation
ch <- value
block Until other goroutines receive messages from the channel. - one receive from channel Operation
<-ch
block Until other goroutines are sent to the channel.
package main
import "fmt"
func main() {
// Create a channel
ch := make(chan string)
// Send value to channel (this will block main)
ch <- "hello" // This line will cause deadlock!
// Receive value from channel
msg := <-ch
fmt.Println(msg)
}
why ch <- "hello"
Causing a deadlock? Since the channel is blocking by nature and we are passing "hello"
It blocks the main goroutine until there is a receiver, and since there is no receiver it gets stuck.
Let’s solve this problem by adding goroutine
package main
import "fmt"
func main() {
ch := make(chan string)
// sender in separate goroutine
go func() {
ch <- "hello" // will no longer block the main goroutine
}()
// Receive in main goroutine
msg := <-ch // This blocks until message is received
fmt.Println(msg)
}
Let’s imagine this:
This time the news is Sent from a different goroutine so main won’t block When sent to a channel, it moves to msg := <-ch
It blocks the main goroutine until a message is received.
Use channels to fix main without waiting for other issues
Now let’s use channels to fix the archive downloader issue (main doesn’t wait for others to finish).
package main
import (
"fmt"
"time"
)
func downloadFile(filename string, done chan bool) {
fmt.Printf("Starting download: %s\n", filename)
time.Sleep(2 * time.Second)
fmt.Printf("Finished download: %s\n", filename)
done <- true // Signal completion
}
func main() {
fmt.Println("Starting downloads...")
startTime := time.Now() // Record start time
// Create a channel to track goroutine completion
done := make(chan bool)
go downloadFile("file1.txt", done)
go downloadFile("file2.txt", done)
go downloadFile("file3.txt", done)
// Wait for all goroutines to signal completion
for i := 0; i < 3; i++ {
<-done // Receive a signal for each completed goroutine
}
elapsedTime := time.Since(startTime)
fmt.Printf("All downloads completed! Time elapsed: %s\n", elapsedTime)
}
Visualize it:
Let’s do a walkthrough to understand better:
Program starts:
The main Goroutine establishes the completion channel
Start three download goroutines
Each goroutine will get a reference to the same channel
Download execution:
- All three downloads run simultaneously
- Each takes 2 seconds
- They may be completed in any order
channel loop:
- The main coroutine enters the loop:
for i := 0; i < 3; i++
- each
<-done
Blocks until a value is received - This loop ensures that we wait for all three completion signals
cyclical behavior:
- Iteration 1:Block until first download completes
- Iteration 2: Block until the second download is complete
- Iteration 3: Block until the final download is complete
The order of completion doesn’t matter!
Observations:
⭐ There is only one reception (<-done) per send (done <- true)
⭐ The main coroutine coordinates everything through loops
How do two goroutines communicate?
We have seen how two goroutines communicate. when? All the time. Let’s not forget that the main function is also a goroutine.
package main
import (
"fmt"
"time"
)
func sender(ch chan string, done chan bool) {
for i := 1; i <= 3; i++ {
ch <- fmt.Sprintf("message %d", i)
time.Sleep(100 * time.Millisecond)
}
close(ch) // Close the channel when done sending
done <- true
}
func receiver(ch chan string, done chan bool) {
// runs until the channel is closed
for msg := range ch {
fmt.Println("Received:", msg)
}
done <- true
}
func main() {
ch := make(chan string)
senderDone := make(chan bool)
receiverDone := make(chan bool)
go sender(ch, senderDone)
go receiver(ch, receiverDone)
// Wait for both sender and receiver to complete
<-senderDone
<-receiverDone
fmt.Println("All operations completed!")
}
Let’s imagine this and test run it:
Trial run:
Program starts (t=0ms)
- The main goroutine initializes three channels:
-
ch
: Used for messages. -
senderDone
:Sends a completion signal to the sender. -
receiverDone
: Indicates receiver completion.
-
- The main goroutine starts two goroutines:
- Main goroutine block, waiting for signals from
<-senderDone
.
The first message (t=1ms)
- this
sender
transmit"message 1"
arrivech
channel. - this
receiver
Wake up and process messages:- print: “Received: Message 1”.
- this
sender
Sleep for 100 milliseconds.
Second message (t=101ms)
- this
sender
wake up and send"message 2"
arrivech
channel. - this
receiver
Process the message:- print: “Received: Message 2”.
- this
sender
Sleep for another 100 milliseconds.
The third message (t=201ms)
- this
sender
wake up and send"message 3"
arrivech
channel. - this
receiver
Process the message:- print: “Received: Message 3”.
- this
sender
Sleep for the last time.
Channel closed (t=301ms)
- this
sender
Finish sleeping and shut downch
channel. - this
sender
send atrue
TowardssenderDone
Channel indication completed. - this
receiver
detectedch
The channel is closed. - this
receiver
Exit itsfor-range
ring shape.
Completed (t=302-303ms)
- The main goroutine receives from
senderDone
And stop waiting. - The main coroutine starts waiting for signals from
receiverDone
. - this
receiver
Send completion signal toreceiverDone
channel. - The main goroutine receives the signal and prints:
- “All operations completed!”.
- The program exits.
buffer channel
Why do we need buffered channels?
Unbuffered channels block the sender and receiver until the other is ready. When high-frequency communication is required, unbuffered channels can become a bottleneck because two goroutines must pause to exchange data.
Buffer channel properties:
- FIFO (first in, first out, similar to queue)
- Fixed size, set at creation time
- Block sender when buffer is full
- Block receiver when buffer is empty
We see it in action:
package main
import (
"fmt"
"time"
)
func main() {
// Create a buffered channel with capacity of 2
ch := make(chan string, 2)
// Send two messages (won't block because buffer has space)
ch <- "first"
fmt.Println("Sent first message")
ch <- "second"
fmt.Println("Sent second message")
// Try to send a third message (this would block!)
// ch <- "third" // Uncomment to see blocking behavior
// Receive messages
fmt.Println(<-ch) // "first"
fmt.Println(<-ch) // "second"
}
Output (before uncommenting ch<-"third"
)
Why doesn’t it block the main goroutine?
-
buffer channel Allow sending to its capacity without blocking Sender.
-
The channel has a capacity of 2, which means it can hold two values in its buffer before blocking.
-
The buffer is already filled with “first” and “second”. Since there are no concurrent receivers to consume the values, the send operation blocks indefinitely.
-
Because the main goroutine is also responsible for sending, and there are no other active goroutines receiving values from the channel, the program enters a deadlock when trying to send the third message.
Uncommenting the third message will cause a deadlock because the capacity is now full and the third message will block until the buffer is released.
When to use buffered vs. unbuffered channels
aspect | buffer channel | unbuffered channel |
---|---|---|
Purpose | Used to decouple transmitter and receiver timing. | For instant synchronization between transmitter and receiver. |
When to use | – When the sender can continue without waiting for the receiver. | – When sender and receiver must be synchronized directly. |
– When buffering improves performance or throughput. | – When you want to force message processing immediately. | |
prevent behavior | Blocks only when the buffer is full. | The sender blocks until the receiver is ready and vice versa. |
Performance | Performance can be improved by reducing synchronization. | Latency may be introduced due to synchronization. |
Example use cases | – Logging with rate limit handling. | – Simple signaling between goroutines. |
– Temporarily queued batches of messages. | – Deliver data without delays or buffering. | |
complex | Buffer sizing needs to be carefully adjusted to avoid overflow. | Easier to use; no adjustments required. |
On sale | Higher memory usage due to buffers. | Lower memory usage; no buffers involved. |
Concurrent mode | Asynchronous communication between sender and receiver. | Synchronous communication; tight coupling. |
Error-prone scenarios | If buffer sizes are not managed properly, deadlocks can occur. | If no goroutine is ready to receive or send, a deadlock occurs. |
focus
use buffered channel if:
- You need to decouple sender and receiver time.
- Batching or queuing messages can improve performance.
- When the buffer is full, the application can tolerate delays in processing messages.
use No buffering channel if:
- Synchronization between Goroutines is crucial.
- You want simplicity and instant delivery of your material.
- The interaction between sender and receiver must occur instantaneously.
These basics lay the foundation for more advanced concepts. In the following articles, we will explore:
Next article:
- Concurrent mode
- Mutual exclusion and memory synchronization
Stay tuned as we continue to deepen our understanding of Go’s powerful concurrency capabilities!