Replace sleep-polling loops with condition-based waits#194
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #194 +/- ##
==========================================
+ Coverage 79.43% 79.78% +0.35%
==========================================
Files 10 10
Lines 1969 1999 +30
==========================================
+ Hits 1564 1595 +31
+ Misses 405 404 -1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
I don't really agree with this. For events that occur infrequently and with a wait time on the order of 100s of milliseconds polling is perfectly fine, and in this case objectively simpler than using conditions. |
The worker synchronization paths busy-waited by calling `sleep` in a
loop while polling shared state (worker connection state, cluster
membership, and worker termination). Migrate these to wait/notify on a
condition so waiters wake promptly on the relevant change.
- Add a module-level `worker_state_cond::Threads.Condition` used for
all waits on worker state or cluster membership.
- Add a `wait_or_deadline(cond, deadline)` helper that waits for a
notification up to an absolute deadline (nanoseconds on the
`time_ns()` clock; `Inf` waits forever), returning `false` once the
deadline passes so callers can do:
`wait_or_deadline(...) || error("timeout")`.
This is another step towards thread-safety, but not complete.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
The timedwait implementation also has numerous threading bugs that this condition variable version doesn't have |
|
Like what? |
vtjnash
left a comment
There was a problem hiding this comment.
bugs marked as review comments. but anyways, why would we ever use a bad implementation (timedwait) when the correct implementation is so trivial to do
| function register_worker(pg, w) | ||
| push!(pg.workers, w) | ||
| map_pid_wrkr[w.id] = w | ||
| @lock worker_state_cond begin |
There was a problem hiding this comment.
Failure to lock before changing state
|
|
||
| start = time_ns() | ||
| while (time_ns() - start) < waitfor*1e9 | ||
| all(w -> (@atomic w.state) === W_TERMINATED, rmprocset) && break |
There was a problem hiding this comment.
O(n^2) loop when O(n) condition was sufficient
| errormonitor( | ||
| @async begin | ||
| timeout = worker_timeout() | ||
| if timedwait(() -> haskey(map_pid_wrkr, 1), timeout) === :timed_out |
There was a problem hiding this comment.
Access to threaded value outside of a lock
| filterfunc(x) = (x.id != 1) && isdefined(x, :config) && | ||
| (notnothing(x.config.ident) in something(wconfig.connect_idents, [])) | ||
|
|
||
| wlist = filter(filterfunc, PGRP.workers) |
There was a problem hiding this comment.
Access to a threaded value outside of a lock
| for jw in PGRP.workers | ||
| if (jw.id != 1) && (jw.id < w.id) | ||
| # wait for wl to join | ||
| if (@atomic jw.state) === W_CREATED |
There was a problem hiding this comment.
Access to a threaded value outside of a lock
| error("peer workers did not connect within $timeout seconds") | ||
| end | ||
| end | ||
| sleep(1.0) |
There was a problem hiding this comment.
Unnecessary delay for more than 1 ms (for :custom topology)
|
Aren't all of those bugs unrelated to polling? Maybe except for the O(n^2) loop, which I kinda doubt will ever be an issue. To clarify, the thing that gives me pause is
|
|
Yes, the claim isn't directly that condition is better than timedwait, the claim is the reverse: if you're using timedwait, it probably means you are doing a bad job at handling concurrency correctly everywhere else. And that's what is seen is true here. Once you fix all the concurrency bugs here, the use of a condition variable falls out naturally, because you already had to have that to structure to fix all the other concurrency mistakes. The use of Handling spurious wakeups are a concurrency-correctness 101 topic. I'd hardly consider a beginner-class intro lecture topic to ever be considered excessively complicated. These are simply clones of the "naive first attempt" vs "correct implementation" template examples from https://en.wikipedia.org/wiki/Monitor_(synchronization) |
|
Aside: some languages do have an overload of the
It isn't unheard for Julia to use |
|
In most situations I would also go for a condition, but here the case is not very convincing to me. Given how infrequently the events occur I don't see a performance reason for avoiding polling, and if I read the code you pointed out it's not clear to me how using a condition would prevent that thread-unsafe behaviour by design. For example, accessing BTW for my own understanding, why is something like |
|
The read must synchronize with the Base.Lockable is also reasonable here. Orthogonal change though since both constructors (Condition and Lockable) take an argument of which lock to use internally, specifically so that they can share the same locks. |
I will repeat, it does not help prevent thread-unsafe behaviors by design. But the converse: thread-safe designs are trivial to make use correct |
The worker synchronization paths busy-waited by calling
sleepin a loop while polling shared state (worker connection state, cluster membership, and worker termination). Migrate these to wait on aThreads.Conditionwith proper lock/notify/wait so waiters wake promptly on the relevant change instead of polling.Worker.c_stateto aThreads.Conditionand take its lock around every state read/write and notify.wait_or_deadline(cond, deadline)helper that waits for a notification up to an absolute deadline (introducing spurious notifications for all users ofcond).worker_state_cond, notified inregister_worker, socheck_master_connectand the custom-topology setup can wait for workers/master to join rather than polling.This is also a step towards more thread-safety, but not complete.