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.