Dwi Wahyudi
Senior Software Engineer (Ruby, Golang, Java)
In this post we will write some code using Golang’s context, a popular package for carrying around values and signals between parts of codes and processes.
Overview
In Golang, it is usual to have context
passed around some parts of codes and processes. We pass a context
as parameter to other function and that function will have its own context
added to that context
, and then pass it to another function. In other words, contexts can be nested or like a tree structure can have multiple branches/children.
A context can have value, we specify a key and assign it a value. It may sound like a common hashmap/key value, but contexts in Golang is more than that. Contexts can also bring signals, we will cover it in later section.
It is also quite usual for a web server to carry around some contexts, making it http request-scoped.
Now let’s inspect the first aspect of context in Golang, add a value to it by using WithValue()
function.
Context Value
In order to use contexts, we need to create a root context first.
// Blank root context
ctx := context.Background()
newContext := context.WithValue(ctx, key, "this is the value")
But, before we start adding a value to it, we should know that key shouldn’t be a string, it will cause key collisions. Use key type instead.
type key string
const (
keyLevel1 key = "lv1"
keyLevel2 key = "lv2"
keyLevel3 key = "lv3"
keyLevel3a key = "lv3a"
nonExistKey key = "non exist"
)
We’re going to create 4 contexts here, and add value for each of them with above keys.
// add more context to the blank context
// added context is with value
ctxLv1 := context.WithValue(ctx, keyLevel1, "value-lv1")
value := ctxLv1.Value(keyLevel1)
fmt.Println(value)
// add another context to the root context
ctxLv2 := context.WithValue(ctxLv1, keyLevel2, "value-lv2")
value2 := ctxLv2.Value(keyLevel2)
fmt.Println(value2)
// add a context to child context
ctxLv3 := context.WithValue(ctxLv2, keyLevel3, "value-lv3")
value3 := ctxLv3.Value(keyLevel3)
fmt.Println(value3)
// add a context to child context
ctxLv3a := context.WithValue(ctxLv2, keyLevel3a, "value-lv3 A")
value3a := ctxLv3a.Value(keyLevel3a)
fmt.Println(value3a)
From code above, we can see there are 4 contexts newly made (each with its own value), and with addition of root context above making a total of 5 contexts.
ctxLv1
is child ofctx
ctxLv2
is child ofctxLv1
ctxLv3
is child ofctxLv2
ctxLv3a
is child ofctxLv2
as wellctxLv3
andctxLv3a
are sibling to each other
Let’s run the code:
And there is a question: “can we access parent context value from a context?” The answer is yes, but not the other way around, parent cannot get value from any of its derived contexts.
And on another note, sibling contexts (contexts that derive from the same parent context don’t share value as well, this includes those siblings children as well).
// access value to parent context from child/grandchild contexts
value4 := ctxLv3.Value(keyLevel2)
fmt.Println("ctxLv3 access to its parent keyLevel2:", value4)
value5 := ctxLv3.Value(keyLevel1)
fmt.Println("ctxLv3 access to its parent keyLevel1:", value5)
// access value from sibling, resulting in nil value
fmt.Println("ctxLv3a access to its sibling ctxLv3:", ctxLv3a.Value(keyLevel3))
// access value to child context from parent context, also resulting in nil value
fmt.Println("ctxLv1 access to its derived/child/grandchild ctxLv3:", ctxLv1.Value(keyLevel3))
// access non-existing key, resulting in nil value
fmt.Println("access to non-existing key:", ctxLv1.Value(nonExistKey))
Here we can see that ctxLv3
tries to access keyLevel2
which is a key from context ctxLv2
(parent context) and keyLevel1
which is a key from context ctxLv1
(grandparent context), both of these will return correct string values.
And below it, ctxLv3a
tries to access keyLevel3
which is a key from its sibling context ctxLv3
, this will result in nil value. This will apply to sibling’s derived context as well, if ctxLv3a
has a child context, it cannot access value from ctxLv3
and its derived contexts.
And then ctxLv1
context tries to access keyLevel3
which is a key from its grandchild context ctxLv3
, this again will result in nil value.
And last, ctxLv1
tries to access to non-existing key, which result in nil value.
The rule of thumb is: a context can only access values from its parent, grandparent, grand-grandparent and so on.
When we run the code, here is the result:
Context Signal
Other than setting values to contexts, we can also add a signal to a context. If we have 10 contexts, we can have 10 separate signals running simultaneously.
A context may carry a signal, whose job is to “done” itself and propagate the “done” signal to all of contexts its derived from: children, grandchildren and so on.
“Done” here means that if a context is done, it will send a “signal” to a channel, EACH on its derived context, EACH by waiting on function Done()
.
And when it’s done, we usually stop children context tasks because we assume that parent context is done because of errors.
We can done a context:
- By Manually cancelling it
- By setting a deadline for it (a specific time, ex: 7pm tomorrow, 9am this wednesday, etc)
- By setting a timeout for it (a specific duration, ex: 5 minutes, 5 hours, etc)
Manual Cancel
To do manual cancel for a context, that context needs to returned by WithCancel()
function:
firstCtx, cancel := context.WithCancel(ctx)
cancel
here is a function we can call later to cancel (done) the firstCtx
. After giving signal of cancellation/done to firstCtx
, all contexts derived from it will be done as well.
Let’s create some demonstration code for simulating tasks with contexts.
There will be Task1
and Task2
, each of these will receive firstCtx
as an argument (meaning that Task1
and Task2
are sibling to each-other), and each of them has a child task and then a grandchild task. But we’re not going to cancel (done) this firstCtx
, we’re going to cancel (done) Task2
ctx (context made inside Task2
function).
Here’s how we want to simulate the tasks:
Let’s write code for child task and grandchild task, child task should call grand child task and pass its context there. Both of tasks will just create a context with value, and then select on which condition happen first, is the context done first, or some duration passed first. We also pass a string named origin
so we can know which parent task this child and grandchild tasks belong to.
ctx.Err()
will return an error of why such context is done for.
func childTask(ctx context.Context, origin string, d time.Duration) {
const (
childTask key = "child-task"
)
ctx2 := context.WithValue(ctx, childTask, "test-value")
go grandChildTask(ctx2, origin, 6*time.Second)
select {
case <-ctx2.Done():
fmt.Println("child task cancelled :( from " + origin + ", cause: " + ctx.Err().Error())
case <-time.After(d):
fmt.Println("child task done, from", origin)
}
}
func grandChildTask(ctx context.Context, origin string, d time.Duration) {
const (
grandChildKey key = "grandchild-task"
)
ctx2 := context.WithValue(ctx, grandChildKey, "test-value")
select {
case <-ctx2.Done():
fmt.Println("grandchild task cancelled :( from " + origin + ", cause: " + ctx.Err().Error())
case <-time.After(d):
fmt.Println("grandchild task done, from", origin)
}
}
Now let’s create Task1
and Task2
. Task1
create a context with a value, and wait for it either to be done or to be completed first. Same thing happen to Task2
, only in this function, context is with cancel, we’re going to call its call function after 5 seconds. select
statement in Task2
will surely get to the <-ctx2.Done()
, because cancellation (5 seconds) is earlier than 7 seconds in code: <-time.After(7 * time.Second)
. So Task2
will that task2 is cancelled.
This cancellation will propagate to all of Task2
context’s derived contexts (children and grandchildren). In other words, Task2
child task and grandchild task will be cancelled as well. Task2
call childTask
and gives waiting time of 9 seconds, later than cancellation which is 5 seconds, and grandchild task will also be cancelled because we give it 6 seconds (still later than 5 seconds).
Task1
on the other hand will be just fine, it has no cancellation, its child task and grandchild task won’t have any context cancellation. Again, in other words, Task1
context is just Task2
context’s sibling. Context cancellation from a sibling context, won’t affect a context.
If we want to cancel both contexts, we need to cancel it from firstCtx
, the parent context of both.
Another thing to note: a context can only be cancelled just once. Multiple cancel propagation to a context won’t work.
func Task1(ctx context.Context) {
const (
ctxKey key = "task1"
)
ctx2 := context.WithValue(ctx, ctxKey, "test-value")
go childTask(ctx2, "Task1", 300*time.Millisecond)
select {
case <-ctx2.Done():
fmt.Println("task 1 cancelled :(, err: ", ctx.Err())
case <-time.After(3 * time.Second):
fmt.Println("task 1 done")
}
}
func Task2(ctx context.Context) {
ctx2, cancel := context.WithCancel(ctx)
go childTask(ctx2, "Task2", 9*time.Second)
go func() {
// simulating bad thing happens after 5 seconds
// we cancel the context
time.Sleep(5 * time.Second)
fmt.Println("Oh no..., something bad happen... cancelling the context !!!")
cancel()
}()
select {
case <-ctx2.Done():
fmt.Println("task 2 cancelled :(, cause: ", ctx2.Err())
case <-time.After(7 * time.Second):
fmt.Println("task 2 done")
}
}
With above code in-place, we can now try to call Task1
and Task2
concurrently.
func DemoContextWithCancellation() {
ctx := context.Background()
firstCtx, _ := context.WithCancel(ctx)
go Task1(firstCtx)
go Task2(firstCtx)
}
And here’s the result:
As we can see above, grandchild task of Task1
is still completed, eventhough it is the last thing to happen, context cancellation on Task2
context doesn’t affect it.
Do note that ctx.Err()
message is “context cancelled”.
Context Deadline
We’ve demonstrated manual cancellation with above code, now let’s try doing cancellation with deadline. It means that we specify an exact time, when the context will be cancelled/done. The rules of cancellation still apply just like we do it with manual cancellation: cancellation goes down to child and grandchild contexts, and won’t affect sibling contexts.
func DemoContextWithDeadline() {
ctx := context.Background()
deadLine := time.Now().Add(5 * time.Second)
ctxWithDeadline, _ := context.WithDeadline(ctx, deadLine)
go Task1B(ctxWithDeadline)
}
func Task1B(ctx context.Context) {
const (
ctxKey key = "task1"
)
select {
case <-ctx.Done():
fmt.Println("task 1B cancelled :(, err: ", ctx.Err())
case <-time.After(6 * time.Second):
fmt.Println("task 1B done")
}
}
Now, let’s run this function.
As we expect, the context will be done before the task 1B is completed. Context deadline is set to a specific time (5 seconds from now), while task 1B needs to wait for 6 seconds. We also found that the error message is “deadline exceeded”.
We can set the deadline to a particular time in the future, no matter what time is now, we can set a deadline of a context on new years eve of 2077, it is doable.
Also note that WithDeadline()
function returns cancel
function as well. We can do manual cancellation as well from it.
Context Timeout
Context timeout is different, we set a duration for a context to be cancelled/done, no matter what time is now, and no matter what time it will be timed-out. The rules of cancellation still apply just like we do it with manual cancellation: cancellation goes down to child and grandchild contexts, and won’t affect sibling contexts.
func DemoWithTimeout() {
ctx := context.Background()
dur := 5 * time.Second
ctxWithTimeout, _ := context.WithTimeout(ctx, dur)
go Task1C(ctxWithTimeout)
}
func Task1C(ctx context.Context) {
const (
ctxKey key = "task1"
)
select {
case <-ctx.Done():
fmt.Println("task 1C cancelled :(, err: ", ctx.Err())
case <-time.After(6 * time.Second):
fmt.Println("task 1C done")
}
}
Here we give a context, 5 seconds duration of timeout. Surely enough, it will trigger cancellation before task 1C’s waiting time. The error message is the same with WithDeadline()
though.
HTTP Request Scoped Context
Why do we want to carry signals all around the places in our code? One of reasons is to preserve resources, each http request in Go web application is handled by a Goroutine, sometimes such http request Goroutine can create/spawn more Goroutines down the road, for interacting with database, 3rd party API, etc. When the http request is cancelled or timed-out, we may want to send signals to all Goroutines working on that http request, “dear Goroutines from this request, stop right there, stop working, stop using some of resources, the request is already cancelled/timed-out”.
Now let’s demonstrate it with some code:
func Demo() {
http.HandleFunc("/demo", demoHandler)
port := ":8080"
log.Println("Running server on", port)
http.ListenAndServe(port, nil)
}
func demoHandler(w http.ResponseWriter, req *http.Request) {
ctx := req.Context()
timeout := 3 * time.Second
ctxWithTimeout, _ := context.WithTimeout(ctx, timeout)
go summonTask(ctxWithTimeout, 500*time.Millisecond, "Sonic The Hedgehog")
go summonTask(ctxWithTimeout, 300*time.Millisecond, "The Flash")
go summonTask(ctxWithTimeout, 1200*time.Millisecond, "Speedy Gonzales")
go summonTask(ctxWithTimeout, 10*time.Second, "Slowpoke")
w.Write([]byte("done"))
}
func summonTask(ctx context.Context, dur time.Duration, character string) {
select {
case <-ctx.Done():
fmt.Println("context is done :( " + character + " " + "is too slow...")
case <-time.After(dur):
fmt.Println(character + " " + "arrive !")
}
}
As we look at the code, we create a new web server, handling request at “/demo”, this request just asynchronously call summonTask
function, multiple times, with various durations (for each completion time) and character names.
We pass the ctxWithTimeout
context to summonTask
function, it has 3 seconds timeout, it is derived from req.Context()
.
There is a problem with this code, let’s run it first, then call http://localhost:8080/demo
Turns out, req.Context()
will return a context that will be closed, once connection to client is closed.
https://golang.org/pkg/net/http/#Request.Context
If we want to continue running background Goroutines, without disrupting the request flow, but with timeouts, we should derive from a blank context instead.
Change ctx := req.Context()
to ctx := context.Background()
. Run the code and visit http://localhost:8080/demo again.
The problem with this approach, if we plan to mutate some transactional data with Goroutines and contexts, we as developers must actively ensure data consistency between each Goroutine. Preventing some database or API call activities (because of context signals) where one depend to another and while other previously Goroutines have been completed, may create data inconsistencies, because we have no control of concurrency order of Goroutines.
Here’s the code for all of the demonstration above: https://github.com/dwahyudi/golang-context