1- //go:build linux
2-
31// Package ptree contains utilities for dealing with Linux process trees.
42package ptree
53
64import (
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.
121235func 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+ }
0 commit comments