Skip to content

Unbounded queues might lead to excessive memory usage #1131

Description

@Matthias247

The endpoint and per connection logic is running on individual tokio tasks. To communicate between those tasks, unbounded futures::channel::mpsc channels are used.
In specific there are 2 channels:

  • One which carries endpoint events (e.g. received datagrams) from the endpoint to the connection task
  • Another one which carries connection events (e.g. produced datagrams) to the endpoint task

The fact that these channels are unbounded raises the question on what exactly will happen if one of the tasks is producing data faster than the other task can consume it. The question probably became more important recently due to adding additional fairness between tasks: Since the endpoint task will yield more often, the connection task might have more chances to enqueue new data that can't be sent right away.

The usual problems with this design are unbounded memory growth - and that as soon as the queue reached a certain size the components mostly work on outdated/non-interesting data, latencies will be terrible and reliability too.

Let's briefly look at each side individually:

Endpoint task -> Connection task

This queue is used for forwarding received datagrams. In the connection task all received datagrams are currently immediately processed. As long as single-threaded runtime is used, the queue is not really unbounded since no new datagrams won't be enqueued by the endpoint as long as the connection is working on the existing ones. Instead if more datagrams would be enqueued in the UDP socket, they would get dropped there.

I think this is ok for the moment.

Connection task -> Endpoint task

The connection produces datagrams whenever it is deemed ok by the congestion controller, and will enqueue them on the endpoint for transmission. I think this queue can get problematic in some situations. E.g. when either the connection task gets scheduled often enough between endpoint task iterations that it produces more datagrams than the endpoint can send in one iteration, or if multiple connections all have data to send.

I don't really think that letting the endpoint task perform writes until the queue is drained would be the right solution - it would again lead to long times where the stack doesn't process either peer data (ACKs) or user data.

I can at the moment see a variety of ways to improve this:

  1. The simplest: Use a bounded channel between the components and try_send. If the queue is full because the endpoint can't keep up, the packet will get dropped and the congestion controller will deal with it. Downsides:
    1. We spend all the CPU time for producing a packet (it's expensive!), and then just drop it
    2. Might not scale will in a scenario where an endpoint is serving a high amount of connections. There doesn't seem any fairness regarding which packets will get dropped (although one could claim random == fair?).
  2. Build some reservation system where connections can only produce packets if endpoint capacity is available. Examples:
    1. Add a single async semaphore to the connection -> endpoint channel, and let the connection try to acquire MAX_TRANSMIT_DATAGRAMS before trying to produce packets and submitting them. The permits will get released once the endpoint is done with transmissing packets. If the semaphore is fair, then all connections should be able to produce batches of packets in order.
    2. Do the same, but with one async semaphore per connection. This essentially builds a per-virtual-channel limit even though there is only one real channel. Seems a bit easier to understand since it essentially limits the amount of outgoing datagrams a single connection can have in-flight. But doesn't really limit the overall amount of outgoing packets queued in the endpoint or channel (matters more for a really high amount of connections)
  3. Restructure the whole quinn module to merge connection and endpoint tasks, and to only call poll_transmit on connections if there is really capacity to transmit packets left. That also allows the endpoint module to implement fairness between connections. This might be computationally cheaper than everything else. But it would be a huge refactoring endeavour.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions