Could we trade all the `ctx context.Context` arguments for one pointer in `g`?

518 views
Skip to first unread message

Sam Vilain

unread,
Feb 16, 2024, 4:43:37 PMFeb 16
to golang-nuts

Hi all,

Many moons ago I wrote a proposal to make execution context a fundamental concept in Go, effectively moving `context.Context` to being part of the language, not just an extension.

The main reason to do this is that then it would not be up to the source maintainer to have to do the refactoring it takes to retrofit `ctx context.Context` arguments passed to functions as the first position, as has become the common boilerplate.  In some places, this is impossible: for instance, the io.Reader and related interfaces forbid a `context.Context` argument, so a reader cannot know where it is being read from.

Why does that matter, you might ask?  The main reason is for observability: tracing, logging, debugging, etc.  Of course, context should in general not be used to alter behavior, but for observability, it seems like it would be fair game for the append–only map that `Context` provides, to be available even when working with less than stellar code.

I'm wondering how people would feel about this now?

A summary of my original proposal would be to add a "soft" keyword ("context") which is only meaningful when used before "var", but would not be prohibited from being a variable name.  I did some experimentation with the go parser and found I could get code with 'context' added to is passing the parsing tests, so I'm pretty sure this would work without slowing things down.  That's the syntax I'll use in this message, but of course this is subject to feedback and taste.

