Loading video…
doing many things at oncedeepcode at lesson-22

Fan-Out and Fan-In: Fetch Every Source at Once

Lesson twenty-one closed by stressing the shape. Three sources is a classroom; a real aggregator follows three hundred, and the loop feedstack shipped last lesson launches one goroutine per source, all of them knocking on other people's servers in the same instant. The promised fix was a crew: a fixed handful of workers, a channel of jobs going in, a channel of results coming out. This lesson builds that crew, and on the way it answers the question lesson twenty-one parked, many senders, one channel, who closes, which is where the WaitGroup walks back through the door carrying its real job.

What actually breaks at three hundred

Before building the fix, be precise about what breaks, because the goroutines themselves are innocent. Lesson twenty priced them: two kilobytes apiece, a hundred thousand of them for about three hundred megabytes. Three hundred goroutines cost less than this paragraph. What breaks is everything a fetching goroutine holds while it waits. Three bills come due.

File descriptors. Lesson twelve taught you that every open file is a small integer claim on a finite per-process kernel table, roughly a thousand claims on a typical setup. Here's the part lesson twelve didn't need yet: network connections live on the same table. A socket is a descriptor. Every in-flight http.Get holds one for the duration of the conversation, so three hundred simultaneous fetches is three hundred simultaneous claims, on top of your files and your standard streams. Three hundred squeaks by. The day the source list hits three thousand, you get dial tcp: too many open files, which is lesson twelve's leak signature, except nothing leaked. You opened them all on purpose, at once. The table doesn't care whether the claims are leaked or deliberate; it has a size.

Other people's patience. Feeds cluster. Forty of your three hundred sources might live on one hosting platform, and the unbounded loop asks that one host forty questions in the same millisecond. A well-run server answers with a status code you can now read: 429 Too Many Requests, from the 4xx family, which lesson fourteen taught you to parse as your fault. This is a rate limit: the server protecting itself by refusing callers who ask too fast. Less polite servers skip the courtesy and just throttle you, drop you, or block your address. An aggregator that gets itself banned has aggregated nothing.

Memory. The goroutine is two kilobytes; the fetch is not. While a fetch is in flight it holds a TCP connection with its buffers, TLS state, a response body streaming in, the decoded wire structs, the batch of feedItems under construction. Hundreds of kilobytes each, sometimes megabytes, and unbounded concurrency means all of it is alive at the same time. Peak memory tracks peak concurrency.

Read the three bills again and notice they share a line item. The problem isn't being concurrent; it's the amount of concurrency, which today is "all of it, immediately." The number of sources should never get to dictate the number of simultaneous fetches. You want a number you choose, five in flight, ten in flight, held steady while the source list grows without limit. That property is called bounded concurrency, and the structure that delivers it is the one this lesson is named after.

The shape has two names

Picture a small depot. One inbound belt carries parcels. A fixed crew stands along it, three workers, five, ten, the number is yours. One outbound belt carries finished work away. A parcel arrives, whichever worker is free takes it, does the job, puts the result on the outbound belt, and turns back for the next one. Nobody is assigned parcels. Nobody waits for a dispatcher. The belt is the dispatcher.

Now swap the props for types you already own. The inbound belt is a channel of jobs. The crew is N goroutines, each running the same loop: range over the jobs channel. The outbound belt is a channel of results. Three pieces, two of them channels and one of them a count. The two motions in that picture have names you'll hear for the rest of your Go career:

  • Fan-out: work spreading from one channel across many goroutines.
  • Fan-in: results funneling from many goroutines into one channel.

The whole assembly is called a worker pool, and it's the standard Go answer to bounded concurrency. Not a framework, not a library: a shape, maybe fifteen lines, built entirely from parts you passed exams on last lesson. A typed channel, range over a channel ending at the close, close as the sender's broadcast, arrows in signatures: all lesson twenty-one. What's genuinely new today is one question (who closes the outbound belt?) and one returning tool. Same build order as last time, too: grammar first on something small enough to see whole, then feedstack for real.

The jobs channel

The toy: numbers in, squares out, three workers. Start with the inbound belt.

jobs := make(chan int, 5)
 
for j := 1; j <= 5; j++ {
	jobs <- j
}
close(jobs)

