golang / go

The Go programming language
https://go.dev
BSD 3-Clause "New" or "Revised" License
124.13k stars 17.69k forks source link

proposal: Go 2: semi-constant conditionals #56256

Closed oakad closed 1 year ago

oakad commented 2 years ago

There exist a very potent diagnostic technique, long and very successfully used in bigger C language projects (most prominently in Linux Kernel). Unfortunately, managed memory languages seldom implement it even though managed memory makes it rather fail safe, as compared to something like C (it is doable in JVM, of course, but then, almost anything is doable in JVM :-).

In Go, it may look the following:

package foo

func Bar() {
    myLabel: if {
        // seldom used diagnostic code
    }
    // oft used code
}

Here we have an if statement without a condition block. By default, it's simply a jump to the (possibly implicit) "else" code block. However, we may add some sort of API (based on reflect for example) which can be used to "steer" that if:

reflect.ValueOf(foo.Bar).ConstIfByLabel("myLabel").Set(true)

This will make the if statement at myLabel to enter the "then" code block. Setting the controller back to "false" will restore the initial state ("then" code block will be skipped). The label can also be made implicit, auto generated out of code location, thus saving some programmers effort.

The feature is pretty straightforward to implement in the compiler. Indeed, 2 methods can be readily employed:

  1. "Zero idle cost" method can involve code modification (x86_64 is pretty friendly in this regards). Compiler can set aside generated opcodes for "goto myLabel_then" and "goto myLabel_else" which will be injected by the steering code in reflect directly into the "myLabel" location (invoking the necessary mprotect and icache magic as necessary, steering performance being of not much concern).
  2. "Safe" method whereupon compiler automatically creates a unique global scope bool flag and emits a simple conditional branch predicated by that flag. The bool flags are then registered in the special compile time table workable by the steering code in reflect (possibly in dedicated linker section).

It shall be noted, that Linux kernel employs both methods for its various features (the "safe" bool flag method uses a macro and a dedicated linker section).

Why is this useful and what are the alternatives?

When working with highly loaded or otherwise long running network servers we often want to extract some data for diagnostics. Unfortunately, side channel data extraction always carries a rather high performance cost. Even a single extra call to a logging system can have a substantial cost if its parameters are chosen in unfortunate way.

Thus, server developers and operators can really benefit from some mechanism to selectively enable diagnostic code blocks on one by one basis in runtime; the benefit will be even greater if the mechanism for doing so is consistent across the wider Go ecosystem, that is, happens to be part of the language.

Right now, Go doesn't offer any sort of viable alternative to address the problem of selective diagnostics in runtime.

Instead, we are left at the mercy of coarse grained, library level, non standardized global debug switches, which invariably emit either too little or too much data and never play nice with the application code (examples being Go runtime itself, libraries like GRPC and many other). The switches themselves require too much boilerplate and programmer's discipline to use on the library level, may only affect things on start-up and can be rather troublesome in general, both for the implementer and for the library user.

Providing an efficient runtime code steering facility in the core language will substantially improve observability and maintainability of a large number of Go programs.

mvdan commented 2 years ago

Please note that you should fill https://github.com/golang/proposal/blob/master/go2-language-changes.md when proposing a language change.

ianlancetaylor commented 2 years ago

CC @golang/runtime

randall77 commented 2 years ago

What's wrong with

package foo

var debugging = false

func Bar() {
    if debugging {
        // seldom used diagnostic code
    }
    // oft used code
}

(or use a sync/atomic.Bool if you want it multithreaded-safe)

The branch is very predictable, so it isn't that much more expensive than a raw jump.

oakad commented 2 years ago

It's exactly the problem I'm trying to address here. :-)

Even if we look at global flags in particular, they are already out of hand: stuff like GODEBUG or GRPC_TRACE or any other similar feature commonly found. They are difficult to work with for both users and implementers, not consistent across packages, poorly documented, never focus on the problem at hand (a lot of noise), etc, etc.

And it's not going to get any better.

Some 20 years ago Linux Kernel moved to per-location switches for printks and function tracing. Those were exceptionally successful, but depend a lot on fancy macros.

Go has no macros and has no runtime code transforms akin to JVM. Thus, to implement something sane in this department we need compiler help (aka language feature).

ianlancetaylor commented 2 years ago

It seems to me that you are replacing one mechanism that is difficult to work with by another mechanism that is difficult to work with.

Why don't we try to standardize on one of our existing approaches, such as GODEBUG, and make it simpler?

oakad commented 2 years ago

It's time for code snippets. :-)

Let's say we want to be able to look at inputs and outputs of typical handler functions and do so without browsing through a lot of noise. In present day Go, the best approach looks as following:

package some

import "flag_registry"
import "sync/atomic"

// The flag variables have to be int, because we need 3 states: disabled, enabled and "first seen"
// Additionally, we would prefer atomic flags, but typed atomic have no initializers and assigning
// "0" to "first seen" state feels somewhat lame.

var _flagFooIn int32 = 1
var _flagFooOut int32 = 1

