[Go language] detailed explanation of context package that beginners can understand: from introduction to mastery

Original link: Detailed explanation of context package that Xiaobai can understand: from introduction to mastery

preface

Hello, everyone, I'm asong. Today, I want to share the context package with you. After a year of precipitation, I will start again and analyze it from the perspective of source code based on Go1.17.1. However, the difference this time is that I intend to start with the introduction, because most novice readers want to know how to use it first, and then they will focus on how to realize the source code.

I believe you will see such code in your daily work development:

func a1(ctx context ...){
  b1(ctx)
}
func b1(ctx context ...){
  c1(ctx)
}
func c1(ctx context ...)

Context is regarded as the first parameter (official suggestion) and is continuously transmitted. Basically, context is everywhere in a project code, but do you really know what it does and how it works? I remember when I first came into contact with context, my colleagues said that this is used for concurrency control. You can set the timeout time, and the timeout will be cancelled. I simply think that as long as the context parameter is passed down in the function, you can cancel the timeout and return quickly. I believe most beginners share the same idea with me. In fact, this is a wrong idea. The cancellation mechanism also adopts the notification mechanism. Simple transparent transmission will not work. For example, you write code like this:

func main()  {
    ctx,cancel := context.WithTimeout(context.Background(),10 * time.Second)
    defer cancel()
    go Monitor(ctx)

    time.Sleep(20 * time.Second)
}

func Monitor(ctx context.Context)  {
    for {
        fmt.Print("monitor")
    }
}

Even if the context is passed through, it will not work if it is not used. Therefore, it is necessary to understand the use of context. This article starts with the use and gradually analyzes the context package of Go language. Let's start now!!!

Origin and function of context package

From the official blog, we can know that the context package was introduced into the standard library in go1.7:

<img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/c0d332914b0c44ae8706589eaef6ebaa~tplv-k3u1fbpfcp-zoom-1.image" style="zoom:50%;" />

Context can be used to transfer context information between goroutines. The same context can be passed to functions running in different goroutines. The context is safe for multiple goroutines to be used at the same time. The context package defines the context type. You can use background and TODO to create a context and propagate the context between function call chains. You can also use WithDeadline Replacing it with a modified copy created by WithTimeout, WithCancel or WithValue sounds a little windy. In fact, it can be summarized in one sentence: the function of context is to synchronize the request for specific data, cancellation signals and the deadline for processing requests between different goroutines.

At present, some of our commonly used libraries support context. For example, gin, database/sql and other libraries support context, which is more convenient for us to do concurrency control. As long as we create a context at the server entrance and continuously transmit it.

Use of context

Create context

Context package mainly provides two ways to create context:

  • context.Backgroud()
  • context.TODO()

In fact, these two functions are just aliases of each other. There is no difference. The official definition is:

  • context.Background is the default value of context. All other contexts should be Derived from it.
  • context.TODO should only be used when it is uncertain which context should be used;

Therefore, in most cases, we use context.Background as the starting context to pass down.

The above two methods are to create a root context without any functions. The specific practice still depends on the With series functions provided by the context package:

func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc)
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
func WithValue(parent Context, key, val interface{}) Context

These four functions are derived from the parent Context. Through these functions, a Context tree is created. Each node of the tree can have any number of child nodes, and the node level can have any number. Draw a diagram to show it:

A parent context can be derived at will. In fact, this is a context tree. Each node of the tree can have any number of child nodes, and the node level can have any number of child nodes. Each child node depends on its parent node. For example, in the above figure, we can derive four child contexts based on Context.Background: ctx1.0-cancel, ctx2.0-deadline, ctx3.0-timeout ctx4.0-withvalue, the four child contexts can also be derived downward as the parent context. Even if the ctx1.0-cancel node is cancelled, the other three parent node branches will not be affected.

Create context methods and derivative methods of context. Let's take a look at how they are used one by one.

WithValue carries data

We hope to have a trace in our daily business development_ ID can connect all logs in series, which requires us to get the trace when printing the log_ ID can be passed by gevent.local in python, ThreadLocal in java, and context in Go language. A trace can be created by using WithValue_ ID context, and then continue to pass it through. You can output it when printing the log. Let's take a look at the use example:

const (
    KEY = "trace_id"
)

func NewRequestID() string {
    return strings.Replace(uuid.New().String(), "-", "", -1)
}

