Tutorials Logic, IN info@tutorialslogic.com

Golang Goroutines: Lightweight Concurrency with Practical Examples

Golang Goroutines

A goroutine is a lightweight concurrent function managed by the Go runtime. Goroutines make it easy to start background work, handle many requests, run independent tasks, and build responsive network programs.

Concurrency is not the same as parallelism. Concurrency is about structuring multiple tasks that can make progress independently. Parallelism is when tasks literally run at the same time on multiple CPU cores. Go gives you goroutines and channels to express concurrency clearly.

Starting Goroutines

Use the `go` keyword before a function call to run that function in a new goroutine. The main function does not automatically wait for goroutines, so real programs need synchronization.

A practical way to think about goroutines is to treat them as independent tasks that need coordination. Starting the task is easy; designing completion, errors, shared state, cancellation, and resource limits is the real work. In a web server, every incoming request may already run concurrently, so adding extra goroutines inside a handler should be done only when there is a clear benefit and a safe way to stop the work.

Beginners often start goroutines for printing messages, but production goroutines usually represent meaningful background work: sending notifications, consuming queue messages, processing uploaded files, collecting metrics, warming caches, or calling several APIs at the same time. Each of those jobs needs a plan for what happens when one task fails, the process shuts down, or the caller no longer cares about the result.

  • A goroutine starts with `go someFunction()`.
  • The Go scheduler multiplexes goroutines onto operating system threads.
  • Do not assume goroutines run in a specific order.
  • Use `sync.WaitGroup`, channels, or contexts to coordinate completion.

Wait for Goroutines

Wait for Goroutines
package main

import (
	"fmt"
	"sync"
)

func sendEmail(id int, wg *sync.WaitGroup) {
	defer wg.Done()
	fmt.Println("sent email", id)
}

func main() {
	var wg sync.WaitGroup

	for i := 1; i <= 3; i++ {
		wg.Add(1)
		go sendEmail(i, &wg)
	}

	wg.Wait()
	fmt.Println("all emails sent")
}

Race Conditions

A race condition happens when multiple goroutines access shared data and at least one goroutine writes to it without proper synchronization. The program may appear to work sometimes and fail under load.

  • Use channels to communicate ownership of data.
  • Use `sync.Mutex` when shared state must be protected.
  • Run `go test -race` to detect many data races during tests.
  • Prefer simple designs where one goroutine owns a piece of mutable state.

Context and Cancellation

Long-running goroutines should have a way to stop. In server code, `context.Context` is commonly used to pass deadlines, cancellation, and request-scoped values.

  • Accept `context.Context` as the first parameter in request-aware functions.
  • Stop work when `ctx.Done()` is closed.
  • Avoid goroutine leaks by ensuring every started goroutine can finish.

Production Guidance

In production Go services, goroutines should be treated as managed work, not as fire-and-forget shortcuts. Every goroutine should have a clear owner, a clear stop condition, and a clear error path. If a goroutine can block forever on a channel receive, network call, database query, or lock, it can slowly leak memory and make the application harder to shut down.

For request handling, avoid starting background goroutines that continue using request data after the request is cancelled unless that behavior is intentional. Pass a context into downstream work, set timeouts for external calls, and return errors through channels, result structs, logs, or observability tools. This makes concurrent code easier to debug during real traffic.

When concurrency is used to improve throughput, measure before and after. More goroutines can increase speed for I/O-bound work, but they can also overload a database connection pool, hit API rate limits, increase memory use, or make logs harder to follow. A small bounded worker pool is often safer than unlimited goroutine creation.

  • Give every goroutine a way to finish.
  • Limit concurrency when work depends on databases, queues, files, or external APIs.
  • Return errors from concurrent work instead of only printing them.
  • Use logs and metrics to observe queue length, worker errors, and processing time.
  • Test shutdown behavior so workers do not lose important in-progress work.

Worker Pool Pattern

