Go is useful for backend services that need to wait on several slow operations at once. A service may call external HTTP APIs, read files, or handle queue messages. Instead of waiting for one operation to finish before starting the next, Go can let several tasks make progress together.
The first idea to learn is the goroutine: a lightweight unit of work managed by the Go runtime. We will begin with a small example of what go changes, then learn how channels let a program wait for a result. Only after those basics will we build toward timeouts and a worker pool.
Go concurrency vs parallelism
Concurrency means that a program can manage more than one task at a time. Parallelism means that two or more tasks are physically executing at the same instant on separate CPU resources. Goroutines express concurrency; the Go runtime and the machine determine how much actual CPU parallelism is available.
For many backend tasks, the immediate benefit is overlapping I/O. While one request waits for a network response, another request can continue. That does not mean every loop should create an unlimited number of goroutines.
What is a goroutine in Go?
A goroutine is a function call that Go can run independently from the function that started it. A normal function call waits until the called function returns. Put go before the same call, and the caller continues without waiting.
import (
"fmt"
"time"
)
func printTask(name string) {
fmt.Println("finished:", name)
}
func main() {
fmt.Println("main starts")
go printTask("background task") // Start this task separately.
fmt.Println("main keeps going") // This can run before printTask finishes.
time.Sleep(10 * time.Millisecond) // Only for this small demonstration.
}
The order of the last two messages is not guaranteed. The important point is that main does not wait at go printTask(...). A Go program exits when main returns, even if another goroutine still has work to do. The time.Sleep line merely gives the background task time to print in this demonstration; it is not how production code should wait for goroutines.
The next section introduces channels, which give us a proper way to receive a result and know when a goroutine has finished.
Go channels: passing a value between goroutines
A channel is a typed path for values to travel between goroutines. Create one with make(chan Type).
messages := make(chan string) // This channel carries strings.
This form creates an unbuffered channel: a sender waits until another goroutine receives the value. make(chan Type, capacity) adds a buffer. The second argument is the number of values the channel may hold temporarily before a sender has to wait. For example, make(chan string, 3) can hold three strings.
The <- operator is used with channels. There are only two basic forms:
messages <- "hello" // Send a string into the channel.
message := <-messages // Receive the next string from the channel.
The arrow points to the value's destination. A receive waits until a value is available, which is helpful when one goroutine needs the result of another.
package main
import "fmt"
func main() {
greetings := make(chan string) // A mailbox shared by two goroutines.
go func() {
// This runs separately from main.
greetings <- "hello from another goroutine"
}()
// main waits here until the other goroutine sends its message.
greeting := <-greetings
fmt.Println(greeting)
}
WaitGroup and channels have different roles
sync.WaitGroup waits for a known group of goroutines to finish. It does not send results or errors; it only tracks whether work has completed.
var wg sync.WaitGroup
wg.Add(1) // We are about to start one goroutine.
go func() {
defer wg.Done() // Mark this work as finished before returning.
// Do one piece of work.
}()
wg.Wait() // Wait until every Add has a matching Done.
Channels move jobs and results. A wait group tells the coordinating code when every worker has finished and a result channel can safely close.
Go worker pool example: bounded parallel HTTP requests
Starting one goroutine for every URL can overload a remote API or use too much memory when the input is large. A worker pool starts a fixed number of workers instead. Each worker takes the next URL when it becomes free.
The following function fetches several URLs with at most workers requests in progress. The comments describe the purpose of each concurrency primitive.
package main
import (
"context"
"io"
"net/http"
"sync"
)
type FetchResult struct {
URL string
Status int
Body []byte
Err error
}
func FetchAll(ctx context.Context, client *http.Client, urls []string, workers int) []FetchResult {
if workers < 1 {
workers = 1 // Always create at least one worker.
}
jobs := make(chan string) // URLs waiting to be fetched; sender and worker meet here.
// The second make argument is the channel buffer size. At most one result
// exists per URL, so len(urls) gives results room for every possible result.
// Workers therefore do not need to wait for the collector after each fetch.
results := make(chan FetchResult, len(urls))
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1) // Register this worker before it starts.
go func() {
defer wg.Done() // Tell the coordinator this worker has stopped.
for {
select {
case <-ctx.Done():
// The caller cancelled or timed out. Stop this worker.
return
case url, open := <-jobs:
// Receiving from a closed channel returns open == false.
// That means the producer has no more URLs to send.
if !open {
return
}
result := fetchOne(ctx, client, url)
select {
case results <- result:
// Give the completed result to the caller.
case <-ctx.Done():
// Do not remain blocked if the caller has left.
return
}
}
}
}()
}
go func() {
// This goroutine produces jobs for the workers.
defer close(jobs) // Closing jobs tells workers that production is over.
for _, url := range urls {
select {
case jobs <- url:
// Send one URL to whichever worker is ready.
case <-ctx.Done():
// Stop producing jobs when the caller cancels.
return
}
}
}()
go func() {
// Only close results after every worker has finished sending.
wg.Wait()
close(results)
}()
collected := make([]FetchResult, 0, len(urls))
for result := range results {
// range ends automatically after the results channel is closed.
collected = append(collected, result)
}
return collected
}
func fetchOne(ctx context.Context, client *http.Client, url string) FetchResult {
// Attach ctx so cancellation can stop the HTTP request too.
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return FetchResult{URL: url, Err: err}
}
response, err := client.Do(request)
if err != nil {
return FetchResult{URL: url, Err: err}
}
defer response.Body.Close() // Release the HTTP connection when we are done.
body, err := io.ReadAll(response.Body)
return FetchResult{URL: url, Status: response.StatusCode, Body: body, Err: err}
}
select may look complex, but its job is simple: wait until one of several channel operations can proceed. Here, a worker either receives another URL or notices that cancellation was requested. Read case <-ctx.Done() as “stop when the caller says this work is no longer needed.”
Go context cancellation and timeouts in plain language
context.Context is a value passed along with an operation. It lets child work learn two things: whether the caller has cancelled, and whether the operation has reached its time limit.
This example creates a context with a three-second timeout:
import (
"context"
"time"
)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel() // Release resources even when the timeout did not fire.
results := FetchAll(ctx, http.DefaultClient, urls, 8)
The 8 is a limit: no more than eight workers fetch URLs at once. It is not a magic performance value. Choose a limit based on the downstream API, connection pool, rate limits, memory cost, and whether the work is mostly I/O or CPU.
What cancellation means in this simple worker pool
The worker pool above is intentionally a simple, best-effort pattern. When its context is cancelled, it stops producing new jobs and asks workers to stop. Jobs that have not completed are not put back into the channel and are not retried. This is appropriate when the caller no longer needs the answer, such as collecting optional search results or data for a page the user has left.
It is not a durable queue. Unlike a system such as SQS, the in-memory jobs channel does not record acknowledgements, persist unfinished work, or make another worker retry a job after a process stops. Work that must happen exactly once or eventually happen, such as payment-related state changes or a required notification, needs a different implementation from the one above.
In an HTTP server, the handler receives the request as *http.Request. Call request.Context() to get the context that belongs to that specific client request, then pass it to the work started for that request.
func handleSearch(response http.ResponseWriter, request *http.Request) {
// This context belongs to the current HTTP request.
// It is cancelled if the client goes away.
ctx := request.Context()
urls := []string{
"https://api.example.com/one",
"https://api.example.com/two",
}
// FetchAll and every worker receive the same cancellation signal.
results := FetchAll(ctx, http.DefaultClient, urls, 2)
// Write results only if this handler still has a client to answer.
if ctx.Err() == nil {
json.NewEncoder(response).Encode(results)
}
}
For request-scoped backend work, use request.Context() rather than creating context.Background() inside the handler. Background() has no connection to the client request, so child work would continue even after the client disconnects. The example assumes encoding/json and net/http are imported.
Go retry example: retry a failed read with a limit
Some practical tasks can retry a failure before returning it. For a read-only HTTP request, a small bounded retry loop can be reasonable when a temporary network error, rate limit, or server error occurs. The retry loop itself should still obey the same context, so it stops immediately when the caller cancels or the deadline expires.
import (
"context"
"net/http"
"time"
)
func fetchWithRetry(ctx context.Context, client *http.Client, url string, maxAttempts int) FetchResult {
var result FetchResult
for attempt := 1; attempt <= maxAttempts; attempt++ {
// Do not start another attempt after the caller has cancelled.
if err := ctx.Err(); err != nil {
return FetchResult{URL: url, Err: err}
}
result = fetchOne(ctx, client, url)
if !shouldRetry(result) || attempt == maxAttempts {
// Success, a non-retryable failure, or no attempts left.
return result
}
// Wait a little longer after each failure: 200ms, 400ms, 600ms, ...
delay := time.Duration(attempt) * 200 * time.Millisecond
timer := time.NewTimer(delay)
select {
case <-ctx.Done():
// Stop waiting and return the cancellation error immediately.
timer.Stop()
return FetchResult{URL: url, Err: ctx.Err()}
case <-timer.C:
// The delay ended; the loop starts the next attempt.
}
}
return result // The loop always returns earlier; this keeps the compiler satisfied.
}
func shouldRetry(result FetchResult) bool {
if result.Err != nil {
return true // A temporary network error may succeed on a later attempt.
}
// Retry rate limits and server errors, but not ordinary client errors such as 404.
return result.Status == http.StatusTooManyRequests || result.Status >= 500
}
To use this policy, replace fetchOne(ctx, client, url) in the worker with fetchWithRetry(ctx, client, url, 3). The number 3 is a policy decision, not a default that fits every API. A real client may also respect a Retry-After response header and add random jitter so many workers do not retry at the same moment.
Retry only operations whose repeated execution is safe. Retrying a GET request is usually easier to reason about than retrying a payment or a POST that creates a record. For state-changing work, use an idempotency key and persist job state in a database or durable queue. That lets a later worker distinguish “never started,” “may have completed,” and “needs another attempt.”
Avoid common Go concurrency mistakes
- Limit work instead of creating one goroutine for every untrusted input item.
- Decide which goroutine closes each channel. Usually it is the sender that knows there will be no more values.
- Never close the same channel from multiple goroutines.
- Give every goroutine a way to stop when its consumer leaves or its context is cancelled.
- Use channels to hand values between goroutines. Use
sync.Mutexwhen several goroutines must safely change the same shared value. - Run
go test -race ./...regularly. The race detector can find accidental simultaneous access to shared memory.
The goal is not to create as many goroutines as possible. It is to make the number of active tasks, the cancellation behavior, and the owner of each result clear. The official Go context documentation, sync package documentation, and pipeline and cancellation article are useful next references.
Kinmokusei
Kinmokusei is a programming language with TypeScript-inspired syntax that compiles to readable Go. It is intended for writing web backends and Go libraries while using the normal Go toolchain and package ecosystem directly.
Top comments (0)