From 78b545a444d297218f6008143b1e018f6aa5ab01 Mon Sep 17 00:00:00 2001 From: Aleksa Sarai Date: Thu, 4 Jul 2024 14:53:44 +1000 Subject: [PATCH] os: optimise cgo clearenv MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For programs with very large environments, calling unsetenv(3) for each environment variable can be fairly expensive because of CGo overhead, but clearenv(3) is much faster. The only thing we have to track is whether GODEBUG is being unset by the operation, which can be done very quickly without resorting to doing unsetenv(3) for every variable. This change makes syscall.Clearenv() ~30% faster when run an environment with 100 environment variable set (and gets better with more environment variables -- 50% for 1000 variables): goos: linux goarch: amd64 pkg: syscall cpu: AMD Ryzen 7 7840U w/ Radeon 780M Graphics │ old.txt │ new.txt │ │ sec/op │ sec/op vs base │ Clearenv/100-16 46.19µ ± 1% 28.67µ ± 1% -37.93% (p=0.000 n=10) Clearenv/1000-16 2.674m ± 1% 1.336m ± 1% -50.02% (p=0.000 n=10) Clearenv/10000-16 245.7m ± 5% 114.5m ± 1% -53.42% (p=0.000 n=10) Setenv/100-16 33.09µ ± 2% 32.39µ ± 1% -2.11% (p=0.000 n=10) Setenv/1000-16 1.384m ± 2% 1.339m ± 3% -3.26% (p=0.000 n=10) Setenv/10000-16 119.0m ± 1% 113.8m ± 2% -4.32% (p=0.000 n=10) geomean 2.343m 1.669m -28.74% Note that the majority of the time spent in these benchmarks is actually the time taken to set up the necessary number of environment variables (see BenchmarkSetenv) so the real performance improvement is much larger (enough that it's actually hard to measure). BenchmarkSetenv takes longer than BenchmarkClearenv, but that's likely because the enivronment map is already full for each iteration and so replacing the existing environments is non-free. The above benchmarks are CGo builds, which require CGo overhead for every setenv(2). If you run the same benchmarks for a non-CGo package (i.e., outside of the "syscall" package), you get a far more modest performance improvement: goos: linux goarch: amd64 pkg: clearenv_nocgo cpu: AMD Ryzen 7 7840U w/ Radeon 780M Graphics │ old.txt │ new.txt │ │ sec/op │ sec/op vs base │ Clearenv/100-16 12.31µ ± 1% 10.94µ ± 1% -11.16% (p=0.000 n=10) Clearenv/1000-16 139.6µ ± 2% 127.5µ ± 1% -8.65% (p=0.000 n=10) Clearenv/10000-16 1.818m ± 1% 1.667m ± 1% -8.27% (p=0.000 n=10) Setenv/100-16 6.002µ ± 1% 6.030µ ± 0% ~ (p=0.255 n=10) Setenv/1000-16 64.25µ ± 1% 64.18µ ± 1% ~ (p=0.631 n=10) Setenv/10000-16 793.8µ ± 2% 789.9µ ± 2% ~ (p=0.353 n=10) geomean 99.26µ 94.47µ -4.82% Change-Id: I589794845854e1721e6fc5badc671474c2bbd037 --- src/runtime/cgo/clearenv.go | 15 +++ src/runtime/cgo/gcc_clearenv.c | 18 ++++ src/runtime/runtime_clearenv.go | 29 ++++++ src/runtime/runtime_noclearenv.go | 18 ++++ src/syscall/env_unix.go | 5 +- src/syscall/env_unix_test.go | 160 ++++++++++++++++++++++++++++++ src/syscall/syscall.go | 4 + 7 files changed, 246 insertions(+), 3 deletions(-) create mode 100644 src/runtime/cgo/clearenv.go create mode 100644 src/runtime/cgo/gcc_clearenv.c create mode 100644 src/runtime/runtime_clearenv.go create mode 100644 src/runtime/runtime_noclearenv.go create mode 100644 src/syscall/env_unix_test.go diff --git a/src/runtime/cgo/clearenv.go b/src/runtime/cgo/clearenv.go new file mode 100644 index 00000000000000..6d605e5f1ad316 --- /dev/null +++ b/src/runtime/cgo/clearenv.go @@ -0,0 +1,15 @@ +// Copyright 2025 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build linux + +package cgo + +import _ "unsafe" // for go:linkname + +//go:cgo_import_static x_cgo_clearenv +//go:linkname x_cgo_clearenv x_cgo_clearenv +//go:linkname _cgo_clearenv runtime._cgo_clearenv +var x_cgo_clearenv byte +var _cgo_clearenv = &x_cgo_clearenv diff --git a/src/runtime/cgo/gcc_clearenv.c b/src/runtime/cgo/gcc_clearenv.c new file mode 100644 index 00000000000000..7657e3562d439c --- /dev/null +++ b/src/runtime/cgo/gcc_clearenv.c @@ -0,0 +1,18 @@ +// Copyright 2025 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build linux + +#include "libcgo.h" + +#include + +/* Stub for calling clearenv */ +void +x_cgo_clearenv(void **_unused) +{ + _cgo_tsan_acquire(); + clearenv(); + _cgo_tsan_release(); +} diff --git a/src/runtime/runtime_clearenv.go b/src/runtime/runtime_clearenv.go new file mode 100644 index 00000000000000..0cb14101c1de20 --- /dev/null +++ b/src/runtime/runtime_clearenv.go @@ -0,0 +1,29 @@ +// Copyright 2025 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build linux + +package runtime + +import "unsafe" + +var _cgo_clearenv unsafe.Pointer // pointer to C function + +// Clear the C environment if cgo is loaded. +func clearenv_c() { + if _cgo_clearenv == nil { + return + } + asmcgocall(_cgo_clearenv, nil) +} + +//go:linkname syscall_runtimeClearenv syscall.runtimeClearenv +func syscall_runtimeClearenv(env map[string]int) { + clearenv_c() + // Did we just unset GODEBUG? + if _, ok := env["GODEBUG"]; ok { + godebugEnv.Store(nil) + godebugNotify(true) + } +} diff --git a/src/runtime/runtime_noclearenv.go b/src/runtime/runtime_noclearenv.go new file mode 100644 index 00000000000000..67e60cc9050377 --- /dev/null +++ b/src/runtime/runtime_noclearenv.go @@ -0,0 +1,18 @@ +// Copyright 2025 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build !linux + +package runtime + +import _ "unsafe" // for go:linkname + +//go:linkname syscall_runtimeClearenv syscall.runtimeClearenv +func syscall_runtimeClearenv(env map[string]int) { + // The system doesn't have clearenv(3) so emulate it by unsetting all of + // the variables manually. + for k := range env { + syscall_runtimeUnsetenv(k) + } +} diff --git a/src/syscall/env_unix.go b/src/syscall/env_unix.go index 256048f6ff1ccb..e1d7e213f7608e 100644 --- a/src/syscall/env_unix.go +++ b/src/syscall/env_unix.go @@ -126,9 +126,8 @@ func Clearenv() { envLock.Lock() defer envLock.Unlock() - for k := range env { - runtimeUnsetenv(k) - } + runtimeClearenv(env) + env = make(map[string]int) envs = []string{} } diff --git a/src/syscall/env_unix_test.go b/src/syscall/env_unix_test.go new file mode 100644 index 00000000000000..dfd637a690d4e0 --- /dev/null +++ b/src/syscall/env_unix_test.go @@ -0,0 +1,160 @@ +// Copyright 2025 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build unix || (js && wasm) || plan9 || wasip1 + +package syscall_test + +import ( + "fmt" + "strconv" + "strings" + "syscall" + "testing" +) + +type env struct { + name, val string +} + +func genDummyEnv(tb testing.TB, size int) []env { + tb.Helper() + envList := make([]env, size) + for idx := range size { + envList[idx] = env{ + name: fmt.Sprintf("DUMMY_VAR_%d", idx), + val: fmt.Sprintf("val-%d", idx*100), + } + } + return envList +} + +func setDummyEnv(tb testing.TB, envList []env) { + tb.Helper() + for _, env := range envList { + if err := syscall.Setenv(env.name, env.val); err != nil { + tb.Fatalf("setenv %s=%q failed: %v", env.name, env.val, err) + } + } +} + +func setupEnvCleanup(tb testing.TB) { + tb.Helper() + originalEnv := map[string]string{} + for _, env := range syscall.Environ() { + fields := strings.SplitN(env, "=", 2) + name, val := fields[0], fields[1] + originalEnv[name] = val + } + tb.Cleanup(func() { + syscall.Clearenv() + for name, val := range originalEnv { + if err := syscall.Setenv(name, val); err != nil { + tb.Fatalf("could not reset env %s=%q: %v", name, val, err) + } + } + }) +} + +func TestClearenv(t *testing.T) { + setupEnvCleanup(t) + + t.Run("DummyVars-4096", func(t *testing.T) { + envList := genDummyEnv(t, 4096) + setDummyEnv(t, envList) + + if env := syscall.Environ(); len(env) < 4096 { + t.Fatalf("env is missing dummy variables: %v", env) + } + for idx := range 4096 { + name := fmt.Sprintf("DUMMY_VAR_%d", idx) + if _, ok := syscall.Getenv(name); !ok { + t.Fatalf("env is missing dummy variable %s", name) + } + } + + syscall.Clearenv() + + if env := syscall.Environ(); len(env) != 0 { + t.Fatalf("clearenv should've cleared all variables: %v still set", env) + } + for idx := range 4096 { + name := fmt.Sprintf("DUMMY_VAR_%d", idx) + if val, ok := syscall.Getenv(name); ok { + t.Fatalf("clearenv should've cleared all variables: %s=%q still set", name, val) + } + } + }) + + // Test that GODEBUG getting cleared by Clearenv also resets the behaviour. + t.Run("GODEBUG", func(t *testing.T) { + envList := genDummyEnv(t, 100) + setDummyEnv(t, envList) + + doNilPanic := func() (ret any) { + defer func() { + ret = recover() + }() + panic(nil) + return "should not return" + } + + // Allow panic(nil). + if err := syscall.Setenv("GODEBUG", "panicnil=1"); err != nil { + t.Fatalf("setenv GODEBUG=panicnil=1 failed: %v", err) + } + + got := doNilPanic() + if got != nil { + t.Fatalf("GODEBUG=panicnil=1 did not allow for nil panic: got %#v", got) + } + + // Disallow panic(nil). + syscall.Clearenv() + + if env := syscall.Environ(); len(env) != 0 { + t.Fatalf("clearenv should've cleared all variables: %v still set", env) + } + + got = doNilPanic() + if got == nil { + t.Fatalf("GODEBUG=panicnil=1 being unset didn't reset panicnil behaviour") + } + if godebug, ok := syscall.Getenv("GODEBUG"); ok { + t.Fatalf("GODEBUG still exists in environment despite being unset: GODEBUG=%q", godebug) + } + }) +} + +func BenchmarkClearenv(b *testing.B) { + setupEnvCleanup(b) + b.ResetTimer() + for _, size := range []int{100, 1000, 10000} { + b.Run(strconv.Itoa(size), func(b *testing.B) { + envList := genDummyEnv(b, size) + b.ResetTimer() + for b.Loop() { + // Ideally we would use StopTimer to hide the environment + // variable setup cost but that causes go test to loop + // seemingly infinitely.... + setDummyEnv(b, envList) + syscall.Clearenv() + } + }) + } +} + +func BenchmarkSetenv(b *testing.B) { + setupEnvCleanup(b) + b.ResetTimer() + for _, size := range []int{100, 1000, 10000} { + b.Run(strconv.Itoa(size), func(b *testing.B) { + envList := genDummyEnv(b, size) + b.ResetTimer() + for b.Loop() { + setDummyEnv(b, envList) + } + }) + } +} diff --git a/src/syscall/syscall.go b/src/syscall/syscall.go index a46f22ddb53c6a..d70f4fbd15a53d 100644 --- a/src/syscall/syscall.go +++ b/src/syscall/syscall.go @@ -104,3 +104,7 @@ func Exit(code int) // runtimeSetenv and runtimeUnsetenv are provided by the runtime. func runtimeSetenv(k, v string) func runtimeUnsetenv(k string) + +// runtimeClearenv is provided by the runtime (on platforms without +// clearenv(3), it is just a wrapper around runtimeUnsetenv). +func runtimeClearenv(env map[string]int)