Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/ko/runtime-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
| `CWIST_DRAIN_TIMEOUT` | supported | 초, `0`–`3600` | `5` | 종료 시 앱을 해제하기 전에 처리 중인 요청을 기다리는 시간. 범위 밖 값은 stderr에 알리고 무시합니다. |
| `CWIST_FD_LIMIT_TARGET` | supported | 0보다 큰 정수 | `1050000` | 서버가 시작할 때 스스로 올리는 열린 파일 soft limit(hard limit까지). [C1M 파일 한도](c1m-file-limits.md) 참고. |
| `CWIST_MALLOC_ARENA_MAX` | tuning | 0 이상 정수 | 설정 안 함(glibc 기본값) | glibc 전용: `mallopt(M_ARENA_MAX)`. `1`은 할당기 동시성을 조금 내주고 RSS를 크게 줄입니다. |
| `CWIST_MALLOC_TRIM_IDLE_SEC` | tuning | 초, `0`–`86400` | `30` | glibc 전용(그 외 libc에서는 동작 없음): 이벤트 없이 유휴 상태가 누적된 시간이 이 값에 도달하면 리액터가 이벤트 없는 wake에서 `malloc_trim(0)`을 호출해 glibc 아레나 상단의 여유 메모리를 OS에 반환합니다. 유휴 wake에서만 동작해 핫 패스 비용은 없습니다. 로드 라운드 후 워커당 중간값 약 1.2 MiB 절감. `0`이면 끕니다. |

## Classic 스레드 풀

Expand Down Expand Up @@ -57,6 +58,7 @@
| `CWIST_HTTPS_IDLE_TIMEOUT_MS` | supported | 밀리초, 0보다 큼 | `30000` | HTTPS 연결을 닫기 전 유휴 시간. |
| `CWIST_HTTPS_PARK` | tuning | `0`/`false`면 끔 | 켜짐 | 유휴 TLS 연결이 풀 스레드를 붙잡지 않고 reactor에 대기합니다. |
| `CWIST_HTTPS_HS_SHARDS` | tuning | `1`–`16` | CPU 코어 수, `4`–`16`으로 제한 | 핸드셰이크 샤드 수. 연결 폭주 튜닝용. |
| `CWIST_TLS_SESS_CACHE_SIZE` | tuning | 정수, `0`–`1048576` | `1024` | BoringSSL 서버측 세션 캐시 상한(`SSL_CTX_sess_set_cache_size`)으로, 모든 서버 TLS 컨텍스트(HTTP/1.1, HTTP/2, HTTP/3)에 적용됩니다. 워커 프로세스당 TLS 1.2 세션 ID 보유량을 제한합니다. `0`이면 BoringSSL 기본값(제한 없음)을 씁니다. 세션 삽입 시에만 참조합니다. |

## HTTP/2

Expand Down
2 changes: 2 additions & 0 deletions docs/runtime-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ Each variable has one status. The status says what v4 promises about it.
| `CWIST_DRAIN_TIMEOUT` | supported | seconds, `0`–`3600` | `5` | How long shutdown waits for in-flight requests before the app is destroyed. Out of range values are reported on stderr and ignored. |
| `CWIST_FD_LIMIT_TARGET` | supported | integer > 0 | `1050000` | Open-file soft limit the server raises itself to at start (capped by the hard limit). See [C1M file limits](c1m-file-limits.md). |
| `CWIST_MALLOC_ARENA_MAX` | tuning | integer ≥ 0 | unset (glibc default) | glibc only: `mallopt(M_ARENA_MAX)`. `1` trades some allocator concurrency for a much lower RSS. |
| `CWIST_MALLOC_TRIM_IDLE_SEC` | tuning | seconds, `0`–`86400` | `30` | glibc only (no-op elsewhere): seconds of cumulative event-free reactor idleness after which the reactor calls `malloc_trim(0)` on a zero-event wake, returning the free arena top to the OS. Runs only on idle wakes, so the hot path pays nothing. Measured ≈1.2 MiB median per worker after a load round. `0` disables the trim. |

## Classic thread pool

Expand Down Expand Up @@ -57,6 +58,7 @@ Each variable has one status. The status says what v4 promises about it.
| `CWIST_HTTPS_IDLE_TIMEOUT_MS` | supported | milliseconds > 0 | `30000` | Idle time before an HTTPS connection is closed. |
| `CWIST_HTTPS_PARK` | tuning | `0`/`false` disables | on | Park idle TLS connections in the reactor instead of holding a pool thread. |
| `CWIST_HTTPS_HS_SHARDS` | tuning | `1`–`16` | CPU cores, clamped to `4`–`16` | Handshake shards. For connect-burst tuning. |
| `CWIST_TLS_SESS_CACHE_SIZE` | tuning | integer, `0`–`1048576` | `1024` | Cap on the BoringSSL server-side session cache (`SSL_CTX_sess_set_cache_size`), applied to every server TLS context (HTTP/1.1, HTTP/2, HTTP/3). Bounds TLS 1.2 session-ID retention per worker process. `0` leaves the BoringSSL default (uncapped). Consulted on session insert only. |

## HTTP/2

Expand Down
35 changes: 32 additions & 3 deletions src/net/http/http.c
Original file line number Diff line number Diff line change
Expand Up @@ -1056,7 +1056,11 @@ static bool http_async_arm_wait(int fd, cwist_http_async_conn_t *conn,
};
if (cwist_reactor_rx_supported(reactor) && !conn->peer_eof && !conn->rx_recv_inflight &&
!conn->rx_prefers_poll) {
if (conn->cap == 0 && !http_async_stash_grow(conn, CWIST_HTTP_READ_BUFFER_SIZE)) {
/* Virgin stash: grow to the 4 KiB idle floor, not the 16 KiB read
* buffer. A mostly-idle C1M connection then pays ~4 KiB, not 16 KiB,
* of rbuf on first arm; any request above the floor regrows through
* http_async_stash_grow exactly as a regrown stash does today. */
if (conn->cap == 0 && !http_async_stash_grow(conn, CWIST_ASYNC_STASH_IDLE)) {
/* Stash allocation failed: the legacy fill path retries growth
* when POLL fires and closes on failure, matching today. */
} else if (conn->cap > 0) {
Expand Down Expand Up @@ -1092,7 +1096,27 @@ static void http_async_dispatch(int fd, cwist_http_async_conn_t *conn,

if (action == CWIST_ASYNC_DEFER) {
/* The handler parked the request on a cwist_async; the completion
* path owns fd and conn now and re-arms or closes when done. */
* path owns fd and conn now and re-arms or closes when done.
*
* A deferred connection used to hold its full rbuf/obuf stashes for
* the entire deferral (SSE, long-poll): the idle shrink only ran at
* re-arm, which a parked connection never reaches. Shrink toward the
* idle floor here instead, so a parked connection pays ~KB, not
* ~32 KB, for the whole deferral.
*
* Defer-specific shrink: the shared helper is keep-alive hysteresis-
* gated (it must be: a busy connection would otherwise
* shrink->regrow every request, a P99.999 stall source). That
* hysteresis is exactly wrong at defer -- the stash was grown moments
* ago, yet the connection is now parked indefinitely and the capacity
* is not needed until the completion re-arms (which regrows on
* demand). So age the anchor out before calling it; the resulting
* shrink->regrow pair costs one realloc per defer cycle, not per
* request. RX-uring invariant holds: defer runs with no RECV SQE in
* flight (rx_recv_inflight was cleared by the RECV completion entry,
* or the wait was a POLL), same as at re-arm. */
conn->stash_grown_sec = 0;
http_async_stash_idle_shrink(conn);
return;
}
if (action == CWIST_ASYNC_DETACH) {
Expand Down Expand Up @@ -4858,7 +4882,12 @@ static bool http_async_stash_grow(cwist_http_async_conn_t *conn, size_t need) {
if (conn->rx_recv_inflight) return false;
if (need > CWIST_ASYNC_STASH_MAX) return false;
if (conn->cap >= need) return true;
size_t cap = conn->cap ? conn->cap : CWIST_HTTP_READ_BUFFER_SIZE;
/* Doubling base: a virgin stash starts at the 4 KiB idle floor (the
* first-arm site grows it to exactly that), so an idle C1M connection
* pays floor memory, not CWIST_HTTP_READ_BUFFER_SIZE. Above the floor
* the doubling ladder is unchanged: need 16 KiB on a virgin stash ends
* at the same 16 KiB cap as before. */
size_t cap = conn->cap ? conn->cap : CWIST_ASYNC_STASH_IDLE;
while (cap < need) {
if (cap > CWIST_ASYNC_STASH_MAX / 2) {
cap = CWIST_ASYNC_STASH_MAX;
Expand Down
1 change: 1 addition & 0 deletions src/net/http/http3.c
Original file line number Diff line number Diff line change
Expand Up @@ -2577,6 +2577,7 @@ static int cwist_h3_ticket_key_cb(SSL *ssl, uint8_t *key_name, uint8_t *iv, EVP_
*/
static void cwist_h3_setup_session_tickets(SSL_CTX *ssl_ctx) {
SSL_CTX_set_session_cache_mode(ssl_ctx, SSL_SESS_CACHE_SERVER);
SSL_CTX_sess_set_cache_size(ssl_ctx, cwist_tls_sess_cache_size());

pthread_once(&g_h3_ticket_key_ex_data_once, cwist_h3_ticket_key_ex_data_init);
if (g_h3_ticket_key_ex_data_idx < 0) return;
Expand Down
1 change: 1 addition & 0 deletions src/net/http/https.c
Original file line number Diff line number Diff line change
Expand Up @@ -1180,6 +1180,7 @@ static void cwist_https_apply_base_tls_defaults(SSL_CTX *ssl_ctx) {
* OpenSSL refuses to resume cached sessions. Combined with HTTP/1.1
* keep-alive this removes most full (asymmetric) handshakes. */
SSL_CTX_set_session_cache_mode(ssl_ctx, SSL_SESS_CACHE_SERVER);
SSL_CTX_sess_set_cache_size(ssl_ctx, cwist_tls_sess_cache_size());
SSL_CTX_set_session_id_context(ssl_ctx, (const unsigned char *)"cwist-v1", 8);
SSL_CTX_set_timeout(ssl_ctx, 300);
}
Expand Down
33 changes: 33 additions & 0 deletions src/net/http/tls_chain.c
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
#include <openssl/x509.h>

#include <limits.h>
#include <stdatomic.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
Expand Down Expand Up @@ -504,6 +505,38 @@ static X509 *cwist_fetch_issuer_from_aia(X509 *subject) {
return issuer;
}

/** Bounded retention for the BoringSSL server-side session cache.
*
* BoringSSL's implicit cap is 20k entries per SSL_CTX (ssl.h
* SSL_SESSION_CACHE_MAX_SIZE); nothing in CWIST called
* SSL_CTX_sess_set_cache_size, so a deployment seeing TLS 1.2 session-ID
* resumption could accumulate up to that per worker process with no bound it
* could tune. The knob caps the cache on every server context. Measured
* session-cache usage under wrk/ab/h2load and openssl s_client TLS 1.2 churn
* is 0 (TLS 1.3 tickets are stateless and never enter the server cache), so
* today this is retention hardening, not an RSS win. The cap is consulted on
* session insert only: zero hot-path impact.
*
* @return The validated CWIST_TLS_SESS_CACHE_SIZE env override or 1024;
* cached after first read. 0 leaves the BoringSSL default uncapped.
*/
unsigned long cwist_tls_sess_cache_size(void) {
static _Atomic long cached = -1;
long v = atomic_load_explicit(&cached, memory_order_relaxed);
if (v < 0) {
const char *s = getenv("CWIST_TLS_SESS_CACHE_SIZE");
if (s && *s) {
char *end = NULL;
long parsed = strtol(s, &end, 10);
v = (end != s && parsed >= 0 && parsed <= 1048576L) ? parsed : 1024;
} else {
v = 1024;
}
atomic_store_explicit(&cached, v, memory_order_relaxed);
}
return (unsigned long)v;
}

/**
* @brief Autoload missing intermediate CA certificates into an SSL_CTX chain.
*
Expand Down
6 changes: 6 additions & 0 deletions src/net/http/tls_chain.h
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,10 @@

int cwist_tls_autoload_intermediates(SSL_CTX *ssl_ctx);

/* Bounded retention for the BoringSSL server-side session cache.
* CWIST_TLS_SESS_CACHE_SIZE cached getter (implemented in tls_chain.c):
* the cache cap applied to every server SSL_CTX; 0 keeps the BoringSSL
* default (no explicit cap). See docs/runtime-config.md. */
unsigned long cwist_tls_sess_cache_size(void);

#endif /* CWIST_NET_HTTP_TLS_CHAIN_H */
81 changes: 76 additions & 5 deletions src/sys/io/reactor.c
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@
#include <sys/epoll.h>
#include <sys/socket.h>
#include <sys/eventfd.h>
/* malloc_trim(3): glibc-only <malloc.h>. The weak declaration below lets the
* binary link (and the trim degrade to a no-op) on Linux libcs without it,
* e.g. musl. */
#include <malloc.h>

#ifndef IORING_ENTER_EXT_ARG
#define IORING_ENTER_EXT_ARG (1U << 3)
Expand Down Expand Up @@ -284,6 +288,10 @@ struct cwist_reactor {
uint64_t max_us;
uint64_t over_5ms; /* samples beyond 5000 us */
} probe[2];
/* CLOCK_MONOTONIC timestamp of the first event-free wake that started the
* current idle streak (0 = not idle). Run thread only. Drives the idle
* malloc_trim cadence; see reactor_idle_note_wake(). */
uint64_t idle_since_ns;
#endif
};

Expand Down Expand Up @@ -1406,6 +1414,60 @@ static uint64_t reactor_round_budget_us(void) {
return (uint64_t)v;
}

/* Idle malloc trim (measured ~1.2 MiB median per worker returned to the OS on
* an idle HTTPS worker after a load round): glibc never gives back the free
* top of the main arena unless asked. CWIST_MALLOC_TRIM_IDLE_SEC bounds how
* many seconds of cumulative event-free idleness a reactor may accumulate
* before it calls malloc_trim(0) on a wake that saw zero events -- a mostly
* idle deployment sheds arena top memory without any hot-path cost. The knob
* is cached after the first read like the other env knobs in this file; 0
* disables the trim. */
static uint32_t reactor_malloc_trim_idle_sec(void) {
static _Atomic int cached = -1;
int v = atomic_load_explicit(&cached, memory_order_relaxed);
if (v < 0) {
const char *s = getenv("CWIST_MALLOC_TRIM_IDLE_SEC");
if (s && *s) {
char *end = NULL;
long parsed = strtol(s, &end, 10);
v = (end != s && parsed >= 0 && parsed < 86400) ? (int)parsed : 30;
} else {
v = 30;
}
atomic_store_explicit(&cached, v, memory_order_relaxed);
}
return (uint32_t)v;
}

/* Weak: musl and other non-glibc Linux libcs have no malloc_trim. */
extern int malloc_trim(size_t __pad) __attribute__((weak));

/* A wake that dispatched events (or ran posted work) ends the idle streak. */
static void reactor_idle_note_eventful(cwist_reactor_t *r) { r->idle_since_ns = 0; }

/* Bookkeeping for a wake that saw zero events. Starts an idle streak on the
* first such wake; once the streak has lasted CWIST_MALLOC_TRIM_IDLE_SEC
* cumulative seconds, returns the arena top to the OS and re-anchors the
* streak. Runs on the run thread only, after the wait returned empty. */
static void reactor_idle_note_wake(cwist_reactor_t *r) {
uint32_t sec = reactor_malloc_trim_idle_sec();
if (sec == 0 || &malloc_trim == NULL) {
r->idle_since_ns = 0;
return;
}
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
uint64_t now = (uint64_t)ts.tv_sec * 1000000000ull + (uint64_t)ts.tv_nsec;
if (r->idle_since_ns == 0) {
r->idle_since_ns = now;
return;
}
if (now - r->idle_since_ns >= (uint64_t)sec * 1000000000ull) {
malloc_trim(0);
r->idle_since_ns = now;
}
}

#if defined(__x86_64__) || defined(__i386__)
#define reactor_cpu_relax() __builtin_ia32_pause()
#elif defined(__aarch64__)
Expand Down Expand Up @@ -1499,17 +1561,19 @@ void cwist_reactor_run(cwist_reactor_t *reactor) {
if (!cq_pending) {
int ret = sys_io_uring_enter_timeout(reactor->impl.ring_fd, 0, 1,
IORING_ENTER_GETEVENTS, &idle_ts);
if (ret < 0) {
if (errno == EINTR) continue;
if (errno == ETIME) continue;
break;
}
/* EINTR and ETIME both leave the CQ untouched, so the
* head/tail check below still decides eventfulness. */
if (ret < 0 && errno != EINTR && errno != ETIME) break;
}
uint32_t head = __atomic_load_n(reactor->impl.cq_head, __ATOMIC_ACQUIRE);
uint32_t tail = *reactor->impl.cq_tail;
if (head == tail) {
/* Zero-completion wake: pure idle. Feeds the idle malloc_trim
* cadence; see reactor_idle_note_wake(). */
reactor_idle_note_wake(reactor);
continue;
}
reactor_idle_note_eventful(reactor);
reactor_tsan_acquire(&reactor->impl);
reactor->dispatching = true;
const uint64_t round_budget_us = reactor_round_budget_us();
Expand Down Expand Up @@ -1635,6 +1699,13 @@ void cwist_reactor_run(cwist_reactor_t *reactor) {
if (errno == EINTR) continue;
break;
}
if (n == 0) {
/* Event-free idle wake: feeds the idle malloc_trim cadence;
* see reactor_idle_note_wake(). */
reactor_idle_note_wake(reactor);
} else {
reactor_idle_note_eventful(reactor);
}
/* Cooperative queuing, same cadence as the io_uring branch (see
* the comment there): epoll_wait hands back up to 1024 events in
* one array, and this loop used to run them all with no drain
Expand Down
Loading