It appears to currently be true, but is it always true that the main task is sticky, and will it stay true in future Julia versions?
Edit: I’m not asking because I view this as a limitation, I’m asking because I’d like to have a guarantee that it remains sticky. I need to know if I can run something on the main task and guarantee it won’t change thread.
There are several questions that you should explore before we can get to the answer.
A Task’s stickiness is not public API. There is not a public setter or getter to indicate stickiness. So an initial answer is “No” because stickiness is not even guaranteed to exist itself. It is an implementation detail. That said the documentation does refer to it a lot more than before.
The next question is what is the public API that allows one to manipulate stickiness. It is the apparent difference between @async and Threads.@spawn.
julia> @macroexpand @async println("Hello World")
quote
#= task.jl:539 =#
let
#= task.jl:540 =#
local var"#73#task" = Base.Task((()->begin
#= REPL[30]:1 =#
println("Hello World")
end))
#= task.jl:541 =#
if $(Expr(:islocal, Symbol("##sync#41")))
#= task.jl:542 =#
Base.put!(var"##sync#41", var"#73#task")
end
#= task.jl:544 =#
Base.schedule(var"#73#task")
#= task.jl:545 =#
var"#73#task"
end
end
julia> @macroexpand Threads.@spawn println("Hello World")
quote
#= threadingconstructs.jl:484 =#
let
#= threadingconstructs.jl:485 =#
local var"#75#task" = Base.Threads.Task((()->begin
#= REPL[31]:1 =#
println("Hello World")
end))
#= threadingconstructs.jl:486 =#
(var"#75#task").sticky = false
#= threadingconstructs.jl:487 =#
Base.Threads._spawn_set_thrpool(var"#75#task", :default)
#= threadingconstructs.jl:488 =#
if $(Expr(:islocal, Symbol("##sync#41")))
#= threadingconstructs.jl:489 =#
Base.Threads.put!(var"##sync#41", var"#75#task")
end
#= threadingconstructs.jl:491 =#
Base.Threads.schedule(var"#75#task")
#= threadingconstructs.jl:492 =#
var"#75#task"
end
end
The higher level question is will the REPL backend ever run using Threads.@spawn. How is the backend Task created anyways?
It turns out that the default REPL backend Task has an even more special property than stickiness. It is the root task! It is not created in Julia.
However, this was not always the case. I know this because my very first pull request to core Julia was to make this true.
This is again another implementation detail, but a very fundamental one. The root task has access to the original stack without any of the libuv virtual threading. It it thus uniquely positioned for interop with other runtimes who know nothing about Julia’s Task implementation.
I became interested in this when working with JavaCall.jl. Generally, for JavaCall.jl to work you need to set the environment variable JULIA_COPY_STACKS=1. This changes how Julia’s task implementation works.
However, if you are running on the root task, you do not have this requirement. JavaCall.jl will work on the root task without that environment variable.
Something that did change recently is that the root task is now on the interactive thread pool.
At the end of the day, I think Base.roottask == Base.current_task() will be around because some interop will be impossible without it. Furthermore, this task will likely not migrate threads. That said Julia does evolve, and I’m not sure if the concept of sticky and “root task” will always be relevant.
Basically, the way PythonCall embeds Python into Julia violates some assumptions that CPython makes about the execution environment - in particular that the Python code is executed in a single ordinary stack on each thread. This is violated because each Julia task has its own stack, and also because tasks can migrate between threads.
This hasn’t been an issue so far but Python 3.14 has made their stack overflow detection mechanism more precise, but heavily relies on this one-stack assumption being true. The upshot is that PythonCall crashes very quickly with Python 3.14 on many platforms.
So I’m thinking about things like:
When can we assume that the thread or stack doesn’t change?
Do I just need to assume it can always change?
Is it sufficient to reset/recreate the Python thread state every time I call into Python?
Will OncePerTask and OncePerThread help?
Do I need to make dedicated sticky tasks where the actual CPython calls occur, so the stack definitely doesn’t move?
How slow are any of these solutions going to be?
(And to answer your question) This led me to wonder if at the very least I can rely on the main task always staying on whichever thread it started on and never having its stack moved somewhere else. It certainly sounds like this is currently true, based on your description (being the root task that is on the root stack where the Julia runtime was initialised).
I’m happy to discuss those other points somewhere else but let’s keep this thread to the original question about stickiness.
Edit to add: I have for a while been considering changing PythonCall to always acquire the GIL before every call into CPython anyway, which would mean you can safely use PythonCall from any task on any thread. This is really just a workaround for the same incompatibility issue - it’s just that things are suddenly a lot lot worse with Python 3.14.
I think we might be in the same situation as JavaCall.jl and QML.jl then. The requirement here is not stickiness but rather that we are actually on the root task. In near term, I think we can rely on the root task existing. Pragmatically, we really have no other option.
To help guarantee the existence of the root task as concept, please consider commenting on the following pull request to add a root task API.
My current thought is that we should only ever call the Python C API from the Julia root task, which should have a normal looking stack as far as Python is concerned. To facilitate this, PythonCall would need to acquire the main task and then start running a task worker there to service other tasks that want to call Python.
You may recall that I started work on an enhanced GIL-aware lock for PythonCall.jl. @jameson intervened leaving a comment about the use of OncePerThread. I had to pause the work at the time because I had discovered a bug in Julia 1.12’s OncePerThread implementation causing instability. It might be time to revisit that now that the bug has been fixed and Julia 1.12 has been released.