-
-
Notifications
You must be signed in to change notification settings - Fork 5.5k
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
Implement Once type for pthread_once like functionality #55439
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 | ||||
---|---|---|---|---|---|---|
|
@@ -556,3 +556,59 @@ end | |||||
import .Base: Event | ||||||
export Event | ||||||
end | ||||||
|
||||||
const _NOT_CALLED = Ptr{Nothing}(0) | ||||||
const _STARTED = Ptr{Nothing}(1) | ||||||
const _DONE = Ptr{Nothing}(2) | ||||||
|
||||||
""" | ||||||
Once() | ||||||
|
||||||
Create a `Once` object that can be used to execute a callback exactly once across all executions of the program. | ||||||
The callback is executed by calling `call_once!`. This is similar to `pthread_once` in POSIX. | ||||||
|
||||||
``` | ||||||
const once = Once() | ||||||
foo() = println("This will be printed exactly once") | ||||||
call_once!(once, foo) | ||||||
call_once!(once, foo) # does nothing | ||||||
``` | ||||||
|
||||||
!!! compat "Julia 1.12" | ||||||
This functionality requires at least Julia 1.12 | ||||||
""" | ||||||
mutable struct Once | ||||||
@atomic done::Ptr{Nothing} # Ptr is used so it resets during precompilation | ||||||
lock::Threads.Event #Ideally would be a Futex | ||||||
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. Seems like this should be a Condition variable, since being an Event is redundant with
Suggested change
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. Does Condition actually do what we want here? Event is here serving just as something for threads to sleep in if they enter the slow path. Specifically I want something that if somehow enters the slow path after the function has happened to go right through it. 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. You need to call it with 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. Right. But does it allow anyone to go through once it's being notified? Because that's the behaviour I want that Event has. |
||||||
Once() = new(_NOT_CALLED, Threads.Event(false)) | ||||||
end | ||||||
|
||||||
""" | ||||||
call_once!(once::Once, f::Function) | ||||||
|
||||||
Execute the callback `f` if it's the first time `call_once!` is called on this `Once` object. | ||||||
|
||||||
!!! compat "Julia 1.12" | ||||||
This functionality requires at least Julia 1.12 | ||||||
""" | ||||||
@inline function call_once!(once::Once, f::F) where {F <: Function} #TODO: Do we want this specialization | ||||||
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.
Suggested change
|
||||||
if (@atomic :acquire once.done) == _DONE #Make sure this is the right atomic | ||||||
return | ||||||
end | ||||||
call_once_slow_path!(once, f) | ||||||
return | ||||||
end | ||||||
|
||||||
@noinline function call_once_slow_path!(once::Once, f::F) where {F <: Function} | ||||||
if (@atomicreplace :acquire_release once.done _NOT_CALLED => _STARTED).success #TODO: Split into slow path function | ||||||
try | ||||||
f() | ||||||
finally #What if we error | ||||||
@atomic :release once.done = _DONE | ||||||
notify(once.lock) | ||||||
end | ||||||
else | ||||||
wait(once.lock) | ||||||
end | ||||||
return | ||||||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -366,3 +366,21 @@ end | |
@test jl_setaffinity(0, mask, cpumasksize) == 0 | ||
end | ||
end | ||
|
||
@testset "Once" begin | ||
mutable struct MyRandomCounter | ||
@atomic x::Int | ||
end | ||
const cnt = MyRandomCounter(0) | ||
const once = Once() | ||
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. Some API sketch ideas, via macros: function foo()
@once global hasrun1 = true
@lazycall :per_thread global hasrun2 = true
@assert hasrun && hasrun2
end Or via global objects: const startup1 = LazyCall(:onlyonce) do # errors if runs during precompile
global hasrun1 = true
end
const startup2 = LazyCall(:concurrently) do # may run on all threads concurrently, as long as none have finished
startup()
global hasrun2
@atomic @__MODULE__.hasrun2 = true
end
const startup3 = LazyCall(:per_process) do # runs once per process, reset on serialization
startup2()
global hasrun3 = true
end
global hasrun4 = Lockable(Dict{Int,Bool}())
const startup4 = LazyCall(:per_thread, () -> @lock hasrun4 empty!(hasrun4[])) do tid # runs once per thread, runs the reset on serialization
startup3()
@lock hasrun4 hasrun4[][tid] = true
end
global hasrun5 = Lockable(WeakRefDict{Task,Bool}())
const startup5 = LazyCall(:per_task) do
startup3()
@lock hasrun5 hasrun5[][current_task()] = true
finalize(current_task()) do t
@lock hasrun5 pop!(hasrun5[], t)
end
nothing
end
function foo()
startup4()
@assert hasrun && hasrun2 && hasrun3
@assert @lock hasrun4 hasrun4[][Threads.threadid()]
@assert @lock hasrun5 hasrun5[][current_task()]
end Very minimal implementation sketch: const Lock = ReentrantLock
mutable struct LazyCall
@atomic hasrun::Ptr{Cvoid}
const allow_compile_time::Bool
const initializer
const lock::Lock
LazyCall(initializer; allow_compile_time::Bool) = new(C_NULL, allow_compile_time, initializer, Lock())
end
function (once::LazyCall)()
(@atomic :acquire once.hasrun) != C_NULL && return
(@noinline function()
Base.__precompile__(once.allow_compile_time)
@lock once.lock begin
(@atomic :acquire once.hasrun) != C_NULL && return
once.initializer()
(@atomic :release once.hasrun = C_NULL + 1)
nothing
end
end)()
nothing
end |
||
function foo() | ||
sleep(1) | ||
@atomic :monotonic cnt.x += 1 | ||
end | ||
@sync begin | ||
Threads.@spawn call_once!(once, foo) | ||
Threads.@spawn call_once!(once, foo) # does nothing | ||
end | ||
@test cnt.x == 1 | ||
|
||
end |
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.
Off. Does this need to be the semantics? Remember precompilation is an optimization, normally Julia would execute the top-level code in the same session and precompilation is just "in the infinentisimal time window before init".
Now the argument for it resetting during precompilation would be that I am guarding a data structure that is being reset as well, but one might be making the opposing statement as well.
I kinda want to discourage people from running their precompilation workload inside the module while it is still open, rather we should run it outside the module and save the state as if the precompilation workload hasn't run yet (Nathan opened a PR, iirc)
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.
So there's two use cases here. One where I think this should just error if hit during precompilation. But you might be running a precompile workload that needs a library to be loaded/initialized and the only thing that would have the right semantics here is
Ptr
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 think one might reasonably want both behavior
Once
andOncePtr
. The challenge is that currently we don't know what the user is referring to. This kinda makes me wonder if we want this to be a ref to the data produces by FThere 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.
So the scenario I am thinking about is:
When Cache is being serialized it depends on what
data
is if I want to resetonce
or not. If data does not contain foreign pointers thenonce
ought to not be reset.Precompilation is equivalent to top-level execution in the target process, but we cache the effect. So IMO
Once
ought to serialized like any other Julia struct and have a reset function that can be used to reset it at the end of precompilation if it is guarding a foreign pointer.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.
+1 that this is the really semantically trick part
I think the rule is more complicated than that... In many cases
Once
is intended to replace__init__
functionality, in which case you might have cache sizes, local library paths, etc. which are selected - not just foreign pointersNone of those are supposed to affect the outcome of the computations at pre-compilation (even though they are generally different than what you'd compute with at run-time) so they are "sound" to compute twice, but you have to re-compute them again after pre-compilation or they're no longer valid.
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.
Yeah. I think have a
reset!
forOnce
would make sense so that the user can choose their behavior. It is IMO easier to explain what to do when your once computation needs to be to reset due to being run during precompilation, than to tell someone "it ran twice since we didn't think you would notice the first time"But that is also the reason why
__init__
is a separate thing.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.
Hmm... Yeah, that's reasonable
Although, where would you put the
reset!
for a sysimage workflow? It'd have to go after all of your dependers in the sysimage, right? It's also a bit awkward that we already do some pretty terrible mutation tearing, since in the pkgimage workflow you get an automatic "reset" from us dropping mutations on the floor if you are run during pre-compilation for another package.Oh, and the sysimage workflow is unfortunately more important now than before, since it's required for #55047.
It'd be ideal for
Once
to support both workflows, esp. since__init__
really does not (it's very easy to call dependent code during a sysimage build that is not initialized).