Repository navigation
Cache program counters to frames #62
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,9 +4,9 @@ package stack | |
| import ( | ||
| "fmt" | ||
| "hash/crc32" | ||
| "os" | ||
| "runtime" | ||
| "strings" | ||
| "sync" | ||
| ) | ||
|
|
||
| var ( | ||
|
|
@@ -16,6 +16,29 @@ var ( | |
| "bitbucket.org/", | ||
| "launchpad.net/", | ||
| } | ||
|
|
||
| // pcBufPool reuses the []uintptr scratch buffers that runtime.Callers writes | ||
| // into. BuildStack runs on every error, so recycling these avoids an 800-byte | ||
| // allocation per call. | ||
| pcBufPool = sync.Pool{ | ||
| New: func() interface{} { | ||
| b := make([]uintptr, 100) | ||
| return &b | ||
| }, | ||
| } | ||
|
|
||
| // symCache memoises the symbolisation of a program counter. A PC always maps | ||
| // to the same frame(s) for the life of the process (code doesn't move), so | ||
| // resolving file/line/function names — the expensive part of building a stack | ||
| // — only has to happen once per distinct call site. The cache is bounded by | ||
| // the number of call sites in the binary, so it cannot grow without bound. | ||
| // | ||
| // We deliberately cache frames by value, not by pointer: BuildStack copies | ||
| // them into a fresh backing array on every call, so a caller that mutates a | ||
| // returned Frame only affects their own Stack and can never corrupt the | ||
| // shared cache (Frame is all strings and ints, so a struct copy is fully | ||
| // independent). | ||
| symCache sync.Map // map[uintptr][]Frame | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we consider using something with a bounded size, or that expires entries? While the number of pc addresses might be statically determined, I'm not sure there's much value in keeping them around forever. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Given we'll only populate the cache with frames from a Terror being constructed, I'm not overly worried about eviction? Having constructed a Terror from that frame is probably a good signal that we'll eventually do it again in this process (or the process will go away, in which case freeing the memory doesn't matter).
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I talked about this here too: #62 (comment) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We do have a typed sync map in wearedev in ... Which we can't use because this is not in the monorepo. 😢
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We'd also need to worry about potential circular dependencies 🤔 |
||
| ) | ||
|
|
||
| type Frame struct { | ||
|
|
@@ -28,37 +51,76 @@ type Frame struct { | |
| type Stack []*Frame | ||
|
|
||
| func BuildStack(skip int) Stack { | ||
| stack := make(Stack, 0) | ||
|
|
||
| // Look up to a maximum depth of 100 | ||
| ret := make([]uintptr, 100) | ||
| // Look up to a maximum depth of 100, reusing a pooled buffer. The buffer is | ||
| // only referenced by runtime.CallersFrames below, so it's safe to return it | ||
| // to the pool once we've finished iterating within this function. | ||
| bufp := pcBufPool.Get().(*[]uintptr) | ||
| ret := *bufp | ||
| defer pcBufPool.Put(bufp) | ||
|
|
||
| // Note that indexes must be one higher when passed to Callers() | ||
| // than they would be when passed to Caller() | ||
| // see https://golang.org/pkg/runtime/#Caller | ||
| index := runtime.Callers(skip+1, ret) | ||
| if index == 0 { | ||
| // We have no frames to report, skip must be too high | ||
| return stack | ||
| return Stack{} | ||
| } | ||
|
|
||
| // This function takes a list of counters and gets function/file/line information | ||
| cf := runtime.CallersFrames(ret[:index]) | ||
| // Symbolise each PC, reusing cached results for call sites we've already | ||
| // resolved. We copy the cached frame values into a backing array that this | ||
| // call exclusively owns, so callers can freely mutate the returned frames | ||
| // without affecting the cache or any other Stack. A PC can expand to more | ||
| // than one frame when calls are inlined, so index is a lower bound. | ||
| frames := make([]Frame, 0, index) | ||
| for _, pc := range ret[:index] { | ||
| frames = append(frames, framesForPC(pc)...) | ||
| } | ||
|
|
||
| // Now the backing array is stable we can hand out pointers into it. | ||
| stack := make(Stack, len(frames)) | ||
| for i := range frames { | ||
| stack[i] = &frames[i] | ||
| } | ||
| return stack | ||
| } | ||
|
|
||
| // framesForPC resolves a single program counter to its frame(s), memoising the | ||
| // result. Symbolisation (decoding the runtime's line tables) is the dominant | ||
| // cost of building a stack, and the mapping is stable for the life of the | ||
| // process, so we only pay it once per distinct PC. | ||
| // | ||
| // The returned slice is the shared, cached copy and must not be mutated; | ||
| // callers append it into a private backing array (see BuildStack). | ||
| func framesForPC(pc uintptr) []Frame { | ||
| if cached, ok := symCache.Load(pc); ok { | ||
| return cached.([]Frame) | ||
| } | ||
|
|
||
| // A single PC can expand into multiple frames due to inlining, so we still | ||
| // have to iterate CallersFrames. Processing one PC at a time yields the same | ||
| // frames as processing the whole slice at once (verified in the tests). | ||
|
Comment on lines
+100
to
+102
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thank you for the detailed comments! |
||
| cf := runtime.CallersFrames([]uintptr{pc}) | ||
| var frames []Frame | ||
| for { | ||
| frame, ok := cf.Next() | ||
| stack = append(stack, &Frame{ | ||
| frame, more := cf.Next() | ||
| frames = append(frames, Frame{ | ||
| Filename: shortenFilePath(frame.File), | ||
| Method: functionName(frame.PC), | ||
| Line: frame.Line, | ||
| PC: frame.PC, | ||
| // frame.Function already carries the fully-qualified name, so we use | ||
| // it directly rather than doing a second runtime.FuncForPC lookup. | ||
| Method: functionName(frame.Function), | ||
| Line: frame.Line, | ||
| PC: frame.PC, | ||
| }) | ||
| if !ok { | ||
| // This was the last valid caller | ||
| if !more { | ||
| break | ||
| } | ||
| } | ||
| return stack | ||
|
|
||
| // LoadOrStore keeps a single canonical slice per PC even if two goroutines | ||
| // race to symbolise the same one concurrently. | ||
| actual, _ := symCache.LoadOrStore(pc, frames) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not blocking! I'm a little anxious about turning this on for all terrors-using code all at once. It might be nice to feature-gate the cache? I wonder if we could hook up some feature-gating mechanism from bedrock?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I guess the biggest concern is the cache sizing. I'm not concerned about this at all. We have a finite number of callsites which we can cache The number of callsites we would symbolise in the pathological case is under 100,000, and even then we're looking at a couple megabytes of space usage |
||
| return actual.([]Frame) | ||
| } | ||
|
|
||
| // Create a fingerprint that uniquely identify a given message. We use the full | ||
|
|
@@ -182,12 +244,11 @@ func shortenFilePath(s string) string { | |
| return s | ||
| } | ||
|
|
||
| func functionName(pc uintptr) string { | ||
| fn := runtime.FuncForPC(pc) | ||
| if fn == nil { | ||
| func functionName(name string) string { | ||
| if name == "" { | ||
| return "???" | ||
| } | ||
| name := fn.Name() | ||
| end := strings.LastIndex(name, string(os.PathSeparator)) | ||
| // Function names are always '/'-separated regardless of the host OS. | ||
| end := strings.LastIndex(name, "/") | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good thing we don't support Windows I guess. 🙈 |
||
| return name[end+1:] | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've always known this operation as "symbolication", but I have seen "symbolisation" used elsewhere.
This probably doesn't matter, but I wonder if go uses one over the other? If so, it might be marginally easier to find relevant documentation if we're consistent. 👀
... Ok, it appears go uses neither, so never mind! 😅