Is the operation scheduling a task blocking?

Here is the doc Asynchronous Programming · The Julia Language

It is common to want to create a task and schedule it right away, so the macro Threads.@spawn is provided for that purpose –- Threads.@spawn x is equivalent to task = @task x; task.sticky = false; schedule(task) .

I don’t understand the “is equivalent to” assertion, considering this example

julia> const v = collect(1:99999);

julia> const u = collect(1:99999);

julia> function busy(v)
           for i = 1:999999
               circshift!(v, rand(Int))
               reverse!(v)
           end
       end;

julia> Threads.@spawn busy(v); # this will return immediately so a new "julia>" can be seen

julia> t = @task busy(u);

julia> t.sticky = false;

julia> schedule(t); # this gets stuck
attempt to type some words, julia has no response

tested with JULIA_NUM_THREADS = 255,1

The documentation is slightly outdated. If you look at

@macroexpand Threads.@spawn busy(v)

you’ll see that @spawn also sets the threadpool.
Before you schedule the t task, you may look at its threadpool:

julia> Threads.threadpool(t)
:interactive

So it runs on the same threadpool as your REPL, with a single thread. And it never yields.

1 Like

But it gets stuck, even if I start with more than one interactive threads. I don’t understand.

❯ julia --threads=1,4
               _
   _       _ _(_)_     |  Documentation: https://docs.julialang.org
  (_)     | (_) (_)    |
   _ _   _| |_  __ _   |  Type "?" for help, "]?" for Pkg help.
  | | | | | | |/ _` |  |
  | | |_| | | | (_| |  |  Version 1.12.1 (2025-10-17)
 _/ |\__'_|_|_|\__'_|  |  Official https://julialang.org release
|__/                   |

julia> function busy(v)
           for i = 1:typemax(Int)
               circshift!(v, 7)
               reverse!(v)
           end
       end;

julia> t = @task busy(collect(1:1000))
Task (runnable) @0x00007a9653bfca60

julia> schedule(t) # gets stuck

I think the task can at most block one thread, isn’t it?

Oh my god, this is terrible. I would consider this an extremely annoying and very breaking bug.

Do you want to open an issue or should I? (you discovered the bug, after all)

$ julia +1.12 -t 4 -e "t=Task(()->Threads.threadid()); t.sticky=false; schedule(t); println(fetch(t))"
1
$ julia +1.11 -t 4 -e "t=Task(()->Threads.threadid()); t.sticky=false; schedule(t); println(fetch(t))"
3

I think the responsible PR is Fix `enq_work` behavior when single-threaded by kpamnany · Pull Request #48702 · JuliaLang/julia · GitHub

PS. It is breaking because up to julia 1.11, the official and documented API for spawning a task in the threadpool was t=Task(fun); t.sticky=false; #=do some other setup =# schedule(t);.

Now, 1.12 silently changed the behavior (without deprecation warnings, not even with documentation update, such that current docs are simply wrong) such that this task now sticks to the threadpool. Hence, lots of old code will now silently degrade in performance / latency, in a way that unit tests are unlikely to notice. And some 1.12-era code will rely on the new (imo terrible) behavior for correctness :scream:

Sticky-ness now has 3 values: Stick-to-thread, Stick-to-Threadpool, and unsticky, while we previously only had 2 values: Stick-to-thread and unsticky, and the update silently mapped unsticky->Stick-to-threadpool.

1 Like

you are more experienced, go ahead then.

Hmm, that’s an unfortunate behaviour. It has to do with the new default for the interactive thread pool.

$ julia +1.12 -t 4,0 -e "t=Task(()->(Threads.threadpool(), Threads.threadid())); t.sticky=false; schedule(t); println(fetch(t))"
(:default, 2)
$ julia +1.12 -t 4 -e "t=Task(()->(Threads.threadpool(), Threads.threadid())); t.sticky=false; schedule(t); println(fetch(t))"
(:interactive, 1)
$ julia +1.11 -t 4 -e "t=Task(()->(Threads.threadpool(), Threads.threadid())); t.sticky=false; schedule(t); println(fetch(t))"
(:default, 2)

Issue: Unsticky Tasks stick to threadpool since 1.12 · Issue #59936 · JuliaLang/julia · GitHub

Sorry, I still don’t understand the cause in my #3 post: I had 4 interactive threads and I was trying to executing only one task. :upside_down_face:


I also don’t understand this

❯ julia --threads=1,0
               _
   _       _ _(_)_     |  Documentation: https://docs.julialang.org
  (_)     | (_) (_)    |
   _ _   _| |_  __ _   |  Type "?" for help, "]?" for Pkg help.
  | | | | | | |/ _` |  |
  | | |_| | | | (_| |  |  Version 1.12.1 (2025-10-17)
 _/ |\__'_|_|_|\__'_|  |  Official https://julialang.org release
|__/                   |

julia> Threads.@spawn(:interactive, println("execute in interactive pool"))
execute in interactive pool
Task (done) @0x00007e85bd1ff2b0

julia> 

I have 0 interactive threads, then how is that task get done???


Also want to ask: what is the difference of the default pool and interactive pool in terms of performance? Is a task specified to be done within the interactive pool slower?

So, if you don’t have an interactive thread pool, you only have the :default pool. This was the default in 1.11. Everything, including the REPL, in the same thread pool. This leads to difficulties when running interactively. If you spawn nthreads() tasks, the REPL must wait until one of them finishes or calls yield(). Compute bound tasks typically don’t call yield(), but io functions, and wait and lock etc. may do that.

In 1.12, the default is to have an interactive thread pool with 1 thread. The same problem as in 1.11 then occurs if you schedule a compute bound task in the :interactive pool. This is to be expected.

However, if you have an interactive pool with more than 1 thread, it should be possible to schedule a compute-bound task to the interactive pool. When calling schedule on a task, a thread in the thread pool is picked for starting the task, and it may only change thread when it calls yield(). I think, in your example, that the initial thread where the task is started happens to be the same thread which the REPL runs in, and the REPL is sticky, and can’t run in another thread. So it’s blocked by the task it has scheduled.

I guess that if you have a yield() before the loop in busy, it may not block the REPL.

1 Like

I did some tests, the behavior is random.

❯ julia --threads=1,2  
               _
   _       _ _(_)_     |  Documentation: https://docs.julialang.org
  (_)     | (_) (_)    |
   _ _   _| |_  __ _   |  Type "?" for help, "]?" for Pkg help.
  | | | | | | |/ _` |  |
  | | |_| | | | (_| |  |  Version 1.12.1 (2025-10-17)
 _/ |\__'_|_|_|\__'_|  |  Official https://julialang.org release
|__/                   |

julia> function busy(v)
           for i = 1:typemax(Int)
               circshift!(v, 7)
               reverse!(v)
           end
       end;

julia> t = @task busy(collect(1:1000))
Task (runnable) @0x00007c217fffca60

julia> t.sticky = false
false

julia> schedule(t)
Task (runnable) @0x00007c217fffca60

julia> 

If I use more than one interactive threads as in this case, then it may gets stuck or maynot (as in the picture).


I agree.


Another question: is it meaningful to have more than one interactive thread? Is only one interactive thread sufficient?


I think there is no big problem if users use only Threads.@spawn to do async tasks and correctly specify the (default_num, interactive_num) parameters, is it true? @foobar_lv2

Yes, and it happens in both 1.11 and 1.12. It’s unfortunate, but not new.

One could imagine that one wants a computation to utilize all the :default threads, while one experiments interactively with some threading, but stay away from the REPL thread. That’s difficult. An improvement would be if it was allowed with more thread pools, then one could fix the interactive pool to one thread, and pick and choose among the other thread pools. I know of some climate models which could benefit from such a partitioning. However, things like that would probably be better to run with Distributed. (or MPI.jl, which is the de facto standard for them).

1 Like