openlang

Workers stuck on the results send after the context is canceled

Ran into a simple thing and spent longer on it than I'd like.

A pool of eight workers reads tasks from jobs and writes results to an unbuffered results. The consumer reads results in a loop and returns on ctx.Done() at the first error. The cancel arrived, the consumer left. And the workers that had already computed their result are still standing on results <- r. Nobody is left to read that channel, and they'll stand there until the process ends.

The cancel by itself does nothing here. ctx.Done() is just a channel that gets closed. If a goroutine isn't listening to it at the spot where it waits, it never hears about the cancel.

The fix is short. Wrap the send in a select with two branches, case results <- r and case <-ctx.Done(), and return in the second. The Stream example in the context docs is built the same way, I just hadn't noticed it before.

Java has a similar picture with SynchronousQueue.put, except there you can at least call interrupt on the thread, and put throws InterruptedException. Go has no such outside lever. The goroutine has to look at ctx itself.

I think the rule is simple. Every place a worker can wait also listens for the cancel.

后续讨论 8

顺着彼此的依据读下去
  1. 固定链接

    Let's count. Eight workers, the consumer left after the first result. If every worker had a task, seven goroutines hang on the send, each holding its result in memory. Plus whoever writes into jobs, if it also sends without a select.

    There's an option without select too. If the number of tasks is known up front, give results a buffer for all of them. Then the send never blocks and nothing leaks. For a stream of unknown length that won't work, and it's select or nothing.

  2. 固定链接

    In Erlang the trap is the opposite one. A send never blocks, the worker sends its result and moves on. If the receiver has already terminated, the message just doesn't arrive. If it's alive but lost interest, the result goes into its mailbox and stays there.

    So the select cures the stuck sender, and that's the Go half. The other half is whether the consumer can still run into a result after it decided to stop, and with an unbuffered channel it can't, because a send to a receiver that left never completes. On the BEAM you'd be draining those leftovers from the mailbox yourself.

  3. 固定链接

    Max, checked it in a test. Eight workers, eight tasks, cancel after the first result, wg.Wait() at the end. The old version never got past wg.Wait(). The run died on the go test -timeout limit, and the goroutine dump showed seven of them in chan send, the number you got. With the select on the send, wg.Wait() returns right away and the dump is gone.

    I'm keeping the select for now. Didn't do the buffer for all tasks, my input isn't always finite.

  4. 固定链接

    What I keep reaching for here is a link between the workers and the consumer, the way Elixir does it. The consumer crashes or exits with an abnormal reason, the exit signal travels over the link, and the workers go down with it. Nobody has to cancel anything, nobody listens on a special channel. The exception is a clean exit. A process that isn't trapping exits drops an exit signal with reason normal, so a consumer that simply finished takes nobody with it.

    Go has no links. ctx is cooperative, and every waiting spot has to listen for it on its own. It works, but it runs on discipline. Miss one send and you have a hanging goroutine.

    The cancel also costs something. A worker that left on ctx.Done() with its result already computed has lost that result. If the task only computed, no harm. If it had already made an external write, nobody knows after the cancel what exactly it did.

  5. 固定链接

    patchkit, agreed on discipline, but a worker doesn't have many waiting spots. Take a task, hand back a result. Two select statements, both visible in one function. Still bearable, as far as I can see.

    You're right about the lost result, and I haven't come up with anything clever. If the task writes outward, let it write before sending the result and carry the task identifier, so a rerun can recognize what it already did. Then after a cancel you can at least ask what managed to happen.

  6. 固定链接

    There's a third spot, and my test is too small to show it. The goroutine that feeds jobs gets stuck on its send after a cancel too, because the workers have already left on ctx.Done() and nobody reads jobs anymore. With eight tasks, one per worker, the feeder is done before the cancel, so the test never sees it.

    Put a select there too. So three sends, three select statements. Looks fine for now, but I can already see someone adding a fourth send without one.

  7. 固定链接

    Three select statements is where I'd stop and change the shape. Fair, any send that ignores the cancel can hang, no argument there. But the program can be dumber. After an error the consumer doesn't leave. It keeps reading results until the channel is closed and throws the values away. The workers keep reading jobs until it's closed and skip the work once ctx.Err() != nil. Every channel gets drained to the end, nothing hangs, and there are zero select statements.

    // worker
    for j := range jobs {
    	if ctx.Err() != nil {
    		continue
    	}
    	v, err := work(j)
    	results <- result{v, err}
    }
    
    // consumer, results is closed after wg.Wait()
    for r := range results {
    	if r.err != nil && first == nil {
    		first = r.err
    		cancel()
    	}
    }

    I ran it with a feeder pushing a hundred jobs into eight workers and one job that fails early. It finishes and prints the first error. No goroutine is left behind, because every send has a reader until the end and every reader has a close coming.

    What I like is that the shutdown order is the normal order. The feeder closes jobs, the workers fall out of their loops, the closer goroutine closes results, the consumer exits. Nobody has to remember a fourth select, because a new send inside the worker loop is covered by the same drain.

    Yes, with a long input the producer runs through all of it for nothing, and each worker still pulls a value off jobs per item just to skip it. That cost sits in one place where you can see it. A missing select shows up nowhere until a goroutine dump shows it.

同一个问题的其他角度

  1. Eight queue slots can still leave 88 goroutines waiting

    A buffered channel bounds the jobs sitting in it. It does nothing about the goroutines blocked trying to send. Say a server g…

    回复 8最近活动 go
  2. Marking the filter async did nothing for the freeze

    Search filters a list of around forty thousand rows, and the UI freezes for a moment on every query. I figured async was the…

    回复 8最近活动 dart
  3. asyncio.TaskGroup when one fetch fails

    Concurrency comparisons between languages usually start with syntax. I'd start with what the calculation needs when something…

    回复 15最近活动 pythonjuliago