func NewContextWithTraceID() context.Context {
    ctx := context.WithValue(context.Background(), KEY,NewRequestID())
    return ctx
}

func PrintLog(ctx context.Context, message string)  {
    fmt.Printf("%s|info|trace_id=%s|%s",time.Now().Format("2006-01-02 15:04:05") , GetContextValue(ctx, KEY), message)
}

func GetContextValue(ctx context.Context,k string)  string{
    v, ok := ctx.Value(k).(string)
    if !ok{
        return ""
    }
    return v
}

func ProcessEnter(ctx context.Context) {
    PrintLog(ctx, "Golang DreamWorks")
}


func main()  {
    ProcessEnter(NewContextWithTraceID())
}

Output results:

2021-10-31 15:13:25|info|trace_id=7572e295351e478e91b1ba0fc37886c0|Golang DreamWorks
Process finished with the exit code 0

We create a carry trace based on context.Background_ The ctx of ID is then passed together through the context tree. Any context derived from it will obtain this value. When we finally print the log, we can take the value from ctx and output it to the log. At present, some RPC frameworks support context, so trace_ It is more convenient to pass the ID down.

There are four things to note when using withVaule:

  • It is not recommended to use context value to pass key parameters. The key parameters should be declared and should not be handled implicitly. It is better to carry signature and trace in context_ Values such as ID.
  • Because carrying value is also in the form of key and value, in order to avoid the conflict of context caused by multiple packages using context at the same time, it is recommended to use the built-in type of key.
  • In the above example, we get trace_ The ID is directly obtained from the current ctx. In fact, we can also obtain the value in the parent context. When obtaining the key value pair, we first look it up from the current context. If we don't find it, we will look up the corresponding value of the key from the parent context until nil is returned or the corresponding value is found in a parent context.
  • The key and value in the data passed by context are of interface type. The type cannot be determined during compilation, so it is not very safe. Therefore, don't forget to ensure the robustness of the program when asserting the type.

Timeout control

Usually, robust programs need to set timeout to avoid resource consumption due to long-time response of the server. Therefore, some web frameworks or rpc frameworks will use withTimeout or withDeadline for timeout control. When a request reaches the timeout we set, it will be cancelled in time and will not be executed further. The functions of withTimeout and withDeadline are the same, but the time parameters passed are different. They will automatically cancel the Context by passing in the time. Note that they will return a cancelfunction method, which can be called to cancel in advance, However, it is recommended to call cancelfunction to stop timing after automatic cancellation to reduce unnecessary waste of resources.

The difference between withtimeout and WithDeadline is that withtimeout takes the duration as the parameter input rather than the time object. Which of the two methods is the same depends on the business scenario and personal habits, because the essence of withTimout is also the called WithDeadline.

Now let's take an example to try out the timeout control. Now let's simulate a request and write two examples:

  • The timeout is reached and the next execution is terminated
func main()  {
    HttpHandler()
}

func NewContextWithTimeout() (context.Context,context.CancelFunc) {
    return context.WithTimeout(context.Background(), 3 * time.Second)
}

func HttpHandler()  {
    ctx, cancel := NewContextWithTimeout()
    defer cancel()
    deal(ctx)
}

func deal(ctx context.Context)  {
    for i:=0; i< 10; i++ {
        time.Sleep(1*time.Second)
        select {
        case <- ctx.Done():
            fmt.Println(ctx.Err())
            return
        default:
            fmt.Printf("deal time is %d\n", i)
        }
    }
}

Output results:

deal time is 0
deal time is 1
context deadline exceeded
  • The timeout period is not reached. Terminate the next execution
func main()  {
    HttpHandler1()
}

func NewContextWithTimeout1() (context.Context,context.CancelFunc) {
    return context.WithTimeout(context.Background(), 3 * time.Second)
}

func HttpHandler1()  {
    ctx, cancel := NewContextWithTimeout1()
    defer cancel()
    deal1(ctx, cancel)
}

func deal1(ctx context.Context, cancel context.CancelFunc)  {
    for i:=0; i< 10; i++ {
        time.Sleep(1*time.Second)
        select {
        case <- ctx.Done():
            fmt.Println(ctx.Err())
            return
        default:
            fmt.Printf("deal time is %d\n", i)
            cancel()
        }
    }
}

Output results:

deal time is 0
context canceled

It is easy to use. It can be cancelled automatically when timeout occurs, and can be manually controlled. A hole to remember here is that the context in the call link transmitted from the request entry carries a timeout. If we want to open a goroutine to deal with other things and will not be cancelled after the request is completed, the delivered context should be re derived from context.Background or context.TODO, The veto will not be in line with expectations. You can see my previous article on stepping on the pit: A bug caused by improper use of context.

withCancel cancel cancel control

In daily business development, we often open multiple goroutines to do some things in order to complete a complex requirement, which leads us to open multiple goroutines in one request, and we really can't control them. At this time, we can use withCancel to derive a context and pass it to different goroutines. When I want to stop these goroutines, You can call cancel to cancel.

Let's take an example:

func main()  {
    ctx,cancel := context.WithCancel(context.Background())
    go Speak(ctx)
    time.Sleep(10*time.Second)
    cancel()
    time.Sleep(1*time.Second)
}

func Speak(ctx context.Context)  {
    for range time.Tick(time.Second){
        select {
        case <- ctx.Done():
            fmt.Println("I'm gonna shut up")
            return
        default:
            fmt.Println("balabalabalabala")
        }
    }
}

Operation results:

balabalabalabala
....ellipsis
balabalabalabala
 I'm gonna shut up

We use withCancel to create a Background based ctx, and then start a speech program to say a word every 1s. The main function cancels after 10s, and then speak will exit when it detects the cancellation signal.

Custom Context

Because Context is essentially an interface, we can achieve the purpose of customizing Context by implementing Context. Generally, this form is often used in the implementation of Web framework or RPC framework. For example, the Context of gin framework has its own encapsulation layer, and the specific code and implementation are posted here. If you are interested, you can see how gin.Context is implemented.

Appreciation of source code

Context is actually an interface that defines four methods:

type Context interface {
 Deadline() (deadline time.Time, ok bool)
 Done() <-chan struct{}
 Err() error
 Value(key interface{}) interface{}
}
  • Deadlne method: returns when the Context is automatically cancelled or cancelled at the cancellation time
  • Done method: when the Context is cancelled or reaches the deadline, a closed channel is returned
  • Err method: when the context is cancelled or closed, the reason for canceling the context is returned
  • Value method: get the value corresponding to the set key

This interface is mainly inherited and implemented by three classes, namely emptyCtx, ValueCtx and cancelCtx. It is written as an anonymous interface, so that any type that implements the interface can be rewritten.

Let's analyze layer by layer from creation to use.

Create root Context

The object created when we call context.Background and context.TODO is empty:

var (
    background = new(emptyCtx)
    todo       = new(emptyCtx)
)

func Background() Context {
    return background
}

func TODO() Context {
    return todo
}

Background and TODO are as like as two peas. The official says: background is usually used by main functions, initialization and testing, and is used as the top-level Context for incoming requests. Todo means that when it is not clear which Context to use or is not available, the code should use context.TODO, which will be replaced later. In the final analysis, it is just different semantics.

emptyCtx class

emptyCtx is mainly used when creating the root Context. Its implementation method is also an empty structure. The actual source code is as follows:

type emptyCtx int

func (*emptyCtx) Deadline() (deadline time.Time, ok bool) {
    return
}

func (*emptyCtx) Done() <-chan struct{} {
    return nil
}

func (*emptyCtx) Err() error {
    return nil
}

func (*emptyCtx) Value(key interface{}) interface{} {
    return nil
}

func (e *emptyCtx) String() string {
    switch e {
    case background:
        return "context.Background"
    case todo:
        return "context.TODO"
    }
    return "unknown empty Context"
}

Implementation of WithValue

Within withValue, it mainly calls valueCtx class:

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}
}

valueCtx class

valueCtx aims to carry key value pairs for Context, because it adopts the inheritance implementation of anonymous interface. It will inherit the parent Context, which is equivalent to embedding in the Context

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

Implements the String method to output Context and carry key value pair information:

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

Implement the Value method to store key Value pairs:

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

Look at the picture to understand:

Therefore, when we call the Value method in the Context, we will call up layer by layer until the final root node. If the key is found in the middle, it will be returned. If not, we will find the final emptyCtx and return nil.

Implementation of WithCancel

Let's take a look at the source code of the entry function of WithCancel:

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) }
}

The execution steps of this function are as follows:

  • Create a cancelCtx object as a child context
  • Then call propagateCancel to build the relationship between the father and child context, so that when the parent context is cancelled, the child context will also be canceled.
  • Return sub context object and sub tree cancellation function

Let's first analyze the class cancelCtx.

cancelCtx class

cancelCtx inherits the Context and implements the interface canceler:

type cancelCtx struct {
    Context

    mu       sync.Mutex            // protects following fields
    done     atomic.Value          // of chan struct{}, created lazily, closed by first cancel call
    children map[canceler]struct{} // set to nil by the first cancel call
    err      error                 // set to non-nil by the first cancel call
}

Short word explanation:

  • mu: it is a mutually exclusive lock that ensures concurrency safety, so context is concurrency safe
  • done: used as the cancellation notification signal of context. The previous version used chan struct {} type. Now atomic.Value is used for lock optimization
  • children: key is the interface type canceller, which is used to store the child nodes that implement the current canceller interface. When the root node cancels, traverse the child nodes and send a cancellation signal
  • error: store the cancellation information when the context is cancelled

The Done method is implemented here, and the return is a read-only channel. The purpose is that we can wait for the notification signal externally through this blocked channel.

The specific code will not be posted. Let's go back to how propagatecontrol constructs the association between parent and child Context.

propagateCancel method

The code is a little long and the explanation is a little troublesome. It looks intuitive when I add comments to the code:

func propagateCancel(parent Context, child canceler) {
  // If nil is returned, it means that the current parent 'context' will never be cancelled. It is an empty node and can be returned directly.
    done := parent.Done()
    if done == nil {
        return // parent is never canceled
    }

  // Judge whether a parent context is cancelled in advance. If it is cancelled, there is no need to build an association,
  // Cancel the current child node and return
    select {
    case <-done:
        // parent is already canceled
        child.cancel(false, parent.Err())
        return
    default:
    }

  // The purpose here is to find a context that can be hung or cancelled
    if p, ok := parentCancelCtx(parent); ok {
        p.mu.Lock()
    // A context that can be hung or cancelled is found, but it has been cancelled, so this child node does not need to be hung
    // Continue to connect, just cancel
        if p.err != nil {
            child.cancel(false, p.err)
        } else {
      // Hang the current node to the child map of the parent node. When you call cancel outside, you can cancel it layer by layer
            if p.children == nil {
        // Here, because the child node will also become the parent node, you need to initialize the map structure
                p.children = make(map[canceler]struct{})
            }
            p.children[child] = struct{}{}
        }
        p.mu.Unlock()
    } else {
    // If the parent node that can be "hung" or "cancelled" is not found, open a goroutine
        atomic.AddInt32(&goroutines, +1)
        go func() {
            select {
            case <-parent.Done():
                child.cancel(false, parent.Err())
            case <-child.Done():
            }
        }()
    }
}

What really puzzles this code is the if and else branches. Don't look at the code, just say why. Because we can customize the context ourselves. When we insert the context into a structure, we will not find the cancelable parent node, and we can only restart a collaboration for listening.

It is recommended to read Rao dada's article: [deep decryption of Go language context](https://www.cnblogs.com/qcrao... ), will be able to solve your worries and doubts.

cancel method

Finally, let's see how the returned cancel method is implemented. This method will close the channels in the context and synchronize the cancellation signal to all sub contexts:

func (c *cancelCtx) cancel(removeFromParent bool, err error) {
  // When canceling, the error information passed in cannot be nil. The context defines the default error:var Canceled = errors.New("context canceled")
    if err == nil {
        panic("context: internal error: missing cancel error")
    }
  // There is already an error message indicating that the current node has been cancelled
    c.mu.Lock()
    if c.err != nil {
        c.mu.Unlock()
        return // already canceled
    }
  
    c.err = err
  // It is used to close the channel and notify other processes
    d, _ := c.done.Load().(chan struct{})
    if d == nil {
        c.done.Store(closedchan)
    } else {
        close(d)
    }
  // The current node goes down, traverses all its child nodes, and then cancels
    for child := range c.children {
        // NOTE: acquiring the child's lock while holding parent's lock.
        child.cancel(false, err)
    }
  // Node empty
    c.children = nil
    c.mu.Unlock()
  // Remove the current node from the parent node, and pass true only when called by the external parent node
  // Others pass false, and internal calls will be rejected because c.children = nil
    if removeFromParent {
        removeChild(c.Context, c)
    }
}

Here, the whole WithCancel method source code is analyzed. Through the source code, we can know that the cancel method can be called repeatedly and is idempotent.

Implementation of withDeadline and WithTimeout

Let's first look at the WithTimeout method, which is the called WithDeadline method:

func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
    return WithDeadline(parent, time.Now().Add(timeout))
}

So let's focus on how withDeadline is implemented:

func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
  // Cannot create a derived context for an empty 'context'
    if parent == nil {
        panic("cannot create context from nil parent")
    }
  
  // When the end time of the parent context is earlier than the time to be set, there is no need to process the timer of the child node separately
    if cur, ok := parent.Deadline(); ok && cur.Before(d) {
        // The current deadline is already sooner than the new one.
        return WithCancel(parent)
    }
  // Create a timerCtx object
    c := &timerCtx{
        cancelCtx: newCancelCtx(parent),
        deadline:  d,
    }
  // Hang the current node to the parent node
    propagateCancel(parent, c)
  
  // Get expiration time
    dur := time.Until(d)
  // If the current time has expired, cancel it directly
    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 it is not cancelled, add a timer directly to cancel regularly
    if c.err == nil {
        c.timer = time.AfterFunc(dur, func() {
            c.cancel(true, DeadlineExceeded)
        })
    }
    return c, func() { c.cancel(true, Canceled) }
}

Compared with the withCancel method, withDeadline has an additional timer to call the cancel method regularly. This cancel method is rewritten in the timerCtx class. Let's take a look at the timerCtx class. It is based on cancelCtx and has two more fields:

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

    deadline time.Time
}

The cancel method implemented by timerCtx also calls the cancel method of cancelCtx to cancel:

func (c *timerCtx) cancel(removeFromParent bool, err error) {
  // Call the cancel method of cancelCtx to cancel the child node context
    c.cancelCtx.cancel(false, err)
  // Removed from the parent context and put it here to do
    if removeFromParent {
        // Remove this timerCtx from its parent cancelCtx's children.
        removeChild(c.cancelCtx.Context, c)
    }
  // Stop the timer and release resources
    c.mu.Lock()
    if c.timer != nil {
        c.timer.Stop()
        c.timer = nil
    }
    c.mu.Unlock()
}

Finally, we finished reading the source code. What do you think now?

Advantages and disadvantages of context

The context package is designed for concurrency control. This package has advantages and disadvantages. I have summarized several advantages and disadvantages. Welcome to add in the comment area.

shortcoming

  • It affects the beauty of the code. Now almost all web frameworks and RPC frameworks implement context, which leads to that a parameter of each function in our code is context. Even if we don't use it, we have to pass it through with this parameter. Personally, I think it's a little ugly.
  • Context can carry values, but there are no restrictions. There are no restrictions on type and size, that is, there are no constraints. This can easily lead to abuse, and the robustness of the program is difficult to ensure. Another problem is that carrying values through context is not as comfortable as explicit value transmission, and the readability becomes worse.
  • You can customize the context so that the risk is uncontrollable and will lead to abuse.
  • The error return of context cancellation and automatic cancellation is not friendly enough, and the error cannot be customized. It is difficult to troubleshoot problems that are difficult to troubleshoot.
  • Creating a derived node is actually creating a linked list node. Its time complexity is O(n). If there are many nodes, the cost loss efficiency will become low.

advantage

  • Using context can better control concurrency and manage goroutine abuse.
  • There are no restrictions on the carrier function of context, so we can transfer any data. It can be said that this is a double-edged sword
  • It is said on the Internet that the context package solves the cancellation problem of goroutine. What do you think?

Reference articles

https://pkg.go.dev/context@go...
https://studygolang.com/artic...
https://draveness.me/golang/d...
https://www.cnblogs.com/qcrao...
https://segmentfault.com/a/11...
https://www.flysnow.org/2017/...

summary

Although the use of context is a little ugly, it can solve many problems. It is inseparable from the use of context in daily business development. However, don't use the wrong context. The cancellation also adopts channel notification. Therefore, there should be monitoring code in the code to monitor the cancellation signal, which is often overlooked by beginners.

The example in the article has been uploaded to github: https://github.com/asong2020/...

Well, that's the end of this article. I'm asong. I'll see you next time.

* * welcome to the official account: Golang DreamWorks.

Keywords: Go Context

Added by dannymm on Sat, 06 Nov 2021 11:43:52 +0200