Line by line. make(chan int, 5) builds a channel of ints with a buffer of five, and that buffer is the first judgment call, so defend it with lesson twenty-one's rule: a buffer is for a measured burst, sized from arithmetic you can defend. The arithmetic here: exactly five jobs, known before the first send. That's the whole proof. The loading loop sends 1 through 5; thanks to the buffer, all five sends complete instantly, drop-offs into a mailbox with exactly enough slots. main never blocks, and no worker needs to exist yet.

Then close(jobs), and read it with last lesson's eyes, because every clause is doing work. Who's closing? main, the only sender on jobs, the same goroutine that sent, which is the easy case, the generator's move, no coordination needed. And notice when: before a single worker has been launched, before anyone has received anything. Legal? You proved it last lesson: a closed channel drains. The five values sit in the buffer and are still owed to whoever receives. So hear what this close says, precisely. Not "stop working." Not "the channel is dead." It says: no more values are coming; the queue you see is final. Workers can draw from it for as long as that takes, and when the last value is gone, their range loops end on their own.

One honesty note: preloading works because the job count is known up front, and feedstack knows its source list before any fetching starts, so this is feedstack's shape. If jobs trickled in over time, you'd feed the channel from its own goroutine and close it when the trickle ends. Same close, different sender. The rule doesn't change: the sender closes, and the sender is whoever produces the work.

Whoever is free takes the next one

Now the crew, with worker IDs so the distribution is visible:

package main
 
import "fmt"
 
func main() {
	jobs := make(chan int, 5)
	results := make(chan string)
 
	for j := 1; j <= 5; j++ {
		jobs <- j
	}
	close(jobs)
 
	for id := 1; id <= 3; id++ {
		go func() {
			for j := range jobs {
				results <- fmt.Sprintf("worker %d: %d squared is %d", id, j, j*j)
			}
		}()
	}
 
	for range 5 {
		fmt.Println(<-results)
	}
}

