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 of ctx
  • ctxLv2 is child of ctxLv1
  • ctxLv3 is child of ctxLv2
  • ctxLv3a is child of ctxLv2 as well
  • ctxLv3 and ctxLv3a 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