Cross-platform process metrics for Go — RSS, VMS, CPU time, thread count, and identity (name, parent PID, executable path, creation time). No CGO, no shell execution, no stateful handles. One function per need.
- Darwin —
libproc.dylibvia purego - Linux —
/procfilesystem, standard library only - Windows —
GetProcessMemoryInfo,GetProcessTimes,CreateToolhelp32Snapshot
On macOS, the traditional approach — sysctl with kinfo_proc — zeros out memory
and CPU fields. The task_for_pid Mach trap is blocked by SIP/AMFI entitlements,
and raw syscalls are volatile across releases. No existing pure-Go library
correctly reads process metrics on all three platforms without CGO or shell
execution.
pidpeek uses proc_pidinfo (a stable, public libproc API) via purego on Darwin,
procfs on Linux, and the Win32 process API on Windows. The result is a single
unified API that works everywhere with CGO_ENABLED=0.
go get github.com/xDarkicex/pidpeek
package main
import (
"fmt"
"os"
"github.com/xDarkicex/pidpeek"
)
func main() {
// Current process
m, err := pidpeek.Self()
if err != nil {
panic(err)
}
fmt.Printf("RSS: %s, Threads: %d, CPU: %.3fs\n",
pidpeek.FormatBytes(m.RSS), m.ThreadNum, m.CPUTotalSec)
// Current process identity
id, err := pidpeek.SelfIdentity()
if err != nil {
panic(err)
}
fmt.Printf("Name: %s, PPID: %d, Exe: %s\n", id.Name, id.Ppid, id.ExePath)
// Arbitrary PID — combine metrics + identity in one call
info, err := pidpeek.GetAll(os.Getpid())
if err != nil {
panic(err)
}
fmt.Printf("VMS: %s, Created: %d\n",
pidpeek.FormatBytes(info.Metrics.VMSSize), info.Identity.CreateTime)
// Go runtime heap
fmt.Printf("Go heap: %s\n", pidpeek.FormatBytes(pidpeek.GoHeapAlloc()))
}Run the full example:
go run ./examples/process-info/
go run ./examples/process-info/ 1 # inspect PID 1
| Function | Returns | Description |
|---|---|---|
Get(pid int) |
(Metrics, error) |
Resource usage for a PID |
GetIdentity(pid int) |
(Identity, error) |
Identity for a PID |
GetAll(pid int) |
(ProcessInfo, error) |
Metrics + identity in one platform call |
Self() |
(Metrics, error) |
Resource usage for the current process |
SelfIdentity() |
(Identity, error) |
Identity for the current process |
FormatBytes(uint64) |
string |
IEC binary formatting: KiB, MiB, GiB |
GoHeapAlloc() |
uint64 |
Live heap bytes from runtime/metrics |
GoRuntime() |
GoRuntimeMetrics |
Fixed-size self-runtime snapshot: live heap and cumulative allocation bytes/objects |
Get(0) returns ErrProcessNotFound on all platforms.
type Metrics struct {
RSS uint64 // resident set size in bytes
VMSSize uint64 // virtual memory size in bytes (platform-mapped)
CPUTotalSec float64 // user + system CPU seconds
ThreadNum int32 // number of threads
}
type Identity struct {
Name string // process name
Ppid int // parent PID
CreateTime int64 // Unix timestamp (seconds since epoch)
ExePath string // absolute path (empty if inaccessible)
}
type ProcessInfo struct {
Metrics
Identity
}For callers that need a testability seam — inject a mock rather than hitting real OS calls:
type Inspector interface {
Get(pid int) (Metrics, error)
GetIdentity(pid int) (Identity, error)
GetAll(pid int) (ProcessInfo, error)
Self() (Metrics, error)
SelfIdentity() (Identity, error)
}Reference the global pidpeek.DefaultInspector or pass your own. The exported
package-level functions delegate to DefaultInspector.
var (
ErrProcessNotFound = errors.New("process not found")
ErrAccessDenied = errors.New("access denied")
ErrResourceExhausted = errors.New("resource exhausted")
)Errors are wrapped with operation and PID context — errors.Is still works on
the sentinel:
pidpeek.Get 999999: process not found
| Platform Error | Unified Sentinel |
|---|---|
ESRCH / ENOENT / ERROR_FILE_NOT_FOUND |
ErrProcessNotFound |
EACCES / EPERM / ERROR_ACCESS_DENIED |
ErrAccessDenied |
| EMFILE / ENFILE | ErrResourceExhausted |
Calls proc_pidinfo (flavors 3, 4, 11) and proc_pidpath from libproc.dylib,
and mach_timebase_info from libSystem.B.dylib — all dynamically linked via
purego. No CGO, no sysctl, no
task_for_pid.
ResidentSizefromproc_taskinfois already in bytes — not multiplied by page size- Name resolution tries
pbi_namefirst, falls back topbi_comm - Zombie processes return
ErrProcessNotFound(proc_pidinfo returns 0) - CPU time uses
mach_timebase_infofor nanosecond precision
Reads /proc/<pid>/stat, /proc/<pid>/statm, /proc/<pid>/exe, /proc/stat,
and /proc/self/auxv. Standard library only.
CLK_TCKresolved from/proc/self/auxvat first use; falls back to 100 if the entry is missing (Alpine/musl containers)- RSS from
statmfield 1 ×pageSize vsizefromstatfield 23 is already in bytes- Zombie processes (
state == 'Z') return zeroedMetricswithnilerror - Auxv reader stops at
AT_NULLsentinel; does not assume a fixed buffer size
Uses GetProcessMemoryInfo (psapi), GetProcessTimes (kernel32),
QueryFullProcessImageNameW (kernel32), and CreateToolhelp32Snapshot /
Thread32First / Thread32Next (kernel32) for thread enumeration.
PROCESS_QUERY_LIMITED_INFORMATIONonly — minimal privilegeSelf()usesGetCurrentProcess()pseudo-handle (never closed)- VMS is
PagefileUsage(Commit Charge), not rawVirtualSize - Thread count via ToolHelp snapshot enumeration (best-effort; returns 0 on failure)
- FILETIME to Unix epoch: offset 116444736000000000 (100ns ticks)
| Platform | RSS meaning |
|---|---|
| Linux | Includes shared memory pages; NUMA reports total pages across all nodes; huge pages inflate by 2MB per page |
| Darwin | Physical RAM pages attributed to the task |
| Windows | Working set (private + shared pages) |
| Platform | VMS meaning |
|---|---|
| Linux | vsize — total virtual address space |
| Darwin | VirtualSize — total virtual address space |
| Windows | PagefileUsage — Commit Charge (private committed memory) |
pidpeek uses xDarkicex/memory — an off-heap, lock-free, mmap-backed allocator — to eliminate GC pressure from per-call struct and buffer allocations on Darwin.
The initial implementation allocated a 96-byte ProcTaskInfo, a 136-byte
ProcBsdInfo, and a 4KB path buffer on the Go heap for every Get /
GetIdentity call. Under memprofile, readExePath alone accounted for
70% of total allocation volume.
Moving these to memory.FreeList (off-heap mmap, invisible to GC) reduced
allocation volume by 84% on GetIdentity and 78% on GetAll:
| Benchmark | Before (B/op) | After (B/op) | Reduction |
|---|---|---|---|
Get |
480 | 384 | -20% |
GetIdentity |
5,120 | 816 | -84% |
GetAll |
5,608 | 1,208 | -78% |
This matters for latency-sensitive observability tools: fewer heap allocations means fewer GC cycles and more predictable p99 tail latency. The remaining allocations are in purego's C-call trampoline and string conversions for returned fields — not fixable from the pidpeek side.
Full benchmark data: BENCHMARK.md.
All exported functions are goroutine-safe. Global constants (CLK_TCK, boot time,
timebase ratio) are initialized once via sync.Once. No mutable state is shared
between calls. No mutexes, no atomics in the hot path.
- CGO_ENABLED=0 — enforced at build time; no C toolchain required
- No
os/exec— no shelling out tops,top, ortasklist - No
runtime.ReadMemStats— usesruntime/metricsexclusively (no STW pauses) - Go 1.25+ — required for
runtime.Pinnerhygiene (Darwin purego path)
MIT — see LICENSE.
Purego is used under the Apache License 2.0. Attribution in NOTICE,
full text at licenses/purego/LICENSE.