Skip to content

Commit cdf5d74

Browse files
Merge pull request #52 from github/tw/ptree-reduce-allocations
ptree: reduce per-poll allocations in GetProcessTreeRSSAnon
2 parents 6f38bbb + 0fac467 commit cdf5d74

3 files changed

Lines changed: 412 additions & 131 deletions

File tree

internal/ptree/ptree.go

Lines changed: 217 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,128 @@
1-
//go:build linux
2-
31
// Package ptree contains utilities for dealing with Linux process trees.
42
package ptree
53

64
import (
7-
"bufio"
5+
"bytes"
86
"errors"
9-
"fmt"
10-
"io/fs"
7+
"io"
118
"os"
12-
"regexp"
139
"strconv"
1410
"strings"
11+
"sync"
1512
)
1613

17-
var (
18-
errNoRss = errors.New("RssAnon was not found")
19-
procfs = os.DirFS("/proc")
20-
rssAnonRE = regexp.MustCompile(`^RssAnon:\s*(\d+)\s+kB($|\s)`)
14+
const (
15+
// initialReadBufSize is the starting capacity of the buffer used for
16+
// reading /proc files. /proc/<pid>/status is typically ~1 KiB and the
17+
// per-task "children" files are smaller, so 4 KiB covers the common
18+
// case in a single read.
19+
initialReadBufSize = 4 * 1024
2120
)
2221

22+
var errNoRss = errors.New("RssAnon was not found")
23+
24+
type ProcessTree struct {
25+
path string
26+
}
27+
28+
func NewProcessTree(path string) ProcessTree {
29+
return ProcessTree{
30+
path: path,
31+
}
32+
}
33+
34+
// readBufPool reuses the byte buffer that holds the contents of a /proc file
35+
// across calls to getProcessRSSAnon / walkChildrenFile, so that the
36+
// per-poll work doesn't allocate (and then garbage-collect) a fresh buffer
37+
// for every process in the tree.
38+
var readBufPool = sync.Pool{
39+
New: func() any {
40+
b := make([]byte, 0, initialReadBufSize)
41+
return &b
42+
},
43+
}
44+
45+
// readProcFile reads all of path into a buffer borrowed from readBufPool.
46+
//
47+
// On success, the returned slice is only valid until bufPtr is returned to
48+
// the pool, which the caller MUST do (typically with
49+
// `defer readBufPool.Put(bufPtr)`, placed after the error check).
50+
//
51+
// On error, bufPtr is nil and the buffer has already been released, so the
52+
// caller must not Put it back.
53+
//
54+
// Compared to os.ReadFile, this skips the (useless for /proc) Stat call
55+
// used to pre-size the buffer and reuses the buffer across calls. It also
56+
// returns the underlying *[]byte rather than a closure so the release path
57+
// allocates nothing.
58+
func readProcFile(path string) (data []byte, bufPtr *[]byte, err error) {
59+
f, err := os.Open(path)
60+
if err != nil {
61+
return nil, nil, err
62+
}
63+
defer f.Close()
64+
65+
bufPtr = readBufPool.Get().(*[]byte)
66+
buf := (*bufPtr)[:0]
67+
for {
68+
if len(buf) == cap(buf) {
69+
// Grow via append; this only allocates if the pooled
70+
// buffer was too small. Subsequent calls will reuse
71+
// the grown buffer because we store it back below.
72+
buf = append(buf, 0)[:len(buf)]
73+
}
74+
n, rerr := f.Read(buf[len(buf):cap(buf)])
75+
buf = buf[:len(buf)+n]
76+
if rerr == io.EOF {
77+
break
78+
}
79+
if rerr != nil {
80+
*bufPtr = buf
81+
readBufPool.Put(bufPtr)
82+
return nil, nil, rerr
83+
}
84+
if n == 0 {
85+
// Defensive: io.Reader allows (0, nil) returns; treat
86+
// as EOF rather than spinning. Real *os.File on /proc
87+
// shouldn't hit this, but mocks or future runtime
88+
// behavior might.
89+
break
90+
}
91+
}
92+
*bufPtr = buf
93+
return buf, bufPtr, nil
94+
}
95+
2396
// Return the RSSAnon of a single process `pid`.
24-
func GetProcessRSSAnon(pid int) (uint64, error) {
25-
status := fmt.Sprintf("%d/status", pid)
26-
f, err := procfs.Open(status)
97+
func (pt ProcessTree) GetProcessRSSAnon(pid int) (uint64, error) {
98+
status := pt.path + "/" + strconv.Itoa(pid) + "/status"
99+
data, bufPtr, err := readProcFile(status)
27100
if os.IsNotExist(err) {
28101
// process is already gone
29102
return 0, nil
30103
}
31104
if err != nil {
32105
return 0, err
33106
}
34-
defer f.Close()
107+
defer readBufPool.Put(bufPtr)
35108

36-
scan := bufio.NewScanner(f)
37-
for scan.Scan() {
38-
line := scan.Text()
39-
if rss, ok := ParseRSSAnon(line); ok {
109+
prefix := []byte("RssAnon:")
110+
rest := data
111+
for len(rest) > 0 {
112+
var line []byte
113+
if nl := bytes.IndexByte(rest, '\n'); nl >= 0 {
114+
line, rest = rest[:nl], rest[nl+1:]
115+
} else {
116+
line, rest = rest, nil
117+
}
118+
// Fast prefix check before paying for the string conversion.
119+
if !bytes.HasPrefix(line, prefix) {
120+
continue
121+
}
122+
if rss, ok := ParseRSSAnon(string(line)); ok {
40123
return rss, nil
41124
}
42125
}
43-
if scan.Err() != nil {
44-
return 0, scan.Err()
45-
}
46126
return 0, errNoRss
47127
}
48128

@@ -53,8 +133,8 @@ func GetProcessRSSAnon(pid int) (uint64, error) {
53133
//
54134
// Errors encountered while walking the children are ignored, since it can
55135
// change while traversing it.
56-
func GetProcessTreeRSSAnon(pid int) (uint64, error) {
57-
total, err := GetProcessRSSAnon(pid)
136+
func (pt ProcessTree) GetProcessTreeRSSAnon(pid int) (uint64, error) {
137+
total, err := pt.GetProcessRSSAnon(pid)
58138
if err != nil {
59139
if err == errNoRss {
60140
// these are typically kernel threads, which don't have an address space to measure
@@ -63,8 +143,8 @@ func GetProcessTreeRSSAnon(pid int) (uint64, error) {
63143
return 0, err
64144
}
65145

66-
WalkChildren(pid, func(pid int) {
67-
mem, err := GetProcessRSSAnon(pid)
146+
pt.WalkChildren(pid, func(pid int) {
147+
mem, err := pt.GetProcessRSSAnon(pid)
68148
if err != nil {
69149
return
70150
}
@@ -74,43 +154,77 @@ func GetProcessTreeRSSAnon(pid int) (uint64, error) {
74154
return total, nil
75155
}
76156

77-
// Walk the child processes of the specified root process. walkFn will be called
78-
// for each child found. It will not be called for the root process. Any errors
79-
// will be ignored, since they may be just a consequence of the process tree
80-
// changing during traversal.
81-
func WalkChildren(pid int, walkFn func(int)) {
82-
walkChildPids(pid, walkFn, map[int]bool{pid: true})
157+
func (pt ProcessTree) WalkChildren(pid int, walkFn func(int)) {
158+
pt.walkChildPids(pid, walkFn, map[int]bool{pid: true})
83159
}
84160

85-
func walkChildPids(pid int, walkFn func(int), visited map[int]bool) {
86-
matches, err := fs.Glob(procfs, fmt.Sprintf("%d/task/*/children", pid))
161+
func (pt ProcessTree) walkChildPids(pid int, walkFn func(int), visited map[int]bool) {
162+
// List the per-thread directories under /proc/<pid>/task and read each
163+
// task's "children" file directly. This avoids filepath.Glob, which
164+
// would Stat every match on top of the readdir we already need.
165+
taskDir := pt.path + "/" + strconv.Itoa(pid) + "/task"
166+
entries, err := os.ReadDir(taskDir)
87167
if err != nil {
88168
return
89169
}
90170

91-
for _, filename := range matches {
92-
walkChildrenFile(filename, walkFn, visited)
171+
for _, entry := range entries {
172+
// task/ should only contain numeric TID directories. Skip
173+
// anything else defensively; this mirrors the implicit
174+
// filtering that filepath.Glob("*/children") provided.
175+
// A byte-range check avoids the error allocation that
176+
// strconv.Atoi would incur for non-numeric names.
177+
if !isAllDigits(entry.Name()) {
178+
continue
179+
}
180+
pt.walkChildrenFile(taskDir+"/"+entry.Name()+"/children", walkFn, visited)
93181
}
94182
}
95183

96-
func walkChildrenFile(filename string, walkFn func(int), visited map[int]bool) {
97-
data, err := fs.ReadFile(procfs, filename)
184+
func (pt ProcessTree) walkChildrenFile(filename string, walkFn func(int), visited map[int]bool) {
185+
data, bufPtr, err := readProcFile(filename)
98186
if err != nil {
99187
return
100188
}
189+
defer readBufPool.Put(bufPtr)
101190

102-
for _, pidStr := range strings.Fields(string(data)) {
103-
pid, err := strconv.Atoi(pidStr)
104-
if err != nil {
191+
// children is a whitespace-separated list of decimal PIDs. Parse it in
192+
// place to avoid the string(data) conversion and the []string allocated
193+
// by strings.Fields.
194+
i := 0
195+
for i < len(data) {
196+
for i < len(data) && isASCIISpace(data[i]) {
197+
i++
198+
}
199+
if i >= len(data) {
200+
return
201+
}
202+
pid := 0
203+
start := i
204+
for i < len(data) && data[i] >= '0' && data[i] <= '9' {
205+
pid = pid*10 + int(data[i]-'0')
206+
i++
207+
}
208+
if i == start {
209+
// Not a digit; skip until next whitespace to stay in sync.
210+
for i < len(data) && !isASCIISpace(data[i]) {
211+
i++
212+
}
213+
continue
214+
}
215+
if i-start > 10 {
216+
// Realistic Linux PIDs fit in well under 10 digits
217+
// (PID_MAX is 2^22). A longer digit run can't be a
218+
// real PID and would risk silently overflowing the
219+
// int accumulator, so skip it.
105220
continue
106221
}
107222
if visited[pid] {
108223
continue
109224
}
110-
111225
walkFn(pid)
112226
visited[pid] = true
113-
walkChildPids(pid, walkFn, visited)
227+
pt.walkChildPids(pid, walkFn, visited)
114228
}
115229
}
116230

@@ -119,13 +233,71 @@ func walkChildrenFile(filename string, walkFn func(int), visited map[int]bool) {
119233
// line looks like "RssAnon: 1234 kB", the byte size will be returned. If the
120234
// line isn't parseable, (0, false) will be returned.
121235
func ParseRSSAnon(s string) (uint64, bool) {
122-
m := rssAnonRE.FindStringSubmatch(s)
123-
if m == nil {
236+
const prefix = "RssAnon:"
237+
if !strings.HasPrefix(s, prefix) {
124238
return 0, false
125239
}
126-
kb, err := strconv.ParseUint(m[1], 10, 64)
240+
s = s[len(prefix):]
241+
242+
// Optional whitespace before the number.
243+
i := 0
244+
for i < len(s) && isASCIISpace(s[i]) {
245+
i++
246+
}
247+
248+
// One or more digits.
249+
digitsStart := i
250+
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
251+
i++
252+
}
253+
if i == digitsStart {
254+
return 0, false
255+
}
256+
kb, err := strconv.ParseUint(s[digitsStart:i], 10, 64)
127257
if err != nil {
128258
return 0, false
129259
}
260+
261+
// At least one whitespace between the number and "kB".
262+
if i >= len(s) || !isASCIISpace(s[i]) {
263+
return 0, false
264+
}
265+
for i < len(s) && isASCIISpace(s[i]) {
266+
i++
267+
}
268+
269+
// Literal "kB", then either end-of-string or whitespace.
270+
const unit = "kB"
271+
if !strings.HasPrefix(s[i:], unit) {
272+
return 0, false
273+
}
274+
i += len(unit)
275+
if i < len(s) && !isASCIISpace(s[i]) {
276+
return 0, false
277+
}
130278
return kb * 1024, true
131279
}
280+
281+
// isASCIISpace matches the character class that Go's regexp engine uses for
282+
// \s in non-Unicode mode: [\t\n\f\r ].
283+
func isASCIISpace(b byte) bool {
284+
switch b {
285+
case ' ', '\t', '\n', '\f', '\r':
286+
return true
287+
}
288+
return false
289+
}
290+
291+
// isAllDigits reports whether s is non-empty and consists entirely of ASCII
292+
// decimal digits. Used as a cheap allocation-free numeric-name filter.
293+
func isAllDigits(s string) bool {
294+
if len(s) == 0 {
295+
return false
296+
}
297+
for i := 0; i < len(s); i++ {
298+
if s[i] < '0' || s[i] > '9' {
299+
return false
300+
}
301+
}
302+
return true
303+
}

internal/ptree/ptree_linux.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package ptree
2+
3+
var DefaultProcessTree = ProcessTree{
4+
path: "/proc",
5+
}
6+
7+
// Walk the child processes of the specified root process. walkFn will be called
8+
// for each child found. It will not be called for the root process. Any errors
9+
// will be ignored, since they may be just a consequence of the process tree
10+
// changing during traversal.
11+
func WalkChildren(pid int, walkFn func(int)) {
12+
DefaultProcessTree.WalkChildren(pid, walkFn)
13+
}
14+
15+
func GetProcessRSSAnon(pid int) (uint64, error) {
16+
return DefaultProcessTree.GetProcessRSSAnon(pid)
17+
}
18+
19+
func GetProcessTreeRSSAnon(pid int) (uint64, error) {
20+
return DefaultProcessTree.GetProcessTreeRSSAnon(pid)
21+
}

0 commit comments

Comments
 (0)