Supervised fire-and-forget in Go
These days, unmanaged go func() calls don’t appear as often as they used to in the early days of Go.
At this point, everyone knows Dave Cheney’s maxim to “never start a goroutine without knowing how it will stop”. Even so, I still occasionally run into unsynchronized go func() calls doing fire-and-forget work.
I’m not talking about jobs handed to a dedicated task queue such as Asynq. In that case, the task system owns the lifecycle of the tasks it starts. I mean smaller, best-effort jobs where it’s super tempting to start a task with go func() and just forget about it. Sending a notification or writing an expensive diagnostic log often falls into this category.
It typically looks like this:
func ( h handler ) createOrder ( w http. ResponseWriter, r http. Request ) { user:= r. URL. Query (). Get ( "user" ) ctx:= r. Context ()
go func () { H. tasks. SendNotification ( ctx, user ) // (1) }() go func () { H. tasks. WriteDiagnosticLog ( ctx, user ) // (2) }()
W. WriteHeader ( http. StatusAccepted ) }
In the handler above:
(1) sends a notification in a background goroutine
(2) writes a diagnostic log in another background goroutine
The response doesn’t wait for either one. On a long-running server this looks reasonable, as main will most likely outlive both calls. But this suffers from a few issues:
every request can start two more goroutines, with no limit on active work
both tasks get r.Context(), which net/http cancels when the handler returns. If the jobs are cancellation aware (ideally they should be), they can bail before they’re done
a panic in either goroutine crashes the whole process, because nothing recovers it
main has no way to wait for either task during shutdown. A restart kills whatever is still running
Every task goes through one worker pool #
One fix I’ve been using is a small worker pool backed by a buffered channel:
each background task is sent to the channel as a func() closure
workers started at the composition root, usually ma…