Some basic principles stated as text for discussion; there are code examples in the original proposal from August 2017.

  • Semantically, creating a new scope creates a new execution context.  Hopefully that is a tautology.
  • Context Variables can be declared in any execution context, using `context var` instead of plain `var`, and assuming the `context var` is in scope of the observer, to all execution scopes created from that execution context, including goroutines.
  • The reverse is not true: the only way you can see changes to a variable declared in a high level context, is if the variable has a pointer, and the sub–level context changes it (i.e., there's no "magic const effect" of declaring a variable with context var.)
  • Functions that don't declare context variables use the same context as their caller for retrieving context variables.
    • Declaring it without assigning it in a function allows you to read it, without changing the context.  Like other variables declared without an assignment, reading it from a context with no parent context that has assigned it would reveal the zero value.
    • You can refer to the old value in the rvalue of the declaration, or at any point from when you declare it to when you assign it within a function.  However, having an assignment in the scope should be thought of creating a new context immediately with an implicit assignment at the declaration.  I think this behavior keeps the semantics as unambiguous as possible, and avoids having to do funny stuff when assigning the variable late in a function.
  • Scope would work differently.  While the state of the value follows the execution context, the visibility of the variable would be defined by (a) the package the variable is declared in, and (b) the Case of the variable.
    • `context var localInfo` can only be read and written in the package it is declared in.
    • While reading the rest of this, be careful not to think of the Monty Python Spam song, as this is about Spans, which are not the same.
    • `context var OpenSpan Span` declares an exported context variable, which can be accessed as below:
    • declaring `context var tracing.OpenSpan` would mean to access the `OpenSpan` context variable exported by the `tracing` package.  This would only be a compile error if the `tracing` package has no `context var OpenSpan x` statement in it.
      • Simply reading the context variable from outside the package might not need a special syntax: `(tracing.OpenSpan)` to read it might work just fine, to allow eg:
        `tracing.OpenSpan.Log("happening", tracing.Tag("id", id))`
    • Finally, `context var tracing.OpenSpan = tracing.OpenSpan.New("canBeSlowFunc", tracing.InternalKind, ...)` is an example of how you could work with the exported context variables from a package.  The example there is an idiomatic way for a function to declare that it is doing something that can be expensive, and so is worthy of tracing representation with a new Span.  `Span.New()` would be a function that returned a new sub–span of the original Span.

Anyway I think that's the nutshell of it.  Thoughts/questions/concerns?

Cheers,
Sam

Axel Wagner

unread,
Feb 16, 2024, 5:43:56 PMFeb 16
to Sam Vilain, golang-nuts
FWIW the common name for this is "dynamic scope": https://en.wikipedia.org/wiki/Scope_(computer_science)#Dynamic_scope

The general reason this has not been accepted for Go, is that passing a Context explicitly removes ambiguities what is meant, in the presence of closures and goroutines.
If you pass a closure to a different execution context (e.g. via a channel), it has two bindings for any dynamically scoped variable: One in the source stack and one in the destination stack. Neither of these seems more clearly correct than the other. Note, for example, that a callback could be called from a different goroutine altogether, yet close over the lexical scope of the function it's defined in.
Goroutines also open some questions on how to efficiently implement this without races. To be fair, Context has the same issue, but by being a library type, it's used more sparingly, so the performance question is less critical.

Ultimately, I don't think a lot has changed about this question over the years.

--
You received this message because you are subscribed to the Google Groups "golang-nuts" group.
To unsubscribe from this group and stop receiving emails from it, send an email to golang-nuts...@googlegroups.com.
To view this discussion on the web visit https://groups.google.com/d/msgid/golang-nuts/24843040-a0f2-4054-8912-acf9defb7697%40vilain.net.

Sam Vilain

unread,
Feb 16, 2024, 8:09:09 PMFeb 16
to golan...@googlegroups.com

Hey Axel, thanks for the response.

The general reason this has not been accepted for Go, is that passing a Context explicitly removes ambiguities what is meant, in the presence of closures and goroutines.
If you pass a closure to a different execution context (e.g. via a channel), it has two bindings for any dynamically scoped variable: One in the source stack and one in the destination stack. Neither of these seems more clearly correct than the other. Note, for example, that a callback could be called from a different goroutine altogether, yet close over the lexical scope of the function it's defined in.

It's an interesting question, however we don't need to use goroutines and channels to evoke this issue.  Closures are enough.  That is, if you make a closure in one function, save it somewhere, and then invoke it from a second location, then the closure has two potential scopes that could apply.

I would argue that the matter can be simply decided by choosing the calling stack, not the destination stack.  Choosing the destination stack would mean that the closure no longer really closes over its calling context.  It is also possible, where semantics require, to pass in specific context variables by value to the closure, and that closure can set them in its immediate (and any potential lower) scopes as needed.

If you want the opposite; context carries from the caller of the func, instead return a bound method call.  The bound method call can have an identical type to a closure, and logically it is the same thing, where the struct is the "closure" of state, but it does not restore the context register when invoked.  This seems like a clear way to choose you want this func to use the destination stack.

Goroutines also open some questions on how to efficiently implement this without races. To be fair, Context has the same issue, but by being a library type, it's used more sparingly, so the performance question is less critical.

I don't believe the data structure in Context presents any race conditions, and I also don't believe it's necessary for it to remain a deep linked list to retain that quality.

For instance, Context could create (static) map[any]any data structures for the subsequent contexts it spawns, eg it might decide to do this if the number of new contexts that have been created from it exceeds the "depth" to the next context with a "flattened" map by some threshold.  So if it starts to get deep for a context that is creating a bunch of new contexts, it creates a flattened map and then switches an internal flag.  This can be race–free.  Would an implementation of this help move the needle on this, in your view?

Cheers & Happy Friday,
Sam.


Axel Wagner

unread,
Feb 17, 2024, 1:32:58 AMFeb 17
to Sam Vilain, golan...@googlegroups.com
On Sat, Feb 17, 2024 at 2:09 AM Sam Vilain <s...@vilain.net> wrote:
I would argue that the matter can be simply decided by choosing the calling stack, not the destination stack.
 
I agree that this is *one choice*. But the point is, that *sometimes* you'd want one and *sometimes* the other. And no matter which choice you make for the language - it means that if the programmers wanted the other, they'd have to jump through annoying hoops and get confusing and hard to debug problems. So if you want to justify either choice, you have to make an argument that it is so overwhelmingly more common what people would want, that the cost of running into these problems is small enough to be justified by the benefit.

I think that's a hard case to make.

Sam Vilain

unread,
Feb 20, 2024, 2:18:44 PMFeb 20
to Axel Wagner, golan...@googlegroups.com

Alex, I agree that there are cases where you might prefer one versus the other.  However, you cut out the part of my reply where I pointed out it was possible to choose semantics by either returning a closure (context is the source stack) or a bound method (context is the destination stack).  Both of these values can be used interchangeably, as they have the same type, func ..., and so the caller does not need to care whether the function they are calling uses the calling context or the original context.  Were you not convinced by the argument?

Sam

Axel Wagner

unread,
Feb 20, 2024, 3:36:22 PMFeb 20
to Sam Vilain, golan...@googlegroups.com
If I may quote myself:

> And no matter which choice you make for the language - it means that if the programmers wanted the other, they'd have to jump through annoying hoops and get confusing and hard to debug problems.

Having a mechanism to get one or the other semantic doesn't change the fact that it's easy to choose wrongly by accident, as long as the effect is implicit. In fact, the mechanism you propose (AIUI) seems extra confusing: Having a function value sometimes create a new dynamic scope and sometimes not, is weird and seems like a recipe for frustration.

But really, convincing me isn't really the point, which is why I'm not super invested in litigating this (otherwise I might try to come up with realistic examples, for instance. Or explain further why I'm still not sure that this can be implemented efficiently). I'm just re-stating what, in the past, where the reasons why things like this have been rejected. In order to predict what I would consider a likely outcome of a proposal like this.

If you think I am wrong or misunderstanding you, you can always file a proposal to get a more official response.

Sam Vilain

unread,
Feb 20, 2024, 7:37:05 PMFeb 20
to Axel Wagner, golan...@googlegroups.com

Alright, well thanks for your input.

I do think these questions can be answered; exploring the use cases in a proposal format should hopefully show that the impact of closures would not normally be an issue.  Possibly the worst case is if you had a library to some external service, and at some low layer you're using a closure, well you might need to refactor that if you wanted to add cross–service tracing support.  But to be honest, it's probably better that the state you're depending on is captured in a struct instead of being scattered about loosely in a scope as with a closure.  And the common practice in Go is to return types that satisfy interfaces, not function references that you blind call.

I think I will submit a proposal, but please don't take that to imply that I think you're "wrong, or mistaken".  Your concerns are legitimate and the proposal should answer them cleanly.  In the proposal format, hopefully the "litigation", or more exploration of possible uses and abuses of the system, along with performance concerns, can be addressed.  I don't want to be dismissive of them, I just want to understand them.

I had a brief look on the Golang issues in Github and could not find any prior proposals along this line using "context" and "dynamic scope" as search terms, so I'll submit this as a "new" proposal for now.

Thanks again, and truly—thanks for responding, >100% better than people who just rolled eyes and marked thread as read.

Cheers,
Sam

-- 
Sam

Robert Engels

unread,
Feb 20, 2024, 8:04:35 PMFeb 20
to Sam Vilain, Axel Wagner, golan...@googlegroups.com
FWIW, I think having a bound context to an execution context is a valuable addition. One thing about Go that has always felt lacking is dynamic code loading execution. There is the plugin facility but it doesn’t seem to have been widely adopted. If it were, I think the Go team would find it needs a security model closer to that of Java - and being able to bind to the thread of execution and inherit there is critical to this working well. 

So I welcome your proposal. 

On Feb 20, 2024, at 6:37 PM, Sam Vilain <s...@vilain.net> wrote:


--
You received this message because you are subscribed to the Google Groups "golang-nuts" group.
To unsubscribe from this group and stop receiving emails from it, send an email to golang-nuts...@googlegroups.com.

TheDiveO

unread,
Feb 21, 2024, 2:34:05 AMFeb 21
to golang-nuts
Forgive me if I missed that, but what if I have multiple context vars, because I need to pass different (derived) contexts into different functions/receivers? Take unit tests as real-world examples.

Sean Liao

unread,
Feb 21, 2024, 5:33:49 AMFeb 21
to golang-nuts

--
You received this message because you are subscribed to the Google Groups "golang-nuts" group.
To unsubscribe from this group and stop receiving emails from it, send an email to golang-nuts...@googlegroups.com.

Robert Engels

unread,
Feb 21, 2024, 7:36:51 AMFeb 21
to Sean Liao, golang-nuts
That is a good reason as to why putting timeouts and cancellation in the “context” always felt wrong to me. These are per request notions - and creating a new context to specify them seems off. But as I mentioned in the other post, without a concept of security context it doesn’t matter much - just throw anything and everything into the context and pass it around. 

On Feb 21, 2024, at 4:33 AM, 'Sean Liao' via golang-nuts <golan...@googlegroups.com> wrote:



Axel Wagner

unread,
Feb 21, 2024, 9:47:31 AMFeb 21
to Sam Vilain, golan...@googlegroups.com
On Wed, Feb 21, 2024 at 1:36 AM Sam Vilain <s...@vilain.net> wrote:
I had a brief look on the Golang issues in Github and could not find any prior proposals along this line using "context" and "dynamic scope" as search terms, so I'll submit this as a "new" proposal for now
 
FWIW some prior discussion on this list:

https://groups.google.com/g/golang-nuts/c/eEDlXAVW9vU (we both participated in it, so I assume you're aware of this one)

Alex Efros

unread,
Feb 22, 2024, 4:21:56 AMFeb 22
to golan...@googlegroups.com
Hi!

One more thing to keep in mind for the proposal: sometimes we need to merge two contexts.
https://github.com/golang/go/issues/36503
https://github.com/golang/go/issues/57928

--
WBR, Alex.

Sam Vilain

unread,
Feb 28, 2024, 1:50:56 PMFeb 28
to golan...@googlegroups.com, TheDiveO

Hey, thanks for raising this important use case.  Do you have any specific examples in mind you could point to?

I think the answer should follow easily from what defines a new scope.  New scopes get their own mini-stack frame created, and in that frame the context pointer can be saved like any other virtual register or 'g' property.  Of course, you know that an if block has its own scope inside it, and variables declared inside the if blocks are not in scope outside of that block.

You can create a new scope without an if statement, like this:

func ScopeTest() {
    var varname = "outer"

    {
        var varname = "inner"
        fmt.Printf("varname = %s\n", varname)
    }
    fmt.Printf("varname = %s\n", varname)
}

This prints:

varname = inner
varname = outer

It should follow that context variables declared in a block like that would have their state unwound when exiting the block.

Of course, any change in the way that statements are written brings with it change, resistance to change, and so on, so if you feel like this is jumping through awkward hoops, of course I can understand that position.  I feel it myself when I consider language changes a lot of the time.  The way I like to look past that is to make sure is that real-world examples don't end up contrived.  It should be clear what is going on when written in the way it needs to be written to function as before, and that includes concerns around backwards compatibility.

So back to the initial question: can you give an example of somewhere that a unit test uses context in a way that you predict this proposal will make problematic?  I'd love to explore it to make sure, to make sure the proposal has lots of good examples.

Thanks,
Sam

--
You received this message because you are subscribed to the Google Groups "golang-nuts" group.
To unsubscribe from this group and stop receiving emails from it, send an email to golang-nuts...@googlegroups.com.

TheDiveO

unread,
Feb 29, 2024, 6:18:26 AMFeb 29
to golang-nuts


Hopefully these two examples might shed some more light for you on a certain class of usages.

Sam Vilain

unread,
Feb 29, 2024, 3:15:21 PMFeb 29
to golan...@googlegroups.com
Thanks for your examples!  I'll cover them in reverse order, to build up the syntax.

On 2/29/24 6:18 AM, 'TheDiveO' via golang-nuts wrote:

This one uses the `context.Done()` / `context.Err()` API:

    ctx, cancel := context.WithCancel(ctx)
    defer cancel()

In general, if you're re-assigning the new context to the same variable, then it can be directly replaced with setting the context variable.  I reason that scope cancellation should be a sync property, so you would write (in the cancelable function):

for {
    select {
        case <-sync.Canceled:
            break
        case <-...
    }
}

OK?  So, assuming that, then it follows that setting context var sync.Canceled (and watching it) is the way to make the current scope cancelable (using the previous described syntax):

    context var sync.Canceled
    var cancelEvents func()
    sync.Canceled, cancelEvents = sync.CancelChan()

Obviously, this seems slightly awkward, but it's syntax and this can be fixed.  In fact as I argue on the latest version of the proposal on my github, that given that there needs to be a path for backwards compatibility.  It should be possible for new code to rely on `sync.Canceled` (or `sync.Cancelled`?) without requiring all callers to have moved away from contextGiven there needs to be way to allow a regular call to `context.WithCancel()` to update the context variable in the caller, and given that the "context register" has callee-save semantics, it should follow that functions that don't restore the pointer could exist.  And given it's a property of the function, not the caller, that's where the marker should live.

I landed on `context func ScopeCanceler()`.  This is in line with  `context var variable SomeType`, and it's also a big warning up front that the function does something special, worthy of your understanding before you just go and call it.  It's also a hint to folk that it's a special type of function, not to be used everywhere like `ctx context.Context` was.  Of course, perhaps the marker ends up on the right with the return values, but like I say—it's syntax, and this can be decided later (especially if I got it wrong and this syntax makes parsing slower).

I expect we would end up with this:
cancelFunc := sync.ScopeCanceler()
defer cancelFunc()
While your test explicitly calls cancel() at the end and validates that everything shuts down (an excellent practice, often overlooked), many cases would be able to get away without the temporary:
defer sync.ScopeCanceler()
If you were to jump-to-definition on `sync.ScopeCanceler()`, it might look like this:
type CancelFunc func()
type CancelChan <-chan struct{}

context func ScopeCanceler() CancelFunc {
    var cancelChan = make(chan struct{})
    context var Canceled CancelChan = cancelChan
    var guard Once
    return func() {
        guard.Do(func() {
            close(cancelChan)
        })
    }
}
Remember, this is syntax, not semantics, and we need to nail down the semantics first, but this is the working syntax I'm using for the proposal.

So, building on the earlier example, you're asking about this block:

    eventsctx, cancelevents := context.WithCancel(ctx)
   
evs, errs := ww.engine.LifecycleEvents(eventsctx)

Here the difference is that you're assigning the context to a different variable, meaning you can choose which of the variables you use in a given line.

That said, your example happens to do something also quite common: it creates a new context, passes it to a specific function, and then does not use it again.  All this means is that you need to create a new scope, which could be a method, closure, or an explicit scope block, and assign the variables you want to use later to variables that are in the outer scope, and call the function you want to inherit the new context from inside the scope.

For example, using an explicit scope:

    var cancelevents func()
    var evs <-chan ContainerEvent
    var errs <-chan error

    {
        cancelevents = sync.ScopeCanceler()
        evs, errs = ww.engine.LifecycleEvents()
    }

That might look like a lot of annoying variable declarations, and that you'd rather take advantage of type inference.  But if you were to put the code into a method, then you can then use a type-inferring assignment statement, and that would be a more likely and natural refactoring approach for that code.

Hopefully these two examples might shed some more light for you on a certain class of usages.

Yes, of course!  Thanks for your input.

Of course there are contrived cases where multiple contexts are passed to a function, or a function really does a lot of juggling of different contexts for use by different functions.  In the cases I've looked at, I have found that there exists a refactoring that aligns the use of the context with a function, and that the resulting code is cleaner.

Cheers,
Sam

Reply all
Reply to author
Forward
0 new messages