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

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