Skip to content

fix: process start time from /proc/<pid>/stat instead of taskstats (B8 of #369) - #379

Open
mayankpande88 wants to merge 4 commits into
mainfrom
port/upstream-b8-proc-start-time
Open

mayankpande88 wants to merge 4 commits into
mainfrom
port/upstream-b8-proc-start-time

Conversation

@mayankpande88

Copy link
Copy Markdown
Contributor

Summary

B8 of #369: process start times now come from /proc/<pid>/stat instead of a taskstats netlink call, with the host boot time read once. Ported from upstream coroot-node-agent with -x.

Upstream Author Change
coroot/coroot-node-agent@27bc75d kvs vishnu kumar onProcessStart takes the start time from /proc/<pid>/stat (start ticks + boot time) instead of TaskstatsPID.
coroot/coroot-node-agent@8c14700 kvs vishnu kumar Cache the host boot time.
coroot/coroot-node-agent@a9fd101 Nikolay Sivko Read the boot time once in init().

Why it matters here:

  • Cost: TaskstatsPID was a netlink round trip, under a global lock, for every process start.
  • Dependency on taskstats: when that call failed, onProcessStart returned nil, so the process was never registered. Registration no longer depends on taskstats; per-process delay accounting (TaskstatsTGID) still does.

Also adds a test for GetStartTime, which upstream didn't have.

Engineering detail

Conflict in 27bc75d: this fork's NewProcess has no tracer or instrumentDone field. Its pid-reuse check in onProcessStart (#353) now compares the start time from /proc/<pid>/stat. That value is the same for every read of one process, and has the same one-second resolution as the taskstats BeginTime it replaces.

Test:

  • start ticks (field 22) plus the boot time give the expected time;
  • a comm containing spaces and ) is parsed from the last );
  • a missing pid gives zero, and so does an unknown boot time.

The fixture is a stat file added to the existing fixture pid 123, so TestListPids is unchanged.

CI: gofmt, goimports, vet, golangci-lint, go test (excluding /containers) and the build all pass in a Linux container with Go 1.26.5.

Local e2e: I built agent binaries from this branch and from main and ran each as a systemd service on a local Debian 12 VM (kernel 6.1), one after the other with the same workload.

  • Restarts: a unit restarted twice showed container_restarts_total = 2 on both builds.
  • CPU: during 60s of about 100 process starts per second, the agent used 3667 ms of CPU on this branch and 4251 ms on main. That's one run each.
  • Logs: neither agent logged an error.

vishnukumarkvs and others added 4 commits October 8, 2026 17:38
…atsPID

(cherry picked from commit 27bc75dedc2ad64e6beaee57c35924bba383c290)

Conflict: this fork's NewProcess has no tracer or instrumentDone field.
Its pid-reuse check in onProcessStart (#353) now compares the start time
from /proc/<pid>/stat, which has the same one-second resolution as the
taskstats BeginTime it replaces.
(cherry picked from commit 8c147002075082d60b91a0554a219f12d9ce8db0)
…okup

(cherry picked from commit a9fd10174a90f5ea515896a2e5c0f0586dc01701)
Start ticks from field 22 of /proc/<pid>/stat plus the boot time, a comm
containing spaces and ')', a missing pid and an unknown boot time.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request replaces the dependency on taskstats for retrieving process start times with a custom implementation that reads and parses /proc//stat directly. It introduces proc.GetStartTime and initializes bootTime from /proc/stat, accompanied by unit tests. Feedback suggests optimizing GetStartTime to scan the byte slice directly instead of converting the file to a string and splitting it, which would eliminate allocations and reduce overhead in high-churn environments.

Comment thread proc/proc.go
Comment on lines +84 to +103
func GetStartTime(pid uint32) time.Time {
data, err := os.ReadFile(Path(pid, "stat"))
if err != nil {
return time.Time{}
}
s := string(data)
idx := strings.LastIndex(s, ")")
if idx < 0 {
return time.Time{}
}
fields := strings.Fields(s[idx+1:])
if len(fields) < 20 {
return time.Time{}
}
startTicks, err := strconv.ParseUint(fields[19], 10, 64)
if err != nil || bootTime == 0 {
return time.Time{}
}
return time.Unix(bootTime+int64(float64(startTicks)/100), 0)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The current implementation of GetStartTime converts the entire /proc/<pid>/stat file to a string and splits it into fields using strings.Fields. Since /proc/<pid>/stat contains around 50 fields, this approach triggers dozens of string allocations on every process start.

Because GetStartTime is executed on every process start event, optimizing it to be allocation-free significantly reduces CPU and memory overhead in high-churn environments.

We can achieve this by scanning the byte slice directly to find the 20th field after the last ) character, and parsing the uint64 directly from the bytes without any string conversions or allocations.

func GetStartTime(pid uint32) time.Time {
	data, err := os.ReadFile(Path(pid, "stat"))
	if err != nil {
		return time.Time{}
	}
	idx := bytes.LastIndex(data, []byte{')'})
	if idx < 0 {
		return time.Time{}
	}
	tail := data[idx+1:]
	fieldCount := 0
	inField := false
	start, end := -1, -1
	for i, b := range tail {
		if b == ' ' || b == '\t' || b == '\n' || b == '\r' {
			if inField {
				inField = false
				if fieldCount == 20 {
					end = i
					break
				}
			}
		} else {
			if !inField {
				inField = true
				fieldCount++
				if fieldCount == 20 {
					start = i
				}
			}
		}
	}
	if inField && fieldCount == 20 {
		end = len(tail)
	}
	if start < 0 || end < 0 {
		return time.Time{}
	}
	var startTicks uint64
	for _, b := range tail[start:end] {
		if b < '0' || b > '9' {
			return time.Time{}
		}
		startTicks = startTicks*10 + uint64(b-'0')
	}
	if bootTime == 0 {
		return time.Time{}
	}
	return time.Unix(bootTime+int64(startTicks/100), 0)
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I measured this, and I'm not changing it.

  • strings.Fields doesn't allocate per field: the substrings share the original string's memory.
  • With testing.AllocsPerRun, GetStartTime makes 10 allocations per call. 8 of them come from Path plus os.ReadFile, which the suggested version keeps. Parsing adds 2: the string(data) copy and the Fields slice.
  • So the hand-written scanner would save 2 small allocations per process start: about 200/s at 100 process starts per second. That isn't worth the extra parsing code.
  • The change this replaces, a taskstats netlink call per process start, already cut agent CPU by about 14% in the e2e.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants