Files
shadowsocks-libev/docs/cpp-core.md
T
Max Lv db861130d7 Lease relay buffers from a shared pool (#3081)
A connection's two 16 KiB relay buffers lived inline in its slot, so every
connection that had moved a byte kept those pages resident even while idle.
They now come from one ObjectPool with two buffers per connection slot: a
connection leases a buffer while bytes wait in it and returns it when it
drains, and the LIFO free list hands the same warm pages to the next busy
connection. The pool covers every direction of every slot, so a lease never
fails and reads never wait on it.

With 1,000 idle connections, ss-server drops from 35.4 to 27.2 KiB per
connection on Linux (C: 26.2) and from 73.9 to 42.2 KiB on macOS. The slot
shrinks from 104 to 68 KiB.

FixedBuffer gains a user-provided constructor so that value-initialising one
(a pool lease, a variant alternative) no longer zero-fills its storage.
2026-10-15 10:19:05 +08:00

9.3 KiB

C++20 core and allocation policy

Status: all ten phases are done. Every module of src/ is C++20. The four relaying programs run on one TCP engine and one UDP engine (src/relay/) built from the ownership and allocation primitives in src/core/, the libuv wrappers in src/event/ and value-type codecs in src/crypto/. On the data path they make no heap allocations (see performance). The original C codecs and cache are kept only as test oracles in tests/reference/.

Goals

  • Move src/ from C11 to C++20, one module at a time, with every step keeping the CLI, the JSON configuration, protocol behaviour and the public shadowsocks.h C ABI unchanged.
  • Make ownership explicit. Each object has exactly one owner, a std::unique_ptr. Non-owning references are generation-checked handles that go stale instead of dangling.
  • No heap allocation after startup. Connections, buffers, cipher contexts, DNS queries and UDP sessions come from fixed-capacity pools reserved once at startup. The steady-state data path does not call malloc. When a pool is full, new work is refused rather than allocated.

Language profile

  • C++20, -fno-exceptions -fno-rtti -fno-threadsafe-statics, warnings as errors. Errors are returned as ss::Result<T> (src/core/result.hpp), which implements a subset of std::expected.
  • Only header-only parts of the standard library are used (<array>, <span>, <optional>, <variant>, <memory>'s std::unique_ptr). This keeps the binaries and the static library free of libstdc++/libc++ symbols, so C consumers of libshadowsocks-c.a do not need a C++ runtime.
  • scripts/check_no_heap.py (a ctest) rejects new, malloc, allocating containers, std::string, std::function and std::shared_ptr in C++ sources under src/. A startup reservation that really must allocate is marked // heap-ok: <reason>.
  • blake3 (src/blake3) and third_party/bloom stay C.
  • Minimum compilers: GCC 11, Clang 14, Apple Clang 15.

Primitives (src/core/)

Header Purpose
result.hpp Result<T>, Status, Errc, SS_TRY.
region.hpp Region, the only startup calloc in the core. Also ASan poison helpers.
pool.hpp ObjectPool<T>, PoolPtr<T> (std::unique_ptr with a pool deleter), Handle<T>. Types that embed libuv handles model AsyncClosable: their slot is recycled only after the asynchronous close finishes.
ref.hpp RefCounted<T>/RefPtr<T>, intrusive shared ownership for the few genuinely shared objects (AEAD-2022 UDP sessions). Used instead of std::shared_ptr because its control block cannot be pooled portably.
buffer.hpp FixedBuffer<N>: inline bytes with std::span views; replaces buffer_t.
arena.hpp MonotonicArena for configuration data that lives as long as the process.
fixed_map.hpp FixedHashMap (open addressing, backward-shift deletion) and FixedLru; replace uthash and cache.cpp.
intrusive_list.hpp IntrusiveList/ListNode; replaces ss_list. Nodes unlink themselves when destroyed.
fixed_heap.hpp Bounded size-class allocator to hand to libuv, c-ares and Mbed TLS through their allocator hooks.
heap_guard.hpp install_library_heaps() routes libuv, c-ares and Mbed TLS into their own FixedHeaps (executables only, never the library); heap_seal() marks the end of startup; log_heap_report() prints heap use with -v.

Event layer (src/event/loop.hpp)

Loop, Poll, Timer, Signal and Async wrap libuv handles that are embedded by value in their owner and call a member function of the owner chosen at compile time (declare that member before the handle). A Poll owns its socket and carries both read and write interest on one libuv handle; a handle closed from another callback gets no further events. Handles close into a CloseGroup, which signals completion after the last asynchronous close, so pooled owners implement AsyncClosable::begin_close by closing their handles into one. DeadlineList drives the idle timeouts of many owners from one timer. program.hpp holds what every main() shares: the loop, SIGINT/SIGTERM (and SIGCHLD when a plugin runs), periodic callbacks, a cross-thread request_stop(), and after_fork() for daemonize().

Relay engines (src/relay/)

File Purpose
tcp.hpp TcpRelay: listeners, a pool of max_connections connections, one idle-deadline timer. Kind selects the handshake (tunnel, SOCKS5 local, redir, server). A full pool pauses accepting until a slot frees.
udp.hpp UdpRelay: sessions in a FixedLru over a pool of max_udp_sessions, one 64 KiB scratch buffer per relay. AEAD-2022 server sessions are RefPtr-shared between the session table and their client sockets.
codec.hpp Method (immutable, from the configuration) and per-connection Encoder/Decoder variants: plain, stream, AEAD, AEAD-2022.
resolver.hpp c-ares with a pool of max_dns_queries queries; answers reach their connection through a generation-checked handle, so a closed connection is never called back.
socket.hpp Upstream socket setup, TCP Fast Open, SOCKS address encoding.

Per-binary behaviour (TOS per redir port, connmark-to-TOS, nftables, manager statistics, Android protect) plugs in through hooks on the configs, so the engines do not link it.

Crypto (src/crypto/)

aead.hpp (SIP004/SIP007 AEAD) and aead2022.hpp (SIP022) provide value-type codecs (the unauthenticated stream ciphers were removed): the cipher state is held inside the codec, TCP decoders reassemble into a fixed buffer sized for one chunk and can hand plaintext out in windows of any size, and HKDF-SHA1 runs on stack SHA-1 state. keys.hpp derives keys and lists the supported methods. All codecs are tested byte for byte against the C reference implementation in tests/reference/. The bundled Mbed TLS enables MBEDTLS_BLOCK_CIPHER_C so GCM key setup does not allocate; with a system Mbed TLS lacking it, mbedtls_gcm_setkey may still allocate.

Capacities

Setting Default Pool
--max-connections / max_connections 1024 TCP connections: a 68 KiB slot each, plus two 16 KiB relay buffers leased from a shared pool while data is in flight
--max-udp-sessions / max_udp_sessions 256 (ss-server 512) UDP associations; the least recently used is dropped when full
--max-dns-queries / max_dns_queries the connection limit (ss-server +64) DNS lookups in flight

Pools are reserved at startup and only touched slots become resident.

Phases

  1. Build and core: C++ in CMake, src/core/, the no-heap lint, the C++ runtime check and the allocation audit.
  2. Mechanical C-to-C++ conversion, leaf modules first. scripts/no_heap_baseline.txt ratchets down the remaining startup allocations in converted modules.
  3. Event layer over libuv (src/event/).
  4. Crypto codecs held by value: AEAD, stream and AEAD-2022.
  5. Services: the cache on a fixed slab, IntrusiveList for ACL rules and the manager, the pooled resolver, and read_jconf on arenas (the file and JSON tree in a scratch arena released before it returns, the parsed strings in one process-lifetime arena). ACL rules and IP-set nodes still come from malloc at startup: the IP-set trie frees nodes as it merges prefixes, and its size depends on the ACL file, so a bump arena would have to reserve for the worst case.
  6. One TCP engine for ss-tunnel, ss-local, ss-redir and ss-server.
  7. One UDP engine.
  8. Library heaps for libuv, c-ares and Mbed TLS, heap_seal(), and the capacity settings above.
  9. Public API: shadowsocks.h unchanged, plus shadowsocks/control.h (ss_local_start/stop/wait/destroy) and the RAII shadowsocks/shadowsocks.hpp.
  10. Removal of ss_event, the C resolver, the C codecs (now test oracles), uthash and ss_list, and the unit tests ported to C++ (tests/test_helpers.hpp).

Allocation audit

tests/alloc_audit/alloc_counter.c is a preloadable malloc counter (glibc and macOS). tests/alloc_audit/measure_alloc.py warms ss-local and ss-server up, snapshots their counters, relays more connections and reports heap allocations per connection; --udp measures datagrams instead and --target picks the destination. The alloc_steady_programs_* ctests require zero for each method family over TCP and UDP.

python3 tests/alloc_audit/measure_alloc.py --bin build/bin \
    --shim build/lib/libss_alloc_audit.dylib --method aes-256-gcm

C baseline (before the rewrite)

Commit 553cdc7, macOS arm64, bundled Release build, 200 connections, each echoing 16 KiB through ss-local and ss-server on loopback. The figures include allocations made inside libuv, c-ares and Mbed TLS. ss-server resolves the destination (localhost) for every connection, so its count includes a DNS lookup.

Method ss-server allocations/connection ss-local allocations/connection
aes-256-gcm 89.1 39.2
chacha20-ietf-poly1305 83.1 33.0
2022-blake3-aes-256-gcm 84.0 33.0
aes-256-cfb (stream, since removed) 77.1 26.5
aes-256-gcm, 1-byte payload 85.0 36.0

The C++ programs are at 0 for every row, including the name-resolving ss-server case; see performance for the full comparison.

Unstripped Release sizes at the same commit: ss-local 1,086,888 bytes, ss-server 1,086,952 bytes, ss-tunnel 542,200 bytes.