Top to bottom. The jobs channel is loaded and closed exactly as before. Then three launches, each goroutine carrying an id (captured per iteration, lesson twenty's loop-variable rule), and each worker body is the same two moves: range jobs receives jobs until the channel is closed and drained, and the send into results delivers one formatted line per job. results is unbuffered. At the bottom, main collects with for range 5, lesson five's count-only range running lesson twenty-one's known-count receive: five jobs, one result each, five receives. Still legal. Run it:

go run .
# worker 3: 1 squared is 1
# worker 1: 2 squared is 4
# worker 2: 3 squared is 9
# worker 3: 4 squared is 16
# worker 1: 5 squared is 25

Run it again and the worker IDs shuffle: same five answers, different hands. Two facts are printing, and the first is load-bearing for everything ahead.

First: every job appears exactly once. When several goroutines receive from one channel, each value is delivered to exactly one of them. Receivers compete, whoever's receive lands first gets that value, and nobody else ever sees it. Channels distribute; they never broadcast. No job done twice, no job skipped. (The only broadcast a channel performs is the close, which reaches every receiver. That asymmetry is the whole rulebook.)

Second: the distribution is demand-driven. Nobody assigned job four to worker three; worker three finished early, came back to the channel, and the next receive handed it whatever was oldest. A slow job doesn't jam the line, because the other workers keep drawing around it. In another language you'd write a dispatcher for this: a queue with locks, a scheduling loop, fifty lines and three bugs. Here the load balancing isn't in the program at all. It falls out of "receive blocks until there's a value." The channel was the queue all along. That's fan-out, working.

A worker is a signature

The worker deserves a name and a doorplate:

func worker(id int, jobs <-chan int, results chan<- string) {
	for j := range jobs {
		results <- fmt.Sprintf("worker %d: %d squared is %d", id, j, j*j)
	}
}
for id := 1; id <= 3; id++ {
	go worker(id, jobs, results)
}

Stop on the two channel types, because lesson twenty-one's arrows just went from documentation to enforcement. jobs <-chan int is receive-only inside the worker: a worker can take work, and that is everything it can do to the queue. It can't sneak a job back in, and it can't close the queue; close(jobs) inside worker doesn't compile, cannot close receive-only channel. The crew physically cannot kill the belt. results chan<- string is send-only: a worker can deliver, and it can't read back a colleague's results. Two arrows and the job description is law.

The launch line got cleaner too: go worker(id, jobs, results), a go statement on a plain function call. Lesson twenty's evaluation rule quietly pays off here: arguments are evaluated at the go statement, so id is copied in right there and the three workers hold 1, 2, 3 with no capture questions at all. And make built both channels bidirectional; at the call boundary they narrow to the directional types automatically, the free one-way conversion from last lesson.

Read the worker's life out loud, because every worker you ever write has this grammar: take a job, do the work, deliver the result, go back for more, and when the range ends, because the queue was closed and drained somewhere upstream, retire. The function returns, the goroutine ends, two kilobytes go back to the runtime.

The count is a coupling

main's collection loop is still for range 5, and that 5 is doing a lot of quiet work. It's really len(jobs) times one result each: the receiver doing arithmetic about the senders, how many jobs, how many results per job. Today the arithmetic is right. Watch how little it takes to make it wrong. One line, in the worker:

for j := range jobs {
	if j%2 != 0 {
		continue // only report even squares
	}
	results <- fmt.Sprintf("worker %d: %d squared is %d", id, j, j*j)
}

A reasonable evolution: maybe results got filtered, maybe only failures need reporting. Run it, and two lines print, then nothing. No crash at first, just a cursor hanging. main wants five receives and only two will ever come; the workers have all retired, queue drained, odd jobs skipped, functions returned. main is parked at a receive that cannot land, and since main is the last goroutine standing, the runtime calls it: all goroutines are asleep - deadlock!. And notice the luck in even getting that crash. If any other goroutine were alive somewhere, a server listening, a ticker ticking, there'd be no crash, just a loop stuck forever inside a healthy-looking process: lesson twenty-one's goroutine leak, the quiet failure hiding inside the loud one.

The count didn't break at the line you edited. It broke silently, at a distance, in the receiver, because a count-based receive couples the receiver to the senders' behavior. Change what the workers send and a number forty lines away is wrong. That's the verdict on counting: it works until somebody touches the workers.

What the receiver should say instead is lesson twenty-one's range: give me values until there are no more, no arithmetic, no assumptions, the stream announces its own ending. But range stops at the close, no close, no stopping. So somebody has to close results. And now you're standing exactly where lesson twenty-one parked you: many senders, one channel. Who closes?

Many senders, one channel: who closes?

Walk the candidates and watch each one disqualify itself.

A worker? Say worker two's range ends and, on its way out, it closes results, the generator's move from last lesson: send everything, close behind you. But read what worker two actually knows at that moment. Its range ended, meaning the jobs channel is empty. That says nothing about the colleagues. Worker one might be mid-job right now, square computed, send half-formed, and the instant worker two closes, that send becomes panic: send on closed channel. The nine-cell table said receive never panics and send panics in exactly one cell; this is that cell. A worker knows when its own sending is finished. close demands more: it's a statement that all sending is finished, everywhere, forever. No worker has that knowledge. Three couriers share the belt, and none of them can see the other two.

The receiver? The iron rule says never, and you don't even need the rule anymore, because the signature got there first: inside anything holding <-chan, close doesn't compile. The receiver can't close because the receiver's type can't spell close.

So nobody can close, and every candidate is missing the same single fact: have all the senders retired? Say that sentence again, slowly. Have all the goroutines finished. You've solved that. Lesson twenty built a counter that goroutines increment at launch and decrement on exit, plus a way to wait for zero, and then lesson twenty-one's channel took its waiting job away and the lesson said it had unfinished business. This is the business. The WaitGroup comes back, not to wait for results (the channel delivers those), but to know the moment the last sender retires. Counting them home was its real job all along.

The wrong assembly first

Wire it the obvious way, wrong on purpose, because the wrong version teaches the idiom's one sharp edge. The counting is exactly as lesson twenty taught:

var wg sync.WaitGroup
for id := 1; id <= 3; id++ {
	wg.Add(1)
	go func() {
		defer wg.Done()
		worker(id, jobs, results)
	}()
}
 
wg.Wait()
close(results)
 
for line := range results {
	fmt.Println(line)
}

Add in the launcher, never inside the goroutine (lesson twenty's gap argument); Done deferred so it runs on every exit (lesson six's any-exit guarantee); the worker call wrapped inside. Then the plan, and it sounds airtight: wait for all workers, then close results, all senders provably retired so the close is safe, then receive everything with a range. Wait, close, range. Run it:

go run .
# fatal error: all goroutines are asleep - deadlock!
#
# goroutine 1 [sync.WaitGroup.Wait]:
# ...
# goroutine 6 [chan send]:
# ...

Read the trace; it names everyone. main is parked in Wait. The workers are all parked on chan send. Now reconstruct the circle. results is unbuffered, so a worker's first send needs a receiver at that moment. Who receives on results? main, in the range, which sits below the Wait. main won't receive until Wait returns; Wait won't return until the workers call Done; the workers won't call Done until their sends complete; their sends won't complete until main receives. main waits for the workers, and the workers wait for main. Everyone asleep, provably forever, and the runtime tears it down with the receipts printed.

This trap deserves respect because of how it hides. Give results a buffer of five, room for every result, and this exact code runs perfectly: workers drop off and retire, Done fires, Wait returns, close, the range drains the mailbox. It ships. It runs for months. Then the job count grows past the buffer one day, and the circle comes back, in production, on the busy day. Lesson twenty-one's judgment, word for word: a buffer is never the fix; it postpones the jam. The fix is structural. Wait and close are the right two lines, sitting in the wrong goroutine.

The closer goroutine

Move them:

go func() {
	wg.Wait()
	close(results)
}()
 
for line := range results {
	fmt.Println(line)
}

Three lines, launched into the background, two kilobytes and a name: the closer goroutine. And main, freed from waiting, goes straight to the range, receiving from the first instant. That single move unwinds the whole circle: worker sends complete, workers retire, the deferred Dones fire, the counter hits zero, Wait returns inside the closer (the one goroutine whose entire job is knowing that exact moment), close(results) fires exactly once, and main's range, having drunk every result, hears the close and ends.

Check the iron rule one more time: the sender closes, never the receiver. Is the closer a sender? It speaks for the senders. It's their appointed representative: it does nothing but wait for all of them to provably finish, then makes the one announcement none of them could safely make alone. One closer, one close; the close-of-closed panic has no path to fire.

So name the WaitGroup's real job, finally. It hands you no values; channels do delivery, that was lesson twenty-one's whole point. It exists to learn the moment the last sender retires, so that one goroutine can close, once, safely. Count the senders home, then close the door.

Here's the assembled toy, whole:

package main
 
import (
	"fmt"
	"sync"
)
 
func worker(id int, jobs <-chan int, results chan<- string) {
	for j := range jobs {
		results <- fmt.Sprintf("worker %d: %d squared is %d", id, j, j*j)
	}
}
 
func main() {
	jobs := make(chan int, 5)
	results := make(chan string)
 
	for j := 1; j <= 5; j++ {
		jobs <- j
	}
	close(jobs)
 
	var wg sync.WaitGroup
	for id := 1; id <= 3; id++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			worker(id, jobs, results)
		}()
	}
 
	go func() {
		wg.Wait()
		close(results)
	}()
 
	for line := range results {
		fmt.Println(line)
	}
}

