← All posts

Goroutine Visualizer and the myth of killing work

Goroutine Visualizer shows a core property of Go concurrency: pressing kill on a goroutine does not give you abrupt external termination. The useful lesson is how to design, verify, and observe cooperative cancellation so work exits cleanly under load and during shutdown.

Concurrency bugs fail in quiet ways. Your process stays up. CPU looks fine. Logs stay sparse. But work stops moving because one goroutine is blocked on a channel send, another is waiting on a lock, and a third is parked on I/O. If you only look at top-line health, you miss the state machine inside the runtime.

That is why Goroutine Visualizer is useful. Its premise is simple. Press kill on a goroutine and watch nothing happen. The point is not the button. The point is the mismatch between what you asked for and what the program model permits. In Go, a goroutine is not an OS process with a kill signal you can rely on as a control surface. If your design needs abrupt termination of arbitrary work, your design is already under strain.

For a practitioner, this demo is a compact lesson in cancellation, ownership, and observability. It shows where intuition imported from threads and processes breaks down. It also gives you a checklist for reviewing your own services. Where does work begin. Who owns its lifetime. How does it stop. What state is exposed while it runs. Those are engineering questions, not style preferences.

What the demo is teaching

The one-line description carries the whole lesson. You press kill on a goroutine. Nothing happens. That outcome maps to a core property of Go’s concurrency model. Goroutines do not expose a safe, general-purpose external kill primitive.

There is a reason for this. A goroutine might hold a mutex. It might be midway through a write. It might own shared state with invariants half-updated. If the runtime let you terminate it from the outside at an arbitrary instruction boundary, you would trade one stuck unit of work for heap corruption, broken invariants, leaked resources, and deadlocks elsewhere.

So Go pushes you toward cooperative cancellation. A goroutine checks for a stop condition, usually through a context.Context, a closed channel, or both. It unwinds its own state. It releases what it owns. It returns in a known place.

This is the design decision worth inspecting in your own systems. When a unit of work needs to stop, is there a path for it to notice. Is it inside a select loop. Does it poll a context before starting expensive work. Does downstream I/O accept context cancellation. Do you close producers before waiting on consumers. If the answer is no, your system has goroutines with no off-ramp.

The demo makes this visible with almost no setup. You ask for termination. The runtime does not comply. Your code structure decides whether the goroutine exits.

How to verify the claim yourself

A good demo should be falsifiable by inspection. This one is.

First, trigger the action the page presents. Press kill on a goroutine. Observe the visible state. If nothing changes, the claim holds at the UI level. But the useful verification goes one step deeper.

Inspect what the goroutine is doing before and after the action. In a typical visualizer, the meaningful states are things like:

  • running
  • runnable
  • blocked on channel send or receive
  • waiting on a mutex
  • sleeping or waiting on a timer
  • in syscall or I/O wait
  • completed

If the goroutine remains in its prior state after the kill action, the demo has shown the right mental model. The control did not preempt execution. It did not tear down stack frames. It did not revoke ownership of shared state. It simply illustrated the absence of a primitive you might expect from another environment.

You can carry the same verification pattern into production code.

  • Trigger cancellation through the path your service exposes.
  • Check whether the goroutine count drops over time.
  • Confirm blocked operations wake up on context cancellation.
  • Confirm wait groups finish.
  • Confirm channels close in the expected order.
  • Confirm file descriptors, sockets, and timers do not stay live.

If your cancellation path works only for idle goroutines and fails for blocked ones, you have learned something important. Your shutdown path exists on paper, not in runtime behavior.

Signals to read in real systems

The demo is small. The class of bug is not. In real services, the signals are often subtle.

A common one is stable or rising goroutine count after work should have drained. If request volume drops to zero but goroutines keep accumulating, you likely have leaked background work or blocked fan-out workers.

Another signal is partial shutdown. The process receives a stop signal, some requests finish, but the service hangs until a timeout. This often means one or more goroutines are waiting on work queues nobody will close, or downstream calls ignore cancellation.

Latency outliers also matter. A deadlocked or blocked goroutine does not always raise average latency much. It often shows up as a long tail. One request path waits forever on a channel receive because the sender exited early. One worker waits on a lock held across a network call. The median looks calm while a small set of requests stalls.

Look for ownership mismatches too. If a parent function starts a goroutine and returns without a clear mechanism to stop it, you have detached work. Detached work is not always wrong, but it needs explicit lifetime rules. A background refresher tied to process lifetime differs from a per-request helper tied to a request context. Mixing those two lifetimes is where leaks start.

You should also inspect observability gaps. If your traces end before background work finishes, or your logs omit goroutine state during shutdown, you are blind at the exact moment lifecycle bugs show up. Expose the count of active workers. Expose queue depth. Expose cancellation reason where possible. Sample goroutine stacks during a hang. A visualizer teaches the model. Your production telemetry proves whether your code follows it.

Where this class of system goes wrong

The first failure mode is assuming force-stop exists somewhere if you look hard enough. Teams build APIs around the fantasy of killing work from the outside, then find out too late their goroutines sit in non-interruptible sections or ignore stop signals entirely. The result is a growing pile of abandoned work.

The second is late cancellation wiring. A context exists at the HTTP layer, but the code stops passing it once work crosses a package boundary. Database calls get it. Cache calls do not. Outbound HTTP uses it in one client, not another. Worker pools drop it completely. You end up with cancellation islands, not cancellation flow.

The third is blocking without a select on cancellation. This shows up in code like a plain channel send to a full buffer, or a receive from a channel whose producer has already failed. If the operation has no alternate case for <-ctx.Done(), the goroutine has no escape path.

The fourth is lock scope. Holding a mutex while performing I/O or waiting on another goroutine creates fragile dependency chains. If cancellation needs the same lock to update state or close a channel, shutdown turns into self-deadlock.

The fifth is confusing completion with cleanup. A goroutine returning is only part of the story. Did it stop child goroutines. Did it drain or close channels according to ownership rules. Did it stop timers. Did it release pooled resources. Cooperative cancellation works when the code is structured to unwind cleanly.

This is where design discipline matters.

  • The creator of a channel should usually close it.
  • The owner of a goroutine should define its lifetime.
  • Long waits should sit in a select with cancellation.
  • Shared state should have narrow critical sections.
  • Shutdown should be exercised under blocked and degraded conditions, not only happy-path tests.

Those are simple rules. They fail often under deadline pressure.

What to inspect in code review

Use the demo as a code review lens.

Start with every go statement. Ask what stops this goroutine. If the answer is “the process exits” for request-scoped work, you found a bug. If the answer is “it reads from this channel forever,” ask who closes the channel and on what event.

Then inspect every blocking point.

  • channel send
  • channel receive
  • mutex lock
  • condition wait
  • timer wait
  • external I/O

For each one, trace the cancellation path. If a goroutine blocks here during shutdown, what wakes it up. If there is no wake-up path, the kill button from the demo is your future production incident.

Finally, inspect how the system proves its own behavior. Do tests assert goroutine cleanup after cancellation. Do integration tests simulate downstream hangs. Do metrics expose worker backlog and in-flight jobs. If your only proof is that the service usually exits in development, you do not yet know whether your concurrency model holds under stress.

What to watch next

The next step after this demo is not a stronger kill button. It is better lifecycle design. Watch how your code propagates context, how your blocking operations listen for it, and how your telemetry shows stuck work before users notice. Quiet failure is normal in concurrent systems. Your job is to make lifetime, ownership, and exit paths visible enough to inspect.