DriversRecommendedOutdated drivers can make a good PC feel brokenScan driver issues before chasing fixes manually.Scan NowOctober DealsAmazon USOctober deal check: compare before you payAmazon US: current deals, useful picks and tech finds.Check DealsPC HealthRecommendedCrashes, freezes, slowdowns? Check your PC nowSpot repairable issues before they interrupt work.Check PC×
Skip to content
World desk9 min

Go Concurrency: How to Run Parallel Tasks with Goroutines, Channels, and Context

A practical guide to Go concurrency: start goroutines, wait for them, pass work through channels, bound active work, cancel with context, and group errors with errgroup.
Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

To run independent work in Go, start each task with the go keyword, pass inputs and results through channels, and give every task a context.Context so it can be cancelled or stopped at a deadline. Two points matter before any code. A goroutine started with go does not make the caller wait for it, so completion has to be coordinated explicitly. And concurrent structure does not by itself mean parallel execution or a faster program.

Concurrent does not mean parallel

Concurrency describes how a program is structured: independent tasks that can make progress in overlapping time, each with its own lifecycle. Parallelism describes tasks executing at the same instant on multiple execution resources, usually to finish computational work sooner. Go code written with goroutines is concurrent by design. Whether it runs in parallel depends on the runtime and the machine. The Go runtime can run goroutines across several OS threads, and runtime.GOMAXPROCS sets how many threads may execute Go code simultaneously. Whether a given program gains speed from that depends on the work.

As an Amazon Associate I earn from qualifying purchases.

Two kinds of work illustrate the difference. CPU-bound computation can benefit from parallel execution when multiple cores are available. Tasks that spend most of their time waiting on a network response, a database, or a disk can be overlapped so the program is not idle while it waits, even on one core. In both cases, measure the result with your own workload before claiming a speedup. The official sources reviewed for this article describe the model and the API behavior; they do not publish benchmark figures for your code.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Start goroutines and wait for them

Prefix a function or method call with go and it runs as a goroutine. The call returns immediately. A goroutine that finishes exits without reporting anything to its caller, so the caller needs a coordination mechanism when it must know that the work is done. Effective Go’s concurrency section demonstrates a channel used as a completion signal.

For a fixed set of tasks whose results you do not need to return as errors, a sync.WaitGroup is the simplest tool:

  1. Call wg.Add(1) before starting each goroutine. Adding inside the goroutine can race with wg.Wait().
  2. Start the goroutine and call defer wg.Done() as its first statement, so completion is recorded even if the function returns early.
  3. Call wg.Wait() in the caller. It blocks until the counter returns to zero.
  4. Close any results channel only after wg.Wait() returns, so no goroutine is still sending on it.
package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	results := make(chan string, 3) // capacity 3 so each sender can finish without a reader

	for _, name := range []string{"a", "b", "c"} {
		wg.Add(1)
		go func(n string) {
			defer wg.Done()
			results <- "done " + n
		}(name)
	}

	wg.Wait()
	close(results)

	for r := range results {
		fmt.Println(r) // order is not guaranteed
	}
}

A WaitGroup tells you that goroutines finished. It does not carry their errors or cancel the others when one fails. That is the job of errgroup, covered below.

Pass work and results through channels

Channels carry values between goroutines and also synchronize them. An unbuffered channel couples the sender and receiver: a send completes only when a receiver takes the value. A buffered channel holds up to its capacity before a send blocks. The buffer delays blocking; it does not limit how much work exists. A buffer of 1,000 entries still accepts unlimited work once it is full, because the sender then waits, and if many senders are waiting, the memory and goroutines they hold remain in use.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

A worker pool is the usual shape for a stream of tasks. A fixed number of goroutines read from a jobs channel and write to a results channel:

package main

import (
	"fmt"
	"sync"
)

func worker(jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
	defer wg.Done()
	for j := range jobs {
		results <- j * 2
	}
}

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

	for w := 0; w < 4; w++ {
		wg.Add(1)
		go worker(jobs, results, &wg)
	}

	go func() {
		for i := 1; i <= 10; i++ {
			jobs <- i
		}
		close(jobs)
	}()

	go func() {
		wg.Wait()
		close(results)
	}()

	for r := range results {
		fmt.Println(r) // order depends on which worker finishes first
	}
}

