JavaRush /Courses /Go SELF /Stop protocol: cancel-on-error and closing channels

Stop protocol: cancel-on-error and closing channels

Go SELF
Level 69, Lesson 3
Available

1. Why stopping is a separate task

When you first write a concurrent setup, it feels like everything is simple: “we found an error in one place — we did return”. And then Go politely (as politely as Go can be) reminds you: return only exits the current goroutine, while the rest keep living their own lives. The problem isn’t that they live, but that they may keep waiting on a channel read/write — and your program will hang so quietly it’s almost embarrassing: no panic, no stack trace, just eternal waiting.

A typical “eternal wait” looks like this: the collector has already stopped reading results, a worker tries to do results <- r and blocks forever; the coordinator waits on wg.Wait(), but the worker won’t finish because it’s blocked on send; and main stands there watching philosophically. At that moment you start to understand why concurrency is not “faster”, but “more complex, yet manageable”.

For stopping to be manageable, you need a protocol: an agreement on who informs whom, and how, that “that’s it, we’re done”.

Stopping tools: close and context.Cancel

Right now we have two very different “stop signals”, and it’s important not to mix up their roles. The first is closing a channel (close(ch)), which means: “there will be no more values”. The second is context cancellation (cancel()), which means: “the operation should stop as soon as possible”.

With context, the key mechanism is that ctx.Done() returns a signal channel that is closed on cancellation; and that’s exactly why it’s convenient to listen to it in select. This isn’t magic, it’s a direct contract of the context package: Done is the cancellation channel, and derived contexts (WithCancel, WithTimeout, etc.) give you a tree where canceling a parent cancels its children.

And close on a channel is not “kill everyone”, but precisely “you must not send anymore”. A closed channel isn’t a trash can, it’s a sign that says “the store is closed”. And, as in real life, if you try to “close the store a second time”, there will be a scandal (panic: close of closed channel).

2. Channel ownership: who is allowed to close

The most common cause of panics in worker pools is fighting over the right to close a channel. It’s like a line for a single microphone: everyone wants to announce “I’m done”, and in the end the mic gets dropped.

Remember a rule that saves hours of your life: the channel is closed by the one who writes to it. But in a worker pool there’s a nuance: many workers write to results, and that’s where “collective responsibility” appears — which in Go usually ends in a panic. That’s why results is closed not by a worker, but by the coordinator — the only one who knows when all workers have definitely finished.

It’s handy to lock this in with a small table (and then keep it in your head like traffic rules):

Channel Who writes Who closes Why it’s them
jobs
producer producer it is the only source of tasks
results
workers coordinator there are many workers; only one should close after wg.Wait()

If you feel like closing results from a worker, stop and ask yourself: “are the other workers definitely not going to try to write there?” If the answer is not “absolutely yes” — then you must not close it.

4. Cancel-on-error: guarantees and where hangs happen

Cancel-on-error is not “on error everything instantly disappears”. It’s more realistic: “on the first serious error we send a global stop signal, and all goroutines are obliged to have an exit path and not hang on channels”.

Two guarantees matter here.

First guarantee: the producer must not hang while sending tasks if the workers have already started winding down. To achieve that, the producer sends into jobs via select with ctx.Done().

Second guarantee: a worker must not hang while sending a result if the collector has already “got tired and left”. To achieve that, the worker sends into results via select with ctx.Done() as well.

That’s exactly why ctx.Done() is so loved: it turns a potentially infinite wait into “waiting, but with an emergency exit”. That’s the practical value of context — the reason it exists at all.

Mini diagram: roles and stop signals

Before code, it’s useful to see the whole system once as a diagram. This reduces the chance that you “fix” a hang in one place and accidentally create it in another (concurrency can take revenge in elaborate ways).

flowchart LR
    P[producer] -- jobs --> W1[worker 1]
    P -- jobs --> W2[worker 2]
    P -- jobs --> W3[worker 3]

    W1 -- results --> C[collector]
    W2 -- results --> C
    W3 -- results --> C

    K[coordinator] -->|wg.Wait| W1
    K -->|wg.Wait| W2
    K -->|wg.Wait| W3
    K -->|"close(results)"| C

    X["cancel()"] -->|"ctx.Done() is closed"| P
    X -->|"ctx.Done()"| W1
    X -->|"ctx.Done()"| W2
    X -->|"ctx.Done()"| W3

