Guard against leaking threads while restarting shake - #5027
Conversation
| a <- async $ restore io | ||
| atomicModifyIORef'_ st (void a :) | ||
| return $ wait a | ||
| registered <- registerAsyncs st [void a] |
There was a problem hiding this comment.
should only run on succ registeration instead of finding out the scope have ended and killing it ?
There was a problem hiding this comment.
e.g. async thread should know if itself is registered, it only run the body if it is registered or do nothing otherwise.
|
|
|
I am seeing some unintended failure in the benchmark result, there must be something I am missing. |
crtschin
left a comment
There was a problem hiding this comment.
waitConcurrently_ should apply the same run the body only if registered pattern.
For individual threads I understand, as it's arbitrary side-effecting user code, but why for waitConcurrently_ as well? Is there an error condition you see here?
I've kept a variant of your changes in the PR, but I don't understand the need yet.
And we should not spread the throw everywhere and trigger the ..exception handler multiple times.
Exception handlers are quite cheap in Haskell. If there's a broken precondition and a thread needs to halt, it's more efficient to have the thread immediately die on its own than wait for a different thread to kill it, which can also be quite tricky if that supervisor thread already died.
I've made some adjustments.
I gave this a review, and pushed some small edits.
| asyncWithUnmask $ \unmask -> unmask $ do | ||
| b <- readMVar gate | ||
| -- Only if the thread was successfully registered do we execute | ||
| if b then io else (error "asyncWithCleanUp: scope closed before thread could be spawned") |
There was a problem hiding this comment.
IIRC, calling error makes GHC throw an IOError, which is less easy to work with than AsyncCancelled which is specifically intended to indicate that a async green thread didn't finish the work it intended.
There was a problem hiding this comment.
perhaps just use undefined ? Since we should never inspect the result after the gate shows that it leave its' scope. And the thread should simply do nothing then finish if it knows it is out of scope.
There was a problem hiding this comment.
undefined is the same as error in this case. AsyncCancelled still makes more sense to me here. The thread hit a invariant, so should terminate (safely). Things we definitely want to avoid are hangs, and exceptions that would otherwise cause HLS to crash hard, which things like "undefined" or "error" open the app up to.
There was a problem hiding this comment.
If it hangs or exceptions are invoked, we must be doing something wrong in our model, a hard crash is what we wanted so we can fix them.
| a <- runAsyncIfRegistered gate io | ||
| registered <- registerAsyncs st [void a] |
There was a problem hiding this comment.
I believe there's a logical gap here now. If the AIO is interrupted between these lines, the async spawned isn't killed.
Notice that runAsyncIfRegistered has a check, and registerAsyncs has a subsequent use. If cleanup fires after the runAsync* call, the lock passes and spawns the async, but the registration fails because the scope is closed, orphaning the thread.
There was a problem hiding this comment.
If cleanup fires after the runAsync* call, doesn't it mean we would cleanup the async during cleanup ?
There was a problem hiding this comment.
Yes, I meant if we didn't cancel these unregistered threads that land in between closing the scope in the cleanup, and the registration.
There was a problem hiding this comment.
we should only create two types of threads,
the ones that are registered and canceled by cleanup.
the others that are not registered, should ended itself without doing anything.
we can choose not to kill the second ones, only let it finish.
Notice we register them into the AIO later. So they are AIO async too and should be managed by AIO. |
|
necessary invariant: |
|
I tweaked the abstractions a bit over the last few weeks.
I think the current implementation matches your invariants as well. |
Bug reproductionThe test
Both fix commits prevent the behavior exercised by this regression test.
Benchmark comparisonCommit
Excluding the cradle-edit outlier, the two fix commits are effectively tied. Overall, both fix the tested bug, but ---------above comparison generated by codex ------- @crtschin The newest head introduce some slowdown, I think it is due to the extra complexity from database repairing and a few places which blurred the ownership model, while the old method stayed minimalized and avoid performance regression. Do you mind use View diff instead. Also I think we should shape the test |
|
Thanks for the benchmark run @soulomoon!, I removed an optimization while I was refactoring for the sake of uniformity (the setup runs singular actions inline, instead of separately spawning a thread). I re-added it and now get similar performance to the baseline again now, when running the benchmark locally.
I'm hesitant, the main difference is in how a thread who's scope is closed, terminates. The branch does a loop expecting the thread that initialized the scope to land the |
| * 'repairRefusal' demotes the key to 'Dirty' before re-throwing. | ||
| * 'build' retries once, so the demoted key recomputes in a live scope. | ||
| * 'isAsyncException' classes it async, so it isn't swallowed. | ||
| -} |
There was a problem hiding this comment.
if there is an exception that is not caused by session restart, it should be a bug for the hls-graph, we should not repair it. see defineEarlyCutoff' and actionCatch, there we catch all the rule's errors and leave only the isAsyncException to surface to the hls-graph to handle.
There was a problem hiding this comment.
That means if build does not compelete, it must be a session restart, we should not retry it.
If there are zombie threads, there must be some other bugs hidden in other part of the codebase which we should also rule out. It is better to do just enough to reveal the problem than over do it and cover an unstable problem up. |
Proving the absence of bugs isn't possible.
I don't understand how having a thread hang is a better solution than having the thread throw. The throw is more immediate and visible (and reportable by a user). The hang is the one that invisibly worsens the state (where the main symptom would be increased memory usage and performance degradation). |
My point is not about a hang is better than a throw, I am sorry for giving you that impression. What I am trying to say is that to be able to observe the fact that the scope miss the kills, the parent should not throw immediatly once it sees a failed spawning due to scope closed since it cover up the problems(We do not know wether the scope kill is just late or won't happen). I agree it is not very ideal to let it hang, perhaps a better way is, wait a bit for the scope kills and if that did not happen, log something out and throw. |
|
Here is the newest perf comparison after the sync to the newest master The old fix approach spawnning remain lockless, but the new one use a MVar as a lock to wait for the spawn, might be a bit slower. But usualy only one child for the same scope would be spawned in the same time, so perf degradation might not be obivious.
|
Alternative variant of #5021.
Third time's the charm.
Instead of keeping track and killing threads via the shake database, do this locally in the
AIOabstraction that does cleanup. Once shutdown, theAIOis closed, so threads that are in the process of being spawned get killed instead.