Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/workflows/goroot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ jobs:
env:
GOPROXY: https://proxy.golang.org,direct
LLGO_GOROOT_HEARTBEAT_SECONDS: "60"
LLGO_GOROOT_GOMAXPROCS: "1"
LLGO_GOROOT_VERBOSE: "1"
strategy:
fail-fast: false
Expand Down Expand Up @@ -123,7 +124,7 @@ jobs:
echo '|---:|---:|---:|---:|---:|'
echo "| $selected | $observed | $passed | $failed | $skipped |"
echo
echo '_Passed means the runner classification succeeded; expected xfail/not-applicable failures and classified flakes are counted as Passed._'
echo '_Passed means the runner classification succeeded; expected xfail failures and classified not-applicable/flake outcomes are counted as Passed._'
echo
echo '**Failed case paths**'
if [[ "$failed" -eq 0 ]]; then
Expand Down Expand Up @@ -211,7 +212,7 @@ jobs:
write_row 'Linux · Go 1.26.5' linux/amd64 1.26.5 4
write_row '**Linux total**' linux/amd64 '' 8
echo
echo '_Passed means the runner classification succeeded; expected xfail/not-applicable failures and classified flakes are counted as Passed._'
echo '_Passed means the runner classification succeeded; expected xfail failures and classified not-applicable/flake outcomes are counted as Passed._'
echo
echo '### Failed case paths'
if [[ -s "$failure_tsv" ]]; then
Expand Down
2 changes: 1 addition & 1 deletion runtime/internal/lib/runtime/pprof_runtime_stub_llgo.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ func ThreadCreateProfile(p []StackRecord) (n int, ok bool) {
}

func NumGoroutine() int {
return 1
return llrt.NumGoroutine()
}

const funcForPCCacheSets = 1024
Expand Down
39 changes: 1 addition & 38 deletions runtime/internal/lib/runtime/sync_runtime_llgo.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,48 +2,11 @@

package runtime

import (
_ "sync/atomic"
_ "unsafe"

psync "github.com/xgo-dev/llgo/runtime/internal/clite/pthread/sync"
)
import _ "unsafe"

var poolCleanup func()
var procPinOnce psync.Once
var procPinMu psync.Mutex

func initProcPinMu() {
procPinMu.Init(nil)
}

//go:linkname sync_runtime_registerPoolCleanup sync.runtime_registerPoolCleanup
func sync_runtime_registerPoolCleanup(cleanup func()) {
poolCleanup = cleanup
}

//go:linkname sync_runtime_procPin sync.runtime_procPin
func sync_runtime_procPin() int {
procPinOnce.Do(initProcPinMu)
procPinMu.Lock()
return 0
}

//go:linkname sync_runtime_procUnpin sync.runtime_procUnpin
func sync_runtime_procUnpin() {
procPinMu.Unlock()
}

// sync/atomic.Value expects these package-local runtime hooks. On darwin and
// linux, LLGo serializes every procPin region with one process-wide mutex
// because it cannot pin an OS-thread goroutine to a Go P.
//
//go:linkname atomic_runtime_procPin sync/atomic.runtime_procPin
func atomic_runtime_procPin() int {
return sync_runtime_procPin()
}

