Handle processes whose main thread has exited#376
Conversation
|
Thanks for looking into this. This looks OK overall and should solve the issue from the user perspective. One of the downsides I see is that while we do not unload the old mappings, we re also not loading new mappings, which may degrade profiling of such processes ( I am still not sure if there are legit applications with dead main thread, or is it a highly infrequent corner case) I personally would prefer if the processmanager "re-elected" a main thread by looking into the process threads, although I realize it may require more work and we may do this later. Another thing to consider is to hook a kprobe on It would be nice to have a unit test for this case regardless of the solution we chose. |
I'm currently working on this, will push new commits (implementing part 2 of the proposed solution in #365) today.
I think we can switch to EDIT: EDIT2: Went back to |
093a15f to
38f6e51
Compare
e11a0dc to
87e351e
Compare
| } else if path != "" { | ||
| // Ignore [vsyscall] and similar executable kernel | ||
| // pages we don't care about | ||
| } else { |
There was a problem hiding this comment.
No semantic change, I just inlined the logic from GetMappings here as this is the more appropriate place.
| func (pm *ProcessManager) processPIDExit(pid libpf.PID) { | ||
| exitKTime := times.GetKTime() | ||
| log.Debugf("- PID: %v", pid) | ||
| log.Warnf("- PID: %v", pid) |
There was a problem hiding this comment.
I'll remove these newly added warnings before merging, they should help with reviewing the PR as you don't need to run the agent with debug logs enabled and sort through a lot of irrelevant noise.
| } | ||
| return | ||
| } | ||
| if len(mappings) == 0 { |
There was a problem hiding this comment.
These comments are no longer relevant.
|
I added some more information and notes on how to review/test to the description. @korniltsev please take another look and review/test. |
87e351e to
48698d5
Compare
|
Great job. Thank you for looking into this. |
94a86eb to
5905d6a
Compare
|
|
||
| // New returns an object with Process interface accessing it | ||
| func New(pid libpf.PID) Process { | ||
| func New(pid, tid libpf.PID) Process { |
There was a problem hiding this comment.
I didn't switch Process to accept libpf.PIDTID as the latter is only used with PID events, and I'd rather not couple it here too.
florianl
left a comment
There was a problem hiding this comment.
Please remove the log.Warn(..) messages as mentioned in #376 (comment) before merging.
c01c8b0 to
9f268e1
Compare
4f58051 to
7f8bea8
Compare
|
I rebased this PR on top of current |
fabled
left a comment
There was a problem hiding this comment.
Thanks! Looks pretty good already. I added few questions and comments. But I'll pre-approve this already so we can go forward.
| // Test for main thread exit by checking for Zombie state | ||
| pidStat, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", sp.pid)) | ||
| if err != nil { | ||
| // Should never happen while process is alive | ||
| return nil, 0, err | ||
| } | ||
|
|
||
| var p int | ||
| var c string | ||
| var state rune | ||
| n, err := fmt.Sscanf(string(pidStat), "%d %s %c", &p, &c, &state) | ||
| if err != nil || n < 3 { | ||
| // Should never happen | ||
| return nil, 0, err | ||
| } | ||
| sp.fileToMapping = fileToMapping | ||
| if state != 'Z' { | ||
| return mappings, numParseErrors, ErrNoMappings | ||
| } | ||
|
|
||
| log.Warnf("PID: %v main thread exit", sp.pid) |
There was a problem hiding this comment.
Is this really needed? I think we can just remove the zombie check.
If the maps file exists, it means process is running.
If the maps file is empty, it means the main thread has exited. There is no other condition that the main maps is empty, because the main thread cannot be executing code if there are no mappings available. This is purely a side effect of kernel having released the main thread specific resources.
Based on the two above things we can determine if: the process exited (since ebpf sent the event), or if the main thread has exited.
Or are you aware of some condition where this makes a difference? I think it was if all mappings entries resulted in parsing error? But I believe this should be handled as an error earlier. The reason is that reading the TID specific maps should be identical to the PID specific as memory mappings are shared between all threads.
Perhaps the only check to do here is if pid == tid then return early with ErrNoMappings.
There was a problem hiding this comment.
Removed the zombie check (we'd need it if we go back to walking /proc but we don't need it now).
| continue | ||
| } | ||
| fileToMapping[m.Path] = m | ||
| if err != nil { |
There was a problem hiding this comment.
I think here we should also return early if err is nil and numParseErrors is non-zero. Or perhaps even better, parseMappings could return an err if it failed to find usable mappings (but it managed to read data). The idea is basically to distinguish here if mappings is empty or all lines were non-parseable.
There was a problem hiding this comment.
Doesn't the len(mappings) == 0 check that follows cover this case?
If err == nil and len(mappings) != 0 then we simply continue and process the mappings. If err == nil and len(mappings) == 0 then we continue and try mappings from another thread. Essentially all branching logic depends on err and len(mappings), not numParseErrors which is purely advisory.
| numParseErrorsAlt := uint32(0) | ||
| mappings, numParseErrorsAlt, err = parseMappings(mapsFileAlt) | ||
| numParseErrors += numParseErrorsAlt |
There was a problem hiding this comment.
I'd just overwrite the numParseErrors instead of adding them. It is only ever used for counters. And since the per TID and per PID maps should be identical, you are basically reporting doubled errors counter in this case.
sched_process_free is called when the task is freed by the kernel, which allows for simpler cleanup of processes whose main thread has exited.
Making TID available to processmanager allows the agent to keep profiling a process whose main thread calls pthread_exit while other threads continue to run.
This allows the agent to continue profiling a process whose main thread has exited, but other threads continue to run. Mapping changes triggered by one of the remaining threads are also tracked.
The latter is OS-agnostic, but the agent only runs on Linux.
7eb3d80 to
ce6940a
Compare
Summary
This PR implements both steps described in #365 (comment).
Thanks to @korniltsev for suggesting
disassociate_ctty, I ended up using another tracepointsched_process_freeinstead as it makes fewer assumptions and is more stable (see this comment for more context). It also allows us to simplify cleanup logic (no need for the extra periodic cleanups I had in the first prototype solution), as userspace will get a final PID notification when the process gets freed by the kernel.Essentially, whenever the main thread exits, we do not unload process information thus allowing profiling the remaining threads to continue. Processmanager can also track mapping changes triggered by one of the remaining threads.
I added some debug warning statements to ease review, I will remove the commit that introduced them before merging. I also added a C program that you can compile and run as a testing workload with the profiling agent also running, that should exercise all the corner cases that this PR addresses. Looking at the warning logs I added and the generated flamegraph in devfiler should make the timeline of processmanager operations very clear.
It's probably easier to review this commit-by-commit.
TODO:
Add test program