Async Task Cancellation Runbook
Runbook for safely cancelling long-running async tasks in Python, JavaScript, Go, and Java: cancellation tokens, context propagation, resource cleanup, timeout strategies, and graceful shutdown procedures with code examples.
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Overview
This runbook covers procedures for safely cancelling long-running async tasks across Python, JavaScript, Go, and Java. Improper cancellation leads to resource leaks, partial writes, orphaned connections, and inconsistent state. This document covers cancellation tokens, context propagation, resource cleanup, timeout strategies, and graceful shutdown.
1. Cancellation Patterns
1.1 Pattern Comparison
Language | Cancellation mechanism | Propagation method
────────────┼────────────────────────────┼──────────────────────────
Python | asyncio.CancelledError | task.cancel() + try/finally
JavaScript | AbortSignal / AbortController | signal.aborted + listeners
Go | context.Context | ctx.Done() channel
Java | CompletableFuture.cancel() | future.cancel(true) + interrupt
1.2 Cancellation Token Concept
A cancellation token is a signal object that:
- Propagates cancellation from caller to callee
- Can be checked at safe points in the code
- Supports cleanup via finally blocks or defer
- Can have a timeout (auto-cancel after N seconds)
- Can have a deadline (cancel at specific time)
Safe points to check cancellation:
- Before I/O operations (DB, HTTP, file)
- Inside loops that process batches
- Before acquiring locks
- At the start of each pipeline stage
2. Python (asyncio)
2.1 Basic Cancellation
import asyncio
async def long_running_task(task_id: str):
try:
for i in range(1000):
# Check for cancellation at safe points
await asyncio.sleep(0.1)
print(f"Task {task_id}: processing item {i}")
except asyncio.CancelledError:
print(f"Task {task_id}: cancelled, cleaning up...")
# Perform cleanup here
await cleanup_resources(task_id)
raise # Re-raise to propagate cancellation
async def cleanup_resources(task_id: str):
print(f"Task {task_id}: closing connections...")
await asyncio.sleep(0.05) # Simulate cleanup
print(f"Task {task_id}: cleanup complete")
async def main():
task = asyncio.create_task(long_running_task("worker-1"))
# Cancel after 2 seconds
await asyncio.sleep(2)
task.cancel()
try:
await task
except asyncio.CancelledError:
print("Task was cancelled successfully")
asyncio.run(main())
2.2 Timeout-Based Cancellation
import asyncio
async def fetch_with_timeout(url: str, timeout: float = 5.0):
try:
result = await asyncio.wait_for(
fetch_data(url),
timeout=timeout,
)
return result
except asyncio.TimeoutError:
print(f"Request to {url} timed out after {timeout}s")
return None
async def fetch_data(url: str):
reader, writer = await asyncio.open_connection(url, 80)
try:
writer.write(b"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n")
await writer.drain()
data = await reader.read(4096)
return data
finally:
writer.close()
await writer.wait_closed()
2.3 Graceful Shutdown with Signal Handling
import asyncio
import signal
class GracefulShutdown:
def __init__(self):
self.shutdown_event = asyncio.Event()
self.tasks: set[asyncio.Task] = set()
def setup_signal_handlers(self):
loop = asyncio.get_running_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, self._signal_handler)
def _signal_handler(self):
print("\nShutdown signal received, cancelling tasks...")
self.shutdown_event.set()
async def run_with_shutdown(self, worker_func, num_workers: int = 4):
self.setup_signal_handlers()
# Start workers
for i in range(num_workers):
task = asyncio.create_task(worker_func(i, self.shutdown_event))
self.tasks.add(task)
task.add_done_callback(self.tasks.discard)
# Wait for shutdown signal
await self.shutdown_event.wait()
# Cancel all tasks
for task in self.tasks:
task.cancel()
# Wait for all tasks to finish cleanup
await asyncio.gather(*self.tasks, return_exceptions=True)
print("All tasks shut down gracefully")
async def worker(worker_id: int, shutdown: asyncio.Event):
try:
while not shutdown.is_set():
print(f"Worker {worker_id}: processing...")
await asyncio.sleep(1)
except asyncio.CancelledError:
print(f"Worker {worker_id}: cleaning up...")
await asyncio.sleep(0.1)
print(f"Worker {worker_id}: done")
raise
asyncio.run(GracefulShutdown().run_with_shutdown(worker))
3. JavaScript (Node.js)
3.1 AbortController Cancellation
async function longRunningTask(signal, taskId) {
try {
for (let i = 0; i < 1000; i++) {
// Check for cancellation at safe points
if (signal.aborted) {
throw new DOMException('Task cancelled', 'AbortError');
}
await sleep(100, signal);
console.log(`Task ${taskId}: processing item ${i}`);
}
} catch (err) {
if (err.name === 'AbortError') {
console.log(`Task ${taskId}: cancelled, cleaning up...`);
await cleanupResources(taskId);
throw err;
}
throw err;
}
}
function sleep(ms, signal) {
return new Promise((resolve, reject) => {
if (signal.aborted) {
reject(new DOMException('Aborted', 'AbortError'));
return;
}
const timer = setTimeout(() => {
signal.removeEventListener('abort', onAbort);
resolve();
}, ms);
const onAbort = () => {
clearTimeout(timer);
reject(new DOMException('Aborted', 'AbortError'));
};
signal.addEventListener('abort', onAbort, { once: true });
});
}
async function cleanupResources(taskId) {
console.log(`Task ${taskId}: closing connections...`);
await sleep(50);
console.log(`Task ${taskId}: cleanup complete`);
}
// Usage
const controller = new AbortController();
const task = longRunningTask(controller.signal, 'worker-1');
setTimeout(() => controller.abort(), 2000);
try {
await task;
} catch (err) {
if (err.name === 'AbortError') {
console.log('Task cancelled successfully');
} else {
throw err;
}
}
3.2 Fetch with Cancellation
async function fetchWithTimeout(url, options = {}, timeout = 5000) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
});
return await response.json();
} catch (err) {
if (err.name === 'AbortError') {
console.error(`Request to ${url} timed out after ${timeout}ms`);
return null;
}
throw err;
} finally {
clearTimeout(timeoutId);
}
}
3.3 Graceful Shutdown in Express
const express = require('express');
const app = express();
const server = app.listen(3000);
let connections = new Set();
server.on('connection', (conn) => {
connections.add(conn);
conn.on('close', () => connections.delete(conn));
});
function gracefulShutdown(signal) {
console.log(`\n${signal} received, shutting down...`);
server.close(() => {
console.log('HTTP server closed');
// Force-close idle connections after 10s
setTimeout(() => {
connections.forEach((conn) => conn.destroy());
process.exit(0);
}, 10000);
});
// Destroy idle connections immediately
connections.forEach((conn) => {
if (!conn.writableEnded) return;
conn.destroy();
});
}
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
4. Go (context.Context)
4.1 Basic Cancellation
package main
import (
"context"
"fmt"
"time"
)
func longRunningTask(ctx context.Context, taskID string) error {
for i := 0; i < 1000; i++ {
// Check for cancellation at safe points
select {
case <-ctx.Done():
fmt.Printf("Task %s: cancelled, cleaning up...\n", taskID)
cleanupResources(taskID)
return ctx.Err()
default:
}
time.Sleep(100 * time.Millisecond)
fmt.Printf("Task %s: processing item %d\n", taskID, i)
}
return nil
}
func cleanupResources(taskID string) {
fmt.Printf("Task %s: closing connections...\n", taskID)
time.Sleep(50 * time.Millisecond)
fmt.Printf("Task %s: cleanup complete\n", taskID)
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
err := longRunningTask(ctx, "worker-1")
if err != nil {
fmt.Printf("Task ended: %v\n", err)
}
}
4.2 Propagating Context Through HTTP Handlers
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
select {
case <-ctx.Done():
// Client disconnected
fmt.Println("Client cancelled request")
return
default:
}
result, err := processWithTimeout(ctx, r.URL.Query().Get("id"))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(result)
}
func processWithTimeout(ctx context.Context, id string) (Result, error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
type result struct {
data Result
err error
}
ch := make(chan result, 1)
go func() {
data, err := slowDatabaseQuery(id)
ch <- result{data, err}
}()
select {
case <-ctx.Done():
return Result{}, ctx.Err()
case res := <-ch:
return res.data, res.err
}
}
5. Java (CompletableFuture)
5.1 Basic Cancellation
import java.util.concurrent.*;
public class TaskCancellation {
public static void main(String[] args) throws Exception {
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<String> future = executor.submit(() -> {
for (int i = 0; i < 1000; i++) {
if (Thread.currentThread().isInterrupted()) {
System.out.println("Task cancelled, cleaning up...");
cleanupResources();
throw new InterruptedException("Task cancelled");
}
Thread.sleep(100);
System.out.println("Processing item " + i);
}
return "done";
});
Thread.sleep(2000);
future.cancel(true);
try {
future.get();
} catch (CancellationException e) {
System.out.println("Task was cancelled");
}
executor.shutdown();
}
static void cleanupResources() {
System.out.println("Closing connections...");
try { Thread.sleep(50); } catch (InterruptedException ignored) {}
System.out.println("Cleanup complete");
}
}
5.2 CompletableFuture with Timeout
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
try {
Thread.sleep(10000);
return "result";
} catch (InterruptedException e) {
throw new CompletionException(e);
}
});
try {
String result = future.orTimeout(5, TimeUnit.SECONDS).get();
System.out.println("Result: " + result);
} catch (ExecutionException e) {
if (e.getCause() instanceof TimeoutException) {
System.out.println("Task timed out after 5 seconds");
future.cancel(true);
}
}
FAQ
What is a cancellation token and why do I need one?
A cancellation token is an object that signals when a task should stop. Without it, you cannot safely stop long-running operations — killing threads abruptly can leave resources open, data inconsistent, and connections leaked. A token lets the task check for cancellation at safe points and perform cleanup in finally blocks or defer statements. It also supports timeouts and deadlines, so tasks auto-cancel if they run too long.
How do I handle cancellation in a pipeline of async stages?
Pass the cancellation token through every stage of the pipeline. Each stage checks the token before starting work and propagates cancellation to downstream stages. In Python, use asyncio.Task objects and cancel the root task. In Go, pass context.Context to every function. In JavaScript, pass AbortSignal to each stage. When any stage is cancelled, all upstream and downstream stages should stop and clean up.
What happens if I don’t propagate cancellation?
The task continues running in the background, consuming CPU, memory, and connections. This is called a “leaked task” or “orphaned goroutine.” Over time, leaked tasks exhaust resources and cause the process to hang or crash. Always propagate cancellation — if a parent is cancelled, cancel all children. Use structured concurrency patterns (TaskGroups in Python, errgroup in Go) to ensure automatic cancellation propagation.
How long should cleanup take after cancellation?
Cleanup should complete within 5-10 seconds. Set a hard timeout on cleanup — if it takes longer, force-close resources. In Python, use asyncio.wait_for(cleanup(), timeout=5). In Go, use context.WithTimeout for cleanup. In JavaScript, use Promise.race([cleanup(), timeout(5000)]). If cleanup exceeds the timeout, log the issue and force-exit. Never let cleanup block indefinitely — it can prevent the process from shutting down.
Should I use cancellation or just kill the process?
Use cancellation for graceful shutdown — it allows tasks to finish current work, flush buffers, close connections, and write final state. Kill the process only as a last resort when cancellation doesn’t work within a timeout. In Kubernetes, set terminationGracePeriodSeconds to 30 (default) — the pod receives SIGTERM, your code should cancel tasks and cleanup, and if it doesn’t exit within 30s, Kubernetes sends SIGKILL. Always handle SIGTERM for graceful shutdown.
See Also
Related Resources
Thread Pool Sizing Template
Template for documenting thread pool configuration per service: pool type selection, sizing formulas, CPU vs I/O bound tuning, queue strategies, rejection policies, monitoring metrics, and tuning examples for Java, Python, Go, and Node.js.
DocRace Condition Debugging Checklist
Checklist for identifying and fixing race conditions in concurrent code: symptom identification, reproduction strategies, debugging tools, common patterns, fixes using locks, atomics, channels, and prevention techniques with code examples.
GuideComplete Guide to Python Asyncio in Production
Run Python asyncio in production with confidence. Covers event loops, task management, debugging, cancellation, timeouts, backpressure, and patterns for high-concurrency async applications.
GuideComplete Guide to Python Asyncio
Master asynchronous Python programming with asyncio. Covers coroutines, tasks, event loops, async/await, gather, semaphores, queues, HTTP clients, websockets, and debugging async code.
GuideConcurrency Patterns Guide
A guide to common concurrency patterns and what works for writing safe, efficient concurrent code.