There are exactly two weak spots here: the jobs and results arrows. If reading stops somewhere, someone will stop writing and will hang. Cancel-on-error is needed so that instead of “hang” you get “see Done and exit cleanly”.

5. Example: parallel task processing

So the examples aren’t “abstract workers that multiply by 2”, let’s imagine a real useful operation in our task app: we have a batch of tasks, and we want to normalize titles before saving/output. For example, we forbid empty titles and remove extra whitespace.

We’ll make a function that takes []Task, processes in parallel, and on the first error stops the rest.

Let’s start with the models (brief and boring — but it’s the kind of boring that saves you from chaos):

package main

import "strings"

type Task struct {
	ID    int
	Title string
}

func normalizeTitle(s string) (string, error) {
	t := strings.TrimSpace(s)
	if t == "" {
		return "", ErrEmptyTitle
	}
	return t, nil
}

Let the ErrEmptyTitle error be a sentinel so it can be recognized via errors.Is (we already discussed this in the errors module, and it’s genuinely useful).

package main

import "errors"

var ErrEmptyTitle = errors.New("empty title")

6. Implementing cancel-on-error in code

Skeleton: context.WithCancel and the defer cancel() discipline

Now for the foundation of the protocol. We create a child context and cancel, and immediately set defer cancel(). This isn’t “superstition”, it’s discipline: even on success we correctly release resources associated with the context (timers/references, etc.).

package main

import (
	"context"
)

func processBatch(parent context.Context, tasks []Task) ([]Task, error) {
	ctx, cancel := context.WithCancel(parent)
	defer cancel()

	_ = ctx
	_ = tasks

	return nil, nil
}

Yes, it’s just a stub for now. But the key point is already there: we have a single stop signal for the whole group of goroutines.

Data types: job and result

Now let’s introduce types for the queue and the result. It slightly increases the amount of typing, but drastically reduces errors like “what is that int in this channel?”.

package main

type job struct {
	Idx  int
	Task Task
}

type result struct {
	Idx  int
	Task Task
	Err  error
}

Next we’ll create two channels: jobs and results. And we immediately agree: jobs is closed by the producer (in our case, a separate goroutine), and results is closed by the coordinator after wg.Wait().

Worker: respecting ctx.Done() and two selects

The “slipperiest” part of cancel-on-error is the worker. It can hang in two places: while reading tasks and while sending a result. That means both places must use a select with ctx.Done().

package main

import (
	"context"
	"fmt"
	"sync"
)

func worker(ctx context.Context, jobs <-chan job, results chan<- result, wg *sync.WaitGroup) {
	defer wg.Done()

	for {
		select {
		case <-ctx.Done():
			return
		case j, ok := <-jobs:
			if !ok {
				return
			}

			title, err := normalizeTitle(j.Task.Title)
			if err != nil {
				err = fmt.Errorf("task id=%d: %w", j.Task.ID, err)
			}

			r := result{Idx: j.Idx, Task: Task{ID: j.Task.ID, Title: title}, Err: err}

			select {
			case <-ctx.Done():
				return
			case results <- r:
			}
		}
	}
}

Notice the error wrapping: fmt.Errorf("...: %w", err) makes the error part of the cause chain, and you can later check it with errors.Is. It’s like a label on a box: “broke while processing task 17”, while still preserving the fact that the root cause is ErrEmptyTitle.

Producer: sending tasks via select and defer close(jobs)

The producer must close jobs no matter how things end: both on success and on cancellation. That’s why defer close(jobs) is a direct part of the protocol.

package main

import "context"

func produce(ctx context.Context, jobs chan<- job, tasks []Task) {
	defer close(jobs)

	for i, t := range tasks {
		select {
		case <-ctx.Done():
			return
		case jobs <- job{Idx: i, Task: t}:
		}
	}
}

There’s an important psychological point here: the producer doesn’t “have to send all tasks at any cost”. If the system is canceled, it stops sending. That’s the whole meaning of cancel-on-error: we save time and resources.

Coordinator: wg.Wait() and closing results once

The coordinator is the “person on duty to turn off the lights”. It doesn’t count or validate anything; its job is boring: wait for wg.Wait() and close results. Boring tasks are the most important ones, because they are what prevent panics.

package main

import "sync"

func closeResultsWhenDone(results chan<- result, wg *sync.WaitGroup) {
	wg.Wait()
	close(results)
}

Yes, it’s a tiny function. But it makes your closing protocol explicit and visually checkable.

