Overview

6 Synchronous APIs for concurrency

This chapter shows how to design a package API that feels simple and synchronous while executing concurrently under the hood. Using an HTTP load-testing client as a running example, it starts with a sequential implementation and then refactors to a concurrent one without changing the exported API. The key idea is to treat concurrency as an implementation detail: users call ordinary functions and iterate in familiar ways, while goroutines, channels, and coordination are kept internal.

The API centers on a small set of types and functions: Send returns a single Result, SendN returns a Results iterator that pushes Result values, and Summarize consumes Results to produce a Summary. Iterators (iter.Seq) standardize how sequences are produced and consumed, enabling lazy, memory-efficient processing and easy early exits, in contrast to prebuilt slices or exposed channels. Options and Defaults provide expert controls—such as concurrency level, rate limiting (RPS), and a pluggable SendFunc—while keeping the default usage straightforward. Tests illustrate iterator use, safety when given nil, and clear, value-based Result passing.

Concurrency is introduced via a pipeline pattern made of focused stages: a producer emits requests, an optional throttler meters flow, and a dispatcher fans out work to worker goroutines that send requests and publish Result values, coordinated with a WaitGroup and clean channel closure. SendN runs the pipeline and pushes results through the iterator, and the CLI integrates by summarizing and printing outcomes. Demonstrations show how throughput scales with concurrency and rate limits, while noting practical limits and the need for cancellation to avoid goroutine leaks. The overarching lesson is that structuring for concurrency enables scalable internals, yet the API remains synchronous, simple, and composable for users.

The flow of requests and results. SendN sends concurrent HTTP requests using Send to the server and returns an iterator that pushes each Result to Summarize, which consumes the iterator to produce a Summary. Although we show two goroutines, the number of them depends on the concurrency level configured.
The Results iterator pushes values by calling the consumer's yield.
SendN runs the pipeline and returns a Results iterator. Stages run concurrently and are connected through channels. Dispatcher goroutines call the configured Send function to send concurrent HTTP requests and return results.
The producer sends messages to the next stage.
The throttler stage slows down the flow of messages in the pipeline.
The dispatcher goroutines send HTTP requests, process HTTP responses, calculate a Result, and deliver each Result to the dispatcher's output channel.

Summary

  • A package's API includes everything it exports. A good API is straightforward, making it easy for beginners while still allowing customization for experts.
  • Avoid overloading types with extra responsibility for maintainable code.
  • Using square brackets in comments allows Go to generate links to documentation.
  • Turning sequential code to concurrent is often straightforward in Go.
  • Iterators are functions that allow us to iterate through a sequence of values. They standardize the way we consume sequences of values, like slices or channels.
  • The iter package provides standard iterator signatures. For example, iter.Seq[T] is a generic type for defining a push iterator, such as iter.Seq[Result], which is identical to the function signature, func(yield func(Result) bool).
  • Consumers provide a yield function to an iterator to receive values.
  • Consumers can stop iterating by returning false from their yield function. They can walk over the iterator results using a for range loop. The compiler can automatically return false from a yield function after the loop ends.
  • While slices offer a simple way to return multiple results, iterators may provide better performance, flexibility, and memory efficiency, especially when dealing with large datasets or when processing may need to stop before all results are required.
  • slices.Values returns an iterator that yields the elements of the given slice.
  • Calling recover in a defer turns off panics; otherwise, it won't stop the panic.
  • The built-in panic function triggers a panic.
  • Avoid using panic and recover for error handling, as they disrupt normal control flow and reduce maintainability. Instead, handle errors explicitly with error values.
  • Once we structure the code correctly, adding new features is a breeze.
  • http.Request carries request details, such as the URL and HTTP headers.
  • http.NewRequest returns a new *http.Request.
  • http.NoBody is an io.Reader that does nothing except satisfy the Reader.
  • A concurrent pipeline is a group of reusable stages that run concurrently. It allows us to compose stages differently without changing their code.
  • Directional channels make introducing bugs less likely and clarify intention.
  • time.Ticker sends periodic intervals to its channel C. As of Go 1.23, closing a Ticker is no longer necessary to avoid leaking a goroutine.
  • sync.WaitGroup lets a goroutine wait until a group of goroutines finishes. Add increments the internal counter while Done decrements it. Wait blocks the current goroutine until the internal counter reaches zero.
  • For-range iterates over a channel until the channel is closed.
  • Close a channel for signaling. Sending to a closed channel panics.
  • The fan-out pattern is about distributing messages across multiple receivers to improve scalability, such as in data processing or batch operations.
  • Concurrency is a way to structure a program so that it runs concurrently, possibly in parallel. Treat it as an implementation detail. Go embraces synchronous and simple APIs, and avoiding exposing concurrency early is likely to lead to simple APIs.

