620
@oncall_omar ·

Why does my worker pool hang on shutdown even after I cancel the context Concurrency

Eight workers pull jobs off a buffered channel (cap 100) and push results onto an unbuffered results channel. On SIGINT I call cancel() then wg.Wait(), and roughly one run in three it just sits there forever instead of exiting. Each worker does a select on ctx.Done() and the jobs channel, so I assumed cancellation was covered. Adding a counter shows two workers never return. What am I missing?

9 answers Share
Report

Answering anonymously — a moderator will review it first.

  • @parquet_pile · 7mo ago · 3 replies

    Your workers aren't stuck on the select, they're stuck on the send. Once cancel fires, whatever was draining the results channel exits, and any worker that already grabbed a job blocks forever on results <- r because the receiver is gone. ctx.Done() does nothing for you there because you're not in a select at that point.

    Two fixes, pick one:

    • wrap the send: select { case results <- r: case <-ctx.Done(): return }
    • keep the consumer alive until wg.Wait() returns, then close results. Producer closes, consumer drains, nobody blocks.

    The second is usually cleaner because it means results actually get flushed instead of silently dropped on shutdown.

    341
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report
    • @oncall_omar · 7mo ago

      That's exactly it. My consumer returns on <-ctx.Done() in its own select, so it wins the race and then the workers are shouting into a closed room. Wrapping the send fixed it in 200 consecutive runs.

      96
      Share
      Reply

      Answering anonymously — a moderator will review it first.

      Report
    • @blue_team_bex · 7mo ago

      Worth adding the rule that saved me a lot of these: the goroutine that sends on a channel owns closing it, and the goroutine that receives never returns early unless it's also the one that told the senders to stop. Break either half and you get exactly this hang.

      58
      Share
      Reply

      Answering anonymously — a moderator will review it first.

      Report
  • @blue_team_bex · 7mo ago · 2 replies

    Separate bug you probably also have: when both cases of a select are ready, Go picks pseudo-randomly. So even after cancel, a worker with a full jobs channel keeps taking the jobs branch about half the time and never notices. If you want cancellation to win, check it first in its own non-blocking select before the real one.

    174
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report
    • @printbed_gremlin · 7mo ago

      This bit me on a batch importer with 40k queued items. Draining took eleven minutes after ctrl-c because the odds of hitting Done were roughly 50/50 each loop and there was always another job waiting.

      61
      Share
      Reply

      Answering anonymously — a moderator will review it first.

      Report
  • @carbon_steel_kit · 7mo ago

    Rewrite the whole thing on golang.org/x/sync/errgroup with errgroup.WithContext. You lose the manual WaitGroup, the first error cancels the shared context, and Wait returns it. I have not hand-rolled a pool in about four years and I do not miss it.

    88
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report
  • @quiet_stacker · 7mo ago

    For diagnosis rather than the fix: next time it hangs, send it SIGQUIT instead of killing it. You get every goroutine's stack dumped to stderr, and the two stuck ones will both say chan send with the exact line. Beats adding print statements. GOTRACEBACK=all if you want the runtime ones too.

    52
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report
  • @flat_rate_finn · 7mo ago · 2 replies

    Put a time.Sleep(500 * time.Millisecond) between cancel and Wait. The goroutines need a moment to observe the cancellation and the scheduler doesn't always get to them in time.

    9
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report
    • @quietmargin · 7mo ago

      That's not what's happening. A blocked channel send stays blocked for the heat death of the universe, not 500ms. Sleeping just moves the hang later.

      7
      Share
      Reply

      Answering anonymously — a moderator will review it first.

      Report