]> Cypherpunks repositories - gostls13.git/commitdiff
syscall: optimise cgo clearenv
authorAleksa Sarai <cyphar@cyphar.com>
Tue, 9 Sep 2025 12:18:49 +0000 (12:18 +0000)
committerKeith Randall <khr@golang.org>
Tue, 9 Sep 2025 19:48:31 +0000 (12:48 -0700)
For programs with very large environments, calling unsetenv(3) for each
environment variable can be very 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() >98% faster when run in an
environment with as little as 100 environment variables. (Note that due
to golang/go#27217, it is necessary to modify BenchmarkClearenv to use
t.StopTimer() and -benchtime=100x in order to get these benchmark times
-- otherwise syscall.Setenv() time is included and the benchmarks give a
more pessimistic 50% performance improvement.)

    goos: linux
    goarch: amd64
    pkg: syscall
    cpu: AMD Ryzen 7 7840U w/ Radeon  780M Graphics
                      │      before      │                after                │
                      │      sec/op      │   sec/op     vs base                │
    Clearenv/100-16        22276.5n ± 5%   285.8n ± 3%  -98.72% (p=0.000 n=10)
    Clearenv/1000-16     1414104.0n ± 1%   783.1n ± 8%  -99.94% (p=0.000 n=10)
    Clearenv/10000-16   143827.554µ ± 1%   7.591µ ± 5%  -99.99% (p=0.000 n=10)
    geomean                  1.655m        1.193µ       -99.93%

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 slightly more modest
performance improvements:

    goos: linux
    goarch: amd64
    pkg: clearenv_nocgo
    cpu: AMD Ryzen 7 7840U w/ Radeon  780M Graphics
                      │     before     │                after                 │
                      │     sec/op     │    sec/op     vs base                │
    Clearenv/100-16       1106.0n ± 3%   230.7n ±  8%  -79.14% (p=0.000 n=10)
    Clearenv/1000-16     11222.0n ± 1%   305.4n ±  6%  -97.28% (p=0.000 n=10)
    Clearenv/10000-16   195676.5n ± 6%   759.9n ± 10%  -99.61% (p=0.000 n=10)
    geomean                13.44µ        376.9n        -97.20%

(As above, this requires modifying the benchmarks to use t.StopTimer()
and -benchtime=100x.)

Change-Id: I53b96a75f189e91affbde423c907888b7e0fafcd
GitHub-Last-Rev: f8d7a8140d8490189d726eb380522dccacc5f176
GitHub-Pull-Request: golang/go#70672
Reviewed-on: https://go-review.googlesource.com/c/go/+/633515
Reviewed-by: Mark Freeman <markfreeman@google.com>
Reviewed-by: Keith Randall <khr@golang.org>
Reviewed-by: Kirill Kolyshkin <kolyshkin@gmail.com>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Keith Randall <khr@google.com>
src/runtime/cgo/clearenv.go [new file with mode: 0644]
src/runtime/cgo/gcc_clearenv.c [new file with mode: 0644]
src/runtime/runtime_clearenv.go [new file with mode: 0644]
src/runtime/runtime_noclearenv.go [new file with mode: 0644]
src/syscall/env_unix.go
src/syscall/env_unix_test.go [new file with mode: 0644]
src/syscall/syscall.go

diff --git a/src/runtime/cgo/clearenv.go b/src/runtime/cgo/clearenv.go
new file mode 100644 (file)
index 0000000..6d605e5
--- /dev/null
@@ -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 (file)
index 0000000..7657e35
--- /dev/null
@@ -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 <stdlib.h>
+
+/* 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 (file)
index 0000000..0cb1410
--- /dev/null
@@ -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 (file)
index 0000000..67e60cc
--- /dev/null
@@ -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)
+       }
+}
index 256048f6ff1ccb09d7e4f0fc02bcf202187cbaa1..e1d7e213f7608eb0185cdeaee87431ae5f8cf0b3 100644 (file)
@@ -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 (file)
index 0000000..9a6cdcb
--- /dev/null
@@ -0,0 +1,145 @@
+// 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)
+                       for b.Loop() {
+                               // Ideally we would use b.StopTimer() for the setDummyEnv
+                               // portion, but this causes the benchmark time to get confused
+                               // and take forever. See <https://go.dev/issue/27217>.
+                               setDummyEnv(b, envList)
+                               syscall.Clearenv()
+                       }
+               })
+       }
+}
index a46f22ddb53c6a091230fb5fd7d433746f6db10b..d70f4fbd15a53d83212d1f8964419de58c51f1d2 100644 (file)
@@ -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)