Principle and Practice of Golang Context

Keywords: Go SQL Database Google Redis

Let's learn how to use the golang Context and how to implement it in the Standard Library.

The golang context package started as a Golang package used internally by Google and was officially introduced into the Standard Library in Golang version 1.7.Start learning below.

Brief introduction

Before learning about context packages, look at several business scenarios that you often encounter in your daily development:

  1. Business needs to have access to databases, RPC s, or API interfaces, and in order to prevent these dependencies from causing our services to time out, time-out controls need to be tailored.
  2. To learn more about service performance, record detailed call chain logs.

These two scenarios are common on the web, and the context package is designed to make it easier for us to use them.

Next, we first learn what methods the context package has for us to use; then, for example, use the context package application to solve the problems we encounter in the scenarios above; and finally, from the source point of view, learn the internal implementation of the context to understand how it works.

Context Package

Context Definition

Many Context objects are implemented in the context package.Context is an interface that describes the context of a program.Four abstract methods are provided in the interface, defined as follows:

type Context interface {
  Deadline() (deadline time.Time, ok bool)
  Done() <-chan struct{}
  Err() error
  Value(key interface{}) interface{}
}
  • Deadline() returns the end time of the context, ok is false if not set
  • Done() When the context of execution is cancelled, chan Chan returned by Done is close d.If this context is not cancelled, return nil
  • Err() has several cases:
    • If Done() returns chan without closing, returns nil
    • If chan returned by Done() is closed, Err returns a non-nil value explaining why Done()
      • If Canceled, return to Canceled
      • If Deadline is exceeded, return "Deadline Esceeded"
  • Value(key) returns the value corresponding to the key in the context

Context Construction

In order to use a Context, we need to understand how it is constructed.

Context provides two methods to initialize:

func Background() Context{}
func TODO() Context {}

The above methods all return an empty Context, but Background is generally the basis of all Contexts, and the source of all Contexts should be it.The TODO method is typically used to avoid a Context initialized with a nil parameter when the incoming method is not sure what type of Context it is.

Other ontexts are implemented based on already constructed contexts.A Context can derive multiple child contexts.The methods to derive a new Context based on ontext are as follows:

func WithCancel(parent Context) (ctx Context, cancel CancelFunc){}
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {}
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {}

The three methods above are similar, generating a child ctx and a Cancel method based on the parent Context.If the cancel method is called, both ctx and a child context constructed from ctx are canceled.The difference is that WithCancel must call the cancel method manually, WithDeadline
You can set a point in time, WithTimeout is to set the duration of the call, after which cancel is called to cancel.

In addition to the above constructs, there are also contexts used to create important data such as traceId, token, and so on.

func WithValue(parent Context, key, val interface{}) Context {}

withValue constructs a new context, which contains a pair of Key-Value data that you can use to get the value that exists in ctx from Context.Value(Key).

From this understanding, it is possible to conclude that a Context is a tree structure, and that a Context can derive many different Contexts.We can probably draw a tree like this:

A background, derives a valueCtx with traceId, and then valueCtx derives a cancelCtx with cancelCtx
context of.Finally, some db queries, http queries, rpc Sasson and other asynchronous calls are reflected.If time-outs occur, cancel these asynchronous calls directly to reduce resource consumption, we can also get the traceId through the Value method and record the data for the corresponding request at the time of the call.

Of course, in addition to the several Contexts above, we can also implement a new Context based on the ontext interface above.

Usage method

Let's take a few examples to learn the above methods.

Example of timeout queries

When making a database query, you need time-out control over the query of the data, for example:

ctx = context.WithTimeout(context.Background(), time.Second)
rows, err := pool.QueryContext(ctx, "select * from products where id = ?", 100)

The code above derives a ctx with timeout cancellation based on Background s and passes into a method with context query that cancels this query if no results are returned for more than 1s.It is very convenient to use.To understand how timeout cancellation is done inside a query, let's see how the incoming ctx is used inside the DB.

When querying, you need to get a db link from the pool first, and the code is probably as follows:

// src/database/sql/sql.go
// func (db *DB) conn(ctx context.Context, strategy connReuseStrategy) *driverConn, error)

// Block getting links from req, return directly if timed out
select {
case <-ctx.Done():
  // Get link timed out, return error directly
  // do something
  return nil, ctx.Err()
case ret, ok := <-req:
  // Get the link, check it and return
  return ret.conn, ret.err
}

Req is also a chan, which is waiting for the link to return chan. If Done() returns Chan after closing, it will no longer be concerned about req returning, and our query will time out.

When you do SQL Prepare, SQL Query, and so on, there are similar methods:

