1
0
mirror of https://github.com/coturn/coturn.git synced 2026-10-24 00:08:57 +03:00
Files
coturn/docs/multiplex-peer.md
Pavel Punsky eda6358d2f multiplex-peer: bound the shared per-thread peer demux table (#2017)
## What

Moves the `--multiplex-peer` demux table out of `ioa_engine_t` into its
own module, `src/apps/relay/mp_peer_table.c/.h`, and gives it two
properties it did not have:

- **A per-allocation endpoint limit.** New `--multiplex-peer-max-peers`
(default 256). Beyond it, CREATE_PERMISSION and CHANNEL_BIND are
answered with 508 and SEND indications are dropped. The first rejection
per allocation is logged once.
- **A reverse index** (session → its endpoints), so
`mp_deregister_permission_peers` and `mp_deregister_session_peers` cost
that session's own endpoints instead of a full scan of the table shared
by every session on the relay thread.

## Why

The table is keyed by exact peer IP:port, but permissions are per peer
IP, so a single permission covers all 65535 ports of that IP: every SEND
indication naming a fresh port added an entry, with no cap and no
backpressure. The table is per relay thread and shared, so one busy
allocation grows a structure its co-tenants pay for — and every
permission expiry and session teardown walked the whole thing.

Ephemeral-port churn produces the same growth without anyone trying, so
this is also a robustness issue for high-volume deployments.

## Behaviour changes

- `--multiplex-peer-max-peers <n>` (1–65535, default 256). Config-file
key works too. No effect unless `--multiplex-peer` is set.
- An allocation exceeding the limit gets **508** on CREATE_PERMISSION /
CHANNEL_BIND. 508 is what this mode already returns for its other
capacity limitation (EVEN-PORT), so the code is not new to
multiplex-peer clients.
- Nothing changes below the limit; the default is far above normal use
(a typical allocation registers two endpoints — its peer's RTP and RTCP
ports).

## Performance

Microbenchmark of the old vs new code paths (arm64, `-O2`, keys
precomputed so only table work is timed):

| table entries | demux lookup (per datagram) | re-register (per SEND) |
permission expiry, 2-endpoint session |
|---|---|---|---|
| 400 | 99 → 96 ns | 190 → 103 ns | 2.5 µs → 0.068 µs |
| 4,000 | 116 → 119 ns | 236 → 125 ns | 9.3 µs → 0.055 µs |
| 50,000 | 129 → 133 ns | 270 → 147 ns | 98.7 µs → 0.24 µs |
| 500,000 | 501 → 510 ns | 1002 → 510 ns | 986 µs → 1.2 µs |

- Data plane is untouched — same map, same lookup; deltas are noise.
- The SEND path is ~2× cheaper: the old code did `ur_addr_map_get`
**and** `ur_addr_map_put`, and `put` re-scans the bucket. Registering an
endpoint the session already owns now returns after the `get`.
- Memory: +128 B per endpoint for the index copy, ~310 B per session
with endpoints (index array starts at 2 slots because `ioa_addr` is 128
B and the common case is RTP + RTCP). Worst case per allocation is now
~66 KB at the default limit rather than unbounded. The per-thread bound
is sessions × limit, so a hard ceiling still wants `--total-quota`.

## Tests

- `tests/test_mp_peer_table.c` (new, 8 cases): idempotent
re-registration, cross-session conflict, a 4096-port registration run
capped at 64, the limit being per-session rather than global, slot reuse
after release, per-IP and per-family deregistration, and a deterministic
assertion — via a `deregister_ops` counter — that deregistration never
touches 200 entries belonging to another session.
- `examples/run_tests_multiplex_peer.sh`: server start/stop refactored
into functions; adds a run under `--multiplex-peer-max-peers=4` (normal
relaying unaffected) and one under `=1` asserting the client sees 508
and the server logs the limit. The second SKIPs where multiplex-peer is
inactive (non-Linux).

## Validation

- macOS: 18/18 ctest, all `examples/run_tests*.sh`, both fuzzing smoke
targets, `make lint` clean.
- Linux (Docker): 17/17 ctest; `run_tests.sh`, `run_tests_conf.sh`,
`run_tests_mobile.sh`, `run_tests_multiplex_peer.sh`,
`run_tests_rfc5780.sh`, `run_tests_stateless_nonce.sh` all green.
- Packaged image: `make docker.image dockerfile=debian` + `make
test.docker` — 19 Bats tests, 0 failures.

## Reviewer notes

- `deregister_ops` is a counter on the table struct read only by the
unit test; it is one increment inside a loop that already runs.
- `mp_deregister_peer()` had no callers before this change and still has
none; it is kept as the exact-key counterpart of the two bulk helpers.
- Docs updated: `docs/multiplex-peer.md` (table layout, files-changed
table, CLI examples) and `examples/etc/turnserver.conf`.
2026-07-26 19:56:41 -07:00

14 KiB
Raw Permalink Blame History

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 49152–65535, ≈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.

/* 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
mp_peer_table     mp_table;         // exact peer IP:port -> turn session

The mp_table demux table (src/apps/relay/mp_peer_table.c):

peer_addr:port -> ts_ur_super_session*   /* which session owns the endpoint */
ts_ur_super_session* -> [peer_addr:port] /* that session's own endpoints */

The table is shared by every session on the relay thread, and a client adds an entry for each new peer endpoint it names in CREATE_PERMISSION, CHANNEL_BIND or a SEND indication — a SEND only needs the per-IP permission, so one permission covers all 65535 ports of that IP. Two properties keep one client from degrading its co-tenants:

  • Each allocation may hold at most --multiplex-peer-max-peers endpoints (default 256); beyond that CREATE_PERMISSION and CHANNEL_BIND are answered with 508 and SEND indications are dropped.
  • The reverse index makes permission expiry (mp_deregister_permission_peers) and teardown (mp_deregister_session_peers) cost the session's own endpoints instead of a scan of the whole shared table.

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:

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/mp_peer_table.c/.h Per-thread demux table: registration cap per session, reverse index for O(own endpoints) deregistration
src/apps/relay/mainrelay.h Add multiplex_peer, multiplex_peer_base_port and multiplex_peer_max_peers to turn_params_t
src/apps/relay/mainrelay.c CLI options; startup validation; call init_multiplex_peer from setup_relay_server

CLI Usage

# 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

# Raise the per-allocation peer-endpoint cap (default 256)
turnserver --multiplex-peer --multiplex-peer-max-peers=1024

# 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 49152–65535 (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 3480–3487. With 8 threads: open 3480–3495.


Limitations & Trade-offs

Limitation Detail
EVEN-PORT rejected with 508 (Insufficient Capacity) 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

# 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