FAQ

Why does the chapter advocate designing synchronous APIs that hide concurrency?Because synchronous APIs are easier to learn, compose, and use correctly. Hiding concurrency keeps goroutines and channels as an internal implementation detail, letting maintainers change internals (sequential or concurrent) without breaking users. It mirrors the Go standard library, which is mostly synchronous even when it runs concurrently inside.
What is a “push iterator” and how is it used in the hit package?A push iterator is a function that “pushes” values to a consumer-provided yield function. The hit package defines type Results iter.Seq[Result] so SendN can push each Result as it’s produced. Consumers iterate with a regular for-range loop; the compiler supplies the yield function under the hood.
Why return an iterator instead of a slice or a channel?Iterators reduce memory (no need to buffer all results), enable early exit (stop consuming at any time), and decouple the consumer from how values are produced (slice, channel, or pipeline). Returning a channel would leak concurrency details into the API; returning an iterator keeps the API synchronous.
How does SendN evolve from sequential to concurrent without changing its API?Initially, SendN loops and pushes results sequentially through a Results iterator. Later, its internals are refactored to run a concurrent pipeline (producer → optional throttler → dispatcher) that still pushes values via the same iterator type, so callers don’t see any API change.
What are the stages of the concurrent pipeline and what does each do?- Producer: emits the request message N times into a channel.
- Throttler (optional): spaces messages at a configured rate (RPS) before forwarding.
- Dispatcher: fan-outs to worker goroutines that send HTTP requests and push each Result to an output channel that SendN reads and forwards via the iterator.
How does the dispatcher safely coordinate worker goroutines and closing channels?It uses a sync.WaitGroup to track workers. A monitoring goroutine waits for WaitGroup to reach zero and then closes the results channel. This avoids sending on a closed channel (which panics) and signals the consumer that no more results will arrive.
What customization does Options provide, and how do defaults work?Options exposes: Concurrency (worker count), RPS (rate limit), and Send (a SendFunc). Defaults()/withDefaults fill unset fields (e.g., concurrency=1, default Send wraps Send(http.DefaultClient, req)). This gives beginners sensible behavior and experts a hook to swap sending logic.
How do I consume Results and build a Summary?Call Summarize(results). It for-ranges over the iterator, aggregates totals (requests, errors, bytes), tracks fastest/slowest, calculates success rate, and sets duration/RPS. It also guards against a nil iterator (returns the zero-value summary instead of panicking).
Can the pipeline leak goroutines if consumers stop early? How is this addressed?Yes, if the consumer stops reading before all results are produced, upstream goroutines can block. The chapter notes this and defers fixing to the next chapter by propagating cancellation with context so all stages can stop gracefully.
Why are Result values passed by value instead of by pointer?The code doesn’t need to mutate results, so values are semantically correct and simpler. Passing by value avoids shared state, often reduces GC pressure (fewer heap allocations of pointers), and makes it easier for each goroutine to work with its own copy.

pro $24.99 per month

  • access to all Manning books, MEAPs, liveVideos, liveProjects, and audiobooks!
  • choose one free eBook per month to keep
  • exclusive 50% discount on all purchases
  • renews monthly, pause or cancel renewal anytime

lite $19.99 per month

  • access to all Manning books, including MEAPs!

team

5, 10 or 20 seats+ for your team - learn more


choose your plan

team

monthly
annual
$49.99
$499.99
only $41.67 per month
  • five seats for your team
  • access to all Manning books, MEAPs, liveVideos, liveProjects, and audiobooks!
  • choose another free product every time you renew
  • choose twelve free products per year
  • exclusive 50% discount on all purchases
  • renews monthly, pause or cancel renewal anytime
  • renews annually, pause or cancel renewal anytime
  • Go by Example ebook for free
choose your plan

team

monthly
annual
$49.99
$499.99
only $41.67 per month
  • five seats for your team
  • access to all Manning books, MEAPs, liveVideos, liveProjects, and audiobooks!
  • choose another free product every time you renew
  • choose twelve free products per year
  • exclusive 50% discount on all purchases
  • renews monthly, pause or cancel renewal anytime
  • renews annually, pause or cancel renewal anytime
  • Go by Example ebook for free
choose your plan

team

monthly
annual
$49.99
$499.99
only $41.67 per month
  • five seats for your team
  • access to all Manning books, MEAPs, liveVideos, liveProjects, and audiobooks!
  • choose another free product every time you renew
  • choose twelve free products per year
  • exclusive 50% discount on all purchases
  • renews monthly, pause or cancel renewal anytime
  • renews annually, pause or cancel renewal anytime
  • Go by Example ebook for free