select {
default:
// Check if timeout has expired and return directly
case <-ctx.Done():
  return nil, ctx.Err()
}
// If it has not timed out yet, call the driver to make the query
return queryer.Query(query, dargs)

When making a query above, the first step is to determine if the time-out has elapsed. If it does, an error will be returned directly, otherwise the query will not proceed.

You can see that when a derived Context with timeout cancellation is derived, the internal method first checks to see if it has been used when doing asynchronous operations, such as getting links, querying, and so on.
Done, if Done, indicates that the request has timed out and returns an error directly; otherwise, continue waiting or do the next work.You can also see here that to achieve timeout control, you need to constantly judge whether Done() is turned off.

Example of link tracking

Context is also very important when doing link tracking.(Link tracing is the ability to track the modules on which a request depends, such as db, redis, rpc downstream, interface downstream, and so on, from which time consumed in the request can be found)

Here is an example of link tracking:

// It is recommended that key types not be exported to prevent overwriting
type traceIdKey struct{}{}

// Define a fixed Key
var TraceIdKey = traceIdKey{}

func ServeHTTP(w http.ResponseWriter, req *http.Request){
  // Get the traceId from the request first
  // You can put traceId in header or body
  // You can also create one yourself (if you are the source of the request)
  traceId := getTraceIdFromRequest(req)

  // Key is stored in ctx
  ctx := context.WithValue(req.Context(), TraceIdKey, traceId)

  // Set interface 1s timeout
  ctx = context.WithTimeout(ctx, time.Second)

  // traceId can be carried with query RPC
  repResp := RequestRPC(ctx, ...)

  // traceId can be carried with query DB
  dbResp := RequestDB(ctx, ...)

  // ...
}

func RequestRPC(ctx context.Context, ...) interface{} {
    // Get traceid and log when rpc is called
    traceId, _ := ctx.Value(TraceIdKey)
    // request

    // do log
    return
}

In the above code, when we get the request, we get the traceId through req and record it in ctx. When we call RPC, DB, etc., we pass in the CTX that we constructed. In the subsequent code, we can get the traceId that we saved through ctx, and use traceId to log the request, which makes it easy to locate the problem later.

Of course, context s aren't simply records for traceId or timeout controls.There is a good chance that they will both.

How to implement

You need to know why.To make the best use of Context, we also need to learn how to implement it.Let's learn how different Contexts implement the Context interface.

Empty Context

Background(), Empty() will return an empty Context emptyCtx.The emptyCtx object returns nil in methods Deadline(), Done(), Err(), Value(interface {}), and String() returns the corresponding string.This implementation is relatively simple and will not be discussed here for the moment.

Cancel context

WithCancel constructs the context as an instance of cancelCtx, with the following code.

type cancelCtx struct {
  Context

  // Mutex Lock for context ual collaboration security
  mu       sync.Mutex
  // close this chan when cancel
  done     chan struct{}
  // Derived context
  children map[canceler]struct{}
  err      error
}

The WithCancel method first builds a new Context based on parent with the following code:

func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
  c := newCancelCtx(parent)  // New Context
  propagateCancel(parent, &c) // Hang onto parent
  return &c, func() { c.cancel(true, Canceled) }
}

Where the propagateCancel method determines whether the parent has been cancelled, if cancelled, it calls the method directly to cancel, and if not, appends a child to the parent's child ren.Here you can see the implementation of the context tree structure.The following is the implementation of propateCancel:

// Hang your child under parent
func propagateCancel(parent Context, child canceler) {
  // If parent is empty, return directly
  if parent.Done() == nil {
    return // parent is never canceled
  }
  
  // Get parent type
  if p, ok := parentCancelCtx(parent); ok {
    p.mu.Lock()
    if p.err != nil {
      // parent has already been canceled
      child.cancel(false, p.err)
    } else {
      if p.children == nil {
        p.children = make(map[canceler]struct{})
      }
      p.children[child] = struct{}{}
    }
    p.mu.Unlock()
  } else {
    // Start goroutine, wait for parent/child Done
    go func() {
      select {
      case <-parent.Done():
        child.cancel(false, parent.Err())
      case <-child.Done():
      }
    }()
  }
}

Done() is a simple implementation by returning a chan and waiting for the chan to close.You can see that the Done operation constructs chan done only when called, and the Done variable is delayed initialization.

func (c *cancelCtx) Done() <-chan struct{} {
  c.mu.Lock()
  if c.done == nil {
    c.done = make(chan struct{})
  }
  d := c.done
  c.mu.Unlock()
  return d
}

When you manually cancel the Context, the cancelCtx cancel method is called with the following code:

func (c *cancelCtx) cancel(removeFromParent bool, err error) {
  // For some judgment, close ctx.done chan
  // ...
  if c.done == nil {
    c.done = closedchan
  } else {
    close(c.done)
  }

  // To broadcast to all child ren, cancel goroutine is required
  for child := range c.children {
    // NOTE: acquiring the child's lock while holding parent's lock.
    child.cancel(false, err)
  }
  c.children = nil
  c.mu.Unlock()

  // Then delete the current context from the parent context
  if removeFromParent {
    removeChild(c.Context, c)
  }
}

Here you can see that when cancel is executed, there are two things to do besides closing the current cancel: 1) all child ren call the cancel method, and 2) because the context is already closed, the current context needs to be removed from the parent context.

Timing Cancel Function Context

WithDeadline, WithTimeout provides a Context method to implement timer functionality, returning a timerCtx structure.WithDeadline is given a execution deadline, WithTimeout is a countdown, WithTImeout is based on WithDeadline, so we'll just look at WithDeadline
That's it.WithDeadline's internal implementation is based on cancelCtx.A timer was added to cancelCtx and Deadline time points were recorded.Here is the timerCtx structure:

type timerCtx struct {
  cancelCtx
  // timer
  timer *time.Timer
  // Deadline
  deadline time.Time
}

WithDeadline implementation:

func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
  // If the parent context ends earlier than the child,
  // Then child can be mounted directly in parent context
  if cur, ok := parent.Deadline(); ok && cur.Before(d) {
    return WithCancel(parent)
  }

  // Create a timerCtx and set deadline
  c := &timerCtx{
    cancelCtx: newCancelCtx(parent),
    deadline:  d,
  }

  // Hang context under parent
  propagateCancel(parent, c)

  // Calculate countdown time
  dur := time.Until(d)
  if dur <= 0 {
    c.cancel(true, DeadlineExceeded) // deadline has already passed
    return c, func() { c.cancel(false, Canceled) }
  }
  c.mu.Lock()
  defer c.mu.Unlock()
  if c.err == nil {
    // Set a timer to call cancel
    c.timer = time.AfterFunc(dur, func() {
      c.cancel(true, DeadlineExceeded)
    })
  }
  return c, func() { c.cancel(true, Canceled) }
}

In the construction method, a new context is hung under the parent and a countdown timer is created to periodically trigger cancel.

The cancel operation of timerCtx is very similar to that of cancelCtx.On the basis of cancelCtx, the operation of turning off the timer is done

func (c *timerCtx) cancel(removeFromParent bool, err error) {
  // Call cancelCtx's cancel method to close chan and notify the child context.
  c.cancelCtx.cancel(false, err)
  // Remove from parent
  if removeFromParent {
    removeChild(c.cancelCtx.Context, c)
  }
  c.mu.Lock()
  // Turn off the timer
  if c.timer != nil {
    c.timer.Stop()
    c.timer = nil
  }
  c.mu.Unlock()
}

The one operation of timeCtx directly multiplies the one operation of cancelCtx, closing the chan done member directly.

Context for passing values

WithValue constructs a context that differs from the above several constructs, which construct the following contextual prototypes:

type valueCtx struct {
  // Preserve the context of the parent node
  Context
  key, val interface{}
}

Each context contains a Key-Value combination.ValueCtx retains the Context of the parent node, but does not retain the Context of the child node as cancelCtx does. Here is how valueCtx is constructed:

func WithValue(parent Context, key, val interface{}) Context {
  if key == nil {
    panic("nil key")
  }
  // The key must be a comparison of the lessons or the Value cannot be obtained
  if !reflect.TypeOf(key).Comparable() {
    panic("key is not comparable")
  }
  return &valueCtx{parent, key, val}
}

The construction can be accomplished by assigning the Key-Value directly to the struct.Here's how to get the Value:

func (c *valueCtx) Value(key interface{}) interface{} {
  if c.key == key {
    return c.val
  }
  // Get from parent context
  return c.Context.Value(key)
}

Value is obtained using a chain-based method.If it is not found in the current Context, it is retrieved from the parent Context.If we want a context to have more data, we can save a map to the context.It is not recommended to construct contexts multiple times to store data here.After all, the cost of data acquisition is relatively high.

Matters needing attention

Finally, the following points should be noted in use:

  • context.Background When requested, all other contexts come from it.
  • When the incoming conttext is not sure what type to use, the TODO context is passed in (it should not be a nil context)
  • context.Value should not pass in optional parameters, it should be some data that is guaranteed to come with each request.(e.g. traceId, authorized token, etc.).When using Value, it is recommended that Key be defined as a global const variable, and the type of key is not exportable to prevent data conflicts.
  • context goroutines security.

Posted by varsha on Mon, 04 May 2020 11:07:21 -0700