func foo() {
    if atomic.LoadInt32(&_flagFooIn) != 0 && flag_registry.Register(&_flagFooIn) {
        // input diagnostics
    }

    // do things

    if atomic.LoadInt32(&_flagFooOut) != 0 && flag_registry.Register(&_flagFooOut) {
        // output diagnostics
    }
}

Whereupon, the flag_registry implements something like:

package flag_registry

func Register(flag *int32) bool {
    flagValue := atomic.LoadInt32(flag)
    if flagValue < 0 {
        return true
    } else if flagValue == 0 {
        panic("Improper use of guard flag")
    }
    // Register new activation location
    pc, file, line, ok := runtime.Caller(0)
    // Add whatever entries to controller map and what not

    atomic.StoreInt32(flag, 0)
    return false
}

This is more or less the best approach right now to have many optional code blocks. It is verbose, typo prone and may need to be repeated many times if there are complex nested handlers.

In a typical C library, this stuff can be packaged as macro, with the actual guard flags having "static" linkage (Go has neither macros nor static vars). Moreover, if we feel like being fancy, C libraries can rather easily utilize a distinct linker section to avoid that flag_registry.Register call - instead, on start up, the distinct section is simply cast into an array and mapped into some convenient control structure.

None of this is possible in Go.

But, it will really make a lot of things easier if it was.

randall77 commented 2 years ago
var flagFooIn atomic.Bool

func init() {
    Register ("flagFooIn", &flagFooIn)
}

func foo() {
    if flagFooIn.Load() {
        // input diagnostics
    }
}
oakad commented 2 years ago

This is actually an inferior solution. Not refactor friendly, not copy-paste resilient, not scalable in the long run (have ample user stories to substantiate). And still a lot of typing for a common pattern. :-)

Basically, I keep claiming the following:

  1. Every big enough project has "disabled by default" code blocks which can be activated via some sort of side channel. This is an easily observable, empirical fact, nothing to even argue about.
  2. Of all the major, concurrency reach languages Go has the worst facilities for implementing this important pattern. This applies the same whether we are talking about a set of manually maintained symbolic flags (some projects go to a surprising depth with those, Wine being a prime example) or some sort of automatic, debug info based labeling (akin to "feature.go:123").

Indeed:

  1. In C/C++ we've got macros, compiler attributes and even inlineable assembly. Harsh for library writers, very powerful for end users.
  2. Rust has AST transforming macros (I don't have enough experience with those, but they surely look very usable)
  3. JVM (the entire family) and .Net have runtime code transformation and developer communities fond of using those (too fond, I'd say - declarative programming with annotations may often go too far).

And only in Go we are forced to keep breaking fingers or keep writing custom code preprocessors (I have several of those :-).

ianlancetaylor commented 2 years ago

If you are including things like C macros, then in Go we have build tags that let us select the default value of some constant at build time.

oakad commented 2 years ago

Of course it has. Have I ever claimed the contrary?

When talking about macros in this context, I'm talking about stuff like this (it's a fairly common technique in C logging these days, and it is also applicable to wide range of other scenarios):

The activation metadata: https://github.com/torvalds/linux/blob/e8bc52cb8df80c31c73c726ab58ea9746e9ff734/include/linux/dynamic_debug.h#L162

The end user construct which can be toggled in runtime via side channel: https://github.com/torvalds/linux/blob/4ac6d90867a4de2e12117e755dbd76e08d88697f/include/linux/dev_printk.h#L228

In Rust, the same technique applies as is.

However, at this point, I would actually like to close this thread and write another, much better worded proposal. Assuming my argument holds at least some water. :-)

ianlancetaylor commented 2 years ago

I suppose that my feeling right now is that there are already multiple ways to do the kind of thing you are discussing. I don't see the need to add another one. Of course I'm only speaking for myself.

(Also the specific suggestion of using reflect seems impossible to implement, but I expect that we could make some vaguely similar approach work.)

oakad commented 2 years ago

Yes, reflect was not the best idea. It's more a debug into thing.

Go being a Turing complete language means that indeed, anything can be implemented. The key question is, of course, one of end user utility.

For example:

     // some code
     OptionalBlock(func() {
         // seldom used code to be activated
     })
     // some more code

Does OptionalBlock can do what I'm asking of it? Yes, clearly.

But, it suffers from one major flaw: the "disabled" case is way too expensive for most uses. The closure is always allocated, OptionalBlock has to always query its caller and some sort of synchronous map to figure out whether the optional code is intended to run and so on.

And something like this will never work outright:

    OptionalBlock(fmt.Println("Some expensive check", expensiveCheck())

Because Go evaluates function arguments eagerly.

However, if OptionalBlock was a macro (or at least could be made to behave like macro by compiler fiat) the desired outcome will be achieved.

ianlancetaylor commented 2 years ago

Based on the discussion above, and the negative emoji voting, this is a likely decline. Leaving open for four weeks for final comments.

oakad commented 2 years ago

Not widely recognized patterns are doomed to linger. :-)

ianlancetaylor commented 1 year ago

No change on consensus.