Bug in sleep() function - main thread work affecting sleep duration on running tasks

What triggered the present experiment was my failure to find a reasonable solution for the issue raised by this topic.

It turned out that the issue was unrelated to the HTTP.jl package and went deeper.

Now, the experiment (with nthreads() > 2).

Important note: to avoid “what if” scenarios about the resource allocation to the running tasks when using 1-2 threads, I suggest running this with -t 3 or above. Also, for this MWE it doesn’t make any difference if the tasks are spawned on :interactive or :default. Variations like -t 12,6 -t 12 are also not impacting the showcased issue - so if you run the MWE with at least -t 3, I think we can be on the same page when discussing the issue.

using Base.Threads, Dates

@info "started with $(nthreads()) threads"
@info "interactive threadpool: $(nthreads(:interactive))"
@info "default threadpool: $(nthreads(:default))"
println(repeat("*", 40))

function mainspin(s, c; withyield=false)
    @info "mainspin executing on thread $(threadid()) "
    ms = Millisecond(s * 1000)
    r = 0.0
    counter = 0
    while counter < c
        t = now()
        while (now() - t) < ms
            r += rand()
        end
        counter += 1
        if withyield
            yield()
        end

    end
end

function sleeper(s, c, id)
    @info "sleeper $id executing on thread $(threadid()) "
    counter = 0
    @time "sleeper $id" while counter < c
        sleep(s)
        counter += 1
    end
end

function realworker(s, c, id)
    @info "worker $id executing on thread $(threadid()) "
    ms = Millisecond(s * 1000)
    r = 0.0
    counter = 0
    @time "worker $id" while counter < c
        t = now()
        while (now() - t) < ms
            r += rand()
        end
        counter += 1
        yield()
    end
end

begin
    # phase 1
    @info "phase 1: control run - no main thread work"

    # yields 10 times (total time ~10 seconds)
    task1 = @spawn realworker(1.02, 10, 1) # 1.02 because weird @time printing overlap
    # yields 10 times (total time ~10 seconds)
    task11 = @spawn sleeper(1, 10, 1)

    wait(task1)
    wait(task11)

    # we waited for task1 and task11 to finish

    # phase 2
    println(repeat("*", 40))
    @info "phase 2: main thread work without yield"

    # yields 10 times (total time ~10 seconds)
    task2 = @spawn realworker(1, 10, 2)

    # yields 10 times (total time ~10 seconds)
    task22 = @spawn sleeper(1, 10, 2)

    # we wait 5 seconds before starting to spin
    # the main thread
    sleep(5)

    # now we start spinning 
    # while task2 and task22 are already running
    mainspin(1, 7)


    # main thread work does not impact the real worker
    # total time ~10 seconds
    wait(task2)

    # main thread work heavily impacting the sleeper
    # total time >15 seconds
    wait(task22)

    # phase 3
    println(repeat("*", 40))
    @info "phase 3: main thread work with yield"

    # yields 10 times (total time ~10 seconds)
    task3 = @spawn realworker(1, 10, 3)

    # yields 10 times (total time ~10 seconds)
    task33 = @spawn sleeper(1, 10, 3)

    # we wait 5 seconds before starting to spin
    # the main thread
    sleep(5)

    # now we start spinning with yield
    # while task3 and task33 are running
    mainspin(1, 7, withyield=true)


    # main thread work does not impact 
    # the real worker - total time ~10 seconds
    wait(task3)

    # main thread work (with yield) still impacting the sleeper
    # but we consistently get better times (total time ~13 seconds)
    wait(task33)
end

Outcome:

[ Info: started with 6 threads
[ Info: interactive threadpool: 6
[ Info: default threadpool: 6
****************************************
[ Info: phase 1: control run - no main thread work
[ Info: sleeper 1 executing on thread 12 
[ Info: worker 1 executing on thread 8 
sleeper 1: 10.021080 seconds (52.62 k allocations: 3.531 MiB)
worker 1: 10.199292 seconds (266 allocations: 12.484 KiB, 0.22% compilation time)
****************************************
[ Info: phase 2: main thread work without yield
[ Info: worker 2 executing on thread 7 
[ Info: sleeper 2 executing on thread 10 
[ Info: mainspin executing on thread 1 
worker 2: 9.999581 seconds (48.15 k allocations: 3.303 MiB, 0.31% compilation time)
sleeper 2: 16.951009 seconds (13.87 k allocations: 957.833 KiB, 0.18% compilation time)
****************************************
[ Info: phase 3: main thread work with yield
[ Info: worker 3 executing on thread 8 
[ Info: sleeper 3 executing on thread 12 
[ Info: mainspin executing on thread 1 
worker 3: 9.999368 seconds (36.94 k allocations: 2.611 MiB, 0.12% compilation time)
sleeper 3: 13.989446 seconds (2.67 k allocations: 249.790 KiB, 0.08% compilation time)

If the task does real work, the overall time needed to finish is as expected (real parallelism).

However, if sleep is used and a task is expected to sleep N seconds, the N value is extended with a factor dependent on the main thread’s work. I advise running the MWE for more details.

Here is sleep function documentation:

Block the current task for a specified number of seconds. The minimum sleep time is 1 millisecond or input of 0.001 .

Reality? The description should be approximately this:

Block the current task for the specified number of seconds - but add a penalty depending on the work done by the main thread.

That means that if we are doing the heavy lifting on the main thread while a task using sleep is running… that task is basically halted.

For the results above, I used the following Julia versions: 1.8.5 (with small changes), 1.9.2, and 1.10.0-alpha1 - and ran the experiment with various interactive/regular threads combinations.

The initial topic was likely too complex, and it turns out that it is not an issue of communication between tasks - main thread work seems to be translated in running tasks halting when using sleep() inside running tasks.

Thank you, @Benny, for adding more clarity to the MWE.

P.S. I added multiple MWEs in the thread discussion in reply to various hypotheses trying to explain the behavior. Some of them showcase the issue by keeping the main thread free and comparing the worker vs. sleeper behavior in a context with multiple spawned tasks (the equivalent in the real world would be the non-blocking HTTP.jl server which spawns a new task for each new connection). HTTP.jl-related MWE can also be found in the current discussion thread.

4 Likes

Does the same thing happen when you use ThreadPools.jl ?

Yes, tested with @tspawnat n only - but the same result as in the OP.

The problem seems to be not just Base.sleep(), which uses a libuv-based Timer, but anything which accesses the libuv event loop, such as normal Julia IO. IIUC the event loop has to be started as an early part of the runtime, and is stuck on the main thread, so this may be tricky to fix. Please file an issue.

2 Likes

This is crazy.

HTTP.jl is also affected by this disease (I checked - not explicit/direct Base.sleep() that can explain the behavior). There are also projects that depend on HTTP.jl - like Genie.

At this point, it seems that the answer to the whole Task-related functionality is maybe it works - it depends if your task invokes anything libuv related - but it also depends on the amount of work you run on the main thread.

Things like this make me feel that parts of Julia didn’t reach version 1 yet (and decent multithreading is a very important part).

1 Like

I’m a bit confused by your experiment - Base.sleep is a runtime aware sleep and only blocks the current task, leaving other tasks available for being run by the scheduler. Libc.systemsleep on the other hand puts the OS thread the task is running on to sleep, which potentially blocks other tasks from running if no other OS thread is available.

Which combination exactly is the one you’re encountering an issue on, and with which setting for -t?

Made some printing changes so it’s easier to track as I time it with a stopwatch. I also added comments on when things happened (on nthreads=1) according to the stopwatch vs what @time printed. To summarize, mainspin and realworker don’t yield so they hold things up. sleeper does its sleep in 1s bursts, so when other tasks are holding things up, sleeper is stuck at an iteration. @time keeps track of absolute time, so it includes the time sleeper was stuck.

If I change sleeper so that counter only goes up to 1 and sleep(s) occurs the full 10s at once, then the 14s timing becomes 10s.

Expand this code
using Base.Threads, Dates

function mainspin(s, id)
    @info "mainspin $id executing on thread $(threadid()) "
    ms = Millisecond(s * 1000)
    t = now()
    r = 0.0
    @time while (now() - t) < ms
        r += rand()
    end
    @info "mainspin $id finished "
end

function sleeper(s, id; libc=false)
    @info "sleeper $id executing on thread $(threadid()) "
    counter = 0
    @time while counter < s
        libc ? Libc.systemsleep(1) : sleep(1)
        counter += 1
    end
    @info "sleeper $id finished"
end

function realworker(s, id)
    @info "worker $id executing on thread $(threadid()) "
    ms = Millisecond(s * 1000)
    t = now()
    r = 0.0
    @time while (now() - t) < ms
        r += rand()
    end
    @info "worker $id finished. result: $r"
end

begin
# phase 1

task0 = @spawn mainspin(10, 1) # 0-10s: @time 10s
# mainspin(10, 1) does not yield, runs for 10s
task00 = @spawn sleeper(10, 1) # 10-20s: @time 10s

wait(task0)
wait(task00) # waits for sleeper(10, 1) to finish

# phase 2

rw = @spawn realworker(10, 1)  # 20-30s: @time 10s 
# realworker(10, 1) does not yield, runs for 10s
sleepy = @spawn sleeper(10, 2) # 30-44s: @time 14s
# sleeper(10, 2) counter=0 yields in <<1s

sleep(5)                       # 30-35s
# sleep(5) yields
# 31s sleeper(10, 2) runs counter=1 then yields
# 32s sleeper(10, 2) runs counter=2 then yields
# 33s sleeper(10, 2) runs counter=3 then yields
# 34s sleeper(10, 2) runs counter=4 then yields
# 35s sleeper(10, 2) runs counter=5 then yields
mainspin(5, 2)                 # 35-40s: time 5s
# mainspin(5, 2) does not yield, runs for 5s

wait(rw)
wait(sleepy) # now sleeper(10,2) runs
# 40s sleeper(10, 2) runs counter=6 then yields
# 41s sleeper(10, 2) runs counter=7 then yields
# 42s sleeper(10, 2) runs counter=8 then yields
# 43s sleeper(10, 2) runs counter=9 then yields
# 44s sleeper(10, 2) runs counter=10 then finishes
end
1 Like

Thank you for the edits (for clarity, can I update the OP with your version)?

Now, let’s settle for a multithreading context and add the following assumptions: besides the main thread, there are at least two additional available threads.

Now, for the phase 2: if I set the sleeper to do a unique 10-seconds it happens to work well by coincidence (e.g., sleeper already started about 5 seconds before the start of mainspeen and after 5-seconds mainspin finishes allowing the sleeper to also finish (more exactly, it allows the Timer which uses libuv to function properly).

Try running the phase-2 mainspin(7,2) instead of mainspin(5,2) - and you’ll realize that the sleeper total time is now 12-seconds.

In a real world project we do not have the luxury to synchronize our tasks with the work done by the main thread: if I schedule a task on the :interactive for example I expect to have a responsive/interactive task (real-world example: HTTP.jl: it 100% dependent on the work done on the main thread - e.g., if I have a non-blocking server, which is scheduled on :interactive by default, and I do bursts of 5-second work on main… well, this server will respond at an interval of 5 seconds).

I am not saying that Base.sleep is blocking other tasks.

The statement is that work on the main thread alters the expected (documented) sleep functionality on all the other tasks running on other threads.

ofc

I got 10s again, I can only increase it if I run mainspin 2 for 11s or more to stall the sleeper 2 task. What’s your code for the unique 10s sleeper? Here’s mine:

function sleeper(s, id; libc=false)
    @info "sleeper $id executing on thread $(threadid()) "
    counter = 0
    @time while counter < 1  ##change###
        libc ? Libc.systemsleep(s) : sleep(s)  ##change##
        counter += 1
    end
    @info "sleeper $id finished"
end

It has nothing to do with a 10-second timespan: any configuration that is showcasing the issue will do.

We can change the experiment from sleep altogether if you want:

  1. Start a non-blocking HTTP.jl server (which is scheduled on :interactive).
  2. Run bursts of work on the main thread (putting mainspin(5,1, steps=20) in a loop might do the job - see the updated mainspin).
  3. Try to get a response from that server - the responsivity will highly depend on when you hit the server (e.g., for mainsping(5,1, steps=20) you’ll get delays depending on main working behavior). Not something that you would expect from a task running on the :interactive.
using Base.Threads, Dates
using HTTP

function mainspin(s, id; steps=1)
    @info "mainspin $id executing on thread $(threadid()) "
    ms = Millisecond(s * 1000)

    r = 0.0
    for _ in 1:steps
        t = now()
        @time while (now() - t) < ms
            r += rand()
        end    
        sleep(1)    
    end
    @info "mainspin $id finished "
end

HTTP.serve!(Returns(HTTP.Response("ok")))

# steps is not relevant, it only buys you time to
# play around with the server
# not counting the sleep in `mainspin`, here we will have about 100 seconds
# and 200 seconds if we do mainspin(10, 1, steps=20)
mainspin(5, 1, steps=20) 


# now go in the terminal and play around
# curl 127.0.0.1:8081

Aah, gotcha!

I think that is to be expected though :thinking: There’s certainly an argument to be made for seperating the scheduler from the :interactive thread(s), but if you saturate all interactive threads, I would expect tasks also running on the interactive threads to stall. That’s the downside of cooperative multitasking in Julia, in contrast to preemptive multitasking (which e.g. the linux kernel does).

There have been wishes for preemption in the past though, so it might become a thing some day. I don’t know of any concrete efforts towards that yet. Some arguments against that are that some programs may rely on cooperative multitasking for correctness, or that it can introduce overhead in programs that expect to have exclusive access to all threads.

But this is not the case in the MWE - because even if I start with 12 interactive threads and schedule a single task on :interactive (which mostly sleeps), the work on the main will still impact the working task (which can migrate on any o the :interactive threads). And - for additional clarity: in the MWE, no other work is done besides the main thread related work and the actual realworker - which behaves OK. realwoker is only added as a control: realworker behaves correctly, sleeper depends on the main thread.

That’s definitely a bug then; please do open an issue at Issues · JuliaLang/julia · GitHub

I think this explains what is going on:

I had a Genie-related issue for weeks now - and I wasn’t able to track it down until yesterday when I tried to help solving this issue. I assumed that the complexity of my project and the difficulty to write proper tests to catch the responsivity issue was on me - and I was in the process of refactoring stuff.

~10 hours later - trying to invalidate my conclusions (I initially assumed that it is just about communication between tasks - so I did invalidate some) I arrived at an even more upsetting conclusion: you cannot do proper (or predictible/interactive) multithreading in Julia if your project involves medium-heavy work on main and your tasks are somehow using libuv. Just run my HTTP.jl MWE from an above post.

So - there is hard to say how many Julia packages are poisoned in this way:

  1. It is not straightforward what tests should be run to determine if there is an issue.
  2. If you want to go into source code for anything you use, it is pretty time-consuming (the language users should not need to do such things).
  3. Many toy examples from documentation will not reveal the issue.

For me, it is just incredible that this is happening. Imagine not being able to properly run a server on a different thread because its responsivity will be conditioned on not doing anything significant on the main thread.

2 Likes

I wholeheartedly agree with you - these kinds of things need to be fixed if people want Julia to expand beyond HPC & scientific computing (though I imagine this can also happen in those domains).

1 Like

Agreed this should be fixed. But for a server, does the problem go away if you just spawn a new thread for any nontrivial work? Like the main task could just literally be

function main()
  spawn_stuff()
  wait_til_done()
end

Or is that not sufficient?

1 Like

That would probably work around it, but you have to be very careful to not run any non-trivial computation on the main thread which can be very annoying.
It’s similar to the advice to not block the thread running the event-loop in GUI programming. Usually, the event loop should run in its own thread though, i.e., not on main.

2 Likes

In Julia as I understand it there’s sometimes confusing terminology. People talk about spawning threads with @spawn for example. Unless I’m mistaken:

  1. @spawn creates tasks which can run on any thread
  2. Thread migration was introduced a while back, maybe version 1.5 ish
  3. If you create a task that can migrate you don’t really have control over what thread that task runs on

Given all of that, is the issue that if you have a high compute task and it gets scheduled onto thread number 1 everything borks? Or is it if you do computation in the task running the REPL it borks?

2 Likes

Good point, how I understand the discussion everything borks if the task happens to run on the thread that’s listening to events – which happens to be the main one, i.e., running all top-level and single threaded code?

2 Likes