Read it top to bottom as a machine. A jobs channel, closed by its only sender. A crew of workers: fan-out, range jobs, deliver to results. A WaitGroup counting the crew. A closer goroutine translating "counter hit zero" into close(results). A receiver that just ranges. The trio at the end, WaitGroup, closer, range, is the answer to many senders, one channel. Not one option among several: it is the standard Go idiom, and you'll find it nearly verbatim in every codebase that fans in.

How many workers?

The pool runs three workers because the demo typed 3. The real question: how many should there be? Two regimes, with different answers.

If the work is compute, hashing, parsing, compressing, goroutines that never wait on anything, then more workers than cores just means queueing at lesson twenty's workbenches. The benches are the limit; set the worker count around the core count, which GOMAXPROCS already knows.

feedstack's work isn't compute. A fetch spends its life parked on the netpoller shelf, waiting for a server across the planet; the cores are barely involved, and eight of them would happily babysit thousands of parked fetches. For waiting work the constraint isn't hardware at all. It's the three bills from the top of the lesson, and notice that each active worker holds at most one fetch in flight, so: descriptors held roughly equals worker count; pressure on any one host is bounded by worker count; peak memory is worker count times the cost of one fetch. The worker count is the knob on all three bills at once.

So for I/O-bound work it's a dial, not a formula, and the dial's endpoints are programs you've already written. One worker takes jobs one at a time, in order: the sequential loop from lesson nineteen, wearing pool clothing. Workers equal to the job count launches everything at once: last lesson's stampede with extra steps. The pool is every point in between, and lesson twenty's arithmetic prices the points: three hundred sources at roughly a quarter second each is seventy-five seconds sequential; ten workers, about seven and a half; forty, under two; three hundred, a third of a second and a reputation.

