Skip to content

refactor(connlib): spawn dedicated threads for UDP sockets#7590

Merged
thomaseizinger merged 12 commits into
mainfrom
refactor/udp-threads
Apr 14, 2025
Merged

refactor(connlib): spawn dedicated threads for UDP sockets#7590
thomaseizinger merged 12 commits into
mainfrom
refactor/udp-threads

Conversation

@thomaseizinger

@thomaseizinger thomaseizinger commented Dec 29, 2024

Copy link
Copy Markdown
Member

Correctly implementing asynchronous IO is notoriously hard. In order to not drop packets in the process, one has to ensure a given socket is ready to accept packets, buffer them if it is not case, suspend everything else until the socket is ready and then continue.

Until now, we did this because it was the only option to run the UDP sockets on the same thread as the actual packet processing. That in turn was motivated by wanting to pass around references of the received packets for processing. Rust's borrow-checker does not allow to pass references between threads which forced us to have the sockets on the same thread as the packet processing.

Like we already did in other places in connlib, this can be solved through the use of buffer pools. Using a buffer pool, we can use heap allocations to store the received packets without having to make a new allocation every time we read new packets. Instead, we can have a dedicated thread that is connected to connlib's packet processing thread via two channels (one for inbound and one for outbound packets). These channels are bounded, which ensures backpressure is maintained in case one of the two threads lags behind. These bounds also mean that we have at most N buffers from the buffer pool in-flight (where N is the capacity of the channel).

Within those dedicated threads, we can then use async/await notation to suspend the entire task when a socket isn't ready for sending.

Resolves: #8000

@vercel

vercel Bot commented Dec 29, 2024

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for Git ↗︎

Name Status Preview Comments Updated (UTC)
firezone ✅ Ready (Inspect) Visit Preview 💬 Add feedback Apr 14, 2025 5:23am

@thomaseizinger

thomaseizinger commented Jan 21, 2025

Copy link
Copy Markdown
Member Author

@jamilbk Initial measurements don't show a performance improvement from this unfortunately. I had hoped that it would further increase throughput. However, it lays the groundwork for spawning multiple threads for the UDP sockets (with SO_REUSEPORT) which should hopefully allow us to improve throughput because we can send and receive from multiple threads concurrently.

It mightly slightly increase memory footprint (< 1MB) because we have new channels that can potentially be filled with buffers from the buffer pool.

@jamilbk

jamilbk commented Jan 21, 2025

Copy link
Copy Markdown
Member

@jamilbk Initial measurements don't show a performance improvement from this unfortunately. I had hoped that it would further increase throughput. However, it lays the groundwork for spawning multiple threads for the UDP sockets (with SO_REUSEPORT) which should hopefully allow us to improve throughput because we can send and receive from multiple threads concurrently.

It mightly slightly increase memory footprint (< 1MB) because we have new channels that can potentially be filled with buffers from the buffer pool.

Based on some rudimentary CPU profiling I've been doing lately on the macOS client, it's possible this might introduce a healthy boost there. Hoping to find some undisturbed time to review this in depth today.

Before this goes in, would it make sense to cut a new release for all affected components? We may want to isolate this change to a single patch release to allow for easy rollbacks if something comes up.

@thomaseizinger

Copy link
Copy Markdown
Member Author

Before this goes in, would it make sense to cut a new release for all affected components? We may want to isolate this change to a single patch release to allow for easy rollbacks if something comes up.

We can! I am not sure if it is actually that risky? The main risk I see is roaming because we have more resources to clean up. But if that works, I think it should be pretty safe.

Comment thread rust/connlib/tunnel/src/sockets.rs Outdated
@jamilbk

jamilbk commented Jan 22, 2025

Copy link
Copy Markdown
Member

It mightly slightly increase memory footprint (< 1MB) because we have new channels that can potentially be filled with buffers from the buffer pool.

Each thread we spawn also consumes 2MB on iOS minimum for the stack size, though that can be tweaked if we need to.

@jamilbk jamilbk left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Mostly following what's going on. Mostly refactoring due to the type changes and addition of async/await.

Can merge for testing after cutting next releases.

@thomaseizinger

Copy link
Copy Markdown
Member Author

Can't measure a speed improvement with this, more the opposite. Throughput seems to drop by about 25% for some reason. I want to understand this better before merging this.

@thomaseizinger thomaseizinger added this pull request to the merge queue Apr 14, 2025
@thomaseizinger thomaseizinger removed this pull request from the merge queue due to a manual request Apr 14, 2025
@thomaseizinger

Copy link
Copy Markdown
Member Author

I am merging this because it fixes the WouldBlock errors. The borked UDP socket under load can be addressed in another PR.

@thomaseizinger thomaseizinger added this pull request to the merge queue Apr 14, 2025
@github-merge-queue github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Apr 14, 2025
@thomaseizinger thomaseizinger added this pull request to the merge queue Apr 14, 2025
Merged via the queue into main with commit b3746b3 Apr 14, 2025
@thomaseizinger thomaseizinger deleted the refactor/udp-threads branch April 14, 2025 06:32
github-merge-queue Bot pushed a commit that referenced this pull request Apr 15, 2025
The UDP socket threads added in #7590 are designed to never exit. UDP
sockets are stateless and therefore any error condition on them should
be isolated to sending / receiving a particular datagram. It is however
possible that code panics which will shut down the threads
irrecoverably. In this unlikely event, `connlib`'s event-loop would keep
spinning and spam the log with "UDP socket stopped". There is no good
way on how we can recover from such a situation automatically, so we
just quit `connlib` in that case and shut everything down.

To model this new error path, we refactor the `DisconnectError` to be
internally backed by `anyhow`.
@jamilbk

jamilbk commented Apr 23, 2025

Copy link
Copy Markdown
Member

I believe this PR may be causing extreme packet loss on Apple for HTTP / TCP connections.

Still gathering data.

github-merge-queue Bot pushed a commit that referenced this pull request Apr 28, 2025
Generic Segmentation Offload (GSO) is a clever way of reducing the
number of syscalls made when a you want to send a lot of packets with
the same length to the same recipient. The way this works is that the
packets are concatenated and passed to the kernel as a single packet
together with the `segment_size` as an out-of-band argument.

The component managing this batching in `connlib` is called `GsoQueue`.
In #8772, we made the order in which these batches are sent to the
kernel explicit by prioritising batches with smaller segments. What we
overlooked with that strategy is that in a particular GSO batch, the
last packet is actually allowed to be of a different length.

For example, say the user is downloading an image of 4500Kb. With our
MTU of 1280, we have a payload size of 1252. This results in three
fully-filled packets and one packet of 744 bytes. With the change in
#8772, the small packet of 744 bytes will be transferred first, followed
by the "train" of fully filled packets.

To fix this, we flip the order here and transfer batches or larger sizes
first. The original problem we attempted to mitigate in #8772 no longer
exists now that we merged #7590. We will simply suspend now if the UDP
socket isn't ready contrary to dropping the next batch.

By flipping the order here, we guarantee that batches with a larger size
are sent before batches with a smaller size. This should also imply that
the encapsulated IP packets of e.g. an image arrive in the correct order
(with the smallest packet last as it is part of a smaller batch). What
we don't guarantee with this is that there won't be any other IP packets
sent "in the middle" of such a batch. This shouldn't be a problem though
as we are simply interleaving packets of different TCP / UDP connections
with each other which already happens on the regular Internet anyway.
github-merge-queue Bot pushed a commit that referenced this pull request Apr 30, 2025
With #7590, we've moved all UDP IO operations to a separate thread. As a
result, some of the error handling of IO errors within the Client's and
Gateway's event-loop no longer applied as those are now captured within
the respective thread. To fix this, we extend the type-signature of the
receive-channel to also allow for errors and use that to send back
errors from sending AND receiving UDP datagrams.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

connlib: event-loop doesn't seem to handle "would block" properly

2 participants