JavaRush /Courses /Go SELF /Stopping background tasks on shutdown

Stopping background tasks on shutdown

Go SELF
Level 63, Lesson 4
Available

1. Application context and the background lifecycle

Background tasks usually appear quietly: first you write a server that “received a request → replied”, and then you add conveniences. For example, you want to purge stale data once a minute, save a snapshot of an in-memory store to disk every 10 seconds, periodically print stats to the log, or refresh a cache. And suddenly you have code that “just runs on its own”.

The problem is that the server can stop “nicely” (via Shutdown), while the background work stops “not so nicely”: it may keep running, hang while waiting, fail to flush data, keep resources (files, connections), or simply prevent the process from exiting on time. In practice it looks like: “the server stopped, but the process is still alive—why?”. Spoiler: because someone somewhere started an infinite loop and forgot to give it an “exit”.

What is a goroutine

A goroutine is a lightweight thread of execution in Go. You start one simply as: go f(). The important word is “lightweight”: goroutines are much cheaper than OS threads, and Go can schedule them using its runtime (scheduler), distributing many goroutines over a limited number of system threads.

But “lightweight” does not mean “free”, and it certainly does not mean “you can run them forever and forget”. A goroutine has a stack, it does work, it may hold references to objects (preventing the garbage collector from freeing memory), it may hold an open file or a lock. So the rule is very down-to-earth: every background goroutine must have a clear shutdown path. Otherwise your server turns into the “Hotel California”: you can check in, but checking out—sometimes you cannot.

Request context vs. application context

In HTTP handlers we use r.Context() and get used to thinking: “there is the context, there is cancellation”. But a background task usually lives longer than a single request, so it needs an application-level context, not the “context of a specific client”.

To avoid confusion, it helps to keep a small table in mind:

Context Where it comes from When it is canceled What it is good for
r.Context()
inside the handler (
r *http.Request
)
when the client disconnects, the request deadline expires, or the server starts shutdown work that is “strictly per request” (request → compute → response)
appCtx (a conventional name) we create it ourselves: context.WithCancel(context.Background()) when the application is exiting (by signal / by shutdown) background loops: cleanup, periodic tasks, cache, service processes

In the docs and articles about context, the main idea is emphasized: a context contains a cancellation signal (Done) and can be passed to many parallel workers; upon cancellation they should terminate quickly.

Why r.Context() is not suitable for background work

Sometimes the temptation appears: “Oh! I already have the request context. I’ll start a background task inside the handler and give it r.Context()—then it will cancel itself.” This is usually a bad idea.

The problem is that r.Context() lives exactly as long as the request lives. If the client closes the connection, if a timeout expires, if the server starts shutdown—this context will be canceled. For “long-running background” work that almost always means premature termination.

Imagine a real-life situation: a client hits an endpoint “recalculate statistics”, and you decide to do the recalculation in the background. The client gets tired of waiting 300 ms and leaves. The request context is canceled—your background work stops too. Result: the stats were not recalculated, but you already “promised yourself” that they are being recalculated. A classic.

So the rule is simple: use r.Context() for work that is needed to respond to this client, and use appCtx for work that is needed for the server to function properly at all.

2. Patterns for starting and stopping background tasks

Base pattern: context, loop, and stop checks

A background task almost always looks like a loop. So we only need two things: (1) run it in a goroutine so it doesn’t block the main thread, (2) regularly check the cancellation signal so the loop can end.

Let’s start with a minimal application context:

package main

import (
	"context"
)

func main() {
	bgCtx, cancelBG := context.WithCancel(context.Background())
	defer cancelBG()

	_ = bgCtx
}

By itself bgCtx does nothing, but it is the “red stop button” for all background tasks we launch.

Now let’s make a background task: a “periodic cleaner” (for example, it cleans up expired tasks in our tasks API). The task itself will be as simple as possible: once per second it “does something”, but on cancellation it exits.

package main

import (
	"context"
	"time"
)

func startCleaner(ctx context.Context) {
	go func() {
		for ctx.Err() == nil {
			// old task cleanup could happen here
			time.Sleep(1 * time.Second)
		}
	}()
}

Notice the detail: we do not do an “eternal” for { } without conditions. We explicitly say: “while the context is not canceled”. That is your managed-shutdown contract.

If you want a slightly more explicit check (sometimes it reads better), you can write it as if err := ctx.Err(); err != nil { ... }. In long-running work it often looks like this:

package main

import (
	"context"
	"time"
)

func doWork(ctx context.Context) error {
	for i := 0; i < 5; i++ {
		if err := ctx.Err(); err != nil {
			return err
		}
		time.Sleep(200 * time.Millisecond)
	}
	return nil
}

This is a very “Go-ish” idea: context cancellation is not “panic” and not “magic”; it is a normal error value that you can return outward.

Tying background shutdown to server shutdown

Now the most important part: how to connect “the server starts shutdown” with “background tasks stop”.

For this, http.Server has a handy mechanism: srv.RegisterOnShutdown(fn). It lets you register a function that the server will call when shutdown begins. This is a great point to send a “stop signal” to all background workers.

Let’s assemble the pieces into a small, clear snippet. We assume your srv is already created and shutdown is implemented correctly with a timeout.

package main

import (
	"context"
	"net/http"
)