Where to set it? Start small and honest, four or eight. Measure. Raise it while the run stays clean: no 429s in the receipts, descriptors comfortable, memory flat. The deeper win isn't any particular number; it's that the number exists. Unbounded, your concurrency was whatever len(srcs) happened to be, the input's choice. Bounded, it's a constant in one line of your file: your choice, reviewable, tunable the day the receipts complain.

Errors survive the funnel

One more thing has to make it through the funnel intact: failure. fetchResult has carried an err field since last lesson, and the fan-in point is where that struct earns its keep at scale. Lesson eleven made failure an ordinary value, and values ride structs, and structs ride channels, so when three hundred fetches funnel through one channel, every failure arrives glued to its own job, already wrapped with its source's context by the code that produced it. The split from last lesson, workers deliver, main decides, survives the pool untouched: no worker ever has an opinion about its error, it just ships it.

And cash an IOU while we're here. Lesson eleven mentioned errors.Join in a single breath and deferred it to exactly this lesson. feedstack prints failures as they arrive, because one dead feed is a Tuesday, but the day fan-in lives inside a function that must return one error from many jobs, the shape is:

var errs []error
for res := range results {
	if res.err != nil {
		errs = append(errs, res.err)
		continue
	}
	fetched = append(fetched, res.items...)
}
return fetched, errors.Join(errs...)

errors.Join folds a slice of errors into one error, returns nil if every input was nil (so the happy path stays a clean nil), and errors.Is and errors.As walk into every joined branch, so nothing about lesson eleven's triage breaks downstream. File it for module six, when fetching moves out of main into a function with a real signature.

feedstack: every source through the pool

main's fetch loop, fourth draft. Nineteen was sequential. Twenty, concurrent and convicted. Twenty-one, concurrent and clean, but a stampede waiting for a longer source list. Today it becomes a pool, and every line is one you've met. First, the dial, named, at the top of the file next to maxItems:

const maxWorkers = 4

Then the belt:

jobs := make(chan source, len(srcs))
for _, src := range srcs {
	jobs <- src
}
close(jobs)

Pause on the element type: a channel of the interface. Lesson sixteen's contract is what rides the belt, so the pool never learns whether a job is HTTP or a file on disk; workers fetch through the contract, blind, and that blindness is exactly why this same pool will carry any source feedstack ever grows. The rest is the toy's move verbatim: buffer of len(srcs), the measured burst with the count right there in the expression; load it; close it; queue final.

Then the crew and the closer:

results := make(chan fetchResult)
 
workers := min(maxWorkers, len(srcs))
var wg sync.WaitGroup
for range workers {
	wg.Go(func() {
		for src := range jobs {
			batch, err := src.fetch()
			results <- fetchResult{items: batch, err: err}
		}
	})
}
 
go func() {
	wg.Wait()
	close(results)
}()

Line by line. min(maxWorkers, len(srcs)) is lesson nineteen's min builtin doing exactly one sensible thing: no fourth worker for three jobs. for range workers is the count-only range. wg.Go is the shorthand lesson twenty mentioned in passing, now earning its keep: Add and Done folded into the launch, impossible to misplace the Add or forget the Done. And the worker it launches is two lines you can recite: batch, err := src.fetch() then results <- fetchResult{...}, last lesson's courier, now wearing a route instead of a single delivery, wrapped in range jobs. Fetch, hand over, back to the belt, retire at the close. Below it, the closer goroutine, character for character from the toy.

The collection side, and look how little survives of the old bookkeeping:

var fetched []feedItem
var failed int
for res := range results {
	if res.err != nil {
		fmt.Println("fetch failed:", res.err)
		failed++
		continue
	}
	fetched = append(fetched, res.items...)
}

The count-only for range srcs is retired; for res := range results replaces it, and main no longer knows or cares how many workers exist or how many deliveries are coming. The stream ends when the closer says so. Inside the loop, the triage is byte for byte last lesson's: error path prints, bumps failed, continues; happy path appends with the spread.

Now the audit, because the pool must pass the same bar as last lesson. Who writes fetched? main. Who writes failed? main. The workers own their locals and two channel ends; jobs flow one way, results flow the other; every crossing is a channel. The race stayed deleted.

