Go Concurrency and Real Programs
A practical Go follow-up: goroutines, channels, context, HTTP handlers, graceful shutdown, and real backend habits.
Why this exists
The basics of Go are pretty small, but real Go starts to get interesting when programs do more than one thing at a time.
This note is about the practical side:
- goroutines
- channels
selectcontext- HTTP handlers
- timeouts
- graceful shutdown
- worker pools
- real backend habits
Concurrency is much easier to use well when the mental model stays clear.
Goroutines
A goroutine is a lightweight thread managed by Go.
Start one with go:
package main
import (
"fmt"
"time"
)
func say(message string) {
for i := 0; i < 3; i++ {
fmt.Println(message)
time.Sleep(100 * time.Millisecond)
}
}
func main() {
go say("hello")
say("world")
}This starts say("hello") in the background while say("world") runs in the main goroutine.
Important: when main exits, the program exits. It does not wait for every goroutine automatically.
Wait groups
Use sync.WaitGroup when work needs to wait for goroutines to finish.
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
fmt.Println("worker", id)
}(i)
}
wg.Wait()
}The pattern:
Add(1)before starting workdefer Done()inside the goroutineWait()where execution should block until all work finishes
Channels
Channels let goroutines communicate.
package main
import "fmt"
func main() {
messages := make(chan string)
go func() {
messages <- "hello"
}()
msg := <-messages
fmt.Println(msg)
}Read this as:
messages <- "hello"sends a value<-messagesreceives a value
By default, sending and receiving block until both sides are ready.
Closing channels
Close a channel when the sender is done sending values.
package main
import "fmt"
func main() {
jobs := make(chan int)
go func() {
for i := 0; i < 3; i++ {
jobs <- i
}
close(jobs)
}()
for job := range jobs {
fmt.Println(job)
}
}Only the sender should close the channel.
select
select waits on multiple channel operations.
package main
import (
"fmt"
"time"
)
func main() {
done := make(chan string)
go func() {
time.Sleep(2 * time.Millisecond)
done <- "finished"
}()
select {
case message := <-done:
fmt.Println(message)
case <-time.After(1 * time.Millisecond):
fmt.Println("timed out")
}
}This is how Go handles "wait for this, but give up if it takes too long."
Context
context.Context carries cancellation and deadlines across function calls and goroutines.
This matters a lot in servers. If a request is canceled, the work for that request should stop too.
package main
import (
"context"
"fmt"
"time"
)
func work(ctx context.Context) error {
select {
case <-time.After(2 * time.Second):
fmt.Println("work complete")
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := work(ctx); err != nil {
fmt.Println(err)
}
}Useful rules:
- pass
context.Contextas the first argument - do not store context in structs
- call
cancelwhen creating a cancelable context - check
ctx.Done()in long-running work
Worker pool
A worker pool limits how much work runs at the same time.
package main
import (
"fmt"
"sync"
)
func worker(id int, jobs <-chan int, results chan<- string, wg *sync.WaitGroup) {
defer wg.Done()
for job := range jobs {
results <- fmt.Sprintf("worker %d processed job %d", id, job)
}
}
func main() {
jobs := make(chan int)
results := make(chan string)
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1)
go worker(i, jobs, results, &wg)
}
go func() {
for i := 1; i <= 5; i++ {
jobs <- i
}
close(jobs)
wg.Wait()
close(results)
}()
for result := range results {
fmt.Println(result)
}
}The worker function uses directional channel types:
<-chan int: receive-onlychan<- string: send-only
That makes the function contract clearer.
Data races
Goroutines can create bugs if they access shared data at the same time.
This is unsafe:
count := 0
go func() {
count++
}()
go func() {
count++
}()Use the race detector:
go test -race ./...For shared state, use one of these:
- channels
sync.Mutexsync.RWMutexsync/atomic
Do not guess. Run the race detector.
HTTP handlers
Go's standard HTTP server is enough for a lot of programs.
package main
import (
"encoding/json"
"net/http"
)
type HealthResponse struct {
OK bool `json:"ok"`
}
func healthHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(HealthResponse{OK: true}); err != nil {
http.Error(w, "failed to encode response", http.StatusInternalServerError)
return
}
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /health", healthHandler)
if err := http.ListenAndServe(":8080", mux); err != nil {
panic(err)
}
}Good habits:
- set response headers before writing the body
- validate request method or use method-aware routes
- return useful status codes
- encode JSON directly to the response writer
- do not ignore encode/decode errors
Request context
Every HTTP request has a context.
func slowHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
select {
case <-time.After(5 * time.Second):
w.WriteHeader(http.StatusNoContent)
case <-ctx.Done():
return
}
}If the client disconnects or the request times out, the context is canceled.
That lets downstream work stop instead of wasting resources.
Server timeouts
Do not run a public HTTP server with default zero-value timeouts.
server := &http.Server{
Addr: ":8080",
Handler: mux,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 2 * time.Second,
}
if err := server.ListenAndServe(); err != nil {
panic(err)
}Timeouts are part of making a server real. In a graceful-shutdown setup, also account for http.ErrServerClosed as shown below.
Graceful shutdown
Real servers should shut down cleanly.
package main
import (
"context"
"errors"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
})
server := &http.Server{
Addr: ":8080",
Handler: mux,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
}
go func() {
log.Println("listening on :8080")
if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
}
}()
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
<-stop
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
log.Fatal(err)
}
}That is a lot, but the idea is simple:
- start the server
- listen for shutdown signal
- give current requests a little time to finish
- exit
Project shape
For a small Go service, a boring structure is usually the right one:
.
├── go.mod
├── cmd
│ └── server
│ └── main.go
├── internal
│ ├── users
│ │ ├── service.go
│ │ └── service_test.go
│ └── storage
│ └── memory.go
└── README.mdNotes:
cmd/server/main.gostarts the appinternalholds app code that should not be imported by other modules- keep package names short and meaningful
- do not over-architect tiny programs
Logging
Go has log in the standard library and log/slog for structured logging.
package main
import "log/slog"
func main() {
slog.Info("server started", "addr", ":8080")
}For real services, structured logs are easier to search than random strings.
Configuration
Start with environment variables.
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}Keep config boring until it needs to be fancy.
Real-program checklist
Before calling a Go program real, these should be in place:
- tests for business logic
go test ./...passinggo test -race ./...passing for concurrent code- request timeouts
- server timeouts
- context passed into slow work
- useful logs
- graceful shutdown
- clear package boundaries
- simple README with run/test commands
Practice projects
Good next projects:
- concurrent URL status checker
- worker pool that processes files
- HTTP JSON API with tests
- tiny job queue
- log parser
- static file server
- webhook receiver
- CLI that calls an HTTP API
For every concurrency project:
- know who starts each goroutine
- know how each goroutine stops
- know who closes each channel
- run the race detector
- avoid shared state unless necessary
The mental model
Go concurrency is not "make everything async."
The better model:
- goroutines do work
- channels communicate ownership or results
- contexts cancel work
- wait groups wait for work
- mutexes protect shared state
- tests and race detection catch problems early
That is the foundation needed before building bigger Go services.