func main() {
	bgCtx, cancelBG := context.WithCancel(context.Background())
	defer cancelBG()

	mux := http.NewServeMux()
	srv := &http.Server{Addr: ":8080", Handler: mux}

	startCleaner(bgCtx)

	srv.RegisterOnShutdown(func() {
		cancelBG()
	})

	_ = srv
}

The logic here is very straightforward: while the server runs, the background work runs. As soon as the server is told “Shutdown”, it calls our on-shutdown hooks, and we cancel bgCtx. After that, all background goroutines that respect ctx.Err() should terminate quickly.

How to wait for completion: the done channel

Sometimes you want not only to “send a stop signal”, but also to wait until the background work truly finishes (for example, so it can write a final log line or close a file). We won’t build complicated waiting schemes, but we’ll show the most minimal and readable option: the start function returns a done channel that is closed when the goroutine exits.

package main

import (
	"context"
	"time"
)

func startCleaner(ctx context.Context) <-chan struct{} {
	done := make(chan struct{})
	go func() {
		defer close(done)

		for ctx.Err() == nil {
			time.Sleep(1 * time.Second)
		}
	}()
	return done
}

Now, in main you can wait for the background task to end at the right moment. For example, after a successful Shutdown:

package main

import (
	"context"
	"log"
	"net/http"
	"time"
)

func main() {
	bgCtx, cancelBG := context.WithCancel(context.Background())
	mux := http.NewServeMux()
	srv := &http.Server{Addr: ":8080", Handler: mux}

	cleanerDone := startCleaner(bgCtx)
	srv.RegisterOnShutdown(func() { cancelBG() })

	go func() {
		time.Sleep(2 * time.Second) // imagine a "stop signal arrived"
		shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
		defer cancel()
		_ = srv.Shutdown(shutdownCtx)
	}()

	_ = srv.ListenAndServe()
	<-cleanerDone
	log.Printf("cleaner stopped") // cleaner stopped
}

Yes, here we simulate the “signal” via Sleep so the example stays short. In your real code, instead of this you’ll wait on a signal context: <-sigCtx.Done(). The point is different: a background goroutine has a formal end, and you can wait for it.

3. How this looks in a tasks application

If you keep our training tasks server in mind (/api/v1/tasks, etc.), a background task can be very practical: “every N seconds delete tasks that have been marked done for more than a day” (or “with an expired TTL”). Even if we don’t have a real TTL yet, the background worker can still serve as a “discipline example”: learn how to start it and stop it correctly.

The scenario looks like this:

flowchart TD
    A[main: start HTTP server] --> B["create bgCtx = WithCancel(Background)"]
    B --> C["go: start cleaner(bgCtx)"]
    A --> D[wait for OS signal]
    D --> E["call srv.Shutdown(shutdownCtx)"]
    E --> F["RegisterOnShutdown: cancelBG()"]
    F --> G["cleaner sees ctx.Err() != nil and exits"]
    G --> H[main waits for cleanerDone and exits process]

That is exactly what we want: a managed lifecycle not only for the server, but also for the entire “service around it”.

4. Common mistakes

Mistake #1: “Started a goroutine and forgot it” (an infinite loop with no exit).
At first this code seems fine, because “the server is running”. But during shutdown the process can hang, or in the best case it will exit without a chance to finish work neatly (for example, without flushing a file). The fix is usually boring but correct: add ctx and a ctx.Err() check inside the loop, and in main create context.WithCancel and call cancel() on shutdown.

Mistake #2: using r.Context() as the context for a background task.
In early tests this may “work”, because you start the background job and make requests quickly. But in real life the client may leave, a load balancer may close the connection, the server may start shutdown—and you’ll get cancellation “too early”. The cure is to separate responsibilities: for application background lifetime use a separate bgCtx, and keep r.Context() for work “within the request”.

Mistake #3: you canceled the context, but the background work still doesn’t stop.
This happens when the loop has no stop points, and the work is one large “chunk” that runs for a long time and doesn’t check ctx.Err(). As a result, cancellation formally happened, but the goroutine will only discover it “sometime later”. In practice, it helps to split the work into smaller pieces and check ctx.Err() between steps, like we did in the example with the loop and time.Sleep.

Mistake #4: you forgot to tie stopping to shutdown and cancel the background “whenever”.
When cancellation is scattered across multiple places, it’s easy to get situations like “Shutdown is already in progress, but the background worker is still running” or “the background was canceled before the server started closing”. srv.RegisterOnShutdown is good because it turns background cancellation into part of the server shutdown protocol: one point, one event, fewer surprises.

Mistake #5: you never called cancel() (the context exists, but it’s useless).
Sometimes people create bgCtx, pass it into a goroutine, check ctx.Err()—everything looks nice… but nobody ever calls cancel. The background won’t stop, because it never received a signal. That’s why the habit “created WithCancel—immediately add defer cancel() and/or tie cancel to shutdown” usually saves you from very silly hangs.

1
Task
Go SELF, level 63, lesson 4
Locked
Managed ticker
Managed ticker
1
Task
Go SELF, level 63, lesson 4
Locked
Shutdown background heartbeat
Shutdown background heartbeat
1
Task
Go SELF, level 63, lesson 4
Locked
Two contexts
Two contexts
1
Task
Go SELF, level 63, lesson 4
Locked
Two services
Two services
1
Survey/quiz
Graceful shutdown, 63 level, 4 lesson
Unavailable
Graceful shutdown
Graceful shutdown
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION