Does stdlib network sockets use only first thread?

I am writing a multi-threaded applications with a GUI (CImGui.jl) and a network sockets (Base.Sockets) to handle low-latency data stream over local network via UDP.

I have a few independent threads to handle data processing and networking. First UDPSocket (source of data) and recv calls in one thread, and second UDPSocket (sink for processed data) and send calls in another thread. Data exchange between threads is done via Channel.

After various test why the output network stream lags, I found two cases:

  1. When I spawn and pin function that handles GUI to thread with Threads.threadid() == 1, the frequency of send call decreases noticeably, and the Channel that is the source of data for send calls saturates.
  2. When I spawn and pin function that handles GUI to thread with Threads.threadid() == 2, the frequency of send calls is equal to the frequency of incoming data, and the Channel that is the source of data for send calls is almost always empty whenever I am checking.

So do functions from Base.Sockets somehow use only thread Threads.threadid() == 1? Is such behavior of program is expected? At this moment, I didn’t found any information related to this in documentation. I would appreciate if somebody could clarify or point the relevant documentation/source code.

I can try to reproduce it with MWE, if needed. :slight_smile:

1 Like

I don‘t know how your pinning works but it might lead to unexpected behavior as Julia is free to migrate tasks between threads.

As far as I know CImGui.jl pins the task with renderloop to specific thread in the following way:

# Helper function to pin tasks to a specific thread. We need this because
# OpenGL/GLFW/ImGui are not threadsafe so task migration could cause segfaults.
function pintask!(task::Task, tid::Integer)
    if tid ∉ Threads.threadpooltids(:default) && tid ∉ Threads.threadpooltids(:interactive)
        error("Thread ID '$tid' does not exist in the :default or :interactive threadpool, cannot schedule a task onto it.")
    end

    task.sticky = true
    ret = ccall(:jl_set_task_tid, Cint, (Any, Cint), task, tid - 1)

    if Threads.threadid(task) != tid
        error("jl_set_task_tid() onto Julia thread ID $tid failed!")
    end
end

Source: Gnimuc/CImGui.jl

1 Like

I think that should be fine. I don‘t have any idea on the socket question though. Do you have a reproducible example you can share?

I have created a minimal work example that yields the same behavior. Keyword argument spawn for function app sets threadid for a renderloop. Check comments at the end of the source code. Let me know what timings you get.

I am using Julia v1.11.1 on x86_64 Linux (Glibc).

Packages to reproduce:

] add CImGui ModernGL GLFW Sockets

I am using following command to run Julia:

julia --project --threads auto
using Sockets
import GLFW
import CImGui
import ModernGL
CImGui.set_backend(:GlfwOpenGL3)



function producer(run::Ref{Bool})
    sockd = UDPSocket()
    i = 0
    last = time()
    try
        while run[]
            x = rand(Int16, 480)
            send(sockd, ip"127.0.0.1", 8080, x)
            sleep(0.001)
            if i % 100 == 0
                t = time()
                @info "Datagram#$(i); Time to send 100 datagrams: $(t - last)"
                last = time()
            end
            i += 1
        end
    finally
        sockd |> close
        @warn "producer exited"
    end
end

function app(; engine=nothing, spawn=1)
    ctx = CImGui.CreateContext()

    run = Ref(true)
    Threads.@spawn producer(run)

    CImGui.render(ctx; engine, window_title="MWE", spawn=spawn) do
        CImGui.Begin("debug")
        CImGui.Text("app thread id $(Threads.threadid())")
        CImGui.End()
        # CImGui.ShowMetricsWindow()
        # CImGui.ShowDemoWindow()
    end

    run[] = false
end

app(spawn=1);
# If spawn set to 1, Time to send 100 datagrams by producer is about 3.3 seconds
# If spawn set to 2, Time to send 100 datagrams by producer is about 0.25 seconds

Heyo, I’m one of the CImGui maintainers :slight_smile: What you’re seeing is expected because by default the libuv event loop only runs on thread 1. This is documented in the render() docs: Backends · CImGui.jl

In particular:

spawn::Union{Bool, Integer, Symbol}=1: How/where to spawn the renderloop. It defaults to thread 1 for safety, but note that currently Julia also uses thread 1 to run the libuv event loop: #50643. The renderloop does yield() on each iteration but it’s still likely to hog thread 1 which may cause libuv things like task switching to become slower. In most cases this is unlikely to be a problem, but keep it in mind if you observe IO/task heavy things being slower than you’d expect.

The main reason for pinning is to prevent task migration from one thread to another, that would break all sorts of things. The reason for pinning to thread 1 is only because that’s what GLFW recommends, but (AFAIU) that’s mostly because of OSX: Multithreading GLFW? - #5 by elmindreda - support - GLFW

TL;DR: if you’re running on Linux you can choose any thread you like (I would suggest one in the interactive threadpool). A different thread might work on Windows and will likely not on OSX (but I’ve never tried it so who knows).

1 Like

Thanks for detailed reply and pointing the right place in the documentation. :slight_smile:
I don’t know why I didn’t notice that. :frowning:

I also learnt just yesterday that event loop runs only on thread 1 in another topic Non-blocking network IO - #9 by Pedro via great JuliaCon talk A retrospect on Julia web services: HTTP, performance and memory | Guliński, Georgakopoulos.