Files
coturn/docs/multiplex-peer.md
Pavel Punsky b17f5c482f Add --udp-sendmmsg-log to observe egress sendmmsg/UDP-GSO batching (#1927)
## Summary

Adds a Linux-only `--udp-sendmmsg-log` flag (mirroring
`--udp-recvmmsg-log`) that logs per-relay-thread **egress** batch
statistics every 10 s: flush count, total datagrams, average batch
occupancy, UDP-GSO engagement
(`gso_flushes`/`gso_datagrams`/`gso_frac`), and a per-flush occupancy
histogram.

## Motivation

`--multiplex-peer` enables `sendmmsg`/UDP-GSO coalescing on the egress
path, but there was no way to see whether it actually coalesces
anything. While investigating this I confirmed a non-obvious property
worth documenting:

- Per-session UDP **client** sockets are children of the listener
(`parent_s`), and `udp_send_fd()` returns the shared **listener fd** for
all of them. Combined with the `recvmmsg`-driven batch window,
relay→client **downlink** sends to *different clients* already coalesce
into one `sendmmsg` on the listener fd — the listener fd is effectively
a shared client-facing send socket. (In non-multiplex mode each
allocation has its own relay socket, so a `recvmmsg` drain spans only
one session and the downlink batch is a singleton — cross-client
batching genuinely requires `--multiplex-peer`.)
- UDP-GSO only engages when destination **and** segment size match
across a batch, so at low per-flow packet rates (VoIP-style, tens of pps
per flow) it rarely fires.

This flag makes both effects measurable instead of assumed.

## Example

Captured under a `--multiplex-peer --udp-gso` load:

```
udp-sendmmsg stats: flushes=21 datagrams=27 avg_batch=1.29 gso_flushes=0 \
  gso_datagrams=0 gso_frac=0.000 hist_1=17 hist_2=2 hist_3_4=2 hist_5_8=0 hist_9_16=0 hist_17_32=0
```

- `avg_batch` — mean datagrams per flush (1.0 = no coalescing)
- `gso_frac` — fraction of datagrams sent via UDP-GSO (~0 = GSO not
earning its keep)
- `hist_*` — per-flush occupancy histogram

Both rise with aggregate pps and sit near the floor on lightly loaded
servers.

## Implementation

- Counters on `ioa_engine` (behind `#if defined(__linux__)`), bumped
once per flush in `udp_sendmmsg_flush()` and once per `recvmmsg` call —
no per-datagram cost.
- New `--udp-sendmmsg-log` CLI flag + 10 s periodic logger in the engine
timer, mirroring the existing recvmmsg stats path.
- Docs: a new "Egress Batching (sendmmsg / UDP-GSO) and Observability"
section in `docs/multiplex-peer.md`.

## Testing

- macOS: build + `ctest` (6/6) + `run_tests.sh` pass (instrumentation is
`#if __linux__`, validating flag plumbing + the non-Linux stub path).
- Linux (clean Ubuntu 24.04 Docker): build + `ctest` (4/4) +
`run_tests.sh` / `run_tests_conf.sh` / `run_tests_multiplex_peer.sh`
pass, and a `--multiplex-peer --udp-gso --udp-sendmmsg-log` load run
emitted the stats line above.
2026-05-30 17:30:45 -07:00

334 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# coturn Multiplex-Peer Mode — Design & Implementation Guide
## Problem Statement
Standard coturn allocates **one dedicated UDP socket (= one OS port) per TURN
session**. The relay port range is bounded (default 4915265535, ≈16 000
ports), so a single server is hard-capped at ≈16 000 simultaneous relay
sessions even when CPU, RAM, and bandwidth are plentiful.
---
## How coturn Normally Allocates Ports
```
handle_turn_allocate_request() [ns_turn_server.c]
└─ create_relay_connection()
└─ create_relay_ioa_sockets() [ns_ioalib_engine_impl.c]
├─ turnipports_allocate() — pick a free port from the pool
├─ create_unbound_relay_ioa_socket() — socket(2)
├─ bind_ioa_socket() — bind(2) to relay-IP:chosen-port
└─ (optionally) a second socket on port+1 for legacy RTCP
```
Every session holds one (or two) bound sockets. The OS demultiplexes
inbound datagrams by destination port → one file descriptor per session.
```
available_ports ≈ max-port min-port + 1 (≈ 16 383 by default)
```
Once exhausted the server rejects new Allocates with `508 Insufficient
Capacity`.
---
## Design: Per-Thread Peer-Side Multiplexing
### Core idea
Replace the per-session `bind()` with **one persistent socket pair per relay
thread** (one IPv4 socket, one IPv6 socket). Sessions are demultiplexed by
the **peer's source IP:port** rather than by the relay destination port.
### Why per-thread (not a single global socket)?
| Concern | Global socket | Per-thread socket |
|---------|---------------|-------------------|
| Mutex on hash table | Required — multiple threads write concurrently | **Not needed** — each thread owns its table |
| Cache thrashing | High — false sharing on the hash head pointer | None |
| libevent integration | Awkward — socket registered in one event_base, but written from others | Natural — socket lives in the thread's own event_base |
| Failure isolation | One bad socket affects all sessions | Failure of one thread's socket is contained |
| Port count consumed | 1 IPv4 + 1 IPv6 = 2 ports total | 2 × N ports (N = relay threads) |
With the default of 4 relay threads you consume 8 ports instead of 16 000.
With 64 threads you consume 128 ports — still orders of magnitude fewer.
### Port assignment formula
```
IPv4 port = base_port + thread_id × 2
IPv6 port = base_port + thread_id × 2 + 1
```
Example with `--multiplex-peer-port=3480` and 4 relay threads:
| Thread | IPv4 port | IPv6 port |
|--------|-----------|-----------|
| 0 | 3480 | 3481 |
| 1 | 3482 | 3483 |
| 2 | 3484 | 3485 |
| 3 | 3486 | 3487 |
No two threads ever share a port. No `SO_REUSEPORT` trickery required
(though we set it anyway for future-proofing).
### Thread pinning
coturn's listener assigns each incoming client to a relay thread
round-robin at connection time and then pins that client there for the
session lifetime. This means a client always reaches the **same thread**
— and thus the same relay port — for every packet. The port returned
in `XOR-RELAYED-ADDRESS` is therefore stable and correct.
### Demultiplexing diagram
```
Normal mode:
Client A ──► relay-ip:55000 (fd A, thread 0) ──► peer A
Client B ──► relay-ip:55002 (fd B, thread 0) ──► peer B
... (one fd + one port consumed per session)
Multiplex-peer mode (4 threads):
Client A (→ thread 0) ──► relay-ip:3480 (shared fd, thread 0) ──► peer A
Client B (→ thread 0) ──► relay-ip:3480 (shared fd, thread 0) ──► peer B
Client C (→ thread 1) ──► relay-ip:3482 (shared fd, thread 1) ──► peer C
...
(2 × N fds total; demux by exact peer IP:port within each thread)
```
---
## Architecture: Where State Lives
All multiplex-peer state is embedded directly in `ioa_engine_t`.
There are **no globals** and **no mutexes**.
```c
/* New fields added to ioa_engine_t in ns_ioalib_engine_impl.c */
int mp_enabled; // 1 when multiplex-peer is active
int relay_thread_id; // zero-based thread index
ioa_socket_handle mp_sock_v4; // thread-local IPv4 relay socket
ioa_socket_handle mp_sock_v6; // thread-local IPv6 relay socket
uint16_t mp_port_v4; // port mp_sock_v4 is bound to
uint16_t mp_port_v6; // port mp_sock_v6 is bound to
ur_addr_map mp_table; // exact peer IP:port -> turn session
```
The `mp_table` address map:
```c
peer_addr:port -> ts_ur_super_session*
```
---
## Data Flow
### Allocate request
```
create_relay_connection()
└─ create_relay_ioa_sockets(..., multiplex_peer_mode=1)
└─ create_relay_socket_multiplex_peer()
├─ shared = mp_get_socket(e, AF_INET) // engine's own socket
└─ *rtp_s = shared // no bind(), no port consumed
```
### Client → peer (send indication / channel data)
The client sends to port 3478 (the TURN listener socket). The server reads
the allocation, looks up the permission, registers the exact peer IP:port
to the session, and sends to the peer from the shared relay socket.
### Peer → client (inbound relay data)
```
libevent fires mp_relay_input_handler(s=shared_sock, data->remote_addr=peer)
└─ mp_table exact lookup: peer IP:port -> turn_session
└─ verify the allocation still has permission for peer IP
└─ forward to client socket
```
This mode assumes each peer IP:port belongs to exactly one client
allocation on the relay worker. If two clients use the same peer IP:port,
the second registration is rejected instead of routing ambiguously.
### Session teardown
```
shutdown_client_connection()
├─ mp_deregister_session_peers(e, ss, 0) // remove exact-peer entries
├─ relay_endpoint_session->s = NULL // prevent shared socket close
└─ clear_allocation() / IOA_CLOSE_SOCKET() // now a safe no-op for relay sock
```
Nulling the relay socket pointer before `clear_allocation()` is the key
safety measure. It ensures the engine-owned shared socket fd is never
closed by session teardown code.
---
## Egress Batching (sendmmsg / UDP-GSO) and Observability
Multiplex-peer mode does more than save ports — it is also what makes
**cross-session egress batching** possible on Linux.
### Why both directions batch under multiplex-peer
`recvmmsg` on a UDP socket drains many datagrams in one syscall, and the
drain loop (`socket_udp_read_batch_recvmmsg`) wraps its per-datagram dispatch
in `udp_sendmmsg_batch_begin()` / `udp_sendmmsg_batch_end()`. Any sends issued
while processing that drain are collected into a thread-local batch keyed by
the **send fd** and flushed once (via `sendmmsg`, or a single UDP-GSO `sendmsg`
when destination and segment size match) at `batch_end`.
- **Uplink (client → relay → peer).** The client listener's `recvmmsg` drain
pulls many clients' datagrams; each is forwarded to its peer on the shared
relay socket → one `sendmmsg` on the relay fd.
- **Downlink (peer → relay → client).** With multiplex-peer the **shared**
relay socket's `recvmmsg` drain pulls datagrams for *many sessions* at once;
each is forwarded to its client. Per-session UDP client sockets are children
of the listener socket (`parent_s`), and `udp_send_fd()` returns the
**listener fd** for all of them — so downlink sends to *different clients*
coalesce into one `sendmmsg` on the shared listener fd, each `mmsghdr`
carrying its own client destination.
This is why downlink-to-client is **not** an unbatched per-packet `sendto`
path under multiplex-peer: the listener fd is effectively a shared
client-facing send socket, and `sendmmsg` amortizes the syscall across
clients. (In non-multiplex mode each allocation has its own relay socket, so a
relay `recvmmsg` drain only ever spans one session and the downlink batch is a
singleton — cross-client batching genuinely requires multiplex-peer.)
`udp_sendmmsg` is enabled automatically whenever `--multiplex-peer` is set;
`--udp-gso` additionally turns on UDP-GSO segmentation for batches that share
destination and size.
### Measuring it: `--udp-sendmmsg-log`
GSO only engages when every datagram in a batch shares the same destination
and size, so at low per-flow packet rates (e.g. VoIP, a few dozen pps per
flow) it rarely fires and batches tend toward singletons. To see what is
actually happening, enable per-thread egress stats every 10 s:
```bash
turnserver --multiplex-peer --udp-gso --udp-sendmmsg-log ...
```
```
udp-sendmmsg stats: flushes=21 datagrams=27 avg_batch=1.29 \
gso_flushes=0 gso_datagrams=0 gso_frac=0.000 \
hist_1=17 hist_2=2 hist_3_4=2 hist_5_8=0 hist_9_16=0 hist_17_32=0
```
- `avg_batch` — mean datagrams coalesced per flush (1.0 = no coalescing).
- `gso_frac` — fraction of datagrams sent via UDP-GSO (≈0 means GSO is not
earning its keep at this workload).
- `hist_*` — per-flush occupancy histogram.
Batch occupancy scales with how many datagrams arrive per `recvmmsg` drain,
which grows with aggregate pps — so these numbers rise under higher load and
stay near 1.0 on lightly loaded servers. Pair with `--udp-recvmmsg-log` to see
the ingress side.
---
## Files Changed
| File | What changes |
|------|-------------|
| `src/apps/relay/ns_ioalib_impl.h` | Multiplex-peer fields; declarations for `init_multiplex_peer`, exact-peer registration cleanup helpers, `mp_get_socket`, and `mp_get_port` |
| `src/apps/relay/ns_ioalib_engine_impl.c` | Shared socket setup, exact-peer routing table, registration cleanup helpers, and the multiplex-peer branch in `create_relay_ioa_sockets` |
| `src/server/ns_turn_ioalib.h` | Add `multiplex_peer_mode` parameter to `create_relay_ioa_sockets` declaration |
| `src/server/ns_turn_server.h` | Add `multiplex_peer_mode` field to `turn_turnserver` |
| `src/server/ns_turn_server.c` | Pass flag; register exact peers from CREATE_PERMISSION/SEND/CHANNEL_BIND; clean mappings on timeout/teardown; keep shared sockets open |
| `src/apps/relay/mainrelay.h` | Add `multiplex_peer` and `multiplex_peer_base_port` to `turn_params_t` |
| `src/apps/relay/mainrelay.c` | CLI options; startup validation; call `init_multiplex_peer` from `setup_relay_server` |
---
## CLI Usage
```bash
# Enable with default base port 3480 and default relay threads (4)
# Opens ports 3480-3487 (4 threads × 2 address families)
turnserver --multiplex-peer
# Custom base port
turnserver --multiplex-peer --multiplex-peer-port=4000
# Full example
turnserver \
--listening-port=3478 \
--relay-ip=203.0.113.1 \
--relay-threads=4 \
--multiplex-peer \
--multiplex-peer-port=3480 \
--lt-cred-mech \
--realm=example.com
```
Startup log:
```
multiplex-peer: 4 thread(s), port range 3480-3487 (IPv4+IPv6 per thread)
multiplex-peer: thread 0 IPv4 socket bound to 203.0.113.1:3480
multiplex-peer: thread 0 IPv6 socket bound to ::1:3481
multiplex-peer: thread 1 IPv4 socket bound to 203.0.113.1:3482
...
```
---
## Firewall Rules
Standard mode requires opening 4915265535 (16 383 ports).
Multiplex-peer mode requires only:
| Port(s) | Protocol | Purpose |
|---------|----------|---------|
| 3478 | UDP + TCP | TURN signalling (unchanged) |
| 5349 | UDP + TCP | TURN/TLS (unchanged, optional) |
| **3480 3480 + threads×2 1** | **UDP** | **Relay media (multiplex-peer)** |
With 4 threads: open 34803487. With 8 threads: open 34803495.
---
## Limitations & Trade-offs
| Limitation | Detail |
|------------|--------|
| EVEN-PORT silently ignored | Modern WebRTC uses `rtcp-mux` — EVEN-PORT is not needed |
| Clients on different threads see different relay ports | Correct and expected; the thread assignment is stable per connection |
| Source-IP must be preserved | If a load balancer SNATs clients, the src-IP uniqueness guarantee breaks — use DSR or PROXY protocol |
| Ports exposed = 2 × relay_threads | Still tiny compared to the default range; adjust `--relay-threads` if needed |
| TCP relay unaffected | RFC 6062 TCP relay allocates one TCP socket per peer — not subject to the same port pressure |
---
## Testing
```bash
# Start server (no auth for easy testing)
turnserver \
--no-auth \
--listening-ip=127.0.0.1 \
--relay-ip=127.0.0.1 \
--relay-threads=2 \
--multiplex-peer \
--multiplex-peer-port=3480
# Confirm ports 3480-3483 are open and listening
ss -ulnp | grep -E '348[0-3]'
# Create 20 000 simultaneous allocations (far above the normal 16k limit)
turnutils_uclient -n 20000 -u test -w test 127.0.0.1
# Observe relay ports in XOR-RELAYED-ADDRESS should only be 3480 or 3482
# (depending on which thread each client lands on)
# Packet-level verification
tcpdump -i lo udp portrange 3480-3483 -n
```