A deadline is a cancel with a clock¶
Level: 201 · anyone who has put a timeout on a call and wants to know what it stops
One line: context.WithTimeout and context.WithDeadline cancel a context when the time comes, and Err then says context.DeadlineExceeded instead of context.Canceled; a child can bring its parent's deadline closer, never push it back.
A context with a deadline closes its Done channel when the deadline passes, when its cancel is called, or when its parent's Done closes, whichever comes first (WithDeadline ↗). WithTimeout(parent, d) is defined as WithDeadline(parent, time.Now().Add(d)) (WithTimeout ↗). So a deadline is the cancel from the previous lesson with one more way to happen, and Err records which way it was.
The program asks a slow supplier for a price. The answer takes three seconds; the lookup gives up as soon as its context is done.
Verified output of a_deadline_is_a_cancel_with_a_clock_go.go — regenerated by tools/run_examples.py, never hand-typed.
1. a 100ms timeout on a lookup that takes 3s
price, err: 0 context deadline exceeded
errors.Is(err, context.DeadlineExceeded): true
errors.Is(err, context.Canceled): false
waited at least 100ms: true
gave up before 3s: true
2. the same timeout, with cancel() called before the clock runs out
err: context canceled
3. a deadline that has already passed
ctx.Err() at once: context deadline exceeded
4. children of a parent whose deadline is a minute away
asked for 1h, deadline equals the parent's: true
asked for 1s, deadline before the parent's: true
5. a parent's 100ms deadline passes under a child that asked for 1h
child.Err(): context deadline exceeded
Reading the output¶
1. The lookup returned context deadline exceeded. The two lines about time are thresholds, not durations: the lookup waited at least the 100ms it was given, and gave up long before the three seconds the answer needed. context.DeadlineExceeded and context.Canceled are two different error values ↗, and errors.Is tells them apart even after the error has been wrapped on its way up.
2. The same timeout, but cancel() came first, and the same lookup returned context canceled. Err says how this context ended, not what kind of context it was.
3. A deadline that has already passed: Err is set the moment the context is made, before anything has waited on it.
4. The parent's deadline is a minute away. The child that asked for an hour got exactly the parent's deadline; the documentation for WithDeadline says that when the parent's deadline is already earlier, the new context is semantically the parent. The child that asked for a second got a deadline of its own, earlier than the parent's. A function cannot give itself more time than its caller had.
5. When a parent's deadline passes, a child that asked for an hour reports context deadline exceeded as well, not context canceled: it ends for its parent's reason.
A deadline stops only code that looks at it. lookupPrice returned because it waits in a select with a <-ctx.Done() case; a time.Sleep, or a loop that never checks the context, runs to its end regardless. A timeout is a channel shows the other way to time out a wait, a time.After case in the select. That times out the one wait it is written into; a deadline in a context reaches every function and goroutine the context is passed to. Testing a deadline without waiting for it is synctest makes time virtual.
a_deadline_is_a_cancel_with_a_clock_go.go
a_deadline_is_a_cancel_with_a_clock_go.go in full — pasted here by tools/run_examples.py from the file CI runs.
// A deadline is a cancel with a clock: WithTimeout and WithDeadline close
// Done when the time comes, and Err then says context.DeadlineExceeded.
// A child context can shorten its parent's deadline, never extend it.
//
// go build a_deadline_is_a_cancel_with_a_clock_go.go && ./a_deadline_is_a_cancel_with_a_clock_go
package main
import (
"context"
"errors"
"fmt"
"time"
)
// lookupPrice stands in for a call to a slow supplier: the answer takes three
// seconds, and the lookup gives up as soon as ctx is done.
func lookupPrice(ctx context.Context) (int, error) {
select {
case <-time.After(3 * time.Second):
return 1299, nil
case <-ctx.Done():
return 0, ctx.Err()
}
}
func main() {
bg := context.Background()
fmt.Println("1. a 100ms timeout on a lookup that takes 3s")
start := time.Now()
ctx, cancel := context.WithTimeout(bg, 100*time.Millisecond)
defer cancel()
price, err := lookupPrice(ctx)
elapsed := time.Since(start)
fmt.Println(" price, err:", price, err)
fmt.Println(" errors.Is(err, context.DeadlineExceeded):", errors.Is(err, context.DeadlineExceeded))
fmt.Println(" errors.Is(err, context.Canceled): ", errors.Is(err, context.Canceled))
fmt.Println(" waited at least 100ms:", elapsed >= 100*time.Millisecond)
fmt.Println(" gave up before 3s: ", elapsed < 3*time.Second)
fmt.Println("2. the same timeout, with cancel() called before the clock runs out")
ctx, cancel = context.WithTimeout(bg, 100*time.Millisecond)
cancel()
_, err = lookupPrice(ctx)
fmt.Println(" err:", err)
fmt.Println("3. a deadline that has already passed")
ctx, cancel = context.WithDeadline(bg, time.Now().Add(-time.Second))
defer cancel()
fmt.Println(" ctx.Err() at once:", ctx.Err())
fmt.Println("4. children of a parent whose deadline is a minute away")
parent, cancelParent := context.WithTimeout(bg, time.Minute)
defer cancelParent()
longer, cancelLonger := context.WithTimeout(parent, time.Hour)
defer cancelLonger()
shorter, cancelShorter := context.WithTimeout(parent, time.Second)
defer cancelShorter()
pd, _ := parent.Deadline()
ld, _ := longer.Deadline()
sd, _ := shorter.Deadline()
fmt.Println(" asked for 1h, deadline equals the parent's:", ld.Equal(pd))
fmt.Println(" asked for 1s, deadline before the parent's:", sd.Before(pd))
fmt.Println("5. a parent's 100ms deadline passes under a child that asked for 1h")
parent, cancelParent = context.WithTimeout(bg, 100*time.Millisecond)
defer cancelParent()
child, cancelChild := context.WithTimeout(parent, time.Hour)
defer cancelChild()
<-child.Done()
fmt.Println(" child.Err():", child.Err())
}
What to do¶
- Set the deadline once, near where the request enters the program, and pass the context down. A longer timeout further down cannot extend it, and a shorter one is the right way to give one step less.
- Tell a timeout from a cancel with
errors.Is(err, context.DeadlineExceeded), never by comparing strings. defer cancel()for a timeout too, even one that will fire: cancelling releases the context's resources ↗, its timer among them.- Ask
ctx.Deadline()how much time is left before starting something expensive; its second result isfalsewhen there is no deadline at all (Context↗).
In other languages¶
- The Concurrency library's Who waits when main returns? ↗ waits for Python threads without a timeout. With one,
Thread.join(timeout)↗ returnsNoneeither way, and the caller has to callis_alive()to learn whether the time ran out; the thread itself is not told. A Go deadline tells every goroutine that watches the context, andErrsays which way it ended. - The Rust library's Spawning a thread ↗:
jointakes no timeout. A caller that wants a deadline on an answer receives it from a channel withReceiver::recv_timeout↗, which is Go'stime.Aftercase: the caller stops waiting, and the thread doing the work is not told. - Timeouts on async tasks, which can be cancelled where they
await, belong to the Concurrency library's planned chapter 06, Async.
Sources¶
contextpackage ↗ —WithDeadline↗,WithTimeout↗, theCanceledandDeadlineExceededvariables ↗, and theDeadlineandErrmethods ofContext↗.- Go Concurrency Patterns: Context ↗ — Sameer Ajmani, the Go blog, 2014.
- Jon Bodner, Learning Go, 2nd ed. (O'Reilly, 2024), chapter 14, "The Context", section "Contexts with Deadlines".
- Burak Serdar, Effective Concurrency in Go (Packt, 2023), chapter 8, "Handling Requests Concurrently", section "The context, cancelations, and timeouts".