Regarding Go’s Context, I have only written one article: Printing All Context Values in Go, and that was quite a while ago. Over time, through continuous use of Go, I have gained a deeper understanding of Context. I wanted to expand on this topic, but modifying the previous article didn’t seem appropriate, so I decided to write a new one in a more systematic way.
First, I learned that Context was not introduced at the inception of Go; it was added in Go 1.7. The motivation behind its addition was to solve issues related to cross-goroutine collaboration and information passing. This is reflected in its interface, which includes the following functions:
func WithValue(parent Context, key, val any) Contextfunc WithCancel(parent Context) (ctx Context, cancel CancelFunc)func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
These interfaces highlight the primary uses of Context. Below, I provide examples to illustrate their application.
Here is a simple example of passing information through Context:
[root@liqiang.io]# cat example0.gofunc main() {type favContextKey stringf := func(ctx context.Context, k interface{}) {if v := ctx.Value(k); v != nil {fmt.Println("found value:", v)return}fmt.Println("key not found:", k)}k := favContextKey("language")ctx := context.WithValue(context.Background(), k, "Go")f(ctx, k)f(ctx, "language")}
The output of this example is:
[root@liqiang.io]# go run example0.gofound value: Gokey not found: language
From the output, you can see that although the key “language” is used in both cases, one retrieves a value while the other does not. This demonstrates that Context keys use strict type comparison. I will discuss this topic further in the future, but if you’re interested, you can check out this document: Go Comparison Operators.
To ensure consistency when storing and retrieving values from Context, it is common practice to define keys as constants, often provided through an SDK. A simplified approach looks like this:
[root@liqiang.io]# cat example1.gofunc businessCode(ctx context.Context) {val := ctx.Value(sdk.CtxKeyHello)if val == nil{fmt.Println("value not found in context")} else {fmt.Println("value found in context ", val.(string))}}
Some developers prefer encapsulating Context operations within an interface, such as:
[root@liqiang.io]# cat example2.goimport "context"type ContextOperator interface {WithHello(ctx context.Context, value interface{})GetHello(ctx context.Context)}
This hides implementation details from users and prevents them from handling Context keys directly, but it is usually used in SDK implementations.
In concurrent programming, a common issue is managing concurrency. Suppose we start multiple goroutines to perform a task, but if any of them fail, we may want the others to terminate early. Context can help us manage this, as shown in the following example:
[root@liqiang.io]# cat main.gofunc main() {ctx, cancel := context.WithCancel(context.Background())wg := sync.WaitGroup{}wg.Add(3)go func() {defer wg.Done()println("goroutine 1 start")<-ctx.Done()println("goroutine 1 done")}()go func() {defer wg.Done()println("goroutine 2 start")<-ctx.Done()println("goroutine 2 done")}()go func() {defer wg.Done()println("goroutine 3 start")time.Sleep(1 * time.Second)cancel()println("goroutine 3 fail, canceled")}()wg.Wait()println("main done")}
Here, three goroutines are started. The third one calls cancel() to cancel the context, notifying the first two to terminate early.
You might have some questions about how Context works internally. For example:
Cancel Contexts are nested, does canceling one affect all?To answer these questions, we need to examine the source code.
[root@liqiang.io]# cat context.gofunc withCancel(parent Context) *cancelCtx {if parent == nil {panic("cannot create context from nil parent")}c := &cancelCtx{}c.propagateCancel(parent, c)return c}
A cancelCtx is structured as a tree:
| Diagram: Context Tree Structure |
|---|
|
| Image source: Understanding Context Package in Golang |
[root@liqiang.io]# cat context.gotype timerCtx struct {cancelCtxtimer *time.Timerdeadline time.Time}
[root@liqiang.io]# cat context.gotype valueCtx struct {Contextkey, val any}func WithValue(parent Context, key, val any) Context {return &valueCtx{parent, key, val}}
The valueCtx implementation shows that retrieving values from a Go Context involves traversing a linked list, making it inefficient. Therefore, storing key-value pairs in Context should be minimized.
Contexts are useful within a single process, but what if we need to transmit them across processes, such as in RPC calls? We need to:
This article explores different approaches to implementing cross-process Context transmission in Go, including timeout handling, cancellation, and best practices.
There are two common approaches:
Selective transmission can be implemented in various ways. One method is using Getter and Setter functions to control which key-value pairs are passed, ensuring consistency and preventing arbitrary modifications. However, this approach requires version updates when new keys need to be supported.
In the open-source RPC framework Kitex, a special key is used to store and pass key-value pairs. This simplifies transmission but can cause issues with service governance, such as key conflicts and length restrictions.
After deciding which key-value pairs to transmit, we need to consider how to transmit them. The implementation varies depending on the protocol:
A common approach is to include metadata in an RPC metadata structure. For example, Kitex uses TTHeader for this purpose. However, different companies implement it in different ways:
When receiving a cross-process message, we must parse the message and extract the transmitted key-value pairs. The method used for parsing is closely tied to how the key-value pairs were transmitted, so the choice of transmission approach directly determines the parsing method. This step does not offer much flexibility and is largely dictated by the first two steps.
After introducing so much, let’s take an example using the Context Operator and assume that we are passing context between two HTTP services. I’ll introduce a simple implementation. First, my Context Operator looks like this:
[root@liqiang.io]# cat example2.gotype ContextOperator interface {WithHello(ctx context.Context, value interface{})WithWorld(ctx context.Context, value interface{})GetHello(ctx context.Context)GetWorld(ctx context.Context)Marshal() ([]byte, error)Unmarshal([]byte) error}
When the Client Service is ready to send a request, it needs a middleware to perform context marshaling. The code might look like this:
[root@liqiang.io]# cat example2.goconst contextHeader = "liqiang-io-context"type Transport struct {rt http.RoundTripperco ContextOperator}func (t *Transport) RoundTrip(r *http.Request) (*http.Response, error) {ctx, err := t.co.Marshal(r.Context())if err == nil {r.Header.Add(contextHeader, string(ctx))}return http.DefaultTransport.RoundTrip(r)}
Here, the Context value is placed in the HTTP request and sent out. The Marshal implementation can be very simple, just string concatenation:
[root@liqiang.io]# cat example2.gofunc (o *ctxOp) Marshal(ctx context.Context) ([]byte, error) {var rtns []stringif hello := o.GetHello(ctx); hello != nil {rtns = append(rtns, "hello="+*hello)}if world := o.GetWorld(ctx); world != nil {rtns = append(rtns, "world="+*world)}return []byte(strings.Join(rtns, string([]rune{0}))), nil}
On the server side, after receiving the HTTP request, we perform the opposite operation by wrapping the HTTP Handler:
[root@liqiang.io]# cat example2.gotype wrapperHandler struct {h http.Handlerco ContextOperator}func (h *wrapperHandler) ServeHTTP(resp http.ResponseWriter, req *http.Request) {ctxVal := req.Header.Get(contextHeader)ctx, err := h.co.Unmarshal([]byte(ctxVal))if err == nil {req = req.WithContext(ctx)}h.h.ServeHTTP(resp, req)}func NewWrapperHandler(h http.Handler) http.Handler {return &wrapperHandler{h: h,}}
Similarly, the Unmarshal operation is simply string decomposition:
[root@liqiang.io]# cat example2.gofunc (o *ctxOp) Unmarshal(content []byte) (context.Context, error) {vals := strings.Split(string(content), string([]rune{0}))ctx := context.Background()for _, val := range vals {switch val {case "hello":ctx = o.WithHello(ctx, val)case "world":ctx = o.WithWorld(ctx, val)}}return ctx, nil}
At first glance, the above example seems to have implemented context passing. However, in reality, it does not cover the full capabilities of Go’s context, especially its most commonly used features: WithTimeout and WithCancel.
The WithCancel function might seem straightforward—it cancels a cross-process request. However, in practice, it’s more complex than expected because Go’s default networking library does not support context.
Let’s look at the net.Conn interface:
[root@liqiang.io]# cat net/net.gotype Conn interface {Read(b []byte) (n int, err error)Write(b []byte) (n int, err error)Close() error}
As we can see, there’s no way to control read or write using context. So when passing a Cancel across processes, it’s not feasible with the default networking interface. Because of this limitation, developers have found some workarounds. Below are two common patterns:
Since goroutines are lightweight in Go, one common approach is:
[root@liqiang.io]# cat example3.goimport "context"func passCancel1(ctx context.Context) {var networkDone chan struct{}go func() {// network operation 1networkDone <- struct{}{}}()select {case <-ctx.Done():// canceledcase <-networkDone:// completed normally}return}
However, this approach has a risk of goroutine leaks, so careful handling is required.
Another approach is to set a very short timeout for network operations. If the operation completes before cancellation, it’s normal; otherwise, it’s considered canceled.
[root@liqiang.io]# cat example4.gofunc networkProcess(ctx context.Context) {for {conn.SetReadDeadline(time.Now().Add(time.Microsecond * 20))readBytes, err := conn.Read(buffer)if err != nil {return}if readBytes > 0 {return}select {case <-ctx.Done():default:continue}}}
This method has an obvious drawback—it wastes a lot of CPU resources. The fundamental issue is that network operations don’t recognize Cancel Context. Therefore, in practice, passing cancelable contexts between processes is rare, while timeout usage is more common.
Passing a Timeout Context is similar to passing context values, but with a key difference: when process A passes the timeout to process B, process B must be able to apply the timeout correctly.
In the in-process context implementation, timeoutCtx is essentially a Deadline Context. Let’s look at how Deadline Context is implemented:
[root@liqiang.io]# cat context/context.gofunc WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {... ...dur := time.Until(d)... ...if c.err == nil {c.timer = time.AfterFunc(dur, func() {c.cancel(true, DeadlineExceeded)})}return c, func() { c.cancel(true, Canceled) }}
We can see that Deadline is implemented using a timer that automatically cancels the context when it expires.
At first glance, it seems simple—just pass the deadline value to the remote process, like this:
[root@liqiang.io]# cat exmaple2.gofunc (o *ctxOp) Marshal(ctx context.Context) ([]byte, error) {var rtns []stringif hello := o.GetHello(ctx); hello != nil {rtns = append(rtns, "hello="+*hello)}if world := o.GetWorld(ctx); world != nil {rtns = append(rtns, "world="+*world)}if dl, ok := ctx.Deadline(); ok {rtns = append(rtns, "deadline="+strconv.FormatInt(dl.UnixMilli(), 10))}return []byte(strings.Join(rtns, string([]rune{0}))), nil}func (o *ctxOp) Unmarshal(content []byte) (context.Context, context.CancelFunc, error) {vals := strings.Split(string(content), string([]rune{0}))ctx := context.Background()var cancel context.CancelFuncfor _, val := range vals {switch val {case "hello":ctx = o.WithHello(ctx, val)case "world":ctx = o.WithWorld(ctx, val)case "deadline":i, err := strconv.ParseInt(val, 10, 64)if err == nil {t := time.Unix(i/1e9, i%1e9)ctx, cancel = context.WithDeadline(ctx, t)}}}return ctx, cancel, nil}
However, this approach has a major issue in a distributed environment: it relies on UNIX timestamps, which means both machines must be perfectly time-synchronized. Otherwise, the timeout might trigger too early or too late.
A better approach is to pass relative time. For example, if process A sets an initial timeout of 5000ms and has already used 500ms, then it should pass 4500ms to process B. To account for network latency, process A might extend its wait time slightly (e.g., 4550ms instead of 4500ms).
Regarding Go’s Context, I have only written one article: Printing All Context Values in Go, and that was quite a while ago. Over time, through continuous use of Go, I have gained a deeper understanding of Context. I wanted to expand on this topic, but modifying the previous article didn’t seem appropriate, so I decided to write a new one in a more systematic way.
First, I learned that Context was not introduced at the inception of Go; it was added in Go 1.7. The motivation behind its addition was to solve issues related to cross-goroutine collaboration and information passing. This is reflected in its interface, which includes the following functions:
func WithValue(parent Context, key, val any) Contextfunc WithCancel(parent Context) (ctx Context, cancel CancelFunc)func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
These interfaces highlight the primary uses of Context. Below, I provide examples to illustrate their application.
Here is a simple example of passing information through Context:
[root@liqiang.io]# cat example0.gofunc main() {type favContextKey stringf := func(ctx context.Context, k interface{}) {if v := ctx.Value(k); v != nil {fmt.Println("found value:", v)return}fmt.Println("key not found:", k)}k := favContextKey("language")ctx := context.WithValue(context.Background(), k, "Go")f(ctx, k)f(ctx, "language")}
The output of this example is:
[root@liqiang.io]# go run example0.gofound value: Gokey not found: language
From the output, you can see that although the key “language” is used in both cases, one retrieves a value while the other does not. This demonstrates that Context keys use strict type comparison. I will discuss this topic further in the future, but if you’re interested, you can check out this document: Go Comparison Operators.
To ensure consistency when storing and retrieving values from Context, it is common practice to define keys as constants, often provided through an SDK. A simplified approach looks like this:
[root@liqiang.io]# cat example1.gofunc businessCode(ctx context.Context) {val := ctx.Value(sdk.CtxKeyHello)if val == nil{fmt.Println("value not found in context")} else {fmt.Println("value found in context ", val.(string))}}
Some developers prefer encapsulating Context operations within an interface, such as:
[root@liqiang.io]# cat example2.goimport "context"type ContextOperator interface {WithHello(ctx context.Context, value interface{})GetHello(ctx context.Context)}
This hides implementation details from users and prevents them from handling Context keys directly, but it is usually used in SDK implementations.
In concurrent programming, a common issue is managing concurrency. Suppose we start multiple goroutines to perform a task, but if any of them fail, we may want the others to terminate early. Context can help us manage this, as shown in the following example:
[root@liqiang.io]# cat main.gofunc main() {ctx, cancel := context.WithCancel(context.Background())wg := sync.WaitGroup{}wg.Add(3)go func() {defer wg.Done()println("goroutine 1 start")<-ctx.Done()println("goroutine 1 done")}()go func() {defer wg.Done()println("goroutine 2 start")<-ctx.Done()println("goroutine 2 done")}()go func() {defer wg.Done()println("goroutine 3 start")time.Sleep(1 * time.Second)cancel()println("goroutine 3 fail, canceled")}()wg.Wait()println("main done")}
Here, three goroutines are started. The third one calls cancel() to cancel the context, notifying the first two to terminate early.
You might have some questions about how Context works internally. For example:
Cancel Contexts are nested, does canceling one affect all?To answer these questions, we need to examine the source code.
[root@liqiang.io]# cat context.gofunc withCancel(parent Context) *cancelCtx {if parent == nil {panic("cannot create context from nil parent")}c := &cancelCtx{}c.propagateCancel(parent, c)return c}
A cancelCtx is structured as a tree:
| Diagram: Context Tree Structure |
|---|
|
| Image source: Understanding Context Package in Golang |
[root@liqiang.io]# cat context.gotype timerCtx struct {cancelCtxtimer *time.Timerdeadline time.Time}
[root@liqiang.io]# cat context.gotype valueCtx struct {Contextkey, val any}func WithValue(parent Context, key, val any) Context {return &valueCtx{parent, key, val}}
The valueCtx implementation shows that retrieving values from a Go Context involves traversing a linked list, making it inefficient. Therefore, storing key-value pairs in Context should be minimized.
Contexts are useful within a single process, but what if we need to transmit them across processes, such as in RPC calls? We need to:
This article explores different approaches to implementing cross-process Context transmission in Go, including timeout handling, cancellation, and best practices.
There are two common approaches:
Selective transmission can be implemented in various ways. One method is using Getter and Setter functions to control which key-value pairs are passed, ensuring consistency and preventing arbitrary modifications. However, this approach requires version updates when new keys need to be supported.
In the open-source RPC framework Kitex, a special key is used to store and pass key-value pairs. This simplifies transmission but can cause issues with service governance, such as key conflicts and length restrictions.
After deciding which key-value pairs to transmit, we need to consider how to transmit them. The implementation varies depending on the protocol:
A common approach is to include metadata in an RPC metadata structure. For example, Kitex uses TTHeader for this purpose. However, different companies implement it in different ways:
When receiving a cross-process message, we must parse the message and extract the transmitted key-value pairs. The method used for parsing is closely tied to how the key-value pairs were transmitted, so the choice of transmission approach directly determines the parsing method. This step does not offer much flexibility and is largely dictated by the first two steps.
After introducing so much, let’s take an example using the Context Operator and assume that we are passing context between two HTTP services. I’ll introduce a simple implementation. First, my Context Operator looks like this:
[root@liqiang.io]# cat example2.gotype ContextOperator interface {WithHello(ctx context.Context, value interface{})WithWorld(ctx context.Context, value interface{})GetHello(ctx context.Context)GetWorld(ctx context.Context)Marshal() ([]byte, error)Unmarshal([]byte) error}
When the Client Service is ready to send a request, it needs a middleware to perform context marshaling. The code might look like this:
[root@liqiang.io]# cat example2.goconst contextHeader = "liqiang-io-context"type Transport struct {rt http.RoundTripperco ContextOperator}func (t *Transport) RoundTrip(r *http.Request) (*http.Response, error) {ctx, err := t.co.Marshal(r.Context())if err == nil {r.Header.Add(contextHeader, string(ctx))}return http.DefaultTransport.RoundTrip(r)}
Here, the Context value is placed in the HTTP request and sent out. The Marshal implementation can be very simple, just string concatenation:
[root@liqiang.io]# cat example2.gofunc (o *ctxOp) Marshal(ctx context.Context) ([]byte, error) {var rtns []stringif hello := o.GetHello(ctx); hello != nil {rtns = append(rtns, "hello="+*hello)}if world := o.GetWorld(ctx); world != nil {rtns = append(rtns, "world="+*world)}return []byte(strings.Join(rtns, string([]rune{0}))), nil}
On the server side, after receiving the HTTP request, we perform the opposite operation by wrapping the HTTP Handler:
[root@liqiang.io]# cat example2.gotype wrapperHandler struct {h http.Handlerco ContextOperator}func (h *wrapperHandler) ServeHTTP(resp http.ResponseWriter, req *http.Request) {ctxVal := req.Header.Get(contextHeader)ctx, err := h.co.Unmarshal([]byte(ctxVal))if err == nil {req = req.WithContext(ctx)}h.h.ServeHTTP(resp, req)}func NewWrapperHandler(h http.Handler) http.Handler {return &wrapperHandler{h: h,}}
Similarly, the Unmarshal operation is simply string decomposition:
[root@liqiang.io]# cat example2.gofunc (o *ctxOp) Unmarshal(content []byte) (context.Context, error) {vals := strings.Split(string(content), string([]rune{0}))ctx := context.Background()for _, val := range vals {switch val {case "hello":ctx = o.WithHello(ctx, val)case "world":ctx = o.WithWorld(ctx, val)}}return ctx, nil}
At first glance, the above example seems to have implemented context passing. However, in reality, it does not cover the full capabilities of Go’s context, especially its most commonly used features: WithTimeout and WithCancel.
The WithCancel function might seem straightforward—it cancels a cross-process request. However, in practice, it’s more complex than expected because Go’s default networking library does not support context.
Let’s look at the net.Conn interface:
[root@liqiang.io]# cat net/net.gotype Conn interface {Read(b []byte) (n int, err error)Write(b []byte) (n int, err error)Close() error}
As we can see, there’s no way to control read or write using context. So when passing a Cancel across processes, it’s not feasible with the default networking interface. Because of this limitation, developers have found some workarounds. Below are two common patterns:
Since goroutines are lightweight in Go, one common approach is:
[root@liqiang.io]# cat example3.goimport "context"func passCancel1(ctx context.Context) {var networkDone chan struct{}go func() {// network operation 1networkDone <- struct{}{}}()select {case <-ctx.Done():// canceledcase <-networkDone:// completed normally}return}
However, this approach has a risk of goroutine leaks, so careful handling is required.
Another approach is to set a very short timeout for network operations. If the operation completes before cancellation, it’s normal; otherwise, it’s considered canceled.
[root@liqiang.io]# cat example4.gofunc networkProcess(ctx context.Context) {for {conn.SetReadDeadline(time.Now().Add(time.Microsecond * 20))readBytes, err := conn.Read(buffer)if err != nil {return}if readBytes > 0 {return}select {case <-ctx.Done():default:continue}}}
This method has an obvious drawback—it wastes a lot of CPU resources. The fundamental issue is that network operations don’t recognize Cancel Context. Therefore, in practice, passing cancelable contexts between processes is rare, while timeout usage is more common.
Passing a Timeout Context is similar to passing context values, but with a key difference: when process A passes the timeout to process B, process B must be able to apply the timeout correctly.
In the in-process context implementation, timeoutCtx is essentially a Deadline Context. Let’s look at how Deadline Context is implemented:
[root@liqiang.io]# cat context/context.gofunc WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {... ...dur := time.Until(d)... ...if c.err == nil {c.timer = time.AfterFunc(dur, func() {c.cancel(true, DeadlineExceeded)})}return c, func() { c.cancel(true, Canceled) }}
We can see that Deadline is implemented using a timer that automatically cancels the context when it expires.
At first glance, it seems simple—just pass the deadline value to the remote process, like this:
[root@liqiang.io]# cat exmaple2.gofunc (o *ctxOp) Marshal(ctx context.Context) ([]byte, error) {var rtns []stringif hello := o.GetHello(ctx); hello != nil {rtns = append(rtns, "hello="+*hello)}if world := o.GetWorld(ctx); world != nil {rtns = append(rtns, "world="+*world)}if dl, ok := ctx.Deadline(); ok {rtns = append(rtns, "deadline="+strconv.FormatInt(dl.UnixMilli(), 10))}return []byte(strings.Join(rtns, string([]rune{0}))), nil}func (o *ctxOp) Unmarshal(content []byte) (context.Context, context.CancelFunc, error) {vals := strings.Split(string(content), string([]rune{0}))ctx := context.Background()var cancel context.CancelFuncfor _, val := range vals {switch val {case "hello":ctx = o.WithHello(ctx, val)case "world":ctx = o.WithWorld(ctx, val)case "deadline":i, err := strconv.ParseInt(val, 10, 64)if err == nil {t := time.Unix(i/1e9, i%1e9)ctx, cancel = context.WithDeadline(ctx, t)}}}return ctx, cancel, nil}
However, this approach has a major issue in a distributed environment: it relies on UNIX timestamps, which means both machines must be perfectly time-synchronized. Otherwise, the timeout might trigger too early or too late.
A better approach is to pass relative time. For example, if process A sets an initial timeout of 5000ms and has already used 500ms, then it should pass 4500ms to process B. To account for network latency, process A might extend its wait time slightly (e.g., 4550ms instead of 4500ms).