//go:linkname atomic_runtime_procUnpin sync/atomic.runtime_procUnpin
func atomic_runtime_procUnpin() {
sync_runtime_procUnpin()
}
8 changes: 8 additions & 0 deletions runtime/internal/runtime/proc.go
Original file line number Diff line number Diff line change
Expand Up @@ -214,3 +214,11 @@ func GMPForTesting() (goid, parentGoid uint64, mid int64, pid int32, gstatus, ps
func GStateForTesting() (count uint64, mainExited bool) {
return gStateForTesting()
}

// NumGoroutine returns the number of live runtime contexts. Calling getg first
// ensures that a lazily initialized main or foreign thread is included.
func NumGoroutine() int {
getg()
count, _ := gStateForTesting()
return int(count)
}
48 changes: 48 additions & 0 deletions runtime/internal/runtime/procpin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
//go:build darwin || linux

package runtime

import (
_ "unsafe"

psync "github.com/xgo-dev/llgo/runtime/internal/clite/pthread/sync"
)

var procPinOnce psync.Once
var procPinMu psync.Mutex

func initProcPinMu() {
procPinMu.Init(nil)
}

// LLGo has no Go P to pin a goroutine to. Serialize procPin regions instead,
// preserving the exclusion that sync.Pool and sync/atomic.Value require.
func procPin() int {
procPinOnce.Do(initProcPinMu)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per-call pthread_once on a hot path (minor perf). psync.Once.Do links to libc pthread_once, so every sync.Pool / sync/atomic.Value operation that hits runtime_procPin now pays a pthread_once call in addition to the mutex lock. pthread_once is cheap after first completion, but it's avoidable: pthread_mutex_init(nil) is equivalent to PTHREAD_MUTEX_INITIALIZER, so procPinMu could be eagerly/statically initialized (or initialized once in an init/first-getg) and the Once guard dropped entirely.

Separately worth flagging in a comment or tracking issue: the single process-wide procPinMu turns what is a contention-free per-P op in upstream Go into a globally serialized critical section, which becomes a scalability ceiling for sync.Pool-heavy concurrent workloads. This is the deliberate correctness tradeoff noted in the comment above — just calling out the ceiling explicitly.

procPinMu.Lock()
return 0
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Panic-safety of the global serialization (minor / latent). procPin/procUnpin are not defer-paired, so if a caller ever panics between procPin() and procUnpin(), procPinMu stays locked and every subsequent procPin on any thread deadlocks. Today's darwin/linux callers (sync/atomic.Value.Store; the repo's patched sync.Pool uses a TLS slot and doesn't call runtime_procPin) don't panic while pinned, so this is latent — but the invariants it relies on are load-bearing and unstated:

  • procUnpin must run on the same OS thread that called procPin (the mutex is PTHREAD_MUTEX_NORMAL via Init(nil); cross-thread unlock is UB), and
  • procPin regions must never nest (non-recursive mutex would self-deadlock).

Both hold under the current 1:1 goroutine↔OS-thread backend, but a future M:N scheduler change could silently break them. Consider a short comment recording these two invariants (and a "must not panic while pinned" note).


func procUnpin() {
procPinMu.Unlock()
}

//go:linkname syncRuntimeProcPin sync.runtime_procPin
func syncRuntimeProcPin() int {
return procPin()
}

//go:linkname syncRuntimeProcUnpin sync.runtime_procUnpin
func syncRuntimeProcUnpin() {
procUnpin()
}

//go:linkname atomicRuntimeProcPin sync/atomic.runtime_procPin
func atomicRuntimeProcPin() int {
return procPin()
}

//go:linkname atomicRuntimeProcUnpin sync/atomic.runtime_procUnpin
func atomicRuntimeProcUnpin() {
procUnpin()
}
25 changes: 25 additions & 0 deletions test/go/runtime_g_state_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,28 @@ func TestRuntimeGStateIsolation(t *testing.T) {
t.Fatalf("main goroutine recovered %v, want %q", recovered, "main panic")
}
}

func TestRuntimeNumGoroutineTracksWorkers(t *testing.T) {
const workerCount = 8
ready := make(chan struct{}, workerCount)
release := make(chan struct{})
done := make(chan struct{}, workerCount)
for range workerCount {
go func() {
ready <- struct{}{}
<-release
done <- struct{}{}
}()
}
for range workerCount {
<-ready
}
if got := runtime.NumGoroutine(); got < workerCount+1 {
t.Fatalf("NumGoroutine with %d blocked workers = %d, want at least %d", workerCount, got, workerCount+1)
}

close(release)
for range workerCount {
<-done
}
}
4 changes: 3 additions & 1 deletion test/goroot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ behavior are classified separately through `notapplicable.yaml`, so the xfail
count continues to represent remaining LLGo compatibility work. Only
explicitly host-unsafe cases are skipped. Each not-applicable entry documents
both the toolchain-specific mechanism under test and why the corresponding
behavior is not an LLGo compatibility goal.
behavior is not an LLGo compatibility goal. Not-applicable classification is
global rather than version- or platform-specific, and both successful and
failed execution are accepted; resource-guard failures remain fatal.

Basic usage:

Expand Down
3 changes: 3 additions & 0 deletions test/goroot/notapplicable.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,9 @@ not_applicable:
directive: run
case: maymorestack.go
reason: "not applicable: LLGo uses fixed native stacks, so gc's growable-stack maymorestack debug hook has no corresponding behavior; supporting this toolchain-specific behavior is not an LLGo compatibility goal"
- directive: run
case: stack.go
reason: "not applicable: this case recursively grows through 8,000 large frames to validate cmd/compile's stack-splitting implementation; LLGo maps each goroutine to a native OS thread with a fixed stack, so reproducing Go's growable-stack mechanism is not currently an LLGo compatibility goal"
- version: go1.26
directive: errorcheckandrundir
case: intrinsic.go
Expand Down
32 changes: 28 additions & 4 deletions test/goroot/runner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,7 @@ func TestGoRootRunCases(t *testing.T) {
}
switch {
case err == nil && notApply:
t.Fatalf("unexpected success for not-applicable case: %s", notApplyReason)
t.Logf("not-applicable case passed: %s", notApplyReason)
case err == nil && match:
t.Fatalf("unexpected success for xfail case: %s", reason)
case err == nil && flaky:
Expand All @@ -378,7 +378,8 @@ func TestGoRootRunCases(t *testing.T) {
func writeStdlibImportCfg(t *testing.T, goCmd string) string {
t.Helper()
cmd := exec.Command(goCmd, "list", "-export", "-f", "{{if .Export}}packagefile {{.ImportPath}}={{.Export}}{{end}}", "std")
cmd.Env = append(os.Environ(), "GOENV=off", "GOFLAGS=")
cmd.Dir = t.TempDir()
cmd.Env = baselineGoEnv()
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("list stdlib exports with %s: %v\n%s", goCmd, err, output)
Expand Down Expand Up @@ -406,7 +407,7 @@ func repoRoot(t *testing.T) string {
func loadToolchainEnv(t *testing.T, goCmd string) toolchainEnv {
t.Helper()
cmd := exec.Command(goCmd, "env", "-json", "GOOS", "GOARCH", "GOVERSION", "CGO_ENABLED")
cmd.Env = append(os.Environ(), "GOENV=off", "GOFLAGS=")
cmd.Env = baselineGoEnv()
var stdout bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &stdout
Expand Down Expand Up @@ -786,6 +787,8 @@ func runnerEnv(repoRoot, goroot, gopath string, extra []string) []string {
env[i] = "GOENV=off"
case strings.HasPrefix(item, "GOFLAGS="):
env[i] = "GOFLAGS="
case strings.HasPrefix(item, "GOTOOLCHAIN="):
env[i] = "GOTOOLCHAIN=local"
case strings.HasPrefix(item, "LLGO_ROOT="):
env[i] = "LLGO_ROOT=" + repoRoot
case strings.HasPrefix(item, "GOPATH="):
Expand All @@ -803,6 +806,7 @@ func runnerEnv(repoRoot, goroot, gopath string, extra []string) []string {
env = appendIfMissing(env, "GOROOT="+goroot)
env = appendIfMissing(env, "GOENV=off")
env = appendIfMissing(env, "GOFLAGS=")
env = appendIfMissing(env, "GOTOOLCHAIN=local")
env = appendIfMissing(env, "LLGO_ROOT="+repoRoot)
env = appendIfMissing(env, "GOPATH="+gopath)
env = appendIfMissing(env, "GO111MODULE=off")
Expand All @@ -812,6 +816,13 @@ func runnerEnv(repoRoot, goroot, gopath string, extra []string) []string {
return env
}

func baselineGoEnv() []string {
env := append([]string{}, os.Environ()...)
env = upsertEnv(env, "GOENV=off")
env = upsertEnv(env, "GOFLAGS=")
return upsertEnv(env, "GOTOOLCHAIN=local")
}

func appendIfMissing(env []string, kv string) []string {
key := strings.SplitN(kv, "=", 2)[0] + "="
for _, item := range env {
Expand Down Expand Up @@ -2066,7 +2077,20 @@ func (cfg xfailConfig) Match(goVersion, platform string, tc testCase) (bool, str
}

func (cfg notApplicableConfig) Match(goVersion, platform string, tc testCase) (bool, string) {
return matchEntries(cfg.Entries, goVersion, platform, tc)
for _, entry := range cfg.Entries {
// Applicability is an LLGo design property, not an observation tied to
// one hosted platform or Go release. Keep accepting legacy selectors in
// the YAML, but intentionally do not use them when classifying a case.
if !matchEntry("", "", entry.Directive, entry.Case, goVersion, platform, tc) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ignored version/platform selectors are a maintainability trap (minor). Passing "", "" here intentionally makes not-applicable classification global, and the comment explains why. But because xfailEntry still parses version:/platform:, a maintainer can add a platform:-scoped not-applicable entry expecting it to be honored, and it will instead apply everywhere. Consider failing config load if a not-applicable entry sets version/platform, or documenting in notapplicable.yaml that those selectors are inert for not-applicable entries. (TestNotApplicableMatch and TestStackIsGloballyNotApplicable do assert the global behavior.)

continue
}
reason := entry.Reason
if reason == "" {
reason = entry.Case
}
return true, reason
}
return false, ""
}

func (cfg xfailConfig) MatchFlaky(goVersion, platform string, tc testCase) (bool, string) {
Expand Down
Loading
Loading