Here the producer, the four workers, and the consumer all run concurrently. The producer stops being able to run ahead of the workers because the unbuffered jobs channel only accepts a value when a worker is ready to receive it.

The design principle behind channels

Effective Go states: “Do not communicate by sharing memory; instead, share memory by communicating.” This is a design principle from the Go Project’s guide, not an absolute ban. The same section notes that some problems, such as reference counts, may be better handled with a mutex. Use channels when handing work or results between tasks is part of the design, and use a mutex when several goroutines need to guard a small piece of shared state.

Bound how much work runs at once

Effective Go warns about a common mistake: starting a goroutine for every incoming request can consume unbounded resources when requests arrive faster than they are processed. The fix is an explicit limit on active work. The guide demonstrates two options: gating goroutine creation, and using a fixed number of workers.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Decide where backpressure happens before adding goroutines to a stream of tasks. A bounded pool or a task limit makes a producer wait or drop work when capacity is exhausted. An unbounded queue only moves the problem into memory.

Fixed worker pool

The worker pool above is the most explicit limit: the number of goroutines equals the number of workers you start. Choose that number from the nature of the work. For CPU-bound tasks it is often tied to the available cores; for tasks that wait on I/O, a larger number may be appropriate. Either way, the bound is visible in the code.

Limit inside an errgroup

The errgroup package described below includes SetLimit, which caps the number of active goroutines in the group. Once the cap is reached, calls to Go block until a running goroutine finishes, so the caller is slowed down rather than the program spawning more work than it can handle.

Cancel work with context

context.Context carries three things across API boundaries: cancellation signals, deadlines, and request-scoped values. Sameer Ajmani’s 2014 article on context in The Go Blog introduces the pattern. Pass a context as the first argument to functions that perform work on behalf of a request, and pass it down to any goroutines those functions start. A context’s Done channel is closed when the context is cancelled or its deadline passes; code doing interruptible work should watch that channel and return.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
  1. Create the root context in the function that owns the operation, for example context.Background() in main or the request handler.
  2. Derive a timeout or cancellable context with context.WithTimeout, context.WithDeadline, or context.WithCancel.
  3. Call the returned cancel function with defer cancel(). This releases the timer and resources associated with the context even when the operation finishes early. The Go Project’s guide on canceling in-progress operations shows the same pattern for database calls.
  4. Pass the derived context to every goroutine and function that should stop when the operation is abandoned.
package main

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

func crawl(ctx context.Context, items []string) error {
	for _, item := range items {
		select {
		case <-ctx.Done():
			return ctx.Err() // stop before starting the next item
		default:
		}
		fmt.Println("processing", item)
		time.Sleep(200 * time.Millisecond) // stands in for real work
	}
	return nil
}

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

	if err := crawl(ctx, []string{"a", "b", "c", "d", "e"}); err != nil {
		fmt.Println("stopped:", err) // context deadline exceeded
	}
}

Cancellation is cooperative. Cancelling a context does not terminate a goroutine. A goroutine stops only when its code notices the signal and returns. Every blocking send, receive, and I/O call in a task that must stop promptly needs a path that also watches ctx.Done(), typically through a select statement. A call that ignores the context keeps running until it returns on its own.

Use context for cancellation, deadlines, and request-scoped data such as a request ID. Do not use it as a general parameter bag for configuration; pass configuration explicitly.

Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Group related tasks with errgroup

The golang.org/x/sync/errgroup package, documented on pkg.go.dev for golang.org/x/sync, adds error propagation and shared cancellation to a group of goroutines. Its source is available in the Go source documentation for errgroup. Use it when several related subtasks should be awaited together and one failure should stop the rest.

  • errgroup.WithContext(parent) returns a group and a derived context.
  • The derived context is cancelled the first time a function passed to Go returns a non-nil error, or when Wait returns, whichever comes first.
  • Wait blocks until every function has returned and reports the first non-nil error. Later errors are not returned, so record them yourself if you need them all.
  • SetLimit(n) caps active goroutines in the group at n. A negative value removes the limit.
