[Go advanced concurrent programming] Context

Posted by kaeRock on Sat, 05 Mar 2022 08:10:50 +0100

Context is a commonly used concurrency control technology in Go application development. Its biggest difference from WaitGroup is that context has stronger control over derived goroutines, which can control multi-level goroutines.

Although there are many disputes, it is convenient to use context in many scenarios, so now it has spread in the Go ecosystem, including many Web application frameworks, which have been switched to the context of the standard library. Context is used in database/sql, os/exec, net, net/http and other packages in the standard library. In addition, if you encounter some of the following scenarios, you can also consider using context:

  • Context information transmission, such as processing http requests and transmitting information on the request processing chain;
  • Control the operation of the sub goroutine;
  • Method call of timeout control;
  • Method calls that can be canceled.

Implementation principle

Interface definition

The package Context defines the Context interface. The specific implementation of Context includes four methods: Deadline, Done, Err and Value, as shown below:

type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key interface{}) interface{}
}

Deadline method will return the deadline when the Context is cancelled. If no deadline is set, the value of ok is false. Each subsequent call to the deadline method of this object will return the same result as the first call.

The Done method returns a Channel object, which is basically used in the select statement. When the Context is cancelled, this Channel will be closed. If it is not cancelled, nil may be returned. When Don is closed, you can use CTX Err gets error information. In fact, the name of the method "Done" is not very good, because the name is too general to clearly reflect the reason why do is closed.

The knowledge you must remember about the Err method is: if Done is not closed, the Err method returns nil; If Done is closed, the Err method returns the reason why Done is closed.

Returns the ct value associated with the specified key.

Context implements two common methods of generating top-level context:

  • context.Background(): returns a non nil, empty Context with no value, no cancel lation, no timeout and no deadline. It is generally used in main function, initialization, testing and creating root Context.
  • context.TODO(): returns a non nil, empty Context with no value, no cancel lation, no timeout, and no deadline. You can use this method when you don't know whether to use Context or what Context information to pass.

In fact, as like as two peas, the two implementations are the same. In most cases, context can be used directly Background.

When using Context, there are some conventional rules:

  1. When a general function uses Context, it will put this parameter in the position of the first parameter.
  2. Nil is never regarded as the parameter value of Context type. You can use Context Background () creates an empty Context object, and do not use nil.
  3. Context is only used for context transmission between functions in the interim, and cannot be persisted or saved for a long time. It is wrong to persist the context to the database, local file or global variable or cache.
  4. The type of key does not recommend string type or other built-in types, otherwise it is easy to conflict when using Context between packages. When using WithValue, the type of key should be the type defined by itself.
  5. struct {} is often used as the underlying type to define the type of key. For the static type of exported key, it is often an interface or pointer. This minimizes memory allocation.

The struct that implements the Context interface in the Context package, except for Context In addition to the emptyCtx of background(), there are three types: cancelCtx, timerCtx and valueCtx.

cancelCtx

type cancelCtx struct {
    Context

    mu       sync.Mutex            // mutex 
    done     atomic.Value          // channel that will be closed when cancel is called
    children map[canceler]struct{} // All children derived from this Context are recorded. When this Context is cancelled, all children will be cancelled at the same time
    err      error                 // error message
}
WithCancel

cancelCtx is generated by the WithCancel method. We often create this type of Context when we need to actively cancel a task for a long time, and then pass this Context to the goroutine that executes the task for a long time. When the task needs to be terminated, we can cancel the Context, so that the goroutine that executes the task for a long time can know that the Context has been cancelled by checking the Context.

The second value in the WithCancel return value is a cancel function. Remember, you don't call cancel only if you want to give up halfway. As long as your task is completed normally, you need to call cancel. In this way, this Context can release its resources (notify its children to handle cancel, remove yourself from its parent, or even release the relevant goroutine).

Take a look at the core source code:

func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
    if parent == nil {
        panic("cannot create context from nil parent")
    }
    c := newCancelCtx(parent)
    propagateCancel(parent, &c)
    return &c, func() { c.cancel(true, Canceled) }
}

func newCancelCtx(parent Context) cancelCtx {
    return cancelCtx{Context: parent}
}