And below the loop, nothing changed. The dedupe still collects through lesson nineteen's iterator, slices.Collect(dedupedBy(fetched, ...)) keyed by link; the by-source counts, the latest three, the save to disk, all untouched. That's worth one beat of admiration: many workers fetch, one goroutine aggregates. Every batch lands in main, so one deduped list emerges from all that concurrency with not a single lock anywhere in the program. Concurrency at the edges, plain code in the middle. That's the Go shape, and feedstack now wears it.

The run, the race, and the dial

Two verdicts, then an experiment.

go run -race .
# ...full output, no warnings, no exit status 66...

Clean, and the structural argument grew teeth since last lesson: now even the work arrives by channel. Jobs in by channel, results out by channel, one writer per variable. There's nothing left for the detector to catch.

go run .
# feedstack sources: [https://www.jsonfeed.org/feed.json local.json legacy.json]
# main.fileSource: 2 items in 472µs
# main.fileSource: 2 items in 691µs
# main.httpSource: 2 items in 289ms
# feedstack done: 6 items from 3 of 3 sources
# by source:
#   JSON Feed: 2
#   feedstack dev notes: 2
#   morning brew: 2
# latest:
# the schema nobody wrote down -> https://morningbrew.example/102
# JSON Feed version 1.1 -> https://www.jsonfeed.org/2020/08/07/json-feed-version.html
# Announcing JSON Feed -> https://www.jsonfeed.org/2017/05/17/announcing-json-feed.html
# saved 6 items to items.txt
# feedstack shutting down

Read it twice: this is last lesson's output. With three sources and four lanes, every job still starts immediately, so even the clock didn't move. The pool changed how fetches are scheduled, not what they produce. It's the invisible-refactor move again, lessons thirteen, fifteen, and nineteen's old trick: the shape is ready before the scale arrives.

So make the pool visible. Hard-code workers := 1, just to look:

go run .
# feedstack sources: [https://www.jsonfeed.org/feed.json local.json legacy.json]
# main.httpSource: 2 items in 290ms
# main.fileSource: 2 items in 401µs
# main.fileSource: 2 items in 277µs
# feedstack done: 6 items from 3 of 3 sources
# ...

Same six items, but the receipts flipped: the network first, then the files. One worker takes jobs in belt order, and feeds.txt lists the network feed first; one worker, one job at a time. The pool with one worker is the sequential program from lesson nineteen, reborn. And workers := len(srcs) is exactly last lesson's all-at-once. One shape generalizes everything module five has built, with a constant deciding where on the dial you live. (The clock barely moves here either way, because one network call dominates three sources. At three hundred sources, the dial is the difference between seventy-five seconds and seven.) Put min(maxWorkers, len(srcs)) back. The crew stands.

What the pool still can't do

Take stock, because the module's last lessons stand on this one. Unbounded concurrency breaks three ways, descriptors, courtesy, memory, so concurrency gets a budget. The budget's shape is the pool: jobs in, a fixed crew ranging, results out. Fan-out is one channel spreading work across many hands, free load balancing included. Fan-in is many hands funneling into one channel, and its one hard question, who closes, has a standard answer: count the senders home with a WaitGroup, let one closer goroutine make the announcement, and the receiver just ranges. Errors ride the results as values, one per job. The worker count is a dial you own: one end is sequential, the other is the stampede, and feedstack sits at four.

Now find the crack. A worker that takes a job is committed. src.fetch() has no deadline; http.Get waits as long as the server cares to dawdle. Picture source two hundred eleven: a feed that accepts the connection and then sends nothing. Not refusing, just slow. That worker's lane is held, for a minute, an hour, forever. Four lanes, one tar pit: a quarter of the crew, gone. Two more tar pits and the pool is a queue behind the slowest server on the internet. And the part that should actually bother you: nobody can do anything about it. main can't reach into a parked fetch. There is no line of Go you currently know that says never mind: no deadline on a fetch, no giving up on a slow source, no stopping the whole run when the caller walks away. The goroutine leak from last lesson is still at large for the same reason, a parked goroutine with no one able to tell it to stand down. What's missing is the ability to wait on several things at once, a result, a clock, a stop signal, and act on whichever speaks first. Go spent a keyword on that too. Next lesson: select, timeouts, and cancellation with context, where feedstack's pool learns to say "that's long enough."

Command Palette

Search for a command to run...