Skip to content

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 is false when 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) returns None either way, and the caller has to call is_alive() to learn whether the time ran out; the thread itself is not told. A Go deadline tells every goroutine that watches the context, and Err says which way it ended.
  • The Rust library's Spawning a thread ↗: join takes no timeout. A caller that wants a deadline on an answer receives it from a channel with Receiver::recv_timeout, which is Go's time.After case: 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