Skip to content

Commit a7ea563

Browse files
vtjnashclaude
andauthored
Replace sleep-polling loops with condition-based waits (#194)
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>
1 parent 6e9c901 commit a7ea563

2 files changed

Lines changed: 107 additions & 45 deletions

File tree

src/cluster.jl

Lines changed: 105 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -103,14 +103,15 @@ end
103103
@enum WorkerState W_CREATED W_CONNECTED W_TERMINATING W_TERMINATED W_UNKNOWN_STATE
104104
mutable struct Worker
105105
id::Int
106-
msg_lock::Threads.ReentrantLock # Lock for del_msgs, add_msgs, and gcflag
106+
msg_lock::ReentrantLock # guards `del_msgs`, `add_msgs`, and `gcflag`
107107
del_msgs::Array{Any,1} # XXX: Could del_msgs and add_msgs be Channels?
108108
add_msgs::Array{Any,1}
109109
@atomic gcflag::Bool
110+
# `state` changes are published via the module-global `worker_state_cond`;
111+
# wait on that (re-checking this atomic) rather than a per-worker condition
110112
@atomic state::WorkerState
111-
c_state::Threads.Condition # wait for state changes, lock for state
112-
ct_time::Float64 # creation time
113-
conn_func::Any # used to setup connections lazily
113+
ct_time::Float64 # creation time
114+
conn_func::Any # used to setup connections lazily
114115

115116
r_stream::IO
116117
w_stream::IO
@@ -142,7 +143,7 @@ mutable struct Worker
142143
if haskey(map_pid_wrkr, id)
143144
return map_pid_wrkr[id]
144145
end
145-
w=new(id, Threads.ReentrantLock(), [], [], false, W_CREATED, Threads.Condition(), time(), conn_func)
146+
w=new(id, ReentrantLock(), [], [], false, W_CREATED, time(), conn_func)
146147
w.initialized = Event()
147148
register_worker(w)
148149
w
@@ -152,10 +153,10 @@ mutable struct Worker
152153
end
153154

154155
function set_worker_state(w, state)
155-
lock(w.c_state) do
156-
@atomic w.state = state
157-
notify(w.c_state; all=true)
158-
end
156+
@atomic w.state = state
157+
# Wake every task waiting on worker state/membership: `wait_for_conn`,
158+
# topology setup in `create_worker`, `_rmprocs`, `check_master_connect`, etc.
159+
@lock worker_state_cond notify(worker_state_cond; all=true)
159160
end
160161

161162
function check_worker_state(w::Worker)
@@ -202,10 +203,14 @@ function wait_for_conn(w)
202203
timeout = worker_timeout() - (time() - w.ct_time)
203204
timeout <= 0 && error("peer $(w.id) has not connected to $(myid())")
204205

205-
if timedwait(() -> (@atomic w.state) === W_CONNECTED, timeout) === :timed_out
206-
# Notify any waiters on the state and throw
207-
@lock w.c_state notify(w.c_state)
208-
error("peer $(w.id) didn't connect to $(myid()) within $timeout seconds")
206+
deadline = time_ns() + timeout * 1e9
207+
@lock worker_state_cond begin
208+
while (@atomic w.state) !== W_CONNECTED
209+
wait_or_deadline(worker_state_cond, deadline) || break
210+
end
211+
if (@atomic w.state) !== W_CONNECTED
212+
error("peer $(w.id) didn't connect to $(myid()) within $(worker_timeout()) seconds")
213+
end
209214
end
210215
end
211216
nothing
@@ -223,6 +228,32 @@ end
223228

224229
worker_timeout() = parse(Float64, get(ENV, "JULIA_WORKER_TIMEOUT", "60.0"))
225230

231+
# Wait for `notify` on the `Threads.Condition`. Triggers its own notification
232+
# when the absolute `deadline` (nanoseconds on the `time_ns()` clock; `Inf`
233+
# waits forever) arrives.
234+
#
235+
# Return `false` once the deadline has passed (so callers can
236+
# `wait_or_deadline(...) || break`) and `true` otherwise. The deadline causes
237+
# spurious wakeups for other waiters, so callers must re-check their own
238+
# condition in a loop.
239+
function wait_or_deadline(c::Threads.Condition, deadline::Real)
240+
remaining = (deadline - time_ns()) / 1e9
241+
if !isfinite(remaining)
242+
wait(c)
243+
return true
244+
end
245+
remaining <= 0 && return false
246+
timer = Timer(remaining) do _
247+
@lock c notify(c; all=true)
248+
end
249+
try
250+
wait(c)
251+
finally
252+
close(timer)
253+
end
254+
return true
255+
end
256+
226257

227258
## worker creation and setup ##
228259
"""
@@ -363,7 +394,7 @@ function read_worker_host_port(io::IO)
363394

364395
# Wait at most for JULIA_WORKER_TIMEOUT seconds to read host:port
365396
# info from the worker
366-
timeout = worker_timeout() * 1e9
397+
deadline = t0 + worker_timeout() * 1e9
367398
# We expect the first line to contain the host:port string. However, as
368399
# the worker may be launched via ssh or a cluster manager like SLURM,
369400
# ignore any informational / warning lines printed by the launch command.
@@ -372,12 +403,20 @@ function read_worker_host_port(io::IO)
372403

373404
ntries = 1000
374405
leader = String[]
406+
# notified when a spawned `readline` task finishes, so we can wait for a line
407+
# to arrive (or the overall timeout to elapse) without polling
408+
readcond = Threads.Condition()
375409
try
376410
while ntries > 0
377-
readtask = @async readline(io)
378-
yield()
379-
while !istaskdone(readtask) && ((time_ns() - t0) < timeout)
380-
sleep(0.05)
411+
readtask = @async try
412+
readline(io)
413+
finally
414+
@lock readcond notify(readcond; all=true)
415+
end
416+
@lock readcond begin
417+
while !istaskdone(readtask)
418+
wait_or_deadline(readcond, deadline) || break
419+
end
381420
end
382421
!istaskdone(readtask) && break
383422

@@ -713,9 +752,9 @@ function create_worker(manager::ClusterManager, wconfig::WorkerConfig)
713752
for jw in PGRP.workers
714753
if (jw.id != 1) && (jw.id < w.id)
715754
# wait for wl to join
716-
if (@atomic jw.state) === W_CREATED
717-
lock(jw.c_state) do
718-
wait(jw.c_state)
755+
@lock worker_state_cond begin
756+
while (@atomic jw.state) === W_CREATED
757+
wait(worker_state_cond)
719758
end
720759
end
721760
push!(join_list, jw)
@@ -727,23 +766,27 @@ function create_worker(manager::ClusterManager, wconfig::WorkerConfig)
727766
filterfunc(x) = (x.id != 1) && isdefined(x, :config) &&
728767
(notnothing(x.config.ident) in something(wconfig.connect_idents, []))
729768

730-
wlist = filter(filterfunc, PGRP.workers)
731-
waittime = 0
732-
while wconfig.connect_idents !== nothing &&
733-
length(wlist) < length(wconfig.connect_idents)
734-
if waittime >= timeout
735-
error("peer workers did not connect within $timeout seconds")
769+
deadline = time_ns() + timeout * 1e9
770+
# Recompute `wlist` under the lock before each wait so we never miss a
771+
# `register_worker` notification that races with the filter (a lost
772+
# wakeup would stall this until the deadline).
773+
local wlist
774+
@lock worker_state_cond begin
775+
while true
776+
wlist = filter(filterfunc, PGRP.workers)
777+
(wconfig.connect_idents === nothing ||
778+
length(wlist) >= length(wconfig.connect_idents)) && break
779+
if !wait_or_deadline(worker_state_cond, deadline)
780+
error("peer workers did not connect within $timeout seconds")
781+
end
736782
end
737-
sleep(1.0)
738-
waittime += 1
739-
wlist = filter(filterfunc, PGRP.workers)
740783
end
741784

742785
for wl in wlist
743-
lock(wl.c_state) do
744-
if (@atomic wl.state) === W_CREATED
786+
@lock worker_state_cond begin
787+
while (@atomic wl.state) === W_CREATED
745788
# wait for wl to join
746-
wait(wl.c_state)
789+
wait(worker_state_cond)
747790
end
748791
end
749792
push!(join_list, wl)
@@ -809,12 +852,18 @@ function check_master_connect()
809852
if ccall(:jl_running_on_valgrind,Cint,()) != 0
810853
return
811854
end
812-
813855
errormonitor(
814856
@async begin
815-
timeout = worker_timeout()
816-
if timedwait(() -> haskey(map_pid_wrkr, 1), timeout) === :timed_out
817-
print(stderr, "Master process (id 1) could not connect within $(timeout) seconds.\nexiting.\n")
857+
timeout = worker_timeout() * 1e9
858+
deadline = time_ns() + timeout
859+
@lock worker_state_cond begin
860+
while !haskey(map_pid_wrkr, 1)
861+
wait_or_deadline(worker_state_cond, deadline) || break
862+
end
863+
end
864+
865+
if !haskey(map_pid_wrkr, 1)
866+
print(stderr, "Master process (id 1) could not connect within $(timeout/1e9) seconds.\nexiting.\n")
818867
exit(1)
819868
end
820869
end
@@ -896,6 +945,11 @@ const LPROCROLE = Ref{Symbol}(:master)
896945
const HDR_VERSION_LEN=16
897946
const HDR_COOKIE_LEN=16
898947
const map_pid_wrkr = Dict{Int, Union{Worker, LocalProcess}}()
948+
# Notified whenever a worker is registered (added to `map_pid_wrkr`/`PGRP.workers`)
949+
# or changes state (via `set_worker_state`), so tasks can wait for peers or the
950+
# master to join the cluster. The lock guards reads/writes of the membership
951+
# collections done under it.
952+
const worker_state_cond = Threads.Condition()
899953
const map_sock_wrkr = IdDict()
900954
const map_del_wrkr = Set{Int}()
901955

@@ -1130,10 +1184,15 @@ function _rmprocs(pids, waitfor)
11301184
end
11311185
end
11321186

1133-
start = time_ns()
1134-
while (time_ns() - start) < waitfor*1e9
1135-
all(w -> (@atomic w.state) === W_TERMINATED, rmprocset) && break
1136-
sleep(min(0.1, waitfor - (time_ns() - start)/1e9))
1187+
# `waitfor == typemax(Int)` means wait indefinitely; otherwise bound the
1188+
# total wait across all workers by a single shared deadline
1189+
deadline = waitfor == typemax(Int) ? Inf : time_ns() + waitfor * 1e9
1190+
for w in rmprocset
1191+
@lock worker_state_cond begin
1192+
while (@atomic w.state) !== W_TERMINATED
1193+
wait_or_deadline(worker_state_cond, deadline) || break
1194+
end
1195+
end
11371196
end
11381197

11391198
unremoved = [wrkr.id for wrkr in filter(w -> (@atomic w.state) !== W_TERMINATED, rmprocset)]
@@ -1203,8 +1262,11 @@ end
12031262

12041263
register_worker(w) = register_worker(PGRP, w)
12051264
function register_worker(pg, w)
1206-
push!(pg.workers, w)
1207-
map_pid_wrkr[w.id] = w
1265+
@lock worker_state_cond begin
1266+
push!(pg.workers, w)
1267+
map_pid_wrkr[w.id] = w
1268+
notify(worker_state_cond; all=true)
1269+
end
12081270
end
12091271

12101272
function register_worker_streams(w)

src/remotecall.jl

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,7 @@ function send_del_client_no_lock(rr)
310310
end
311311

312312
function publish_del_msg!(w::Worker, msg)
313-
lock(w.msg_lock) do
313+
@lock w.msg_lock begin
314314
push!(w.del_msgs, msg)
315315
@atomic w.gcflag = true
316316
end
@@ -354,7 +354,7 @@ function send_add_client(rr::AbstractRemoteRef, i)
354354
# to the processor that owns the remote ref. it will add_client
355355
# itself inside deserialize().
356356
w = worker_from_id(rr.where)
357-
lock(w.msg_lock) do
357+
@lock w.msg_lock begin
358358
push!(w.add_msgs, (remoteref_id(rr), i))
359359
@atomic w.gcflag = true
360360
end

0 commit comments

Comments
 (0)