Skip to content

Go Context Implementation(2)

In the blog about symbol table, we discuss the context implementation for variables storage and the symbol table due to they are similar. In this blog, I will discuss the remaining parts about the context implementation.

Besides the value storage, context provides the cancel, timeout and deadline features. In this article, we focus on how the sub contexts are notified by the parent one while children context doesn't affect their parents.

Basic Design

The code of context is fair easy, as it encapsulates the different logic well. Last time, we focus on the valueCtx only due to the topic there is symbol tables. The valueCtx concerns only the value storage and retrieval.

The other contexts are designed based on the cancel contexts. They focuses on the communicating between parent and children, and utilize the Value functionality for another usage.

Besides the valueCtx, another fundamental context is cancelCtx, who takes charge for cancel among parents and children contexts. The other contexts are depends on the functionality provided by cancelCtx.

Generally speaking, the whole context package keeps wrapping new contexts along with the desired functionality to create new contexts.

The correct communicating is guaranteed by propagating parent information to children context. This idea is especially clear once we see the withoutCancelCtx, which cuts the propagation to ignore the parent cancelling.

Find Parent Cancel Context

Previously, we know how the value is retrieved from valueCtx. The other contexts utilize Value in a different way. This section introduces the Value usage in the other contexts to find parent context by function parentCancelCtx.

It ensures the parent hasn't done, and then get the parent by Value method with key &cancelCtxKey. The cancelCtxKey is the key to find out the cancel context.

// &cancelCtxKey is the key that a cancelCtx returns itself for.
var cancelCtxKey int

The logic is simple, one of the parent context is returned if it's a cancel context and the key is &cancelCtxKey. Note that cancelCtx doesn't have key field because it isn't designated for key-value storage.

func (c *cancelCtx) Value(key any) any {
    if key == &cancelCtxKey {
        return c
    }
    return value(c.Context, key)
}

Cancel Context

Overall

The context.WithCancel provides a context along with a cancel function to cancel it. The cancel refers to the returned context's Done channel is closed, when the returned cancel function is called or when the parent context's Done channel is closed, whichever happens first.

Hence, the cancel is expressed by the Done channel. The cancel context doesn't make sense if the code don't respect the Done channel.

The code implementation is simple, we can focus on the following key points:

  • the returned cancel function and how it cancels contexts
  • how children context detects the parent is cancelled(done)
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
    c := withCancel(parent)
    return c, func() { c.cancel(true, Canceled, nil) }
}

Wrap a New Cancel Context

When constructing a new cancel context, a new wrapper typed cancelCtx is created and wraps its parent context. Propagating the parent information is compulsory to make sure the cancel context works.

The propagateCancel validates the Done channel before processing. It tries to find its parent cancel context, so we could let the parent hold the link to its children. By this way, parent cancel could cancel all its children as well.

Cancel Function

The cancel function closes its own Done channel, and then notify all its children to cancel as well. It provides an option to check whether the context should be removed from parent as it generally has two usages:

  • the cancel function is triggered by parent's cancel function
  • the cancel function is triggered by its own cancel function

During cancelling, the cause and err are set up for the future inspection. Note that Go implementation ensures they are only set once by checking the err field:

    if c.err != nil {
        c.mu.Unlock()
        return // already canceled
    }

Timeout and Deadline Context

Timeout and deadline contexts are implemented based on the cancel context. They intend to cancel the contexts automatically once the time is elapsed.

More specific, timeout context utilizes the deadline context for easy use. Because they are fundamental cancel contexts, the cancel function is returned during construction:

func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

Besides the basic functionalities of cancel context, there is an internal timer to trigger the callback automatically as well.

Wrap a New Timer Context

Both timeout and deadline contexts use timerCtx to represent the context. Notably it holds a cancelCtx as it wants to utilize the cancel context but keep its own identity.

type timerCtx struct {
    cancelCtx
    timer *time.Timer // Under cancelCtx.mu.

    deadline time.Time
}

During wrapping, it reuses the cancel context to maintain the cancel and communication among parents and children contexts. Additionally, it sets up a timer to execute the cancel function to accomplish its functionality.

    if c.err == nil {
        c.timer = time.AfterFunc(dur, func() {
            c.cancel(true, DeadlineExceeded, cause)
        })
    }

Notes about Timeout/Deadline Context

If the current deadline is already sooner than the new one, we don't construct the new context. Instead, wrapping a cancel context outside and return.

    if cur, ok := parent.Deadline(); ok && cur.Before(d) {
        // The current deadline is already sooner than the new one.
        return WithCancel(parent)
    }

Without Cancel Context

As mentioned before, if we don't respect the Done channel from the context, the cancel doesn't make sense. However, this is a quite bad idea because if new contexts are created and passed into the other functions, how could you let them know your un-conventional and bad expectation?

The parent and children of cancel contexts are connected tightly due to the implementation, so to utilize the old context without cancelling, the withoutCancelCtx came into being. It still wraps a new context on the original context, but discards instead of propagating the parent cancel information.

Wrap a New Without Cancel Context

The *withoutCancelCtx is very simple which wraps another context only. This is obvious as it wants to cut the connections among parent and children.

type withoutCancelCtx struct {
    c Context
}

When constructing a context without cancel, it simply wraps the parent context into it. How it prevents the cancel from parent event is achieved by the implementation of cancel context.

func WithoutCancel(parent Context) Context {
    if parent == nil {
        panic("cannot create context from nil parent")
    }
    return withoutCancelCtx{parent}
}

How Without Cancel Context Prevents Cancelling

The withoutCancelCtx prevents the following contexts being cancelled by avoiding a child to find its parent cancel parent but a without cancel context.

As the propagating parents in cancel context requires to find the nearest cancel context, the withoutCancelCtx has a trick in the recursive finding function value.

Once we find a withoutCancelCtx while key is &cancelCtxKey, the nil is returned which tells the caller it doesn't have cancel context parent. By this way, the connections from parent to children fails to established so the parent cancel doesn't affect the children anymore.

func value(c Context, key any) any {
    for {
        switch ctx := c.(type) {
        case withoutCancelCtx:
            if key == &cancelCtxKey {
                // This implements Cause(ctx) == nil
                // when ctx is created using WithoutCancel.
                return nil
            }
            c = ctx.c

        // ignore the other cases, changed by the blog author
        }
    }
}

Todo and Background Context

Todo and background are equivalent underlying because both of them embed an emptyCtx. They are designated for the better code management.

type backgroundCtx struct{ emptyCtx }
type todoCtx struct{ emptyCtx }

type emptyCtx struct{}