Channel Tricks You Didn’t Know Go Could Do

Most Go developers understand channels as a way of communicating between goroutines. You send, you receive, and that's how it works.
But channels are more than just data pipes. In the hands of a thoughtful developer, channels become the semantics of timing, coordination, resource sharing, and even process control.
In this article, I'll walk you through the tricks, patterns, and lesser-known features of channels that will enhance your concurrency in Go - all without touching a third-party library.
Trick 1. Using Channels as Semaphores
Suppose you are writing a program that needs to make multiple calls to an external service, such as a weather API. The API has a rate limit of, say, 5 requests per second. If you send too many, they will block you!
We can use channels as signals to make sure we don't exceed this limit. With this trick, you can limit concurrency without complex tracking.
package main
import (
"fmt"
"time"
)
func callWeatherAPI(city string) {
fmt.Printf("Making API call for %s...\n", city)
time.Sleep(1 * time.Second) // Simulate API call taking 1 second
fmt.Printf("Received weather for %s.\n", city)
}
func main() {
fmt.Println("Starting weather data fetch.")
// Semaphore for API calls: allows 5 concurrent calls
apiLimiter := make(chan struct{}, 5)
cities := []string{"London", "Paris", "New York", "Tokyo", "Berlin",
"Rome", "Madrid", "Sydney", "Cairo", "Dubai"}
for _, city := range cities {
go func(c string) {
// Acquire a slot for an API call
apiLimiter <- struct{}{}
fmt.Printf("--- Acquired API slot for %s ---\n", c)
callWeatherAPI(c)
// Release the API slot
<-apiLimiter
fmt.Printf("--- Released API slot for %s ---\n", c)
}(city)
}
// Wait long enough for all API calls to potentially finish
time.Sleep(15 * time.Second)
fmt.Println("\nAll weather data fetched!")
}
Key Concepts:
make(chan struct{}, N)
: Creates a semaphore that allowsN
concurrent operations.channel <- struct{}
(Acquire): Tries to put a token into the channel. If the channel is full, the Goroutine waits here.<-channel
(Release): Takes a token out of the channel, freeing up a slot for another waiting Goroutine.
Trick 2. Fan-In: Merging Multiple Channels into One
Fan-In means taking data from multiple input channels and combining all the data into a single output channel. It is like having several manufacturers sending data to a centralized collector.
Let's say you own a small business that sells custom t-shirts. You receive orders from various online platforms:
Your own website:
yourwebsite.com
Etsy store:
etsy.com/my-shirts
Ebay store:
ebay.com/my-shirts
Each platform sends you new orders. You, as the "order fulfillment manager," don't care which platform the order came from; you just want to see all new orders in one place so you can start making the t-shirts.
This is a perfect scenario for Fan-In. Each online store will be a "producer" (sending orders through a channel), and you'll have a single "collector" (a channel that receives all orders).
package main
import (
"fmt"
"sync"
"time"
)
// simulateOrderSource represents an online store sending orders
func simulateOrderSource(storeName string, orders <-chan string, wg *sync.WaitGroup) {
defer wg.Done() // Signal that this goroutine is done when it exits
for order := range orders {
fmt.Printf("[%s] Received Order: %s\n", storeName, order)
// In a real scenario, you might log, process, or just acknowledge here
time.Sleep(50 * time.Millisecond) // Simulate some processing time
}
fmt.Printf("[%s] Finished sending orders.\n", storeName)
}
// fanIn function merges multiple input channels into a single output channel
func fanIn(inputChannels ...<-chan string) <-chan string {
var wg sync.WaitGroup // Used to wait for all input goroutines to finish
mergedChannel := make(chan string) // This will be our single output channel
// This function starts a goroutine for each input channel
// to read its values and send them to the mergedChannel.
output := func(c <-chan string) {
defer wg.Done()
for item := range c {
mergedChannel <- item // Send item from input channel to merged channel
}
}
// For each input channel provided, start a goroutine to process it
wg.Add(len(inputChannels)) // Add count for each input channel
for _, c := range inputChannels {
go output(c) // Start a goroutine for each input channel
}
// Start another goroutine to close the mergedChannel once all input
// goroutines have finished. This is important so the receiver knows
// when there are no more items coming.
go func() {
wg.Wait() // Wait for all 'output' goroutines to finish
close(mergedChannel) // Close the merged channel
}()
return mergedChannel // Return the single merged channel
}
func main() {
fmt.Println("Starting Order Processing System...\n")
// 1. Create individual channels for each online store
myWebsiteOrders := make(chan string)
etsyOrders := make(chan string)
ebayOrders := make(chan string)
// We'll use a WaitGroup to ensure all "producer" goroutines finish before main exits.
var producerWg sync.WaitGroup
// 2. Simulate each store sending orders concurrently
producerWg.Add(3) // We have 3 producers
go func() {
defer producerWg.Done()
time.Sleep(100 * time.Millisecond) // Simulate startup delay
myWebsiteOrders <- "T-shirt Order #W101"
time.Sleep(200 * time.Millisecond)
myWebsiteOrders <- "Mug Order #W102"
close(myWebsiteOrders) // No more orders from mywebsite
}()
go func() {
defer producerWg.Done()
time.Sleep(150 * time.Millisecond)
etsyOrders <- "Custom Print #E201"
time.Sleep(100 * time.Millisecond)
etsyOrders <- "T-shirt Order #E202"
time.Sleep(50 * time.Millisecond)
etsyOrders <- "Hoodie Order #E203"
close(etsyOrders) // No more orders from Etsy
}()
go func() {
defer producerWg.Done()
time.Sleep(50 * time.Millisecond)
ebayOrders <- "Hat Order #B301"
close(ebayOrders) // No more orders from Ebay
}()
// 3. Use Fan-In to merge all order channels into one
allIncomingOrders := fanIn(myWebsiteOrders, etsyOrders, ebayOrders)
// 4. The "Order Fulfillment Manager" receives all orders from the single merged channel
fmt.Println("Order Fulfillment Manager is ready to process ALL orders:\n")
for order := range allIncomingOrders {
fmt.Printf("--> PROCESSING GLOBAL ORDER: %s\n", order)
time.Sleep(150 * time.Millisecond) // Simulate processing an order
}
// Ensure all producers have finished sending their orders before we print the final message
producerWg.Wait()
fmt.Println("\nAll orders processed. System shutting down.")
}
Key concepts:
sync.WaitGroup: In Go, a WaitGroup
is a synchronization primitive that allows a Goroutine to wait for a collection of other Goroutines to finish. Think of it as a counter.
Add(n)
: Increases the counter byn
.Done()
: Decreases the counter by 1.Wait()
: Blocks (pauses) the Goroutine that calls it until the counter becomes zero.
fanIn(myWebsiteOrders, etsyOrders, ebayOrders): Combines three channels.
Trick 3. Timeout Using select
+ time.After
Suppose you are building a dashboard that displays real-time stock prices. You get the data from the Stock Market API. Sometimes this API can be a little slow or even completely unresponsive. You want to make sure that your dashboard remains responsive and doesn't hang because stock data takes too long to load. You decide to set a timeout of 2 seconds for any stock data retrieval.
package main
import (
"fmt"
"math/rand"
"time"
)
// simulateStockFetch simulates fetching stock data, which can be slow
func simulateStockFetch(stockSymbol string) (string, error) {
// Simulate varying network delays
// Some fetches are fast, some are slow, some timeout
delay := time.Duration(rand.Intn(3000)) * time.Millisecond // Random delay up to 3 seconds
time.Sleep(delay)
if delay > 2500*time.Millisecond { // Simulate a very long delay that might exceed timeout
return "", fmt.Errorf("simulated network error for %s (too slow)", stockSymbol)
}
return fmt.Sprintf("Stock: %s, Price: $%.2f (fetched in %v)", stockSymbol, rand.Float64()*100+50, delay), nil
}
func main() {
fmt.Println("Stock Dashboard Initializing...")
stockSymbols := []string{"AAPL", "GOOG", "MSFT", "AMZN", "TSLA", "NFLX"}
for _, symbol := range stockSymbols {
// Launch a goroutine to fetch each stock symbol concurrently
go func(s string) {
// This channel will receive the result of the stock fetch
resultChan := make(chan string)
// This channel will receive any error from the stock fetch
errorChan := make(chan error)
// Start another goroutine to perform the actual fetch
go func() {
data, err := simulateStockFetch(s)
if err != nil {
errorChan <- err // Send error to errorChan
return
}
resultChan <- data // Send data to resultChan
}()
// --- THE TIMEOUT MAGIC HAPPENS HERE ---
select {
case data := <-resultChan: // Case 1: Stock data arrived
fmt.Printf("✅ Success: %s\n", data)
case err := <-errorChan: // Case 2: An error occurred during fetch
fmt.Printf("❌ Error for %s: %v\n", s, err)
case <-time.After(2 * time.Second): // Case 3: Timeout occurred after 2 seconds
fmt.Printf("⏰ Timeout! Could not fetch %s within 2 seconds.\n", s)
}
}(symbol)
}
// Give enough time for all stock fetches (and potential timeouts) to complete
time.Sleep(5 * time.Second)
fmt.Println("\nStock Dashboard Update Complete.")
}
case <-time.After(2 \ time.Second)*: Wait up to 2 seconds. If nothing else happens before that, run this case.
Trick 4. Channel of Channels
Imagine you have a large folder with many text files (e.g. log files, documents). Your program needs to
Process each file independently: read its content, count words, analyze sentiment, etc. Provide real-time progress updates for each file: When a file is being processed, you want to see something like "Processing file A... (20%)“, ”Processing file A... (50%)“, ”Processing file A...(50%)“, ”File A is complete", and so on. Collect all final results: Get a summary of each file.
The challenge is to get these individual, ongoing updates back to the central monitoring point without cluttering it up or forcing it to poll dozens or hundreds of specific channels.
package main
import (
"fmt"
"math/rand"
"sync"
"time"
)
// Worker represents a Goroutine processing a single file
func fileProcessor(filePath string, progressChan chan<- string, wg *sync.WaitGroup) {
defer wg.Done()
defer close(progressChan) // Close the progress channel when this worker is done
fmt.Printf("[Processor for %s]: Starting...\n", filePath)
totalSteps := rand.Intn(5) + 3 // Simulate varying work steps (3 to 7 steps)
for i := 1; i <= totalSteps; i++ {
// Simulate work for a step
time.Sleep(time.Duration(rand.Intn(200)+100) * time.Millisecond)
progress := fmt.Sprintf("[Processor for %s]: Step %d of %d (%.0f%%)",
filePath, i, totalSteps, float64(i)/float64(totalSteps)*100)
progressChan <- progress // Send progress update on its dedicated channel
}
finalResult := fmt.Sprintf("[Processor for %s]: FINISHED! Processed %d steps.", filePath, totalSteps)
progressChan <- finalResult // Send final result as the last message
fmt.Printf("[Processor for %s]: Done.\n", filePath)
}
func main() {
fmt.Println("--- File Processing System Started ---")
filesToProcess := []string{"document_A.txt", "log_B.log", "report_C.csv", "image_D.jpg", "data_E.json"}
// This is our "Channel of Channels"
// It's a channel that will receive other channels (specifically, channels that send strings)
progressChannelsQueue := make(chan (<-chan string)) // `<-chan string` means a receive-only string channel
var workerWg sync.WaitGroup // To wait for all fileProcessor goroutines to finish
// --- Step 1: Launch File Processors and Send their Progress Channels ---
go func() {
defer close(progressChannelsQueue) // Close the main queue after all workers are launched
for _, file := range filesToProcess {
// Create a new, unbuffered channel for THIS file's progress updates
fileProgressChan := make(chan string)
workerWg.Add(1) // Add to the WaitGroup for each worker launched
// Launch the file processor, giving it its unique progress channel
go fileProcessor(file, fileProgressChan, &workerWg)
// Send this worker's unique progress channel to the main queue
progressChannelsQueue <- fileProgressChan
fmt.Printf("Main Monitor: Sent progress channel for %s\n", file)
}
}()
// --- Step 2: The Central Monitor receives and handles progress updates ---
fmt.Println("\n--- Central Monitor: Collecting live updates ---")
// This WaitGroup ensures the main monitor waits until all individual progress channels are closed
var monitorWg sync.WaitGroup
// This Goroutine receives the individual progress channels from the queue
// and sets up a listener for each one.
go func() {
defer monitorWg.Done() // Signal that the monitor is done when it finishes collecting all updates
for fileProgressChan := range progressChannelsQueue {
// For each individual file's progress channel received:
monitorWg.Add(1) // Add to the monitor's WaitGroup
go func(progressChan <-chan string) { // Launch a new Goroutine to listen to THIS specific channel
defer monitorWg.Done() // Signal that this listener is done when its channel closes
for update := range progressChan {
// Print the update from this specific file's progress channel
fmt.Printf(" Live Update: %s\n", update)
}
// This loop exits when fileProgressChan is closed by its fileProcessor
}(fileProgressChan) // Pass the channel into the goroutine
}
// This loop exits when progressChannelsQueue is closed by the launcher goroutine
}()
monitorWg.Add(1) // Add for the main monitor goroutine that collects individual progress channels
// Wait for all worker goroutines to complete their processing
workerWg.Wait()
fmt.Println("\n--- All File Processors Finished ---")
// Close the main monitor goroutine to ensure all updates are collected.
// This will cause the for range progressChannelsQueue loop to finish in the monitor.
// We need to wait for `monitorWg` after `workerWg` to ensure all children goroutines have handled their channels.
monitorWg.Wait()
fmt.Println("\n--- File Processing System Shutdown ---")
}
Key Concepts:
progressChannelsQueue := make(chan (<-chan string))
: This is our "Channel of Channels"!
It's a channel.
What does it send? It sends other channels.
What kind of channels does it send?
<-chan string
(receive-only channels that carrystring
updates).Think of it as a central inbox where workers drop off a note saying, "Here's my dedicated progress update channel; listen to this if you want to know how I'm doing!"
Conclusion
Go's passes may look simple, but they hide a lot of power. Used creatively, they can replace mutexes, conditional variables, and even entire job schedules in lightweight systems.
Subscribe to my newsletter
Read articles from Bhuwan directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