A worker pool is a common goroutine pattern for processing many jobs with a fixed amount of concurrency. Instead of starting one goroutine for every possible job, you start a limited number of workers and send jobs through a channel. This protects the system from creating too much work at once.

Worker pools are useful for email sending, image processing, queue consumers, API calls, report generation, and background tasks. The important design choice is the worker count. Too few workers may be slow; too many workers may overload the database, network, or external service.

  • Create a jobs channel that carries units of work.
  • Start a fixed number of worker goroutines.
  • Close the jobs channel when no more jobs will be sent.
  • Use a WaitGroup to wait until all workers finish.
  • Add context cancellation when jobs should stop early after timeout or shutdown.

Simple Worker Pool

Simple Worker Pool
package main

import (
	"fmt"
	"sync"
)

func worker(id int, jobs <-chan int, wg *sync.WaitGroup) {
	defer wg.Done()
	for job := range jobs {
		fmt.Printf("worker %d processed job %d\n", id, job)
	}
}

func main() {
	jobs := make(chan int)
	var wg sync.WaitGroup

	for id := 1; id <= 3; id++ {
		wg.Add(1)
		go worker(id, jobs, &wg)
	}

	for job := 1; job <= 10; job++ {
		jobs <- job
	}
	close(jobs)

	wg.Wait()
	fmt.Println("all jobs completed")
}

Production Guidance

In production Go services, goroutines should be treated as managed work, not as fire-and-forget shortcuts. Every goroutine should have a clear owner, a clear stop condition, and a clear error path. If a goroutine can block forever on a channel receive, network call, database query, or lock, it can slowly leak memory and make the application harder to shut down.

For request handling, avoid starting background goroutines that continue using request data after the request is cancelled unless that behavior is intentional. Pass a context into downstream work, set timeouts for external calls, and return errors through channels, result structs, logs, or observability tools. This makes concurrent code easier to debug during real traffic.

When concurrency is used to improve throughput, measure before and after. More goroutines can increase speed for I/O-bound work, but they can also overload a database connection pool, hit API rate limits, increase memory use, or make logs harder to follow. A small bounded worker pool is often safer than unlimited goroutine creation.

  • Give every goroutine a way to finish.
  • Limit concurrency when work depends on databases, queues, files, or external APIs.
  • Return errors from concurrent work instead of only printing them.
  • Use logs and metrics to observe queue length, worker errors, and processing time.
  • Test shutdown behavior so workers do not lose important in-progress work.

Cancellation with Context

Cancellation with Context
package main

import (
	"context"
	"fmt"
	"time"
)

func worker(ctx context.Context) {
	for {
		select {
		case <-ctx.Done():
			fmt.Println("worker stopped")
			return
		default:
			fmt.Println("working...")
			time.Sleep(200 * time.Millisecond)
		}
	}
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 700*time.Millisecond)
	defer cancel()

	go worker(ctx)
	<-ctx.Done()
	time.Sleep(100 * time.Millisecond)
}
Key Takeaways
  • Use WaitGroup or channels when main must wait for goroutines.
  • Protect shared writable data with synchronization.
  • Use context cancellation for request-scoped or long-running work.
  • Run race detection when testing concurrent code.
Common Mistakes to Avoid
WRONG Start goroutines and let main exit immediately.
RIGHT Wait for completion with WaitGroup or channel coordination.
When main exits, the program ends even if goroutines were still running.
WRONG Write to shared variables from many goroutines directly.
RIGHT Use channels, mutexes, or ownership patterns.
Unsynchronized shared writes cause data races.

Practice Tasks

  • Start five goroutines that process job IDs and wait for all to finish.
  • Create a race condition, run `go test -race`, then fix it with a mutex.
  • Write a worker that stops when a context timeout expires.

Frequently Asked Questions

No. Goroutines are managed by the Go runtime and are lighter than operating system threads.

No. They help structure concurrent work, but speed depends on CPU, I/O, synchronization, and workload design.

Ready to Level Up Your Skills?

Explore 500+ free tutorials across 20+ languages and frameworks.