Usage Scenarios

There are three main usage scenarios for Context

  • Passing timeout information, which is most used.
  • Passing signals, used for message notification, handling multi-process communication
  • Passing data, commonly used in the framework layer trace-id, metadata

Let’s take an example of etcd watch to get a better understanding.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
func watch(ctx context.Context, revision int64) {
 ctx, cancel := context.WithCancel(ctx)
 defer cancel()

 for {
  rch := watcher.Watch(ctx, watchPath, clientv3.WithRev(revision))
  for wresp := range rch {
    ......
      doSomething()
  }

  select {
  case <-ctx.Done():
   // server closed, return
   return
  default:
  }
 }
}

First the child ctx and cancel functions are generated based on the parent ctx passed in as an argument. Then watch passes in child ctx, and if parent ctx is cascaded by cancel, child ctx is cascaded by cancel, rch is closed by etcd, and then the for loop goes to the select logic, at which point child ctx is cancelled. So <-ctx.Done() takes effect and the watch function returns

The context makes it possible for multiple goroutines to collaborate and manage timeouts, which greatly simplifies development work. This is the beauty of Go

Principle

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

Context is an interface

  • Deadline ctx returns this value if it is closed at a certain point in time. Otherwise ok is false
  • Done returns a channel that will be closed if it times out or is cancelled, enabling message communication.
  • Err Returns an error if the current ctx has timed out or been cancelled.
  • Value Returns a value based on a key, similar to a dictionary.

Current implementations are emptyCtx , valueCtx , cancelCtx , timerCtx . Child Context can be generated based on a Parent.

1
2
3
4
func WithValue(parent Context, key, val interface{}) Context
func WithCancel(parent Context) (Context, CancelFunc)
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

go context

After multiple derivations, ctx is a multinomial tree-like structure. When ctx-1 is canceled, the whole tree with ctx-1 as root is cascaded and canceled, but the original root, ctx2 ctx3, is not affected.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
func (c *cancelCtx) cancel(removeFromParent bool, err error) {
	if err == nil {
		panic("context: internal error: missing cancel error")
	}
	c.mu.Lock()
	if c.err != nil {
		c.mu.Unlock()
		return // already canceled
	}
	c.err = err
	d, _ := c.done.Load().(chan struct{})
	if d == nil {
		c.done.Store(closedchan)
	} else {
		close(d)
	}
	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()

	if removeFromParent {
		removeChild(c.Context, c)
	}
}

First detect the done channel, if someone is listening, then close it, then all goroutines waiting for this ctx will receive the message.

Then it iterates through the children map, canceling all children in turn, similar to the prior order traversal of a tree. Finally, removeFromParent removes itself from the parent node.

A few questions

prints Ctx

Using WithCancel as an example, you can see that child also references parent, and with the propagateCancel function, parent also references child (when parent is of type cancelCtx).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
 c := newCancelCtx(parent)
 propagateCancel(parent, &c)
 return &c, func() { c.cancel(true, Canceled) }
}

// newCancelCtx returns an initialized cancelCtx.
func newCancelCtx(parent Context) cancelCtx {
 return cancelCtx{Context: parent}
}

If ctx is printed at this point, the String() method will be called recursively, and the key/value will be printed. If the value is non-thread-safe at this point, such as map, it will raise a concurrent read and write panic.

This case is an implementation of the http standard library server.go:2906 line of code that saves the http server to the ctx.

1
ctx := context.WithValue(baseCtx, ServerContextKey, srv)

The final call to the business layer code passes the ctx to the user.

1
go c.serve(connCtx)

If you print ctx at this point, you will print the http srv structure, which contains the map. If you are interested, you can do an experiment and take the ab pressure test to easily reproduce it.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
func stringify(v interface{}) string {
	switch s := v.(type) {
	case stringer:
		return s.String()
	case string:
		return s
	}
	return "<not Stringer>"
}

func (c *valueCtx) String() string {
	return contextName(c.Context) + ".WithValue(type " +
		reflectlite.TypeOf(c.key).String() +
		", val " + stringify(c.val) + ")"
}

go context

Also note that go has since made a partial fix for this, which has somewhat solved the problem. But also remember not to print ctx.

Key/Value type is not safe