Collector: first error, cancel(), and a careful shutdown

The collector reads results and decides what to do. In a cancel-on-error strategy the logic is usually: we find the first error — we remember it, call cancel(), and then keep reading results until it’s closed, to finish the pipeline cleanly.

Why keep reading? Because even with a select in the worker, some results may arrive after cancellation (the worker might have managed to send), and we want to finish the range results loop cleanly. This isn’t “always mandatory”, but it’s a very stable habit.

package main

import (
	"context"
	"sync"
)

func processBatch(parent context.Context, tasks []Task) ([]Task, error) {
	ctx, cancel := context.WithCancel(parent)
	defer cancel()

	jobs := make(chan job)
	results := make(chan result)

	var wg sync.WaitGroup
	workers := 3

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

	go produce(ctx, jobs, tasks)
	go closeResultsWhenDone(results, &wg)

	out := make([]Task, len(tasks))
	var firstErr error

	for r := range results {
		if r.Err != nil && firstErr == nil {
			firstErr = r.Err
			cancel()
		}
		if r.Err == nil {
			out[r.Idx] = r.Task
		}
	}

	if firstErr != nil {
		return nil, firstErr
	}
	return out, nil
}

There’s an important engineering detail here: we don’t “exit range results immediately after cancel”. We wait until results is closed by the coordinator — that is, until all workers have finished. This way we don’t leave “dangling” goroutines.

7. Where the protocol is broken most often

In cancel-on-error, people usually break one of two places.

First: the worker reads for range jobs and sends results <- r without a select. In a peaceful scenario it works. But with an early stop, the collector stops reading — and the worker hangs on send. That hang “propagates” upward: wg.Wait() never finishes, results never closes, main waits forever.

Second: the producer does not listen to ctx.Done() and keeps trying to send tasks into jobs. But the workers are already exiting, and the queue may stop being read. The producer hangs on jobs <- .... And then everything follows the same sad chain.

Cancel-on-error doesn’t work “by itself”. It works only if every potentially blocking operation (send/receive) has an emergency exit via ctx.Done().

Mental check: who owes what to whom

So the protocol is not a bag of tricks but a clear system, it’s useful to keep three short commitments in mind, like in a rental contract (except here the tenant is a goroutine).

  • The producer commits to closing jobs and stopping task sends on context cancellation.
  • The worker commits to having an exit path and not hanging either on reading tasks or on sending results.
  • The coordinator commits to closing results exactly once, and only after all workers finish.

The collector commits to doing nothing that leaves the system “half alive”. If it initiates a stop, it either continues reading until results is closed, or it guarantees that workers cannot hang on send (which is exactly what select on send achieves).

8. Typical mistakes with cancel-on-error and closing channels

Mistake #1: closing results from a worker.
This almost always leads to panic: close of closed channel, because there are multiple workers: the “first one who makes it” closes the channel, and the next one will try to close again or send a value into an already closed channel. The coordinator should close results after wg.Wait().

Mistake #2: sending the result as results <- r without a select with ctx.Done().
In a normal scenario it looks fine, but on an early stop the collector may stop reading, and the worker will hang on send. The correct pattern is select { case results <- r: case <-ctx.Done(): return }, so the worker has an emergency exit.

Mistake #3: reading tasks via for range jobs, and not accounting for context cancellation.
range over a channel ends only when the channel is closed. If the producer hangs for some reason or doesn’t close jobs, the worker will wait forever. For cancel-on-error, it’s better to use an explicit loop with select, where there is a case <-ctx.Done(): return branch.

Mistake #4: the producer writes to jobs without select and doesn’t react to cancellation.
If workers started shutting down, the producer may hang on jobs <- .... As a result you get: “we called cancel(), but the program still hung”. The producer must send tasks via select and have a case <-ctx.Done(): return branch.

Mistake #5: canceling the context but forgetting the defer cancel() discipline.
If you create WithCancel but never call cancel() at all, weird effects start showing up in the code: someone waits for a signal that never comes, or resources are held longer than needed. The simple rule “created cancel — immediately defer cancel()” makes behavior predictable.

1
Task
Go SELF, level 69, lesson 3
Locked
Sum Generator
Sum Generator
1
Task
Go SELF, level 69, lesson 3
Locked
Three workers
Three workers
1
Task
Go SELF, level 69, lesson 3
Locked
Division Without Draining
Division Without Draining
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION