32 Context. Context Types

32 context #

In the previous article, we talked about the sync.WaitGroup type: a synchronization tool that helps us implement a cooperative workflow between multiple goroutines.

When using a WaitGroup value, it is best to follow the standard pattern of “first Add, then Done concurrently, and finally Wait” to build a cooperative workflow.

If the Wait method of the value is called at the same time as the Add method is called concurrently to increase its counter value, it may cause a panic.

This brings up a problem - if we cannot determine the number of goroutines executing the subtasks at the beginning, using a WaitGroup value to coordinate and distribute goroutines for subtasks can be risky. One solution is to start the goroutines for executing subtasks in batches.

Preface: WaitGroup Value Supplementary Knowledge #

As we all know, the WaitGroup value can be reused, but the integrity of its counting cycle must be ensured. In particular, when it comes to calling its Wait method, the next counting cycle must wait until the Wait method call corresponding to the current counting cycle is completed before it can begin.

The panic-inducing situation I mentioned earlier is caused by not following this rule.

As long as we strictly follow the above rules and enable goroutines to execute subtasks in batches, there will be no problems. There are many ways to implement it, and the simplest way is to use a for loop as an assistant. The code here is as follows:

func coordinateWithWaitGroup() {
 total := 12
 stride := 3
 var num int32
 fmt.Printf("The number: %d [with sync.WaitGroup]\n", num)
 var wg sync.WaitGroup
 for i := 1; i <= total; i = i + stride {
  wg.Add(stride)
  for j := 0; j < stride; j++ {
   go addNum(&num, i+j, wg.Done)
  }
  wg.Wait()
 }
 fmt.Println("End.")
}

The coordinateWithWaitGroup function shown here is a modified version of the function with the same name in the previous article. The addNum function called in it is a simplified version of the function with the same name in the previous article. These two functions have been placed in the demo67.go file.

As we can see, the modified coordinateWithWaitGroup function uses the WaitGroup value represented by the variable wg in a loop. It still uses the pattern of “unify Add, concurrently Done, and finally Wait”, but it reuses it using the for statement.

Okay, by now you should have some understanding of the use of WaitGroup values. However, I now want you to use another tool to implement the above collaboration process.

Our question today is: How to use the program entities in the context package to implement a one-to-many goroutine collaboration process?

More specifically, I need you to write a function named coordinateWithContext. This function should have the same functionality as the coordinateWithWaitGroup function above.

Obviously, you can no longer use the sync.WaitGroup and instead use the functions and Context type in the context package as implementation tools. Here’s one point to note, whether to enable the execution of subtasks in batches is actually not important.

Here’s a reference answer for you.

func coordinateWithContext() {
 total := 12
 var num int32
 fmt.Printf("The number: %d [with context.Context]\n", num)
 cxt, cancelFunc := context.WithCancel(context.Background())
 for i := 1; i <= total; i++ {
  go addNum(&num, i, func() {
   if atomic.LoadInt32(&num) == int32(total) {
    cancelFunc()
   }
  })
 }
 <-cxt.Done()
 fmt.Println("End.")
}

In this function body, I call context.Background and context.WithCancel subsequently, obtaining a cancellable context.Context value (represented by the variable cxt) and a cancellable function of type context.CancelFunc (represented by the variable cancelFunc).

In the only for statement that follows, I asynchronously call the addNum function through a go statement in each iteration, and the total number of calls is based only on the value of the total variable.

Please note the last argument value given to the addNum function. It is an anonymous function that contains only one if statement. This if statement “atomically” loads the value of the num variable and checks if it is equal to the value of the total variable.

If the two values are equal, then call the cancelFunc function. Its meaning is that if all the addNum functions are completed, immediately notify the goroutine distributing the sub-tasks.

The goroutine distributing the sub-tasks is the goroutine that executes the coordinateWithContext function. After the execution of the for statement, it immediately calls the Done method of the cxt variable and attempts to receive from the channel returned by the function.

Since as soon as the cancelFunc function is called, the receiving operation on the channel will immediately end, this can achieve the function of “waiting for all addNum functions to complete”.

Problem Analysis #

The context.Context type (referred to as the Context type for short) was added to the standard library only with the release of Go 1.7. Since then, many other packages in the standard library have been extended to support it, including the os/exec package, the net package, the database/sql package, as well as the runtime/pprof package and the runtime/trace package, among others.

The reason why the Context type is supported by many packages in the standard library is mainly because it is a very versatile synchronization tool. Its value can not only be spread arbitrarily, but it can also be used to pass additional information and signals.

More specifically, the Context type can provide a class of values that represent a context. These values are concurrency-safe, meaning that they can be propagated to multiple goroutines.

Because the Context type is actually an interface type, and all private types implementing this interface in the context package are based on pointer types of some data type, propagating them in this way does not affect the functionality and safety of the value of that type.

The values of the Context type (referred to as Context values for short) can be multiplied, which means that we can generate any number of child values from a Context value. These child values can carry the attributes and data of their parent value and can also respond to signals conveyed through their parent value.

Because of this, all Context values together constitute a tree structure that represents the entire context. The root of this tree (or the context root node) is a Context value pre-defined in the context package, and it is globally unique. We can obtain it by calling the context.Background function (which is what I did in the coordinateWithContext function).

Note that this root context node is only a basic pivot point and does not provide any additional functionality. In other words, it cannot be canceled and cannot carry any data.

In addition, the context package also includes four functions for multiplying Context values, namely WithCancel, WithDeadline, WithTimeout, and WithValue.

The first parameter of these functions is of type context.Context, and the name is parent. As the name suggests, this parameter corresponds to the parent value of the Context value they will produce.

The WithCancel function is used to generate a cancelable child value of the parent. In the coordinateWithContext function, I call this function to get a Context value derived from the context root node and a function that triggers the cancellation signal.

The WithDeadline function and the WithTimeout function can be used to generate a child value of the parent that will be cancelled after a certain period of time. As for the WithValue function, we can call it to generate a child value of the parent that carries additional data.

So far, we have gained a basic understanding of the functions and Context type in the context package. However, this is not enough, so let’s expand further.

Knowledge Expansion #

Question 1: What does “cancelable” represent in the context package? What does canceling a Context value mean? #

I believe many Go developers who are new to the context package would have this question. Indeed, “cancelable” is quite abstract here and can easily be confusing. Let me explain further.

This starts with the declaration of the Context type. There are two methods in this interface that are closely related to “canceling”. The Done method returns a receiving channel of type struct{}. However, the purpose of this receiving channel is not to transmit element values, but to allow the caller to perceive the signal of “canceling” the current Context value.

Once the current Context value is canceled, this receiving channel will be immediately closed. As we know, closing a channel without any element values will cause any receive operation on it to terminate immediately.

Because of this, in the coordinateWithContext function, based on the receive operation of the call expression cxt.Done(), the signal of canceling can be perceived.

In addition to allowing the users of Context values to perceive the canceling signal, sometimes it is necessary for them to understand the specific reason for the “canceling”. This is the role of the Err method of the Context type. The result of this method is of type error, and its value can only be equal to the value of the context.Canceled variable or the value of the context.DeadlineExceeded variable.

The former is used to indicate manual canceling, while the latter represents canceling due to the expiration of the given deadline.

You may already sense that for a Context value, if we use the word “canceling” as a noun, it actually refers to the signal that is used to express the state of “canceling”; if we use it as a verb, it means the process of transmitting the canceling signal; and “cancelable” means the ability to convey this canceling signal.

As I mentioned earlier, when we create a cancelable Context value by calling the context.WithCancel function, we also get a function that can trigger the canceling signal.

By calling this function, we can trigger the canceling signal for this Context value. Once triggered, the canceling signal will be immediately conveyed to this Context value and expressed by the result value (a receiving channel) of its Done method.

The canceling function is only responsible for triggering the signal, and the corresponding cancelable Context value is only responsible for conveying the signal. Neither of them cares about the specific “canceling” operations that follow. In fact, our code can perform any arbitrary operation after perceiving the canceling signal, and the Context value does not impose any constraints on this.

Finally, if we delve further, the original meaning of “canceling” here is to terminate the response of a program to a certain request (such as an HTTP request) or cancel the processing of a certain instruction (such as an SQL instruction). This was also the original intention of the Go language team when creating the context code package and the Context type.

If we check the APIs and source code of the net package and the database/sql package, we can understand their typical applications in this regard.

Question 2: How is the canceling signal propagated in the context tree? #