1
2
3
4
5
6
// A valueCtx carries a key-value pair. It implements Value for that key and
// delegates all other calls to the embedded Context.
type valueCtx struct {
	Context
	key, val interface{}
}

Strongly do not use Context to pass too much data, here you can see that key / value types are interface{} , compile-time can not determine the type, run-time need to assert, there are performance and security issues

closes the underlying connection

The Context timeout triggers the http pool to close the underlying connection, resulting in frequent connection rebuilding.

The problem is that if the application layer reads it and then drops it, the connection is still available, but if the OS tcp stack handles the useless data, it closes directly. grpc doesn’t have this problem because it is multiplexed and each request is a virtual stream, so if it times out, it just closes the stream, not the underlying tcp connection

Doubly linked list

When Context is derived from more layers, it forms a doubly linked list, and key / value fetching is likely to degenerate into an O(N) operation, which is very slow

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
type valueCtx struct {
	Context
	key, val interface{}
}

func WithValue(parent Context, key, val interface{}) Context {
	if parent == nil {
		panic("cannot create context from nil parent")
	}
	if key == nil {
		panic("nil key")
	}
	if !reflectlite.TypeOf(key).Comparable() {
		panic("key is not comparable")
	}
	return &valueCtx{parent, key, val}
}

func (c *valueCtx) Value(key interface{}) interface{} {
	if c.key == key {
		return c.val
	}
	return c.Context.Value(key)
}

Whenever a key / value is added a new valueCtx is generated, and when queried, if the current ctx does not have a key, the c.Context is queried recursively.

timeout in advance

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func test(){
 ctx, cancel := context.WithCancel(ctx)
 defer cancel()
  
  doSomething(ctx)
}

func doSomething(ctx){
  go doOthers(ctx)
}

When the call stack is deep, it is easy to generate this situation when multiple people cooperate. In fact, still do not understand how ctx cancel works, asynchronous go out of the business logic needs to be based on context.Background() and then derive child ctx, otherwise it will return early timeout.

Another point that is easy to ignore is that by default grpc will pass through the timeout, for example, if the entry A service calls B and the timeout is set to 2s, if B uses the same Context to call downstream C, then the timeout will be subtracted from B’s own processing time. If the link is long, it is likely to time out by the time it reaches the G service

Passing the timeout can release the resources earlier, otherwise the backend is still processing the request after the entry timeout.

Custom Ctx

The reason why custom Context is very unconventional is that it is handled differently in the source code.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// propagateCancel arranges for child to be canceled when parent is.
func propagateCancel(parent Context, child canceler) {
  ......
 if p, ok := parentCancelCtx(parent); ok {
  p.mu.Lock()
  if p.err != nil {
  ......
  } else {
  ......
   p.children[child] = struct{}{}
  }
  p.mu.Unlock()
 } else {
  atomic.AddInt32(&goroutines, +1)
  go func() {
   select {
   case <-parent.Done():
    child.cancel(false, parent.Err())
   case <-child.Done():
   }
  }()
 }
}

func parentCancelCtx(parent Context) (*cancelCtx, bool) {
  ......
 p, ok := parent.Value(&cancelCtxKey).(*cancelCtx)
 if !ok {
  return nil, false
 }
  ......
 return p, true
}

As you can see from the source code, there are two ways for parent to refer to child, the official cancelCtx type is saved with map. But the unofficial one needs to open goroutine to monitor it. The business code is already full of goroutines, so using them unchecked will only increase the burden on the system.

Suggestions for use

Finally, to summarize a few principles of context usage.

  • Don’t use WithValue to carry business data except at the framework level, this type is interface{}, which cannot be determined at compile time, and assert has overhead at runtime. If you do carry it, use thread-safe data
  • Be sure not to print Context, especially if it’s derived from the http standard library, who knows what’s in it
  • Context is usually passed as the first argument to a function, but if the life cycle of Context is equivalent to that of a structure, it’s fine to treat it as a structure member
  • Don’t customize user-level Context if possible, unless the benefits are huge
  • Asynchronous goroutine logic uses Context to be clear about who is still holding it and whether it will time out early, especially when calling rpc, db, redis.
  • The derived child ctx must be used with defer cancel() to free up resources.