package main

import (
	"context"
	"fmt"

	"golang.org/x/sync/errgroup"
)

func process(ctx context.Context, id int) error {
	select {
	case <-ctx.Done():
		return ctx.Err()
	default:
	}
	fmt.Println("processed", id)
	return nil
}

func run(parent context.Context, ids []int) error {
	g, ctx := errgroup.WithContext(parent)
	g.SetLimit(4) // at most four active goroutines

	for _, id := range ids {
		id := id // needed before Go 1.22, where loop variables were shared across iterations
		g.Go(func() error {
			return process(ctx, id)
		})
	}

	return g.Wait()
}

func main() {
	if err := run(context.Background(), []int{1, 2, 3, 4, 5, 6}); err != nil {
		fmt.Println("error:", err)
	}
}

Remember that errgroup cancels cooperatively, just like context. A function that ignores ctx will still run to completion before Wait returns.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Choose an approach

The four patterns differ in what limits active work, where errors go, and how cancellation reaches the tasks. The official examples establish these patterns and their behavior; they do not rank them by speed.

Approach What limits active work Where errors go How cancellation reaches tasks Best fit
go with sync.WaitGroup Nothing, unless you add a cap Not collected; your code must record them Only if you pass a context you wrote A small, fixed set of tasks you await together
Fixed worker pool with channels The number of workers you start Your code sends them on a results channel or separate error channel Only if workers select on the context you pass in A stream of jobs where the concurrency count should be explicit
errgroup with WithContext Optional SetLimit value First non-nil error is returned by Wait Derived context is cancelled on the first error Related subtasks that must succeed together and stop together
Buffered channel used as a semaphore The buffer capacity you set No built-in path; your code must handle them No built-in path; you add it Adding a cap around existing code without restructuring it

Choose by workload and lifecycle, not by habit. The deciding questions are how many tasks can be active at once without exhausting memory or downstream services, whether a failure should stop the others, and whether the tasks are CPU-bound or mostly waiting on I/O.

Checks before shipping concurrent code

  • Define how each goroutine reports completion and errors before you launch it. A goroutine with no completion or error path is easy to lose track of.
  • Identify the point where backpressure happens for every stream of tasks, and confirm that it is bounded.
  • Pass context.Context explicitly to every function that performs cancellable work.
  • Confirm that each blocking channel operation and I/O call in a task has a way to return when the context is done.
  • Call every cancel function returned by WithCancel, WithTimeout, or WithDeadline when its scope ends.
  • Use a WaitGroup when you only need to wait, and errgroup when errors and group-wide cancellation matter.

Test the program under the workload it will actually face. Concurrent code can look correct in a small run and still hold goroutines or block senders when arrivals outpace processing.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.

What’s actually slowing this PC down?

Pick the symptom - the matching free tool is one click away.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Leave a Reply

Your email address will not be published. Required fields are marked *

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

More from the Wire

  1. World desk4 min
    How to Spot an AI Voice Scam Before Sending MoneyDon’t rely on how a caller sounds. Pause, call back through a known number, and verify the emergency with another trusted person before sending money.
  2. Mountain View desk4 min
    Google’s SynthID Detector: How to Check AI-Generated Images, Video and AudioGoogle’s SynthID Detector looks for an embedded watermark in supported images, video and audio. Here is what its results do—and do not—show.
  3. Redmond desk20 min
    How to create a link to File or Folder in Windows 11Windows 11 gives you several ways to point to a file or folder without moving or duplicating it. You can create a desktop shortcut,…
Recommended PC Tool
Recommended PC Tool
Outdated Drivers Are Slowing You DownFree scan - exact matches
Windows Errors? Fix Them Before They SpreadFree repair scan

Two free Windows tools

One Free Minute Could Fix That PC

Before you go - each of these free tools takes about a minute and tackles what quietly slows a Windows PC down.

Special offer. View Outbyte info, uninstall instructions, EULA, and Privacy Policy.