Julia 1.7 says it can switch the thread your task is on. How often does that happen, and how can it be disabled?

According to the release notes:

Tasks can now migrate among threads when they are re-scheduled. Previously, a Task would always run on whichever thread executed it first

Does this mean that the thread I’m running on can no longer be controlled? I was working on an OpenGL project, and OpenGL requires that all calls essentially happen on a single thread, so if I can’t depend on my task staying on the same thread anymore, then it’s virtually impossible to write OpenGL code in Julia.

See Thread affinitization: pinning Julia threads to cores - #5 by carstenbauer

That discussion leads to a package, ThreadPinning.jl, which only supports Linux.

Is there really no standard way to do this? That seems like a serious regression; OpenGL isn’t the only un-thread-safe C library…

Let’s not confuse threads with tasks here. Primarily, ThreadPinning.jl is for pinning Julia threads to specific cores. That’s it. Whether tasks are started on and/or stay on a certain Julia thread is an entirely different thing.

First things first: Julia implements task-based multithreading (somewhat similar to Go), so the general idea is that users care about tasks and not so much about threads. You just @spawn whatever you want and let the scheduler put this on a certain thread or, if necessary, let the task migrate to another thread if necessary. In some sense, this is great since it abstracts away some of the more low-level scheduling aspects and also facilitates composable / nested multithreading.
However, sometimes you want / need to control on which Julia thread (and, if pinned to a core in some way, which core) a task runs on. The good news is, this is still possible (see below). However, I must say that my feeling is that it is not really supported since it might undermine the idea of composable task-based multithreading (personally, I think we should support both!).

So, how can we “pin” a task to a certain Julia thread? The keyword here is sticky. If you look at the source code of @threads (in particular this function), you see that the task(s) corresponding to the body of the multithreaded loop will get put on specific threads and, importantly, get the property sticky = true. This means, that the task will stay on this Julia thread and won’t migrate away. So, if you write a loop like

@threads for i in 1:nthreads()
    f(i)
end

you can be sure that f(i) will run on the Julia thread i and will stay there. Contrast this to @spawn which has sticky = false. Hence, tasks spawned in that way can, in principle, migrate between threads.

Now you might say, well that’s great but I sometimes want to “spawn” a single task on a specific thread… While you could use @threads for this,

@threads for i in 1:nthreads()
    if i == thread_that_should_run_the_computation
        f()
    end
end

this is arguably overly complex and also somewhat of a misuse of @threads. Fortunately, we can essentially just define our own hybrid of @threads and @spawn with sticky = true. That’s what @tspawnat is which I’ve added to ThreadPinning.jl yesterday (note that ThreadPools.jl also has a @tspawnat but, counterintuitively, it has sticky = false, see this issue). So, you can now just do

using ThreadPinning
t = @tspawnat 3 f()
[...]
fetch(t)

to run f() on the third Julia thread without any task migration.

(Note that while the thread pinning part of ThreadPinning.jl only supports Linux, @tspawnat can, of course, be used on any system since it is only about making Julia tasks sticky.)

Thank you for the detailed explanation!

Isn’t it really that you can’t call OpenGL from multiple threads at once? Does it really know which OS thread it’s being called from? I mean, I suppose it might create some kind of thread local storage or something… but usually it’d just mean that you can’t have multiple threads calling it at once. When a task switches from one julia thread to another it’s still a single thread at a time that is calling the OpenGL code.

An OpenGL “context” is attached to a specific thread, acting as a thread-local singleton. You can detach and reattach it to another thread, but it’s not a trivial process and certainly not something you want to have to constantly check for.