As I mentioned earlier, the context package includes four functions used for generating child values based on a given Context value. The WithCancel, WithDeadline, and WithTimeout functions in the context package are all used to create cancelable child values.

The context package’s WithCancel function produces two result values when called. The first result value is the cancelable Context value, and the second result value is the function used to trigger the canceling signal. After the cancellation function is called, the corresponding Context value will first close its internal receive channel, which means that its Done method will return the channel.

Then, it will propagate the cancellation signal to all of its child values (or nodes). These child values will do the same and continue propagating the cancellation signal. Finally, the Context value will sever its association with its parent value.

(Propagation of cancellation signal in the context tree)

The Context values generated by calling the WithDeadline or WithTimeout functions in the context package are also cancellable. They can be cancelled manually, and they will also be automatically cancelled based on the expiration time provided when they were generated. The timed cancellation feature is implemented using an internal timer.

When the expiration time is reached, the behavior of these two types of Context values is almost the same as when a Context value is manually cancelled, except that the former will stop and release their internal timers.

Finally, it is worth noting that the Context values obtained by calling the context.WithValue function are not cancellable. When the cancellation signal is propagated, it will directly skip over these values and attempt to pass the signal directly to their child values.

Question 3: How can data be carried in a Context value? How can data be retrieved from it? #

Since we have mentioned the WithValue function in the context package, let’s talk about how data can be carried in a Context value.

When the WithValue function generates a new Context value with data (hereinafter referred to as a “context value with data”), it requires three parameters: the parent value, the key, and the value. Similar to the “constraints on keys in dictionaries,” the type of the key must be comparable.

The reason is simple. When we retrieve the data from it, it needs to find the corresponding value based on the given key. However, this type of Context value does not store the key-value pair in a dictionary. The latter two are simply stored in the corresponding fields of the former.

The Value method of the Context type is used to retrieve the data. When we call the Value method of a context value with data, it first checks if the given key is equal to the stored key in the current value. If they are equal, it directly returns the stored value. Otherwise, it continues to search in its parent value.

If the equal key is still not stored in its parent value, the method will continue searching along the direction of the contextual root node.

Note that other than context values with data, the other types of Context values cannot carry data. Therefore, when the Value method of a Context value is searching, it directly skips over those types of values.

If the Value method we call belongs to a value that does not have data itself, it will actually call the Value method of its parent or ancestor. This is because these several types of Context values are all struct types and they express the parent-child relationship by “embedding their parent values into themselves.”

Finally, it is worth noting that the Context interface does not provide a method to modify the data. Therefore, in most cases, we can only use context values with data to store new data by adding them to the context tree or discard the corresponding data by cancelling the parent value of this type of value. If the data you store here can be modified from outside, you must ensure its safety yourself.

Summary #

Today, we mainly discussed the functions and Context type in the context package. The functions in this package are used to generate new Context type values. The Context type is a synchronization tool that helps us implement cooperative workflows for multiple goroutines. Moreover, we can use values of this type to convey cancellation signals or pass data.

There are three main types of Context values: root Context value, cancellable Context value, and value with data Context value. All Context values together form a context tree. The scope of this tree is global, and the root Context value is the root of this tree. It is globally unique and does not provide any additional functionality.

Cancellable Context values can be further divided into manually cancellable Context values and timed cancellable Context values.

We can manually cancel a cancellable Context value using the cancellation function obtained when it was generated. For the latter, the cancellation time must be fully determined at generation and cannot be changed. However, we can still manually cancel it before the expiration time is reached.

Once the cancellation function is called, the cancellation signal will be immediately propagated to the corresponding Context value and expressed by the receiving channel returned by its Done method.

“Cancellation” is the key to the coordination of multiple goroutines by Context values. The cancellation signal always propagates along the path of the leaf nodes of the context tree.

Value with data Context values can carry data. Each value can store a key-value pair. When we call its Value method, it will search for the values one by one in the direction from the root of the context tree. If an equal key is found, it will immediately return the corresponding value; otherwise, it will return nil at the end.

A value with data Context value cannot be cancelled, while a cancellable Context value cannot carry data. However, because they together form an organic whole (i.e., the context tree), they are much more powerful in terms of functionality than sync.WaitGroup.

Thought question #

Today’s thought question is: Is the Context value breadth-first or depth-first when conveying cancellation signals? What are its advantages and disadvantages?

Click here to view detailed code for the Go language column.