func propagateCancel(parent Context, child canceler) {
    done := parent.Done()

    if p, ok := parentCancelCtx(parent); ok {
        p.mu.Lock()
        if p.err != nil {
            // The parent has been canceled. Cancel the child Context directly
            child.cancel(false, p.err)
        } else {
            // Add child to the children slice of the parent
            if p.children == nil {
                p.children = make(map[canceler]struct{})
            }
            p.children[child] = struct{}{}
        }
        p.mu.Unlock()
    } else {
        atomic.AddInt32(&goroutines, +1)
        // No parent can be "mounted". Start a goroutine to listen to the cancel of the parent and cancel itself at the same time
        go func() {
            select {
            case <-parent.Done():
                child.cancel(false, parent.Err())
            case <-child.Done():
            }
        }()
    }
}

The propagateCancel method invoked in the code will go up the parent path until you find a cancelCtx or nil. If it is not empty, add yourself to the child of the cancelctx so that you can be notified when the cancelctx is cancelled. If it is empty, a new goroutine will be created to monitor whether the parent's Done has been closed.

When the cancel function of cancelCtx is called or the parent's Done is closed, the Done of cancelCtx will be closed.

cancel is passed down. If a Context generated by WithCancel is cancelled, it will be cancelled if its child Context (it may also be a grandson, or lower, depending on the type of child) is also of cancelCtx type.

cancel

The function of the cancel method is to close the done channel of itself and its descendants to notify cancellation. The second return value of the WithCancel method is this function. Let's take a look at the main code implementation:

func (c *cancelCtx) cancel(removeFromParent bool, err error) {
    c.mu.Lock()
    // Reason for setting cancel
    c.err = err 
    // Close your done channel
    d, _ := c.done.Load().(chan struct{})
    if d == nil {
        c.done.Store(closedchan)
    } else {
        close(d)
    }
    // Traverse all children and call the cancel method one by one
    for child := range c.children {
        child.cancel(false, err)
    }
    c.children = nil
    c.mu.Unlock()

    // Under normal circumstances, you need to delete yourself from the children slice of the parent
    if removeFromParent {
        removeChild(c.Context, c)
    }
}

timerCtx

type timerCtx struct {
    cancelCtx
    timer *time.Timer 

    deadline time.Time
}

timerCtx adds a deadline on the basis of cancelCtx to mark the final time of automatic cancellation, and timer is a timer that triggers automatic cancellation. timerCtx can be generated by WithDeadline and WithTimeout. WithTimeout actually calls WithDeadline. Their implementation principles are the same, but the use context is different: WithDeadline specifies the deadline and WithTimeout specifies the maximum survival time.

WithDeadline

Let's take a look at the implementation of WithDeadline method:

func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
    // If the deadline of the parent is earlier, you can directly return a cancelCtx
    if cur, ok := parent.Deadline(); ok && cur.Before(d) {
        return WithCancel(parent)
    }
    c := &timerCtx{
        cancelCtx: newCancelCtx(parent),
        deadline:  d,
    }
    propagateCancel(parent, c) // Same as the processing logic of cancelCtx
    dur := time.Until(d)
    if dur <= 0 { //The current time has exceeded the deadline. cancel directly
        c.cancel(true, DeadlineExceeded)
        return c, func() { c.cancel(false, Canceled) }
    }
    c.mu.Lock()
    defer c.mu.Unlock()
    if c.err == nil {
        // Set a timer and cancel after the deadline
        c.timer = time.AfterFunc(dur, func() {
            c.cancel(true, DeadlineExceeded)
        })
    }
    return c, func() { c.cancel(true, Canceled) }
}

WithDeadline will return a copy of the parent and set a deadline no later than parameter d. the type is timerCtx (or cancelCtx).

If the deadline is later than the deadline of the parent, the deadline of the parent will prevail and a Context of type cancelCtx will be returned. Because the deadline of the parent is up, the cancelCtx will be cancelled. If the current time has exceeded the deadline, a cancelled timerCtx will be returned directly. Otherwise, a timer will be started and the timerCtx will be cancelled by the deadline.

To sum up, the Done of timerCtx is closed, which is mainly triggered by one of the following events:

  • The deadline is up;
  • The cancel function is called;
  • The parent's Done is close d.

Like cancelCtx, the cancel returned by WithDeadline (WithTimeout) must be called as early as possible, so as to release resources as soon as possible. Do not simply rely on the deadline to cancel passively.

valueCtx

type valueCtx struct {
    Context
    key, val interface{}
}

valueCtx only adds a key value pair on the basis of Context, which is used to transfer some data between processes at all levels.

WithValue generates a new valueCtx based on the parent Context and saves a key Value pair. valueCtx overrides the Value method. The key is checked from its own storage first. If it does not exist, it will continue to be checked from the parent.

Topics: Go