CUBRID 스레드 매니저 — 고동시성 코드 수준 심층 분석
이 문서의 위치: 상위 수준 분석 문서인
cubrid-thread-manager.md가 설계 의도와 동기, CBRD-26177 + CBRD-26662 2단계 진화 과정을 다루고, 기반 엔진은cubrid-thread-worker-pool.md에서 설명한다. 이 문서는 모든 분기와 필드를 코드 수준에서 추적한다. 각 장은 독립적으로 읽을 수 있지만, 순서대로 읽으면 고동시성 환경에서 클라이언트 요청 하나가 겪는 전체 생애주기를 따라갈 수 있다 — epoll reactor가 요청을 accept하고, coordinator가 배치하고, 후단 task worker에게 넘겨진 뒤, (Phase 2에서는) core별 concurrency slot 아래 admit되어 논리적 대기가 걸릴 때마다 그 slot을 반납한다.출처 정보: 1
11장은 병합된14장은 아직 병합되지 않은 PR #7323 (develop체크아웃을 추적한다. 12feature/worker_pool_elastic) 을 추적하며, 해당 심벌과 라인 번호는 이 브랜치 기준이므로 병합 전까지 바뀔 수 있다. PR 전용 파일의 위치 힌트 행에는PR:경로 접두사를 붙여 표시했다.
목차:
Chapter 1: 데이터 구조 지도 I — 커넥션 리액터
섹션 제목: “Chapter 1: 데이터 구조 지도 I — 커넥션 리액터”새로 수락된 커넥션은 새 프런트엔드 안에서 세 개의 장수명 C++ 객체가
이루는 삼각형으로 산다: 커넥션의 바이트 단위 상태를 담는 context,
하나의 epoll 루프 위에서 다수의 컨텍스트를 서비스하는 스레드인
worker, 그리고 컨텍스트를 할당하고 워커 벡터를 쥐고 있는 소유자
**pool**이다. 이번 장은 이 삼각형의 모든 필드에 더해 두 개의 CV(조건
변수) 프리미티브(thread_watcher, message_blocker)와 워커가 소비하는
제어 어휘(message와 그 enum들, 타이머 슬롯, 백프레셔 기록)까지 지도로
그린다. 움직임은 뒤의 장들이 추적하고, 여기서는 포인터를 정직하게
유지시키는 모양과 불변식을 먼저 고정한다.
연계 참조: 이 리액터가 왜 존재하는지 — C10K/리액터 근거, 커넥션 워커와 백엔드 풀의 분리 — 는 짝을 이루는
cubrid-thread-manager.md(“리액터 프런트엔드”)에 있다.
1.1 한눈에 보는 리액터 삼각형
섹션 제목: “1.1 한눈에 보는 리액터 삼각형”소유 관계는 한 방향으로만 흐르고, 역참조는 반대 방향이다. pool은
worker 객체들을(std::unique_ptr로) 소유하고, context 저장소도(인트루시브
프리리스트로) 소유한다. worker는 raw pool * 하나와 자신이 서비스하는
context * 집합을 들고 있다. context는 자신을 소유한 워커의 인덱스만
기록한다 — 인덱스는 컨텍스트가 워커 사이를 옮겨 다녀도(7장) 살아남지만,
raw 포인터였다면 대롱대롱 매달리게 됐을 것이다.
Figure 1-1. 리액터 삼각형 — 소유(실선) 대 역참조·인덱스(점선).
pool이 꼭짓점에서 worker 벡터와 context 프리리스트를 모두 소유하고, 밑변 두 모서리의 worker와 context는 각각 raw 포인터와 정수 인덱스로 pool을 되짚는다.
불변식 — 컨텍스트는 자신의 워커에 대한 raw 포인터를 결코 소유하지 않는다.
context::m_worker는 pool::m_workers를 가리키는 int 인덱스다.
리밸런스 시점(6장/7장)에는 이 정수 하나만 다시 쓰일 뿐, 아무것도 재포인팅되지
않으며 어떤 worker *도 워커 소멸을 넘어 살아남을 수 없다.
1.2 context — 수락된 커넥션 하나
섹션 제목: “1.2 context — 수락된 커넥션 하나”// connection::context -- src/connection/connection_context.hppstruct context { /* THIS MUST BE THE FIRST */ css_conn_entry *m_conn; int m_worker; uint64_t m_id; ignore_level m_ignore; bool m_removed; struct { state m_state; receiver m_receiver; cubbase::span<std::byte> m_header; int m_request_id; bool m_command; } m_recv; struct { transmitter m_transmitter; std::shared_ptr<message_blocker> m_blocker; } m_send; statistics::metrics<statistics::context> m_stats; /* copy+move deleted; ctors (capacity) and (); reset() */};| 필드 | 역할 | 존재 이유 |
|---|---|---|
m_conn | 감싸고 있는 레거시 css_conn_entry에 대한 포인터 | 기존의 모든 css_conn_entry 코드와의 다리; context *가 &m_conn과 별칭 관계를 갖도록 맨 앞에 둔다 |
m_worker | pool::m_workers 안에서 소유 워커의 인덱스 | 핸드오프를 견딤; 인덱스는 대롱대롱 매달릴 수 없다 |
m_id | claim 시점에 부여되는 단조 증가 id | 포인터 없이도 m_exhausted를 키잉하고 메시지를 라우팅 |
m_ignore | 오래된 ERR/HUP 이벤트를 막는 ignore_level 가드 | close가 결정된 뒤 뒤늦게 온 에러 이벤트는 다시 실행되지 않고 삼켜진다 |
m_removed | 정리는 됐지만 여전히 m_context에 남아 있음 | purge_stale_contexts의 대상 표시; 닫히는 중과 이미 사라짐을 구분 |
m_recv.m_state | state — HEADER/DATA/ERROR | 수신 측은 2단계 파서: 먼저 헤더, 그다음 헤더가 알려준 크기만큼의 본문 |
m_recv.m_receiver | receiver — 풀링된 재조립 버퍼 + drain 상태 | 소켓→span 재조립을 소유 |
m_recv.m_header | 파싱된 헤더를 가리키는 span<std::byte> | 본문이 스트리밍되는 동안에도 헤더가 계속 주소 가능하도록 유지 |
m_recv.m_request_id | 헤더에서 나온 요청 id | 응답과 요청을 대응시킴 |
m_recv.m_command | 헤더가 명령 패킷이었음 | true면 본문이 다 도착한 시점에 백엔드 태스크가 하나 푸시된다 |
m_send.m_transmitter | transmitter — 풀링된 송신 버퍼 + deleter | 부분 write 상태를 소유 |
m_send.m_blocker | shared_ptr<message_blocker> | 블로킹 전송을 하는 백엔드 워커가 여기서 대기; drain 시점에 시그널됨. 동시 블로킹 전송이 허용되면 벡터로 바뀔 수 있다는 메모가 소스에 있다 |
m_stats | 컨텍스트별 메트릭 | 워커/코디네이터로 롤업됨(11장) |
불변식 — m_conn이 맨 앞에 있어 context *는 css_conn_entry **
와 별칭 관계를 갖는다. connection::context와 master::context
모두 m_conn에 /* THIS MUST BE THE FIRST */라고 표시해 둔다; 오프셋 0에
있기 때문에 &ctx->m_conn을 쥔 레거시 코드는 주소만으로 이를 감싸는
context를 복원할 수 있고, 프리리스트 트릭(§1.8)도 동일한 보장에
의존한다. 순서를 바꾸면 이런 캐스트들이 조용히 깨진다.
context는 복사/이동 불가다(네 개의 특수 멤버 함수 모두
= delete) — 풀링된 버퍼를 쥐고 있고 오직 포인터로만 사용된다. 생성자
context (std::size_t capacity)는 리시버 풀의 크기를 정하고, 기본 생성자
context ()는 그것을 미룬다. reset()은 프리리스트 재사용(7장)을 위해
m_conn=null, m_worker=-1, m_id=0, 두 서브구조체, m_stats를 원래
상태로 되돌린다.
// connection::state / ignore_level -- src/connection/connection_context.hppenum class state { HEADER, DATA, ERROR };enum class ignore_level : uint8_t { DONT_IGNORE = 0, IGNORE_ALL };state는 수신 측의 단계를 나타낸다. ignore_level이
uint8_t 기반인 이유는 message(§1.5) 안에도 실려 다니기 때문이며,
DONT_IGNORE = 0이 기본값인 덕분에 제로 초기화된 컨텍스트가 실수로
이벤트를 억제하는 일이 없다.
1.3 동기화 프리미티브 — thread_watcher, message_blocker
섹션 제목: “1.3 동기화 프리미티브 — thread_watcher, message_blocker”둘 다 한 단계 위 네임스페이스(cubconn)에 선언된 CV 묶음이다. pool,
worker들, coordinator가 모두 이들을 공유하기 때문이다.
// thread_watcher / message_blocker -- src/connection/connection_context.hppstruct thread_watcher { std::mutex mtx; std::condition_variable cv; int active; };struct message_blocker { std::mutex m; std::condition_variable cv; bool done; };| 구조체.필드 | 역할 | 존재 이유 |
|---|---|---|
thread_watcher.mtx | active를 보호 | active는 대기자들과 원자적으로 함께 움직여야 한다 |
thread_watcher.cv | 시작/종료 쿼럼을 신호 | 워커들이 뜨고/내려가는 동안 pool이 대기 |
thread_watcher.active | 살아 있는 워커 스레드 수 | pool은 N개 전부가 READY에 도달하거나 전부 종료할 때까지 블록 |
message_blocker.m | done을 보호 | CV를 위한 프레디케이트 락 |
message_blocker.cv | 블록된 프로듀서에게 신호 | 블로킹 메시지의 프로듀서가 여기서 대기 |
message_blocker.done | 프레디케이트: 워커가 이 요청을 마쳤음 | 스퓨리어스 웨이크업과 유실된 신호를 방지 |
불변식 — message_blocker는 shared_ptr을 통해 양쪽 당사자보다
오래 산다. 이것은 항상 shared_ptr로 쥐어진다(message::waiter_handle과
context::m_send.m_blocker 양쪽에서). 프로듀서와 워커가 각각 참조를
하나씩 쥐고 있으며 마지막에 놓는 쪽이 그것을 파괴한다. 그래서 프로듀서가
타임아웃된 뒤에도 워커는 cv를 시그널할 수 있고, 워커가 아직 자신의
사본을 쥐고 있는 동안에도 프로듀서는 소멸할 수 있다 — raw 포인터였다면
타임아웃된 프로듀서가 워커 밑에서 블로커를 해제해 버릴 수 있었을 것이다.
1.4 worker — 서비스 스레드
섹션 제목: “1.4 worker — 서비스 스레드”OS 스레드 하나, epoll 루프 하나, 그 위에서 다수의 컨텍스트.
// connection::worker (member layout) -- src/connection/connection_worker.hpppool *m_parent; std::shared_ptr<coordinator> m_coordinator;std::shared_ptr<thread_watcher> m_watcher;std::thread m_thread; std::size_t m_core; status m_status; bool m_stop;cubthread::entry *m_entry; std::unordered_set<context *> m_context; std::size_t m_index;cubsocket::epoll m_events; int m_eventfd; int m_timerfd; uint64_t m_timens;std::array<timer_handle, (size_t) timer_type::TYPE_COUNT> m_timer_handler;bool m_has_retry;tbb::concurrent_queue<message> m_queue[(size_t) queue_type::TYPE_COUNT];std::atomic<uint64_t> m_queue_size[(size_t) queue_type::TYPE_COUNT];std::vector<context *> m_removed_context;size_t m_recv_budget; size_t m_send_budget;std::unordered_map<uint64_t, exhausted_context> m_exhausted;statistics::metrics<statistics::worker> m_stats;| 필드 | 역할 | 존재 이유 |
|---|---|---|
m_parent | raw pool * 소유자 | 컨텍스트 반환/설정 조회용; pool은 모든 워커보다 오래 산다 |
m_coordinator | shared_ptr<coordinator> | 메트릭을 보고하고 배치를 받음(6장); 여러 워커가 공유 |
m_watcher | shared_ptr<thread_watcher> | pool의 쿼럼을 위해 시작/종료 시 active를 증감 |
m_thread | run()을 실행하는 std::thread | OS 핸들; 종료 시 join |
m_core | 고정된 CPU 코어 | 캐시 지역성을 위한 어피니티(3장) |
m_status | status — READY/RUNNING/HIBERNATING/TERMINATING | 루프와 hibernation을 이끈다(4장) |
m_stop | 강제 정지 플래그 | 상태와 무관하게 종료 시 루프를 끊는다 |
m_entry | cubthread::entry * | 이 스레드를 레거시 스레드 매니저에 묶는다 |
m_context | 서비스 중인 unordered_set<context *> | 루프가 순회하는 멤버십 집합; O(1) 마이그레이션 |
m_index | pool::m_workers 안에서 이 워커의 인덱스 | context::m_worker와 대응; 라우팅/핸드오프 식별자 |
m_events | cubsocket::epoll | 클라이언트 소켓 + 제어용 fd들을 아우르는 epoll fd |
m_eventfd | 스레드 간 웨이크업용 eventfd | 프로듀서가 시그널하면 epoll_wait가 깨어난다 |
m_timerfd | epoll 집합에 들어 있는 timerfd | fd 하나가 모든 주기적 타이머를 멀티플렉싱 |
m_timens | 현재 타이머 주기(ns) | timerfd가 무장된 해상도를 캐시 |
m_timer_handler | timer_type별 array<timer_handle, TYPE_COUNT> | 타이머 종류당 슬롯 하나; 핫 경로에서 할당 없음 |
m_has_retry | 재시도를 기다리는 지연된 close가 있는지 | 비어 있으면 재시도 스캔을 저렴하게 건너뜀 |
m_queue[] | concurrent_queue<message> 두 개(IMMEDIATE, LAZY) | 어떤 스레드에서든 오는 MPSC 제어 채널 |
m_queue_size[] | 큐별 atomic<uint64_t> | 한 번의 drain을 진입 시점에 존재하던 메시지 수로 한정 |
m_removed_context | 제거 표시되어 회수를 기다리는 컨텍스트들 | 순회 도중 지우지 않는 지연 정리 |
m_recv_budget / m_send_budget | 패스당 바이트 상한 | 하나의 커넥션이 독점하지 못하게 하는 공정성(5장) |
m_exhausted | map<uint64_t, exhausted_context> | 도중에 예산을 소진한 컨텍스트를 m_id로 키잉(§1.7) |
m_stats | 워커별 메트릭 | coordinator로 집계됨(11장) |
불변식 — m_queue_size 스냅샷이 각 drain 패스를 한정한다. 워커는
m_queue_size를 읽어 그만큼의 메시지만 처리한다; 패스 도중에 도착하는
MPSC 항목들은 다음 웨이크업을 기다린다. “비워질 때까지” drain하도록
두었다면 활발한 프로듀서 하나가 유일한 소비자를 영원히 붙들어 둘 수
있었을 것이다. 이 카운터는 atomic이다 — 프로듀서들은 다른 스레드에서
증가시키고, 유일한 소비자가 감소시킨다.
// worker::status -- src/connection/connection_worker.hppenum class status { READY, RUNNING, HIBERNATING, TERMINATING };READY = 생성되어 실행 전 대기 중; RUNNING = 활성 루프; HIBERNATING =
유휴 상태로 타이머로만 깨어나며 소켓은 파킹됨(4장); TERMINATING = 종료를
향해 drain 중. m_stop은 이와 직교한다 — 어떤 상태에서든 강제로 빠져나가게
만든다.
1.5 제어 어휘 — queue_type, message_type, message
섹션 제목: “1.5 제어 어휘 — queue_type, message_type, message”모든 지시는 두 큐 중 하나에 올라가는 message다. 큐는 언제를,
타입은 무엇을을 말한다.
// worker::queue_type / message_type -- src/connection/connection_worker.hppenum class queue_type : uint8_t { IMMEDIATE, LAZY, TYPE_COUNT };enum class message_type { START, HIBERNATE /*lazy*/, AWAKEN /*lazy*/, SHUTDOWN, NEW_CLIENT, HANDOFF_CLIENT /*lazy*/, TAKEOVER_CLIENT, SHUTDOWN_CLIENT /*lazy*/, SEND_PACKET, RELEASE_PACKET, TYPE_COUNT };IMMEDIATE 메시지는 IO 전에 drain되고, LAZY 메시지(HIBERNATE,
AWAKEN, HANDOFF_CLIENT, SHUTDOWN_CLIENT — enum 안에서 /* lazy queue */로 표시됨)는 IO 이후에 실행되어 안정된 상태를 본다. 두 enum의
TYPE_COUNT는 배열 크기 산정용 센티널(m_queue, m_queue_size,
m_timer_handler)이지 값이 아니다. message_type은 워커 제어(START,
HIBERNATE, AWAKEN, SHUTDOWN)와 클라이언트 제어(나머지)로 나뉘며,
이들의 핸들러는 7장/8장에서 다룬다.
// worker::message -- src/connection/connection_worker.hppstruct message { message_type type; uint64_t id; context *ctx; css_conn_entry *conn; std::vector<cubbase::span<std::byte>> packet; /* SEND/RELEASE_PACKET */ std::function<void ()> deleter; /* SEND_PACKET */ worker *worker_ptr; int worker_index; /* HANDOFF_CLIENT */ ignore_level ignore; bool retry; /* SHUTDOWN_CLIENT */ std::shared_ptr<message_blocker> waiter_handle; /* START/SHUTDOWN_CLIENT/SEND_PACKET */#if !defined (NDEBUG) uint64_t message_id;#endif};| 필드 | 역할 | 사용처 | 존재 이유 |
|---|---|---|---|
type | 판별자 | 전체 | 핸들러와 유효한 페이로드 필드를 결정 |
id | 대상 컨텍스트 id | 클라이언트 메시지 | 포인터 없이 컨텍스트로 라우팅(m_id와 대응) |
ctx | 대상 컨텍스트 | 클라이언트 메시지 | 프로듀서가 이미 쥐고 있을 때의 직접 핸들 |
conn | 레거시 커넥션 엔트리 | NEW_CLIENT 등 | 감쌀 raw css_conn_entry를 건네줌 |
packet | 전송/해제할 바이트 span들 | SEND/RELEASE_PACKET | 아웃바운드 페이로드, 또는 해제할 버퍼들 |
deleter | 전송 후 packet을 해제 | SEND_PACKET | 프로듀서가 버퍼 수명 소유권을 유지 |
worker_ptr | 목적지 워커 | HANDOFF_CLIENT | 마이그레이션된 컨텍스트를 받는 상대 워커 |
worker_index | 목적지 인덱스 | HANDOFF_CLIENT | 라우팅/로깅용 인덱스 형태 |
ignore | 찍어 둘 억제 레벨 | SHUTDOWN_CLIENT | teardown 중 context::m_ignore를 설정 |
retry | 블록되어 있으면 close를 재시도 | SHUTDOWN_CLIENT | 아직 대기 중인 태스크가 있는 close를 미룸 |
waiter_handle | 완료 시 신호할 블로커 | START/SHUTDOWN_CLIENT/SEND_PACKET | 프로듀서가 워커가 끝낼 때까지 블록하게 함 |
message_id | 디버그용 단조 증가 id | 전체(디버그) | 순서 추적용; 릴리스에서는 컴파일아웃 |
불변식 — message는 이동 전용이다. 복사는 = delete, 이동은
= default noexcept. 벡터 하나, std::function deleter 하나,
shared_ptr 블로커 하나를 소유하므로 복사하면 버퍼가 이중 해제될 수
있다. 항상 std::move되어 큐로 들어가고(enqueue (type, std::move (item))), 이 noexcept 이동 덕분에 tbb::concurrent_queue가 복사
폴백 없이 이를 저장할 수 있다.
1.6 타이머 메커니즘 — timer_type, timer_latency, timer_handle
섹션 제목: “1.6 타이머 메커니즘 — timer_type, timer_latency, timer_handle”timerfd 하나가 최대 네 개의 주기적 작업을 구동한다. timer_type은
작업의 이름을 매기고(m_timer_handler의 인덱스이기도 함), timer_latency는
ns 단위 주기이며, timer_handle은 무장된 슬롯 하나다.
// worker timers -- src/connection/connection_worker.hppenum class timer_type : uint32_t { NA, HIBERNATE, STATISTICS, QUEUE, HA, TYPE_COUNT };enum class timer_latency : uint64_t { NA = 0, /* off */ LOW_LATENCY = (uint64_t)(1 * 1e6), /* 1 msec */ MEDIUM_LATENCY = (uint64_t)(1 * 1e9), /* 1 sec */ HIGH_LATENCY = (uint64_t)(2 * 1e9) };/* 2 sec */struct timer_handle { bool valid; timer_latency latency; std::function<bool ()> function; uint64_t last_time; };timer_type: NA = 미사용 슬롯 0; HIBERNATE는 유휴 검사를 발동시키고;
STATISTICS는 메트릭을 플러시하고; QUEUE는 메시지 큐들을 재점검하고;
HA는 하트비트/HA close를 이끈다. timer_latency는 주기 값을 직접
인코딩한다(0 = 꺼짐, 그 외에는 1ms / 1초 / 2초).
timer_handle 필드 | 역할 | 존재 이유 |
|---|---|---|
valid | 슬롯 무장 여부 | 고정 배열에는 슬롯별 점유 표시가 필요하다 |
latency | 이 타이머의 주기 | 경과 시간과 비교해 발동 여부를 결정 |
function | std::function<bool ()> 콜백 | 작업 본체; 타이머를 유지할지 여부를 반환 |
last_time | 마지막 발동 시각(ns) | 경과 = now − last_time; 다음 발동을 통제 |
불변식 — timerfd 주기는 활성 latency 중 최솟값이다. fd 하나가 모든
슬롯을 멀티플렉싱하므로, valid한 슬롯들 중 가장 작은 latency에
맞춰 무장되고, 각 핸들러는 자신의 last_time이 충분한 경과를 보일 때만
실행된다. 더 성기게 무장하면 LOW_LATENCY 작업이 데드라인을 놓친다;
성긴 작업들이 과도하게 발동하지 않도록 막는 것이 바로 last_time이다.
1.7 백프레셔 기록 — exhausted_context
섹션 제목: “1.7 백프레셔 기록 — exhausted_context”컨텍스트가 전송 도중 m_recv_budget/m_send_budget을 소진하면, 워커는
멈춘 지점부터 재개할 수 있도록 그것을 m_exhausted에(m_id로 키잉해)
파킹한다.
// worker::exhausted_context -- src/connection/connection_worker.hppstruct exhausted_context { bool prepared; uint32_t events; context *ctx; };| 필드 | 역할 | 존재 이유 |
|---|---|---|
prepared | 재개 준비 완료 여부 | 갓 파킹된 것과 이미 재무장된 것을 구분 |
events | 예산 소진 시점에 대기 중이던 epoll 마스크 | 재개 시 정확히 그 EPOLLIN/EPOLLOUT 의도를 재생 |
ctx | 멈춰 있는 컨텍스트 | 재조회 없는 직접 재개 핸들 |
uint64_t m_id로(포인터가 아니라) 키잉한다는 것은, 소진된 상태에서
닫힌 컨텍스트를 이미 회수됐을지 모르는 포인터를 쫓지 않고 id로 그냥
버릴 수 있다는 뜻이다(5장).
1.8 pool — 소유자이자 할당자
섹션 제목: “1.8 pool — 소유자이자 할당자”pool은 모든 것을 소유하며, 오직 이곳만이 락 아래에서 프리리스트와
워커 벡터를 건드린다.
// connection::pool (members) -- src/connection/connection_pool.hppstd::mutex m_mutex;#if !defined (NDEBUG)std::atomic<std::thread::id> m_mutex_holder { std::thread::id () };#endifstd::vector<std::unique_ptr<worker>> m_workers;std::shared_ptr<coordinator> m_coordinator; std::shared_ptr<thread_watcher> m_watcher;std::uint32_t m_max_connections, m_max_connection_workers, m_min_connection_workers;struct { freelist *m_head; std::size_t m_max; std::size_t m_claim; } m_freelist;| 필드 | 역할 | 존재 이유 |
|---|---|---|
m_mutex | pool의 모든 가변 상태를 보호 | 컨텍스트/워커는 여러 스레드에서 건드려짐 |
m_mutex_holder | 디버그용 현재 락 소유자 | 디버그 빌드에서 저렴하게 올바른 스레드인지 검증 |
m_workers | vector<unique_ptr<worker>> | 워커 수명의 유일한 소유자; 깔끔한 teardown |
m_coordinator | shared_ptr<coordinator> | 모든 워커에게 건네짐(2장/6장) |
m_watcher | shared_ptr<thread_watcher> | 워커들과 공유하는 시작/정지 쿼럼 |
m_max_connections | 살아 있는 커넥션 수의 하드 캡 | 프리리스트 크기를 결정 |
m_max_connection_workers | 워커 수의 상한 | 오토스케일 상한(6장) |
m_min_connection_workers | 워커 수의 하한 | 오토스케일 하한 / 항상-켜짐 |
m_freelist.m_head | 프리 컨텍스트 리스트의 head | O(1) claim/retire |
m_freelist.m_max | 할당된 컨텍스트 총수 | 용량 산정 |
m_freelist.m_claim | 현재 claim된 컨텍스트 수 | m_max 대비 소진 여부를 판정 |
프리리스트 노드는 인트루시브 구조다:
// pool::freelist -- src/connection/connection_pool.hppstruct freelist { /* THIS MUST BE THE FIRST */ context m_context; /* embedded by value */ freelist *m_next; freelist (std::size_t capacity) : m_context (capacity), m_next (nullptr) {}};| 필드 | 역할 | 존재 이유 |
|---|---|---|
m_context | 값으로 내장된 context 저장소 | 커넥션당 별도 할당이 없다; 노드 자체가 곧 컨텍스트다 |
m_next | 다음 프리 노드 | m_head에서 뻗어나가는 단일 연결 프리 체인 |
불변식 — freelist::m_context가 맨 앞에 있어 context *가
freelist *로 왕복 변환된다. claim_context()는 m_head를 pop해서
&node->m_context를 반환한다; m_context가 오프셋 0에 있기 때문에,
retire_context (context *ctx)는 reinterpret_cast<freelist *> (ctx)로 노드를 복원할 수 있다. m_next를 앞에 두면 retire가 프리
체인을 망가뜨린다. §1.2의 m_conn-먼저와 동일한 주소 별칭 트릭이다.
flowchart LR H["m_freelist.m_head"] --> N0["freelist #0<br/>m_context (offset 0)<br/>m_next"] N0 --> N1["freelist #1<br/>m_context<br/>m_next"] N1 --> NIL["nullptr"] CLAIM["claim_context()<br/>&head->m_context 반환"] -.-> N0
Figure 1-2. 인트루시브 프리리스트: 각 노드는 오프셋 0에 context를
내장하며, claim/retire는 freelist *와 context * 사이를 캐스팅한다.
리액터 경로가 아니라 마스터-커넥터 경로에 속하는 형제 격 존재인
master::context와 controller<RX,TX> 템플릿(controller.hpp의
유닉스 소켓 제어 채널)은, 여기서 모든 IO 호출이 반환하는 result
enum — Ok, Partial, Error, Reset, Pending, BudgetExhausted,
PeerReset, RefuseConnection, ClosedConnection, Aborted,
Skewed(buffer.hpp) — 을 공유하며, 이는 이후 IO 분기를 다루는 장들이
기대는 개념이다.
1.9 챕터 요약 — 핵심 정리
섹션 제목: “1.9 챕터 요약 — 핵심 정리”- 삼각형은
pool이worker를 소유하고,worker는 다수의context를 가리키는 구조다. 소유는unique_ptr(워커)과 인트루시브 프리리스트(컨텍스트)로 이루어지고, 역참조는 rawpool *와 인덱스인context::m_worker이지 결코worker *가 아니다. context는 커넥션당 바이트 상태 전체를 담는다 —m_conn브리지,m_id/m_worker신원, 2단계 HEADER→DATA 파서와 receiver로 이루어진m_recv, transmitter와 선택적 블로커로 이루어진m_send서브구조체. 복사/이동이 불가능하며 재사용을 위해 reset된다.- 레이아웃 불변식 두 가지가 하중을 지탱한다:
context::m_conn이 맨 앞(css_conn_entry **와 별칭)에 있다는 것, 그리고freelist::m_context가 맨 앞(retire_context가context *를freelist *로 되돌려 캐스팅할 수 있게 함)에 있다는 것. 둘 중 하나라도 순서를 바꾸면 캐스트가 조용히 망가진다. - 워커는 제어 메시지 소비자이지, RPC 대상이 아니다. 모든 지시는
IMMEDIATE 또는 LAZY
tbb::concurrent_queue위의 이동 전용message이며,m_queue_size스냅샷이 각 drain 패스를 한정해 프로듀서 기아를 막는다. timerfd하나가 네 작업을 멀티플렉싱한다 —m_timer_handler[timer_type]를 통해, 활성timer_latency의 최솟값에 맞춰 무장되며, 각timer_handle::last_time이 자신의 발동을 통제한다.- 백프레셔는 명시적 상태다: 예산을 소진한 컨텍스트는
m_id로 키잉되어m_exhausted에 파킹되며, 다음 패스에 재생할 정확한 epollevents를 기억한다. thread_watcher와message_blocker가 유일한 두 CV 프리미티브다 — 전자는 pool의 시작/정지 쿼럼을 위해 살아 있는 워커 수를 세고, 항상shared_ptr인 후자는 프로듀서가 특정 메시지에 대해 워커가done을 시그널할 때까지 블록할 수 있게 한다.
Chapter 2: 데이터 구조 지도 II — 코디네이터
섹션 제목: “Chapter 2: 데이터 구조 지도 II — 코디네이터”1장은 커넥션 리액터 — 바이트를 실어 나르는 워커별 epoll 루프 — 를
지도로 그렸다. 이번 장은 그 짝을 이루는 존재, 즉 클라이언트 소켓을 단 한
번도 직접 건드리지 않으면서도 각 커넥션을 어느 워커가 소유할지, 언제
핸드오프할지, 워커가 몇 개 있어야 하는지를 결정하는 단일 코디네이터
스레드를 지도로 그린다. 이 장은 어디까지나 필드 단위의 지도일 뿐이며, 이
상태를 읽어 들이는 알고리즘 — 스코어 계산, 리밸런싱 대상 선정,
트라이얼 기반 오토스케일링 — 은 6장에서 다룬다.
연결 고리: 왜 단일 라이터(single-writer) 제어 평면이 필요한가에 대한 논거는 별도 문서인
cubrid-thread-manager.md의 Auto scaling (CBRD-26406), Score-based connection assignment, Coordinator + context freelist 절에 있다.
2.1 코디네이터 한눈에 보기
섹션 제목: “2.1 코디네이터 한눈에 보기”coordinator(cubconn::connection 소속)는 여섯 개의 기능 군집으로
멤버가 나뉘는, 특별할 것 없는 멤버 소유형 클래스다. 아래에서 필드
단위로 하나씩 걸어가며 살펴본다.
그림 2-1 — 코디네이터 소유권 맵.
제어 계열 필드(m_controller, control_*)는 컴파일 타임에 켜고 끌 수
있는 옵션이다. §2.7 참고.
2.2 통계 프리미티브 — metrics<T, VT>
섹션 제목: “2.2 통계 프리미티브 — metrics<T, VT>”모든 카운터는 connection_statistics.hpp에 정의된, 고정 크기에 키
타입을 갖는 벡터다.
// metrics -- src/connection/connection_statistics.hpptemplate <class T, typename VT = std::uint64_t>class metrics { inline metrics<T, double> operator- (const metrics &other); /* <- delta is double */ inline metrics<T, double> operator* (double multiplier); private: VT m_values[static_cast<std::size_t> (T::STATS_COUNT)]; /* <- one slot per key */};T는 마지막 실값인 STATS_COUNT가 배열 크기를 정하는 enum class이며,
키별 개별 할당이 없다. 이 템플릿을 실체화하는 어휘 집합은 두 가지다.
| 키 enum | 멤버 (별도 표기가 없으면 각각 /* count */) |
|---|---|
statistics::context | BYTES_IN_TOTAL, BYTES_OUT_TOTAL, OPEND_NS, LAST_ACTIVE_NS, LAST_MOVED_NS, MOVE_COUNT, RECV_BUDGET_HIT, SEND_BUDGET_HIT |
statistics::worker | PACKET_COUNT, CLIENT_NUM, MQ_REQUESTED, MQ_COMPLETED, MQ_NEW_CLIENT, MQ_HANDOFF_CLIENT, MQ_TAKEOVER_CLIENT, MQ_SHUTDOWN_CLIENT, MQ_SEND_PACKET, MQ_RELEASE_PACKET, BLOCKED_RMUTEX (마이크로초) |
worker는 STATS_COUNT 뒤에 (절대 할당되지 않는) NA를 하나 더
가지고 있으며, worker_to_string[]이 출력을 위해 worker의 각 키에
이름을 붙여 준다.
불변식 — 원시값/파생값 분리. 카운터는
VT = uint64_t로 쌓이지만operator-/operator*는metrics<T, double>을 반환한다. 코디네이터는 워커 카운터의 사본을 두 벌 들고 있다 —uint64“이전” 스냅숏과doubleEWMA 누적값. 원시 슬롯 하나에 비율(rate)을 그대로 저장하면 타입 시스템이 막는 문제, 즉 비율 계산에서 오버플로가 나거나 소수점 감쇠가 사라지는 문제가 생기기 때문이다.
2.3 worker_statistics — 워커별 성적표
섹션 제목: “2.3 worker_statistics — 워커별 성적표”m_statistics는 std::vector<worker_statistics>이며, 커넥션 워커
하나당 한 항목씩 존재한다 — 코디네이터가 갖고 있는 “그 워커의 부하가
어느 정도인가”에 대한 모델이다.
// worker_statistics -- src/connection/coordinator.hppstruct worker_statistics { double m_score; /* score */ double m_core; uint64_t m_last_cpu_time; /* resource */ uint32_t m_client_num; uint64_t m_last_updated; /* immediate */ statistics::metrics<statistics::context, double> m_sum; /* sum of contexts */ std::pair<statistics::metrics<statistics::worker, double>, statistics::metrics<statistics::worker>> m_worker; /* accumulated , previous */ std::unordered_map<uint64_t, std::pair<statistics::metrics<statistics::context, double>, statistics::metrics<statistics::context>>> m_contexts;};| 필드 | 역할 | 존재 이유 |
|---|---|---|
m_score | 배치/스케일링이 워커 간에 비교하는 유일한 스칼라 값 | 부하 벡터를 순서를 매길 수 있는 숫자 하나로 압축한다 (6장) |
m_core | 이 워커에 귀속된 CPU 코어 사용 비율(분수) | 실제로 받은 CPU 양으로 스코어를 정규화한다 |
m_last_cpu_time | 마지막 CPU 시간 샘플(ns) | 다음 업데이트가 절대값이 아니라 CPU 시간 델타를 계산하게 해 준다 |
m_client_num | 즉시 조회 가능한 커넥션 개수 | m_contexts를 순회하지 않고도 빠르게 배치 입력으로 쓸 수 있다 |
m_last_updated | 마지막으로 접었던(fold) 시각의 모노토닉 ns | EWMA의 time_delta 분모가 된다 |
m_sum | 업데이트마다 계산되는 모든 컨텍스트 지표의 double 합 | 맵을 순회하지 않고도 집계 뷰를 얻을 수 있다 |
m_worker | first = EWMA로 누적된 워커 카운터, second = 이전 원시 스냅숏 | §2.2의 원시/파생 쌍을 워커 레벨에 그대로 적용한 것 |
m_contexts | 맵: 커넥션 id → 커넥션별 (누적값, 이전값) 쌍 | 살아 있는 커넥션마다 동일한 쌍을 두어, 활성 클라이언트 하나도 놓치지 않고 보이게 한다 |
불변식 — 슬롯 인덱스는 워커 id와 같다.
m_statistics[i]는 곧 워커i이며, 이 벡터는m_current_worker(§2.9)와 인덱스가 나란히 대응한다. 모든 스코어 조회, 리밸런싱 소스/타깃,CLIENT_MOVE타깃이 전부 이 인덱스로 접근한다 — 살아 있는 워커 집합과 어긋나는 순간 배치가 낡은 슬롯에 쓰이고 모델 전체가 썩어 버린다.
불변식 —
.first는 누적,.second는 교체. EWMA 업데이트(6장/11장)는current - m_worker.second를 델타 비율로 스케일링해m_worker.first에 접어 넣은 다음m_worker.second = current로 설정한다..first는 오직 감쇠(decay)만 되고.second는 오직 교체(replace)만 된다.m_contexts의 각 쌍도 커넥션별로 동일한 규칙을 따른다.
2.4 생명주기 열거형 — status
섹션 제목: “2.4 생명주기 열거형 — status”m_status는 coordinator::status 값을 하나 들고 있으며, 어느 단계에서
어떤 타이머 구동 동작이 허용되는지를 가른다 — 그래서 코디네이터는 아직
준비 중인 동안에는 절대 드레인을 시작하지 않는다.
status | 의미 |
|---|---|
PREPARING | 서비스 시작 전 준비 단계; 타이머가 아직 무장되지 않음 |
STABLE | 정상 가동 상태; 통계·리밸런싱은 돌지만 진행 중인 스케일 변경은 없음 |
DRAINING | 축소 진행 중; 워커 하나가 자신의 커넥션을 비우는 중 |
EXPANDING | 확장 진행 중; 새 워커가 연결되는 중 |
전이(transition) 자체는 스케일링 알고리즘(6장)의 몫이며, m_status는
그저 그 알고리즘의 판별값(discriminator)일 뿐이다.
2.5 오토스케일링 윈도우 — 트라이얼 상태
섹션 제목: “2.5 오토스케일링 윈도우 — 트라이얼 상태”오토스케일링은 시도하고 관찰하는(trial-and-observe) 컨트롤러다: 워커 개수를 살짝 건드려 보고, 집계 스코어가 나아지는지 지켜본 뒤, 커밋하거나 되돌린다. 이 윈도우는 두 개의 enum, 하나의 샘플 레코드, 그리고 두 개의 익명 멤버로 이루어진다.
// scaling enums + record -- src/connection/coordinator.hppenum class scaling_status { TRIAL, STABLE };enum class scaling_direction { DOWN, UP };struct scaling_statistics { std::size_t scale; double score; }; /* one (count,score) sample */scaling_statistics는 트라이얼 데이터 포인트 하나 — 판정(verdict, 6장)이
근사(fit)하는 “워커 수 대 스코어” 곡선 위의 점 하나다.
| 필드 | 역할 | 존재 이유 |
|---|---|---|
scale (std::size_t) | 이 샘플 시점의 워커 개수 | 트라이얼 데이터 포인트의 x축 |
score (double) | 그 워커 개수에서 측정한 풀 전체 집계 스코어 | 판정이 샘플들 간에 비교하는 y축 |
이 샘플들을 모으는 윈도우는 익명 멤버로 표현된다.
// m_scaling_statistics -- src/connection/coordinator.hppstruct { scaling_status status; std::size_t window_size; std::vector<scaling_statistics> history; scaling_direction previous_direction; std::size_t previous_scale; scaling_direction direction; std::size_t count;} m_scaling_statistics;| 필드 | 역할 | 존재 이유 |
|---|---|---|
status | TRIAL(변화를 측정 중) 대 STABLE(안정됨) | 측정 중인 상태와 정착된 상태를 구분한다 |
window_size | 트라이얼이 평균을 내는 샘플 개수 | 판정 전에 틱마다 들쭉날쭉한 스코어를 매끄럽게 만든다 |
history | scaling_statistics 샘플의 한도가 있는 버퍼 | 판정이 근거로 삼는 증거 |
previous_direction | 마지막으로 커밋된 이동의 방향 | 진동(oscillation)이나 백오프를 감지한다 |
previous_scale | 현재 트라이얼 이전의 워커 개수 | 트라이얼 스코어를 비교하는 기준선 |
direction | 현재 평가 중인 트라이얼의 방향 | 이번 윈도우에서 검증 중인 가설 |
count | 이번 윈도우에서 수집된 샘플 수 | window_size에 도달하면 판정을 발동시킨다 |
이동(move) 자체의 메커니즘(통계가 아니라)은 두 번째 익명 구조체에 산다.
// m_scaling -- src/connection/coordinator.hppstruct { uint64_t last_drain_ns; uint64_t last_expand_ns; int draining_worker; } m_scaling;| 필드 | 역할 | 존재 이유 |
|---|---|---|
last_drain_ns | 마지막 축소(scale-down)의 모노토닉 ns | 축소 속도를 제한해 하강 방향의 스래싱을 막는다 |
last_expand_ns | 마지막 확장(scale-up)의 모노토닉 ns | 대칭적으로 확장 속도도 제한한다 |
draining_worker | 비워지는(drain) 워커의 인덱스, 또는 음수 센티널 | 동시에 진행되는 드레인이 최대 하나임을 보장한다 |
불변식 — 한 번에 드레인되는 워커는 최대 하나.
draining_worker가 유효한 인덱스이고(그리고m_status == DRAINING인) 동안에는 두 번째 드레인이 시작되지 않는다. 그래서 두 워커가 같은 대상 슬롯을 두고 경합해m_migrating(§2.9)을 망가뜨리는 일이 없다.
2.6 타이머 휠 — timer_handle과 m_timer_handler
섹션 제목: “2.6 타이머 휠 — timer_handle과 m_timer_handler”모든 주기적 작업은 하나의 timerfd가 구동한다. 코디네이터는 작업마다
fd를 하나씩 두는 대신 고정된 테이블을 통해 디스패치한다.
// timer types -- src/connection/coordinator.hppenum class timer_latency : uint64_t { NA = 0, /* off */ LOW_LATENCY = static_cast<uint64_t>(1 * 1e9), /* 1 sec */ MEDIUM_LATENCY = static_cast<uint64_t>(5 * 1e9), /* 5 sec */ HIGH_LATENCY = static_cast<uint64_t>(60 * 1e9) /* 1 min */};enum class timer_type : uint32_t { NA, STATISTICS, REBALANCING, SCALING, TYPE_COUNT };struct timer_handle { bool valid; timer_latency latency; std::function<bool ()> function; uint64_t last_time; };m_timer_handler는 std::array<timer_handle, TYPE_COUNT>이며,
timer_type으로 인덱싱된다.
timer_handle 필드 | 역할 | 존재 이유 |
|---|---|---|
valid | 이 슬롯이 무장되어 있는가? | 배열 크기를 바꾸지 않고도 등록/해제할 수 있다 |
latency | 원하는 주기(ns 단위 timer_latency) | 해당 핸들러가 원하는 간격 |
function | bool() 콜백(통계/리밸런싱/스케일링 패스) | 타입 소거(type-erased)로 균일하게 디스패치하며, 계속 진행할지 여부를 반환한다 |
last_time | 마지막 실행의 모노토닉 ns | now - last_time >= latency일 때만 발동한다 |
슬롯 구성: STATISTICS는 샘플을 m_statistics/m_task_statistics에
접어 넣고, REBALANCING은 CLIENT_MOVE를 발행하며, SCALING은 트라이얼
한 단계를 실행한다(모두 6장/11장).
불변식 — fd 하나에 논리적 타이머는 여럿.
m_timens는 항상 현재 활성화된 것 중 가장 짧은 주기이며, 각 핸들러는last_time으로 스스로 속도를 조절한다. 너무 느리게 무장하면 어떤 핸들러는 굶고,last_time검사를 빼 버리면 모든 핸들러가 매 틱마다 발동해 버린다.
2.7 제어 채널 — controller + control_* (컴파일 타임 옵션)
섹션 제목: “2.7 제어 채널 — controller + control_* (컴파일 타임 옵션)”콜아웃 — 가드된 서브시스템. PR #7323 워크트리 기준으로 제어 관련 클러스터 전체 —
control_type/control_recv/control_send,m_controller와m_ctrlfd필드(§2.9), 그리고handle_controller*메서드들 — 는 전부#if defined (ENABLE_CONTROLLER)로 감싸여 있으며 기본 빌드에는 컴파일되지 않는다. 이 절에서 다루는 모든 내용은 오직 이 매크로가 켜졌을 때만 존재한다.
통계와 메시지는 코디네이터 안으로 흘러 들어간다. 제어 채널은 그
반대다: 특정 워커에게 보내는 명령형 커맨드와 그에 대한 응답이 밖으로
나가며, controller<RX, TX>가 감싸는 AF_UNIX SOCK_DGRAM 소켓을
통해 오간다.
// controller -- src/connection/controller.hpptemplate <typename RX, typename TX>class controller { bool open (std::string path, int flags); /* asserts SOCK_NONBLOCK */ result recv (RX &data, sockaddr_un &peer, socklen_t &peerlen); result send (TX &data, sockaddr_un &peer, socklen_t &peerlen); private: int m_ctrlfd; std::string m_path;};코디네이터는 controller<control_recv, control_send> m_controller를
들고 있으며(자신의 epoll 셋을 위해 캐시해 둔 m_ctrlfd도 함께), 페이로드는
고정 크기 POD다.
// control_type / control_recv / control_send -- src/connection/coordinator.hpp#if defined (ENABLE_CONTROLLER)enum class control_type : uint32_t { SHOW_STATS, SCALE_UP, SCALE_DOWN, CLIENT_MOVE, /* RECV: coordinator -> worker */ OK, NOK, /* SEND: worker -> coordinator */ TYPE_COUNT};struct control_recv { control_type type; int from; int to; int id; };struct control_send { control_type type; };#endif이름 붙이기는 워커의 시점에서다: control_recv = 수신한 명령,
control_send = 되돌려 보낸 ack.
control_recv 필드 | 역할 | 존재 이유 |
|---|---|---|
type | SHOW_STATS / SCALE_UP / SCALE_DOWN / CLIENT_MOVE | 네 가지 명령형을 구분한다 |
from | 소스 워커 인덱스 | CLIENT_MOVE: 누가 커넥션을 내주는가 |
to | 타깃 워커 인덱스 | CLIENT_MOVE: 누가 그것을 넘겨받는가 |
id | 처리 대상 커넥션 id | 어떤 커넥션인지 식별한다 |
control_send 필드 | 역할 | 존재 이유 |
|---|---|---|
type | 워커가 돌려주는 OK/NOK 판정 | 다른 페이로드는 필요 없다 — 코디네이터는 이미 자신이 무슨 명령을 내렸는지 알고 있다 |
불변식 — 제어 데이터그램은 정확히
sizeof(RX)/sizeof(TX).recv/send는recvfrom/sendto가 구조체 크기만큼 정확히 옮기지 않으면result::Error를 반환한다 — 잘려 나간 데이터그램이 부분적으로 적용되는 일은 없다.SOCK_DGRAM+SOCK_NONBLOCK조합은 메시지 경계를 보존하면서, 유일한 코디네이터 스레드를 블로킹시키는 대신result::Pending(공유resultenum,buffer.hpp)을 반환한다 — 그래서 여기서는 1장의 바이트 스트림 리액터가 아니라 데이터그램을 쓴다.
2.8 메시지 수신함 — message_type과 message
섹션 제목: “2.8 메시지 수신함 — message_type과 message”코디네이터 안으로 들어오는 고빈도 비동기 경로는 메시지 큐다: 새 클라이언트, 반환되는 컨텍스트, 핸드오프 결과, 통계 배치, 종료 신호.
// message_type -- src/connection/coordinator.hppenum class message_type { START, NEW_CLIENT, RETURN_TO_POOL, HANDOFF_REPLY, STATISTICS, SHUTDOWN, TYPE_COUNT };message는 **이동 전용 태그드 구조체(move-only tagged struct)**로,
복사는 = delete, 이동은 기본 구현이다. type이 어떤 필드가 살아
있는지를 고른다. union이 아니라 모든 필드가 공존하므로, type은
컴파일러의 계약이 아니라 읽는 쪽의 계약이다.
// message -- src/connection/coordinator.hppstruct message { message (const message &) = delete; /* <- move-only: owns conn / vectors */ message (message &&) noexcept = default; message_type type; css_conn_entry *conn; /* NEW_CLIENT */ std::vector<context *> resource; /* RETURN_TO_POOL */ bool transferred; int from; int to; uint64_t id; /* HANDOFF_REPLY */ struct { /* STATISTICS */ uint64_t cpu_time_ns; uint64_t time_ns; std::pair<std::size_t, statistics::metrics<statistics::worker>> worker; std::vector<std::pair<uint64_t, statistics::metrics<statistics::context>>> contexts; } statistics;};역할 매트릭스 — type별로 어떤 필드가 살아 있는가(START/SHUTDOWN은
아무 필드도 싣지 않는다):
| 필드 | NEW_CLIENT | RETURN_TO_POOL | HANDOFF_REPLY | STATISTICS |
|---|---|---|---|---|
conn | 수락된 css_conn_entry* | – | – | – |
resource | – | 재활용할 컨텍스트 | – | – |
transferred/from/to/id | – | – | 이동 결과 + 양 끝점 | – |
statistics.cpu_time_ns/time_ns | – | – | – | 샘플 구간 |
statistics.worker | – | – | – | (워커 인덱스, 원시 워커 지표) |
statistics.contexts | – | – | – | (커넥션 id, 원시 컨텍스트 지표)[] |
statistics.* 지표들은 원시값(uint64)으로 도착하며, 코디네이터는
수신 즉시 이를 §2.3의 double EWMA 누적값으로 접어 넣는다 — 원시/파생
경계가 바로 이 큐다.
그림 2-2 — 메시지 수신함.
flowchart LR A["accept 경로"] -->|NEW_CLIENT| Q W["connection worker"] -->|HANDOFF_REPLY / STATISTICS / RETURN_TO_POOL| Q P["pool / shutdown"] -->|START / SHUTDOWN| Q Q["m_queue<br/>tbb::concurrent_queue<message><br/>+ m_queue_size (atomic)"] Q -->|단일 소비자| C["coordinator 스레드<br/>handle_message_queue"]
// m_queue / m_queue_size -- src/connection/coordinator.hpptbb::concurrent_queue<message> m_queue; /* multi-producer, single-consumer */std::atomic<uint64_t> m_queue_size;불변식 — MPSC 규율 + 한도가 있는 드레인. 생산자는 어디서든 큐에 넣을 수 있지만, 소비는 오직 코디네이터 스레드에서만 일어난다.
m_queue_size는 한 번의 드레인 패스를 그 패스가 시작될 당시 있던 메시지 수만큼으로 한정한다. 그래야 꾸준히 밀어 넣는 생산자가 소비자를 붙들어 놓고, 같은epoll순회를 공유하는 타이머/제어 작업을 굶기는 일이 없다.message가 이동 전용이므로enqueue(message &&)는 내부에 담긴conn과 벡터들의 소유권을 그대로 넘긴다 — 생산자는std::move이후 그것들을 다시 건드리면 안 된다.
2.9 코디네이터의 나머지 필드들
섹션 제목: “2.9 코디네이터의 나머지 필드들”정체성, 리액터 배관, 워커 계정 관리 — 완전성을 위해 정리한다.
| 필드 | 타입 | 역할 |
|---|---|---|
m_parent | pool * | 소유하는 커넥션 pool로의 역참조 |
m_watcher | shared_ptr<thread_watcher> | 생존/헬스 등록 |
m_thread | std::thread | 코디네이터 자신의 OS 스레드 |
m_core | std::size_t | 이 스레드가 고정(pin)된 CPU 코어 |
m_status | status | 생명주기 단계(§2.4) |
m_stop | bool | SHUTDOWN이 설정해 실행 루프를 빠져나가게 한다 |
m_entry | cubthread::entry * | 스레드별 엔진 컨텍스트(별도 문서) |
m_events | cubsocket::epoll | 코디네이터 자신의 epoll 셋 |
m_eventfd | int | 이벤트 기반 웨이크업 fd(큐/제어용) |
m_timerfd | int | 단일 타이머 fd(§2.6) |
m_timens | uint64_t | 현재 timerfd 간격(ns) |
m_ctrlfd | int | 캐시된 m_controller fd — 가드됨, ENABLE_CONTROLLER에서만(§2.7) |
m_max_worker / m_min_worker | uint32_t | 스케일 상한/하한 |
m_current_worker | uint32_t | 현재 살아 있는 워커 개수; m_statistics.size()와 나란히 대응 |
m_migrating | unordered_set<uint64_t> | 현재 핸드오프/테이크오버 중인 커넥션 id들 |
m_controller | controller<control_recv, control_send> | 나가는 제어 채널 — 가드됨, ENABLE_CONTROLLER에서만(§2.7) |
m_task_statistics | 익명 구조체 | 풀 전체 처리량 EWMA(아래) |
m_statistics | vector<worker_statistics> | 워커별 성적표(§2.3) |
불변식 —
m_migrating은 진행 중인 이동을 감싼다.CLIENT_MOVE가 발행될 때 id가 삽입되고, 그에 대응하는HANDOFF_REPLY가 오면 지워진다. 이 id가 들어 있는 동안에는 배치/리밸런싱 대상에서 제외되므로, 한 커넥션이 동시에 두 타깃에게 넘어가는 일이 없다. 응답의id가 일치하지 않으면 id가 새어 나가고, 그 커넥션은 이후의 모든 리밸런싱에서 영원히 배제되어 버린다.
m_task_statistics는 §2.3의 풀 전체 버전으로, 풀 전체에 대해 구조체
하나만 존재한다.
// m_task_statistics -- src/connection/coordinator.hppstruct { std::uint32_t workers; uint64_t time_ns; std::pair<double, uint64_t> requested; /* first: EWMA rate, second: previous raw */ std::pair<double, uint64_t> started; std::pair<double, uint64_t> completed; std::pair<double, uint64_t> depth;} m_task_statistics;| 필드 | 역할 |
|---|---|
workers / time_ns | 마지막 샘플이 커버한 워커 개수 + 타임스탬프(EWMA 정규화 인자/분모) |
requested | 백엔드 풀에 넘겨진 작업의 (비율, 이전값) — 확장 압력 신호 |
started | 풀이 착수한 작업의 (비율, 이전값) — 처리 시작률 |
completed | 완료된 작업의 (비율, 이전값) — 처리량 |
depth | 큐 깊이의 (비율, 이전값) — 적체(backlog) 신호 |
각 쌍은 §2.2와 같은 분리 구조다(first = 감쇠된 double 비율, second
= 이전 원시 uint64). 이 풀 전체 비율들이 백엔드 elastic 풀(12–14장)과
오토스케일러(6장)를 구동한다.
2.10 챕터 요약 — 핵심 정리
섹션 제목: “2.10 챕터 요약 — 핵심 정리”- 코디네이터는 여섯 개의 군집을 소유한다: 워커별 성적표
(
m_statistics), 오토스케일링 트라이얼 윈도우(m_scaling_statisticsm_scaling), 타이머 테이블(m_timer_handler), 나가는 제어 채널 (m_controller), 들어오는 메시지 큐(m_queue), 풀 전체 처리량 (m_task_statistics).
- 모든 카운터는
metrics<T, VT>고정 배열이며, 원시uint64이전값과doubleEWMA 누적값을 함께 유지한다. 타입 시스템이 둘을 섞어 쓰는 것을 막는다. m_statistics[i]는 곧 워커i이다 — 살아 있는 워커 집합과 인덱스가 나란히 대응하며, 모든 배치/리밸런싱/스케일 결정이 이 인덱스로 접근한다.- 제어 채널은 컴파일 타임 옵션이다(
ENABLE_CONTROLLER): 켜져 있을 때는 명령이 고정 크기 AF_UNIX 데이터그램으로 나가고, 메시지는 언제나 MPSCtbb::concurrent_queue를 통해 들어오며,m_queue_size로 한도가 정해진 채 드레인된다. message는 이동 전용 태그드 구조체다:type이 살아 있는 필드를 고르며, 이를 이동하면 내부에 담긴conn과 벡터들의 소유권이 함께 넘어간다.- 이동을 안전하게 지키는 멤버십/카운트 불변식은 두 가지다: 한 번에
드레인되는 워커는 최대 하나뿐이라는 것, 그리고 id는 핸드오프가
지속되는 딱 그 구간 동안만
m_migrating에 머문다는 것. - 이 장은 어디까지나 구조뿐이다 — 이를 읽어 들이는 스코어 계산,
리밸런싱 대상 선정, 트라이얼 스케일링은 6장에서,
m_task_statistics를 소비하는 백엔드 풀은 9–14장에서 다룬다.
Chapter 3: 초기화와 메모리
섹션 제목: “Chapter 3: 초기화와 메모리”이 장이 답하려는 질문은 이것이다. main()에서 완전히 배선된 리액터에 이르기까지, 누가 무엇을 어느 스레드에서 어떤 순서로 할당하는가? 리액터가 왜 존재하는지는 동반 문서(cubrid-thread-manager.md의 “Connection worker (CBRD-26212)”, “Coordinator + context freelist (CBRD-26407)“)가, 워커 풀이 무엇인지는 cubrid-thread-worker-pool.md가 설명한다. 이 장이 다루는 것은 순전히 부팅 메커니즘이다 — 모든 new, 모든 std::thread, 모든 epoll_ctl, 그리고 모든 종료 분기까지.
css_init (server_support.c) 안에서 두 개의 객체 그래프가 연이어 세워진다.
- 백엔드 요청 풀 —
css_Server_request_worker_pool.thread_create_stats_worker_pool이 만드는cubthread::stats_worker_pool_type이며, 9-11장에서 해부하는 태스크 엔진이다. - 커넥션 리액터 — 스택 로컬
cubconn::connection::pool connections.pool::initialize가 세워 올리며, 1-8장의 epoll 프론트엔드다.
두 그래프 모두 부팅 시점에 미리 할당된 하나의 스레드 엔트리 풀에서 항목을 꺼내 쓴다. 이번 장은 먼저 엔트리 풀을, 그다음 두 그래프를, 마지막으로 종료 절차를 순서대로 따라간다.
3.1 부팅 호출 사슬 — main에서 css_init까지
섹션 제목: “3.1 부팅 호출 사슬 — main에서 css_init까지”main (server.c)은 치명적 시그널 핸들러를 등록하고 실행 파일 경로를 저장한 뒤 setsid()를 호출하고, 이어서 net_server_start (database_name)를 호출해 그 반환값을 그대로 돌려준다. 리액터 부팅은 전부 이 호출의 하위에서 일어난다.
// main -- src/executables/server.c (condensed) register_fatal_signal_handler (SIGSEGV); /* ... SIGILL/FPE/BUS/SYS/ABRT ... */ database_name = argv[1]; ret_val = net_server_start (database_name); /* <- never returns until shutdown */실제 오케스트레이터는 net_server_start (network_sr.c)다. 순서대로 er_init, cubthread::initialize (thread_p) (매니저 싱글턴과 메인 entry를 생성 — §3.2), sysprm_load_and_init, css_initialize_server_interfaces (net_server_request), 그다음 boot_restart_server (복구를 수행하면서 동시에 cubthread::initialize_thread_entries를 호출해 엔트리 풀의 크기를 정하고 할당한다 — §3.2), 마지막으로 성공했을 때만 css_init (thread_p, packed_name, name_length, TCP_PORT_ID)가 호출된다. 즉 css_init이 실행될 시점에는 이미 DB 복구가 끝나 있고 엔트리 풀도 존재한다. css_init은 그 위에 두 개의 스레드 그래프를 짓기만 하면 된다.
flowchart TD M["main -- server.c"] --> NS["net_server_start -- network_sr.c"] NS --> TI["cubthread::initialize\n매니저 + 메인 entry 생성"] NS --> BR["boot_restart_server\n복구 + initialize_thread_entries"] NS --> CI["css_init -- server_support.c"] CI --> WP["thread_create_stats_worker_pool\n백엔드 요청 풀"] CI --> RP["connections.initialize\n커넥션 리액터"] CI --> RUN["connector.run 포트 이름\n셧다운까지 블록"]
Figure 3-1. 프로세스 진입부터 두 스레드 그래프까지. net_server_start는 css_init이 풀을 만들기 전에 엔트리 풀 크기 산정을 먼저 배치한다.
3.2 스레드 우주의 크기 정하기 — 엔트리 풀
섹션 제목: “3.2 스레드 우주의 크기 정하기 — 엔트리 풀”스레드 엔트리(cubthread::entry, 동반 문서의 데이터 맵 2장 참고)는 무거운 객체다. 그래서 매니저는 스레드마다 개별 생성하는 대신 고정 크기 배열을 미리 할당해 두고 claim/retire 방식으로 나눠 준다. 이 개수는 설정값이 아니라 파생값이며, manager::set_max_thread_count_from_config가 계산한다.
// manager::set_max_thread_count_from_config -- src/thread/thread_manager.cpp m_max_threads = cubbase::count_registry<connection>::total () + cubbase::count_registry<worker_pool>::total () + cubbase::count_registry<daemon>::total () + 1 /* PAD */;각 서브시스템은 정적 초기화 시점에 매크로를 통해 자신의 수요를 등록한다. 리액터는 두 줄을 기여한다 — REGISTER_CONNECTION (coordinator, 1) (coordinator.cpp)은 엔트리 1개를 예약하고, REGISTER_CONNECTION (connection_worker, [](){ return prm_get_integer_value (PRM_ID_CSS_MAX_CONNECTION_WORKER); }) (connection_worker.cpp)은 **max_connection_worker**개를 예약한다 — 이 getter는 count_registry::total()이 레지스트리를 합산하는 시점에 평가된다. 백엔드 풀은 REGISTER_WORKERPOOL (transaction, ...PRM_ID_TASK_WORKER)를 통해 등록한다. initialize_thread_entries는 set_max_thread_count_from_config를 호출한 다음 alloc_entries를 호출한다.
// manager::alloc_entries -- src/thread/thread_manager.cpp (SERVER_MODE branch) m_available_entries_count = m_max_threads; m_all_entries = new entry[m_max_threads]; /* the entire entry pool, one shot */ m_entry_dispatcher = new entry_dispatcher (m_all_entries, m_max_threads);init_lockfree_system은 락프리 트랜잭션 시스템의 크기를 m_max_threads + 1로 잡고, init_entries는 각 엔트리에 index를 찍고 LF-tran 인덱스를 배정한다. 이후로는 모든 리액터 스레드가 manager::claim_entry로 자신의 컨텍스트를 받아 (tl_Entry_p에 저장) retire_entry로 돌려준다.
불변조건 — 예약된 엔트리 수는 등록된 수요와 일치한다.
m_available_entries_count는m_max_threads에서 시작해, 각 풀이 자신의REGISTER_*매크로로 예약한 만큼 정확히create_and_track_resource(§3.3)에 의해 차감된다. 코디네이터의claim_entry와 각 워커의claim_entry는 모두 같은 배열에서 소비한다. 만약 등록 시점과pool::initialize시점 사이에PRM_ID_CSS_MAX_CONNECTION_WORKER가 바뀐다면, 리액터는 예약된 엔트리 수보다 더 많은 워커를 만들려 하게 되고 결국claim_entry가nullptr을 반환하게 된다 — 모든 호출자는 이를assert_release (false)로 처리한다.
3.3 백엔드 요청 풀 — create_worker_pool
섹션 제목: “3.3 백엔드 요청 풀 — create_worker_pool”css_init은 태스크 엔진을 먼저 짓는다. 인라인 헬퍼 thread_create_stats_worker_pool이 매니저 템플릿으로 위임한다.
// manager::create_worker_pool -- src/thread/thread_manager.hpp (SERVER_MODE) static_assert (std::is_base_of_v<worker_pool, Res>); workerpool = create_and_track_resource<Res> (m_worker_pools, pool_size, /* reserve pool_size entries */ pool_size, core_count, std::forward<CtArgs> (args)...); if (workerpool) { workerpool->initialize (pool_size, core_count); } /* <- only if reservation succeeded */ return workerpool;create_and_track_resource가 문지기다. m_entries_mutex 아래서 m_available_entries_count < entries_count이면 아무것도 생성하지 않고 NULL을 돌려준다 (스레드 예산 초과가 표면화되는 지점이 바로 여기다). 그렇지 않으면 카운트를 차감하고 new Res (...)로 리소스를 만든 뒤 m_worker_pools에 밀어 넣는다.
여기서 Res는 stats_worker_pool_type = worker_pool_impl<true>이다 — 템플릿 인자 true가 원자적 통계 특수화 버전을 선택한다(11장). 그 생성자는 assert (core_count > 0 && core_count <= pool_size)를 거치고 m_max_workers = pool_size를 기록한다. 이어지는 initialize는 풀을 코어 단위로 쪼갠다 — allocate_cores (core_count) + assign_workers_to_cores (worker_count) (코어별 스레드 기동 자체는 9-10장). css_init으로 돌아오면, NULL 반환은 치명적이다.
// css_init -- src/connection/server_support.c if (css_Server_request_worker_pool == NULL) { assert (false); er_set (ER_FATAL_ERROR_SEVERITY, ARG_FILE_LINE, ER_GENERIC_ERROR, 0); status = ER_FAILED; goto shutdown; } /* <- skips reactor bring-up, jumps to teardown */pool_size = PRM_ID_TASK_WORKER, core_count = PRM_ID_TASK_GROUP이며, 풀링과 idle-timeout 값은 css_get_server_request_thread_pooling_configuration / _timeout_configuration에서 온다. 소유권 측면에서는, 원시 포인터가 파일 정적 변수 css_Server_request_worker_pool에 살아 있다가 css_init 말미의 destroy_worker_pool에서 파괴된다.
3.4 connection::pool::initialize — 리액터의 척추
섹션 제목: “3.4 connection::pool::initialize — 리액터의 척추”태스크 엔진이 올라온 뒤, css_init은 connections.initialize (MAX_CONNECTIONS, max_connection_workers, min_connection_workers)를 호출한다. MAX_CONNECTIONS는 css_get_max_conn () + 1이다. 분기까지 빠짐없이 옮기면 다음과 같다.
// pool::initialize -- src/connection/connection_pool.cpp (void) os_set_signal_handler (SIGPIPE, SIG_IGN); /* 1. writes to dead sockets must not kill us */ (void) os_set_signal_handler (SIGFPE, SIG_IGN); max_connection_workers = this->initialize_topology (max_connection_workers); /* 2. clamp to real cores */ if (min_connection_workers > max_connection_workers) /* 3. floor cannot exceed ceiling */ { min_connection_workers = max_connection_workers; } this->lock_resource (); /* 4. take the pool baton */ this->initialize_freelist (max_connections); /* 5. context slab */ this->initialize_coordinator (max_connection_workers, min_connection_workers); /* 6. spawn coordinator thread */ this->initialize_workers (max_connection_workers, min_connection_workers); /* 7. spawn N worker threads */ this->release_resource (); /* 8. hand the baton off */ this->start_coordinator (); /* 9. ping the coordinator */ m_max_connections = max_connections; /* ...record the three sizes... */2단계 initialize_topology에서 max_connection_workers가 비로소 물리적인 값이 된다. 이 함수는 os::resources::cpu::effective ()를 읽는다 — 그 adjusted_effective는 선택적 코어 ID 벡터이고 adjusted_max는 실제 사용 가능한 코어 수다 — 그중 min(effective.size(), max_connection_workers)개의 코어를 취해 NIC을 그 코어들에 매핑하고(net::map_nic_to_index), min(adjusted_max, max_connection_workers)를 반환한다. 즉 설정된 최댓값은 프로세스가 실제로 돌 수 있는 코어 수만큼으로 조용히 줄어든다.
flowchart TD
A["SIGPIPE/SIGFPE 무시"] --> B["initialize_topology\n워커 수를 adjusted_max로 클램프"]
B --> C{"min > max?"}
C -- 예 --> D["min = max"]
C -- 아니오 --> E["lock_resource\n풀 뮤텍스 획득"]
D --> E
E --> F["initialize_freelist\n컨텍스트 슬랩"]
F --> G["initialize_coordinator\nmake_shared가 스레드 생성"]
G --> H["initialize_workers\nN개 unique_ptr 워커 + 프리웜"]
H --> I["release_resource\n바톤을 코디네이터에게"]
I --> J["start_coordinator\nSTART 큐잉 + notify"]
Figure 3-2. pool::initialize의 모든 단계. 4-8단계는 css_init 스레드에서 실행되는 하나의 임계 구역이다.
불변조건 —
m_mutex는 바톤이지 스코프드 락이 아니다.pool::initialize는 4단계에서lock_resource()를 호출하지만, 짝을 이루는release_resource()(8단계)가 이야기의 끝이 아니다.coordinator::initialize(§3.6)가 그 말미에서 같은 뮤텍스를 다시 획득해 코디네이터의 생애 전체 동안 쥐고 있다가,coordinator::finalize에서야 놓는다. 셋업 구간은css_init스레드에서 실행되며, 그 스레드가 뮤텍스를 놓는 순간 방금 스폰된 코디네이터 스레드(자기 자신의lock_resource에서 대기 중이던)가 뮤텍스를 낚아채 영원히 소유한다. 이것이claim_context/retire_context/get_workers가 모두assert (m_mutex_holder == std::this_thread::get_id ())를 거는 이유다 — 이들은 오직 바톤을 확실히 쥐고 있는 코디네이터 스레드에서만 호출된다. 이 인계 규약을 어기면(예를 들어 다른 스레드가claim_context를 호출하면) 이 assert가 걸린다.
3.5 initialize_freelist — 컨텍스트 슬랩
섹션 제목: “3.5 initialize_freelist — 컨텍스트 슬랩”프리리스트는 “모든 커넥션 객체”가 미리 할당되어, 정상 운영 경로에서는 malloc이 전혀 일어나지 않게 하는 자리다. 그 노드는 context를 통째로 품고 있다.
// pool::freelist -- src/connection/connection_pool.hpp struct freelist { context m_context; freelist *m_next; freelist (std::size_t capacity) : m_context (capacity), m_next (nullptr) {} };| 필드 | 역할 | 존재 이유 |
|---|---|---|
m_context | 노드에 내장된 커넥션별 context, capacity 바이트 버퍼로 생성 | 노드 안에 내장함으로써 커넥션 하나당 할당이 두 번이 아니라 한 번으로 끝난다 |
m_next | 침습적(intrusive) free-chain 링크 | 풀 바톤을 쥐고 있는 동안 O(1) push/pop이 가능하다 |
풀은 이 체인을 익명 구조체로 추적한다.
| 필드 | 역할 | 존재 이유 |
|---|---|---|
m_head | free chain의 LIFO 헤드 | claim_context가 여기서 팝하고, retire_context가 여기에 푸시한다 |
m_max | 상한선 = max_connections * 1.1 | retire_context가 이 선을 넘는 노드는 쌓아두지 않고 삭제한다 |
m_claim | 현재 대여 중(claimed)인 컨텍스트 개수 | finalize 시점에 반드시 0이어야 하며, 이 assert가 컨텍스트 누수를 잡아낸다 |
// pool::initialize_freelist -- src/connection/connection_pool.cpp m_freelist.m_head = nullptr; m_freelist.m_claim = 0; m_freelist.m_max = static_cast<std::size_t> (static_cast<float> (max_connections) * /* margin */ 1.1); for (i = 0; i < m_freelist.m_max; i++) { head = m_freelist.m_head; m_freelist.m_head = new freelist (32 * 1024); /* 32 KB context buffer each */ m_freelist.m_head->m_next = head; }모든 노드의 context는 32 * 1024 바이트로 크기가 잡힌다 — 같은 리터럴이 claim_context의 오버플로 경로에서도 재사용되는데, 그곳에서는 프리리스트가 비어 있으면 블로킹하는 대신 새 new freelist (32 * 1024)를 즉석에서 만든다. 즉 10% 여유분은 하드 캡이 아니라 소프트 목표일 뿐이다 — 이를 넘으면 claim_context가 그때그때 할당하고, retire_context가 다시 m_max까지 줄인다.
불변조건 — 종료 시점에
m_claim == 0.finalize_freelist는 첫 줄에서assert (m_freelist.m_claim == 0)을 건다. 모든claim_context는m_claim++을, 모든retire_context는m_claim--을 하므로, 셧다운 시점에 0이 아니라는 것은 어떤 커넥션 컨텍스트가 대여된 채 코디네이터의RETURN_TO_POOL경로(7장)로 돌아오지 못했다는 뜻이다. 이 assert는 그 누수를 정확히 셧다운 경계에서 디버그 빌드 크래시로 바꿔 드러낸다.
3.6 initialize_coordinator — epoll 코디네이터 스폰
섹션 제목: “3.6 initialize_coordinator — epoll 코디네이터 스폰”initialize_coordinator는 코디네이터가 상주할 홈 코어(adjusted_effective[0], 없으면 0)를 고르고 make_shared<coordinator>를 호출한다 — 생성자가 무거운 작업을 css_init 스레드에서 수행한 뒤, 코디네이터 스레드를 스폰한다.
// coordinator::coordinator -- src/connection/coordinator.cpp (condensed) m_controller.open ("/tmp/cub_server_" + std::to_string (getpid ()) + "_coordinator.sock", SOCK_NONBLOCK | SOCK_CLOEXEC); m_ctrlfd = m_controller.get_fd (); m_eventfd = eventfd (0, EFD_NONBLOCK | EFD_CLOEXEC); /* wake-on-message */ m_timerfd = timerfd_create (CLOCK_MONOTONIC, TFD_NONBLOCK | TFD_CLOEXEC); if (m_eventfd < 0 || m_timerfd < 0) { assert_release (false); } if (!this->eventfd_register (m_eventfd) || !this->eventfd_register (m_timerfd) || !this->eventfd_register (m_ctrlfd)) { assert_release (false); } /* all three into epoll */ /* three timers: STATISTICS/REBALANCING/SCALING via eventfd_addtimer */ m_thread = std::thread (&coordinator::attach, this); /* <- the coordinator thread begins */eventfd_register는 각 fd를 코디네이터 전용 cubsocket::epoll m_events에 엣지 트리거 read 모드로 등록한다.
// coordinator::eventfd_register -- src/connection/coordinator.cpp if (!m_events.add_descriptor (fd, EPOLLET | EPOLLIN)) { return false; }epoll 래퍼(epoll.cpp)는 얇은 RAII 껍데기다 — 생성자는 m_epoll = epoll_create1 (EPOLL_CLOEXEC)(-1이면 assert)이고, add_descriptor는 epoll_event를 채워 data.ptr에 호출자의 쿠키(없으면 data.fd)를 넣은 뒤 epoll_ctl (EPOLL_CTL_ADD)를 호출한다. 코디네이터는 맨 fd로 등록한다(쿠키가 nullptr이므로 data.fd 사용) — §3.7에서 보듯 워커는 대신 컨텍스트 포인터로 등록한다.
스폰된 스레드는 coordinator::attach = initialize(); run(); finalize();를 실행한다. coordinator::initialize에서 코디네이터가 자신의 OS 신원을 선언하고 바톤을 쥔다.
// coordinator::initialize -- src/connection/coordinator.cpp m_watcher->mtx.lock (); m_watcher->active++; m_watcher->mtx.unlock (); /* announce liveness */ pthread_setname_np (pthread_self (), "coordinator"); os::resources::cpu::setaffinity (m_core); /* pin to home core */ m_entry = cubthread::get_manager ()->claim_entry (); /* pull a thread entry */ if (m_entry == nullptr) { assert_release (false); } m_entry->register_id (); m_entry->type = TT_SERVER; m_entry->tran_index = -1; m_entry->m_status = cubthread::entry::status::TS_RUN; m_entry->shutdown = false; m_entry->get_error_context ().register_thread_local (); m_status = status::STABLE; m_parent->lock_resource (); /* <- takes the baton for life */m_watcher는 풀, 코디네이터, 그리고 모든 워커가 (shared_ptr로) 공유하는 thread_watcher { std::mutex mtx; std::condition_variable cv; int active; }다. active는 살아 있는 리액터 스레드 수이며, 풀의 finalize는 이 값이 빠질 때까지 cv에서 대기한다.
graph TD P["pool\n값으로 소유"] -->|shared_ptr| C["coordinator\nm_thread + epoll m_events"] P -->|"vector unique_ptr"| W["worker[0..N-1]\n각각 m_thread + epoll m_events"] P -->|shared_ptr| WA["thread_watcher\nmtx cv active"] C -->|shared_ptr| WA W -->|shared_ptr| WA C -->|워커들이 보관하는 shared_ptr| C2["coordinator\n(핸드오프 메시지용)"]
Figure 3-3. pool::initialize 이후의 소유권 구조. 코디네이터는 shared_ptr이며(워커들이 핸드오프를 위해 이를 붙들고 있다), 워커는 오직 풀만이 소유하는 unique_ptr이고, watcher는 모두가 공유한다.
3.7 initialize_workers — 커넥션 워커 생성과 프리웜
섹션 제목: “3.7 initialize_workers — 커넥션 워커 생성과 프리웜”initialize_workers는 벡터를 예약하고 코어 목록을 확정한 뒤, 정확히 max_connection_workers개의 워커를 코어당 하나씩 생성한다.
// pool::initialize_workers -- src/connection/connection_pool.cpp m_workers.reserve (max_connection_workers); cores = ctx.adjusted_effective ? *ctx.adjusted_effective : /* iota 0..adjusted_max */ ...; assert (cores.size () >= max_connection_workers); for (i = 0; i < max_connection_workers; i++) { m_workers.emplace_back (std::make_unique<worker> (this, m_coordinator, m_watcher, cores[i], i)); }각 worker 생성자(connection_worker.cpp)는 코디네이터의 생성자를 그대로 거울처럼 따른다 — m_context를 예약하고, RECV/SEND_BUDGET 파라미터를 읽고, 자신만의 eventfd/timerfd를 만들어 등록하고, HIBERNATE/STATISTICS/HA 세 타이머를 설치한 뒤 m_thread = std::thread (&worker::attach, this)로 스폰한다. 다만 워커의 eventfd_register는 코디네이터의 것과 다르다 — 각 fd를 가짜 context로 감싸서, 실행 루프가 실제 커넥션과 wakeup을 동일한 방식으로 다룰 수 있게 한다.
// worker::eventfd_register -- src/connection/connection_worker.cpp ctx = new context (); conn = reinterpret_cast<css_conn_entry *> (new int { fd }); /* a heap int masquerading as a conn */ ctx->m_conn = conn; if (!m_events.add_descriptor (fd, EPOLLET | EPOLLIN, ctx)) { delete ctx; delete conn; return false; }쿠키는 context*이며, worker::run은 events[i].data.ptr을 다시 context*로 읽어 ctx->m_conn->fd를 m_eventfd/m_timerfd와 비교함으로써 wakeup과 클라이언트 I/O를 구분한다(4장). worker::initialize(워커 스레드에서 실행)는 코디네이터와 같은 watcher/affinity/claim_entry 순서를 따르되 바톤은 취하지 않으며, m_context/m_removed_context를 비운다.
모든 워커가 생성된 뒤 initialize_workers는 시작 시점의 경쟁 상태를 피하기 위해 모든 큐를 프리웜한다.
// pool::initialize_workers -- src/connection/connection_pool.cpp (prewarm) for (std::unique_ptr<worker> &worker : m_workers) for (i = 0; i < static_cast<std::size_t> (worker::queue_type::TYPE_COUNT); i++) { worker::message request; request.type = worker::message_type::START; if (!worker->enqueue_and_notify ((worker::queue_type) i, std::move (request), nullptr, -1 /* infinite */)) { assert_release (false); } }enqueue_and_notify는 -1을 넘기면 워커가 응답할 때까지 블록한다. 그래서 실제 클라이언트가 도착하기 전에 모든 워커의 IMMEDIATE, LAZY 큐가 한 번씩 실제로 작동해 본 상태가 된다.
불변조건 — 코어당 워커 하나, 그리고 모든
max는 시작 시점에 미리 만들어진다.assert (cores.size () >= max_connection_workers)는 토폴로지(§3.4)가 충분한 코어를 제공했음을 보장하며, 워커 i는cores[i]에 고정된다. 주목할 점은min과 무관하게max_connection_workers전체가 여기서 생성된다는 것이다 — 코디네이터의m_current_worker는 생성자에서max_worker로 시딩되므로, 리액터는 완전히 확장된 상태로 부팅하고 이후 오토스케일러(6장)가 워커를min쪽으로 줄여 간다.min_connection_workers는 오직scale_down의 하한선일 뿐, 초기 할당량을 제한하지는 않는다.
3.8 start_coordinator — 웨이크업 핸드셰이크
섹션 제목: “3.8 start_coordinator — 웨이크업 핸드셰이크”코디네이터 스레드는 §3.6에서 스폰된 이후 이미 run 안에서 루프를 돌고 있다. start_coordinator는 그저 START 메시지를 게시하고 eventfd를 울릴 뿐이다.
// pool::start_coordinator -- src/connection/connection_pool.cpp request.type = coordinator::message_type::START; m_coordinator->enqueue (std::move (request)); if (!m_coordinator->notify ()) { assert_release (false); }enqueue는 코디네이터의 TBB 큐에 밀어 넣고 m_queue_size를 (release 순서로) 올린다. notify는 eventfd에 1을 쓰는데, 이 fd는 이미 epoll 루프에 등록되어 있다. handle_message_queue_start는 아무 일도 하지 않고 true만 반환하는 no-op이다 — START가 존재하는 이유는 루프가 한 바퀴 온전히 돌게 만들고, message_type으로 인덱싱되는 핸들러 테이블이 NEW_CLIENT 트래픽이 오기 전에 제대로 배선되어 있음을 검증하기 위해서다. 이 호출이 끝나면 css_init은 connector를 붙이고 connector.run을 호출하며, 이는 셧다운까지 블록한다.
3.9 종료 — pool::finalize와 역방향 바톤
섹션 제목: “3.9 종료 — pool::finalize와 역방향 바톤”셧다운은 css_init 스레드에서 connections.finalize ()를 실행한다. 이는 부팅 순서를 거꾸로 돌리되, 코디네이터로부터 뮤텍스 바톤을 반드시 회수해야 한다. 분기까지 빠짐없이 옮기면 다음과 같다.
// pool::finalize -- src/connection/connection_pool.cpp this->finalize_workers (); /* 1. SHUTDOWN every worker, wait until active == 1 */ this->finalize_coordinator (); /* 2. SHUTDOWN coordinator, wait until active == 0 */ this->try_to_lock_resource (); /* 3. reclaim the baton the coordinator just dropped */ m_workers.clear (); /* 4. ~worker joins each thread */ this->finalize_freelist (); /* 5. assert m_claim==0; delete the chain */ this->release_resource (); this->finalize_topology (); /* no-op */ m_max_connections = -1; /* ... */finalize_workers는 각 워커의 IMMEDIATE 큐에 SHUTDOWN을 게시하고 notify한 뒤, 셧다운 타임아웃을 두고 watcher의 조건 변수에서 대기한다: cv.wait_for (lock, wait_for, [this]{ return m_watcher->active == 1; }) — 여기서 살아남는 1이 코디네이터다. 각 워커 스레드는 SHUTDOWN을 받으면 worker::finalize를 실행한다 — 살아 있는 모든 컨텍스트를 강제로 닫고(ignore_level::IGNORE_ALL + handle_connection_close), LAZY 큐를 비우고 m_context가 빌 때까지 1 ms 간격으로 purge_stale_contexts를 반복한 뒤, retire_entry, 그리고 active-- + cv.notify_one으로 마친다. 데드라인이 지났는데도 active != 1이면, finalize_workers는 로그를 남기고 _exit (0)한다 — 멈춰버린 워커는 셧다운을 무한정 매달아 두는 대신 프로세스를 통째로 중단시킨다.
이어서 finalize_coordinator가 코디네이터에게 SHUTDOWN을 게시하고 active == 0을 기다린다. 코디네이터의 handle_message_queue_shutdown은 m_stop = true를 세워 run을 끊는다. coordinator::finalize는 가장 먼저 m_parent->release_resource ()를 실행해 바톤을 놓고, 그다음 retire_entry, active--, cv.notify_one을 한다. 타임아웃 시 마찬가지로 _exit (0)한다.
css_init 스레드가 뮤텍스를 되찾는 것은 오직 이 시점, try_to_lock_resource를 통해서다 — 10 ms 간격으로 최대 1000회 시도하며, 코디네이터가 끝내 뮤텍스를 놓지 않으면 _exit (0)한다. 바톤을 되찾으면 m_workers.clear()가 각 unique_ptr<worker>를 파괴한다. ~worker는 스레드를 join하고 워커의 eventfd/timerfd를 close한다. finalize_freelist는 m_claim == 0을 assert하고 m_head를 훑으며 노드를 삭제한다. ~coordinator(마지막 shared_ptr이 사라질 때)는 코디네이터 스레드를 join하고 그 fd들을 닫는다.
flowchart TD
F0["finalize_workers\n각 워커에 SHUTDOWN + notify"] --> F1{"타임아웃 전에\nactive == 1?"}
F1 -- 아니오 --> X1["_exit 0"]
F1 -- 예 --> F2["finalize_coordinator\nSHUTDOWN + notify"]
F2 --> F3{"타임아웃 전에\nactive == 0?"}
F3 -- 아니오 --> X2["_exit 0"]
F3 -- 예 --> F4["try_to_lock_resource\n바톤 회수"]
F4 --> F5["m_workers.clear\n스레드 join"]
F5 --> F6["finalize_freelist\nassert claim==0"]
Figure 3-4. pool::finalize. 워커가 active == 1에 도달한 뒤에야 코디네이터가 멈춰지며, 바톤은 active == 0 이후에만 회수된다.
불변조건 — 종료 순서: 워커가 코디네이터보다 먼저. 리액터는 (
active가1로 떨어질 때까지 기다려) 워커를 먼저 멈추고, 그다음 (0이 될 때까지 기다려) 코디네이터를 멈춘다. 코디네이터가 워커보다 오래 살아남는 이유는, 워커의SHUTDOWN/컨텍스트 반환이 여전히RETURN_TO_POOL메시지를 흘려보내고 코디네이터가 이를 다 비워내야 하기 때문이다(7장). 만약 이 순서를 뒤집어 코디네이터를 먼저 죽인다면, 코디네이터가 쥐고 있던 풀 뮤텍스가 좌초되고 진행 중이던 컨텍스트 반환들이 방치되어m_claim이 오염되고finalize_freelist의 assert가 걸리게 된다. 백엔드 요청 풀(css_Server_request_worker_pool)은 리액터가 정리된 이후에,css_init말미의destroy_worker_pool을 통해 파괴된다 — 살아 있는 클라이언트 태스크들이 자신의 태스크 엔진이 사라지기 전에 먼저 끝나야 하기 때문이다.
3.10 챕터 요약 — 핵심 정리
섹션 제목: “3.10 챕터 요약 — 핵심 정리”- 부팅은
css_init안에서 이뤄지는 두 그래프의 순차 작업이다. 백엔드 요청 풀(create_worker_pool→worker_pool_impl<true>::initialize)이 먼저이고, 그다음이 커넥션 리액터(pool::initialize)다.net_server_start는 이 둘보다 앞서 DB 복구와 엔트리 풀 할당의 순서를 잡아 놓는다. - 엔트리 풀의 크기는 설정이 아니라 등록에 의해 정해진다.
set_max_thread_count_from_config는REGISTER_CONNECTION/REGISTER_WORKERPOOL카운트(coordinator = 1, connection_worker =max_connection_worker, transaction =task_worker)에 PAD를 더해 합산하고,alloc_entries는 모든 스레드가 그로부터 claim해 가는 단 하나의new entry[]배열을 만든다. - 풀 뮤텍스는 스코프가 아니라 바톤이다.
pool::initialize는css_init스레드에서 이를 잠갔다가 풀어, 갓 스폰된 코디네이터 스레드가 평생 쥘 수 있게 한다.claim_context/retire_context는 홀더가 코디네이터임을 assert한다. 종료 시에는try_to_lock_resource로 회수한다. - 프리리스트는
max_connections * 1.1개의 32 KB 컨텍스트를 미리 할당한다. 이를 넘어서면 즉석 할당이 일어나고, retire가 다시m_max까지 줄인다.m_claim == 0은 누수를 잡아내는 셧다운 assert다. - 코디네이터와 각 워커는 각자 별도의 epoll 인스턴스를 만들고 eventfd와 timerfd를 등록한다. 코디네이터는 추가로 제어용 유닉스 소켓을 등록한다. 워커는 각 fd를 가짜
context(new int{fd}를 유사css_conn_entry로 씀)로 감싸서 실행 루프가 wakeup과 클라이언트 I/O를 동일하게 다룰 수 있게 한다. - 모든
max워커가 처음부터 코어당 하나씩 부팅된다(assert cores.size() >= max).min_connection_worker는 오토스케일러가 줄여 가는 하한선일 뿐이다. 모든 큐는 블로킹START로 미리 프리웜되어 시작 시점의 경쟁을 없앤다. - 종료는 엄격한 순서를 따른다. 워커를
active == 1까지, 코디네이터를active == 0까지 멈춘 뒤 바톤을 회수하고 join·해제한다 — 모든 셧다운 타임아웃 분기에_exit(0)가드가 걸려 있으며, 태스크 엔진은 가장 마지막에 파괴된다.
Chapter 4: epoll 커넥션 워커 루프
섹션 제목: “Chapter 4: epoll 커넥션 워커 루프”3장에서는 worker를 만들고 초기화했다. worker는 epoll 셋 하나, eventfd 하나, timerfd 하나, 워커별 타이머 테이블, 그리고 두 개의 tbb::concurrent_queue<message> 수신함(inbox)을 소유한다. 생성자가 만드는 std::thread의 진입점인 worker::attach는 initialize (); run (); finalize (); 순서로 실행된다. 이 장에서 해부할 대상은 run이다: 일단 실행에 들어가면, 워커 스레드 하나가 수백 개의 클라이언트 소켓에 걸친 이벤트를 어떻게 기다리고, 어느 한 커넥션에도 발이 묶이지 않은 채 디스패치하는가? 그 이유(리액터 패턴, C10K)는 cubrid-thread-worker-pool.md의 “Reactor and the single event loop” 절에 있으며, 여기서는 한 줄 한 줄 뜯어보는 방법을 다룬다.
불변식 4-A (리액터 논블로킹 규칙). 워커는 자신에게 할당된 모든 커넥션을 서비스하지만, 블로킹되는 지점은
run맨 위의m_events.wait ()(epoll_wait) 단 한 곳뿐이다. 모든 소켓은 에지 트리거(edge-triggered) 논블로킹이므로 커넥션 단위의read/write가 스레드를 멈춰 세우는 일이 없고, 소켓은 예산(budget) 한도까지만 비워진다(5장). 만약 어떤 핸들러가 한 커넥션에서 블로킹된다면, 그 워커에 속한 다른 모든 클라이언트가 그대로 얼어붙는다.
4.1 세 이벤트 소스, 하나의 대기
섹션 제목: “4.1 세 이벤트 소스, 하나의 대기”워커의 epoll 셋은 세 종류의 디스크립터를 담고 있으며, 모두 epoll_event.data.ptr가 context *를 가리키도록 등록된다: 클라이언트 소켓(EPOLLET | EPOLLIN | EPOLLRDHUP, 전송이 대기 중일 때는 EPOLLOUT도 추가), 다른 스레드가 찔러서 깨우는 카운터인 m_eventfd, 그리고 현재 활성 타이머 중 가장 짧은 지연시간에 맞춰 무장되는 **m_timerfd**다. 두 제어용 fd는 생성 시점에 각각 일회용 context로 감싸진다(eventfd_register는 new context ()를 호출하고, reinterpret_cast<css_conn_entry *> (new int { fd })로 원시 fd를 저장한다). 그 덕분에 콜백 데이터는 항상 균일하게 context *가 된다. run은 ctx->m_conn->fd를 m_eventfd / m_timerfd와 비교해서 이들을 구분한다.
flowchart LR C1["클라이언트 fd 1<br/>EPOLLIN/OUT/RDHUP"] --> EP["epoll m_events"] C2["클라이언트 fd N"] --> EP EF["m_eventfd<br/>(큐 초인종)"] --> EP TF["m_timerfd<br/>(가장 짧은 지연시간)"] --> EP EP -- "data.ptr = context*" --> RUN["worker::run 디먹스"] RUN -- "eventfds[0]" --> MQ["handle_message_queue"] RUN -- "eventfds[1]" --> TMR["타이머 콜백"] RUN -- "client fd" --> IO["수신 / 송신"]
Figure 4-1. 모든 소스는 하나의 epoll 인스턴스로 모여들고, run은 context에 담긴 fd 식별자를 기준으로 디먹스한다.
4.2 worker::run — 분기 전체
섹션 제목: “4.2 worker::run — 분기 전체”전체 골격은 이렇다: 스택에 놓인 512개짜리 epoll_event 배열, 두 슬롯짜리 지연 래치인 bool eventfds[2], 그리고 while (!m_stop) 루프.
// worker::run -- src/connection/connection_worker.cpp nfds = m_events.wait (events.data (), events.size (), m_exhausted.empty () ? TIMEOUT_INFINITE : TIMEOUT_NOWAIT); /* <- 4.2.1 */ if (nfds < 0) { if (errno == EINTR) continue; assert_release (false); continue; } /* signal: retry; else log+loop */ m_timens = this->get_time_ns (CLOCK_MONOTONIC); /* <- one clock read, reused all loop */ // ... per-fd demux (4.2.2) ... if (m_exhausted.size () > 0) this->handle_exhausted (); /* <- Chapter 5 */ if (eventfds[0] || eventfds[1]) this->eventfd_handler (eventfds);4.2.1 타임아웃 분기. m_exhausted가 비어 있으면 TIMEOUT_INFINITE(-1), 그렇지 않으면 TIMEOUT_NOWAIT(0)이다. m_exhausted는 커넥션별 IO 예산을 다 쓰고도 처리할 일이 남아 있는 컨텍스트들을 담아 둔다. 하나라도 있으면 워커는 즉시 리턴하는(nfds == 0일 수도 있는) 폴링 모드로 전환해서, handle_exhausted(5장의 메커니즘)를 통해 이들을 다시 찾아가야 한다. 맵이 비어 있으면 fd가 준비될 때까지 잠든다.
4.2.2 fd별 디먹스. nfds개의 디스크립터 각각에 대해: (1) ctx = reinterpret_cast<context *> (events[i].data.ptr) (non-null을 assert). (2) 행업/에러: events[i].events에 EPOLLHUP | EPOLLRDHUP | EPOLLERR 중 하나라도 있고, 동시에 그 fd가 m_eventfd도 m_timerfd도 아니면 handle_hangup_or_error (ctx, events[i].events & EPOLLERR)를 호출하고 continue한다 — 제어용 fd를 제외하는 이유는, 가짜 HUP을 클라이언트 접속 종료로 오인하지 않기 위해서다. (3) LAST_ACTIVE_NS = m_timens를 찍는다. (4) EPOLLIN: fd가 m_eventfd이면 eventfds[0] = true; continue;, fd가 m_timerfd이면 eventfds[1] = true; continue;, 그 외 클라이언트면 handle_reception (ctx, false)를 호출한다: ClosedConnection/PeerReset이면 continue, Error면 로그를 남기고 return false(치명적 오류), Ok/Pending이면 아래로 흘러 내려간다. (5) EPOLLOUT: handle_transmission (ctx, false)를 호출하며 마찬가지로 세 갈래로 처리한다.
제어용 fd는 fd 루프 안에서는 그저 래치될 뿐이고, 실제 작업은 그 배치(batch) 안의 모든 클라이언트 fd 처리가 끝난 뒤에 eventfd_handler로 미뤄진다 — 클라이언트 IO는 언제나 제어 평면(control-plane) 부기(bookkeeping)보다 먼저 처리된다.
flowchart TD
A["epoll_wait<br/>timeout = exhausted? 0 : -1"] --> B{"nfds < 0?"}
B -- "EINTR / 그 외" --> A
B -- "아니오" --> C["m_timens = 현재 시각"]
C --> D{"준비된 fd마다 반복"}
D --> E{"HUP/RDHUP/ERR이면서<br/>eventfd/timerfd가 아닌가?"}
E -- 예 --> F["handle_hangup_or_error; continue"]
E -- 아니오 --> G{"EPOLLIN?"}
G -- "fd==eventfd" --> H["eventfds[0]=true; continue"]
G -- "fd==timerfd" --> I["eventfds[1]=true; continue"]
G -- "클라이언트" --> J["handle_reception"]
J -- "Closed/PeerReset" --> D
J -- "Error" --> Z["return false"]
G -- 아니오 --> K{"EPOLLOUT?"}
K -- 예 --> L["handle_transmission"]
L -- "Error" --> Z
D -- "완료" --> M{"m_exhausted>0?"}
M -- 예 --> N["handle_exhausted"]
M -- 아니오 --> O{"eventfds 설정됨?"}
N --> O
O -- 예 --> P["eventfd_handler"]
O -- 아니오 --> A
P --> A
Figure 4-2. worker::run의 모든 분기. 수신/송신에서 발생하는 치명적 Error가 m_stop 외에 유일한 종료 경로이며, 리턴하면 attach는 finalize로 넘어간다.
4.3 worker::eventfd_handler — 지연된 디먹스
섹션 제목: “4.3 worker::eventfd_handler — 지연된 디먹스”두 래치 중 하나라도 설정돼 있으면 반복(iteration)마다 한 번 호출되며, 진입 시 m_has_retry를 리셋하고 두 래치를 모두 소비한다.
// worker::eventfd_handler -- src/connection/connection_worker.cpp m_has_retry = false; if (eventfds[0]) { eventfds[0] = false; if (!this->eventfd_clear (m_eventfd)) return false; /* <- drain the counter */ if (!this->handle_message_queue ()) return false; /* <- 4.5 */ m_timer_handler[(size_t) timer_type::QUEUE].last_time = m_timens; } if (eventfds[1]) { eventfds[1] = false; for (i = 0; i < timer_type::TYPE_COUNT; i++) { if (!m_timer_handler[i].valid) continue; if (m_timens - m_timer_handler[i].last_time > (uint64_t) m_timer_handler[i].latency) { if (!m_timer_handler[i].function ()) return false; /* <- fire callback */ m_timer_handler[i].last_time = m_timens; } } if (!this->eventfd_clear (m_timerfd)) return false; if (!m_has_retry) return this->eventfd_removetimer (timer_type::QUEUE); } return true;Eventfd 분기(eventfds[0]). eventfd_clear는 m_eventfd에 대해 논블로킹 read 루프를 돌려(EINTR이면 재시도, EAGAIN이면 종료) 64비트 카운터를 0으로 만든다 — notify의 write와 짝을 이루는 동작이다. 그다음 handle_message_queue가 두 수신함을 모두 비우고, 재시도 타이머가 즉시 다시 발동하지 않도록 QUEUE 타이머의 last_time을 갱신한다.
Timerfd 분기(eventfds[1]). timerfd는 현재 활성 지연시간 중 가장 짧은 값 하나로(단일 주기로) 무장되므로(eventfd_starttimer), 만료되면 핸들러는 모든 슬롯을 훑으면서 각자의 경과 시간이 자신의 지연시간을 넘긴 슬롯만 발동시킨다. 어떤 콜백이든 false를 리턴하면 그 false가 그대로 전파되어 루프를 죽인다. 네 개의 슬롯은 생성자에서 설정된다:
timer_type | 지연시간 | 콜백 | 역할 |
|---|---|---|---|
HIBERNATE | MEDIUM 1 s | hibernate_check | 유휴 워커를 파킹(park) |
STATISTICS | MEDIUM 1 s | statistics_metrics_to_coordinator | 코디네이터로 메트릭 전송 |
HA | HIGH 2 s | ha_close_all_connections | HA 스탠바이 상태에서 커넥션 정리 |
QUEUE | LOW 1 ms | handle_message_queue | 지연된 종료(close)에 대한 빠른 재시도 |
QUEUE 타이머 재시도. handle_connection_close는 클라이언트 종료를 재시도해야 할 때, LAZY 큐에 SHUTDOWN_CLIENT를 다시 enqueue하고 m_has_retry = true로 설정한 다음, handle_message_queue에 묶인 QUEUE 타이머를 추가한다. 재시도가 대기 중인 동안에는 1 ms 타이머가 살아 있고, 마지막의 if (!m_has_retry) eventfd_removetimer (QUEUE)가 대기 중인 재시도가 더 이상 없을 때 이를 걷어낸다 — 종료 처리가 막힌 커넥션은 공격적으로 폴링되지만, 그렇다고 1 ms짜리 타이머 바퀴가 영구히 돌아가지는 않는다.
flowchart TD
S["m_has_retry=false"] --> A{"eventfds[0]?"}
A -- 예 --> B["eventfd_clear(eventfd)"] --> C["handle_message_queue"] --> D["QUEUE last_time = 현재 시각"]
A -- 아니오 --> E{"eventfds[1]?"}
D --> E
E -- 예 --> F["유효한 타이머 중<br/>경과시간 > latency 인 것 발동"] --> G["eventfd_clear(timerfd)"] --> H{"m_has_retry?"}
H -- 아니오 --> I["eventfd_removetimer(QUEUE)"] --> J["return true"]
H -- 예 --> J
E -- 아니오 --> J
Figure 4-3. eventfd_handler: 큐 비우기, 타이머 팬아웃(fan-out), QUEUE 타이머 해제.
4.4 프로듀서 측: enqueue, notify, enqueue_and_notify
섹션 제목: “4.4 프로듀서 측: enqueue, notify, enqueue_and_notify”이 큐들은 MPSC(다중 생산자-단일 소비자) 구조다 — 코디네이터, 다른 워커들, 네트워크 계층이 push하고, pop은 오직 이를 소유한 워커만 한다. **enqueue**는 선택된 TBB 큐에 push한 다음 **memory_order_release**로 카운트를 올리는데, 이것이 소비자 쪽의 exchange (acquire)(4.5절)와 짝을 이뤄 메시지 내용을 공개(publish)한다:
// worker::enqueue -- src/connection/connection_worker.cpp m_queue[(size_t) type].push (std::move (item)); /* MPSC producer push */ m_queue_size[(size_t) type].fetch_add (1, std::memory_order_release); /* publishes the push */맨 앞의 assert는 라우팅 가능한 대상(살아 있는 conn fd, 살아 있는 ctx fd, 양수인 id, 또는 START/SHUTDOWN/HIBERNATE/AWAKEN 같은 제어용 타입)을 요구한다. enqueue는 워커를 깨우지 않는다 — 호출자가 여러 push를 모아 두었다가 notify를 한 번만 부를 수 있도록 일부러 분리해 둔 것이다. **notify**는 m_eventfd에 1을 쓰며 EINTR이면 재시도한다. 짧은 쓰기(short write)는 false를 리턴하고, EAGAIN(카운터 포화)은 워커가 이미 스케줄되어 있다는 뜻이므로 성공으로 취급한다(break):
// worker::notify -- src/connection/connection_worker.cpp bytes = ::write (m_eventfd, &u /*=1*/, sizeof (u)); if (bytes == sizeof (u)) break; if (bytes == 0 || (bytes > 0 && bytes < sizeof (u))) return false; /* short write */ if (errno == EINTR) continue; if (errno == EAGAIN) break; /* saturated == fine */ return false;불변식 4-C (notify 병합은 손실 없이 안전하다). 메시지 자체는 큐에 있고, eventfd는 그저 초인종일 뿐이다. 소비자는 eventfd 값을 믿는 대신
exchange (0)으로 카운트를 스냅샷하기 때문에,notify쓰기가 몇 번이든 — 심지어 포화된 쓰기라도 — 대기 중인 메시지는 반드시 보이게 된다. 유일하게 금지된 순서는enqueue보다notify가 먼저 오는 것이며, 모든 경로는 항상 enqueue를 먼저 한다.
**enqueue_and_notify**는 이 둘을 결합하며, message_blocker를 통한 동기 대기를 선택적으로 제공한다:
// worker::enqueue_and_notify -- src/connection/connection_worker.cpp if (wait_time) { handle = std::make_shared<message_blocker> (); handle->done = false; lock = std::unique_lock<std::mutex> (handle->m); /* LOCK BEFORE enqueue: no lost wakeup */ item.waiter_handle = handle; } this->enqueue (type, std::move (item)); if (!this->notify ()) { if (func) func (); return false; } if (func) func (); /* post-notify hook, runs on success too */ if (wait_time) { if (wait_time < 0) handle->cv.wait (lock, [&]{ return handle->done; }); else handle->cv.wait_for (lock, std::chrono::seconds (wait_time), [&]{ return handle->done; }); }wait_time은 오직 START, SHUTDOWN_CLIENT, SEND_PACKET에서만 쓰이도록 assert된다 — 이 셋만이 wakeup_blocked_worker (item.waiter_handle)를 호출해서 done을 설정하고 시그널을 보내는 핸들러다. wait_time < 0이면 무한정 기다리고, > 0이면 그 시간만큼만 기다리며, 0(기본값)이면 handle을 아예 할당하지 않는다.
불변식 4-D (waiter 락 순서). 호출자는
enqueue를 하기 전부터cv.wait에 들어갈 때까지message_blocker::m을 계속 쥐고 있고, 소비자 쪽의wakeup_blocked_worker도done을 설정하기 전에 같은 뮤텍스를 잡는다. 이렇게 앞뒤로 락을 걸어 두면, 설령 워커가 요청을 먼저 끝내 버리더라도 스레드 간 대기가 안전해진다 — 재확인되는 술어(predicate)handle->done이 레이스를 막아 준다.
4.5 handle_message_queue와 디스패치 테이블
섹션 제목: “4.5 handle_message_queue와 디스패치 테이블”handle_message_queue는 정해진 순서로 두 큐를 모두 비운 다음, purge_stale_contexts를 통해 죽은 컨텍스트를 회수한다(코디네이터로 일괄 반환). queue_type은 두 값을 갖는다: 먼저 비워지는 IMMEDIATE(NEW_CLIENT, TAKEOVER_CLIENT, SEND_PACKET, RELEASE_PACKET)와 LAZY(HIBERNATE, AWAKEN, HANDOFF_CLIENT, SHUTDOWN_CLIENT)다. 어느 큐로 갈지는 각 message_type의 태그에 따라 enqueue 시점에 프로듀서가 정한다. handle_message_queue_by_index가 디스패치의 핵심으로, message_type으로 직접 인덱싱되는 { 멤버 함수 포인터, 통계 슬롯 } 쌍의 constexpr 배열이며, static_assert로 정확히 0번부터 시작하는 10개 항목임을 고정해 둔다:
// worker::handle_message_queue_by_index -- src/connection/connection_worker.cpp size = m_queue_size[(size_t) type].exchange (0, std::memory_order_acquire); /* SNAPSHOT + reset */ while (i++ < size && m_queue[(size_t) type].try_pop (request)) /* bounded by snapshot */ { if (! (message_type::START <= request.type && message_type::TYPE_COUNT > request.type)) { assert_release (false); continue; } /* corrupt type: skip */ if (! (this->*handler[(size_t) request.type].first) (request)) return false; /* member fn ptr */ /* ... stats: MQ_<slot> + MQ_COMPLETED ... */ }어떤 핸들러든 false를 리턴하면 드레인이 중단되고, 그 false가 run으로 전파되어 루프를 죽인다.
불변식 4-B (드레인 상한, 프로듀서 기아 없음).
size = exchange (0, acquire)는 그 순간의 카운트를 스냅샷하면서 원자적으로 0으로 되돌린다.while (i++ < size && ...)는 드레인 도중 프로듀서들이 계속 push하더라도 최대size개의 메시지만 처리한다. 새로 push된 메시지는 카운터를 다시 증가시키고 자기 몫의notify를 동반하므로, 이후 패스에서 처리된다. 이렇게 한 번의 드레인이 할 일에 상한이 걸린다.acquire는enqueue의release와 짝을 이루므로,size개 메시지의 내용은 온전히 가시적(visible)이다.
4.6 handle_message_queue_send_packet — 분기 전체
섹션 제목: “4.6 handle_message_queue_send_packet — 분기 전체”SEND_PACKET은 소유 중인 커넥션에 바이트를 송신한다. 모든 작업은 커넥션의 재귀적(recursive) cmutex 아래에서 실행되며, 이 뮤텍스는 epoll의 EPOLLOUT 경로와 공유된다.
assert (item.conn);rmutex_lock (&item.conn->cmutex).ctx = item.conn->context. null이면(컨텍스트가 이미 해체됨): unlock하고,item.deleter ()가 있으면 실행하고,wakeup_blocked_worker (item.waiter_handle)를 호출한 뒤return true— 전송은 조용히 버려진다.item.packet의 각 span을push_for_send로 push하고,stamp ()를 찍은 다음,push_for_deleter (std::move (item.deleter))를 호출한다(버퍼는 완전히 전송된 뒤 해제된다).status = ctx->m_send.m_transmitter.fill (fd)— 즉시 논블로킹 쓰기를 수행한다.PeerReset/Error: unlock하고, waiter를 깨우고,ctx->m_ignore = IGNORE_ALL을 설정한 뒤(메인 루프가 강제로 제거한다)return true— 워커가 아니라 그 커넥션에만 치명적이다.assert (Ok || Pending).Ok:m_transmitter.clear (), unlock, waiter 깨움,return true.Pending(커널 버퍼가 가득 참):m_events.modify_descriptor (fd, EPOLLET|EPOLLIN|EPOLLOUT|EPOLLRDHUP, ctx). 실패하면 → unlock, waiter 깨움,return false(epoll이 고장 난 것이므로 치명적). 성공하면 → unlock한 다음ctx->m_send.m_blocker = std::move (item.waiter_handle)— waiter는 깨우지 않는다. blocker는 컨텍스트에 대기하다가, 나중에 pending 상태였던 전송이 다 나가면 그때 풀린다.
flowchart TD
A["cmutex 잠금"] --> B{"ctx==null?"}
B -- 예 --> C["잠금 해제; deleter 실행;<br/>waiter 깨움; return true"]
B -- 아니오 --> D["spans + deleter 푸시; fill(fd)"] --> E{"상태?"}
E -- "PeerReset/Error" --> F["잠금 해제; waiter 깨움;<br/>m_ignore=IGNORE_ALL; return true"]
E -- "Ok" --> G["초기화; 잠금 해제;<br/>waiter 깨움; return true"]
E -- "Pending" --> H["modify_descriptor +EPOLLOUT"]
H -- 실패 --> I["잠금 해제; waiter 깨움; return false"]
H -- 성공 --> J["잠금 해제;<br/>m_blocker = move(waiter); return true"]
Figure 4-4. handle_message_queue_send_packet. 종단 분기는 모두 waiter를 깨우지만 Pending만은 예외로, blocker를 컨텍스트에 대기시켜 둔다 — 그래서 wait_time을 지정한 호출자는 자신의 바이트가 실제로 머신을 떠날 때까지 블록된다.
4.7 handle_message_queue_release_packet
섹션 제목: “4.7 handle_message_queue_release_packet”RELEASE_PACKET은 백엔드 태스크가 다 쓴 수신 패킷 메모리를 커넥션의 리시버 풀로 돌려준다(그 태스크는 풀 스레드에서 실행되었으므로 리시버를 직접 건드릴 수 없고, 오직 이를 소유한 워커만 가능하다). 분기는 이렇다: (1) assert (item.conn), assert (item.packet.size () > 0), rmutex_lock (&item.conn->cmutex). (2) ctx = item.conn->context; null이면 → unlock, 로그 남기고 return true(커넥션과 그 아레나(arena)가 이미 사라진 것이다). (3) 각 span에 대해 ctx->m_recv.m_receiver.release (packet.data ())를 호출한다. (4) unlock하고 return true. waiter도 없고 null 가드 이외의 실패 경로도 없다 — 메모리 반환은 워커를 실패시킬 수 없다. 이로써 8장에서 다룰 “바이트에서 태스크로” 소유권 루프가 닫힌다.
4.8 handle_message_queue_new_client
섹션 제목: “4.8 handle_message_queue_new_client”NEW_CLIENT는 갓 accept된 커넥션을 설치한다(코디네이터가 미리 만들어 둔 context를 건네주며, 이는 6장에서 다룬다). 분기는 다음과 같다:
assert (item.conn && item.conn->fd != -1);ctx = item.ctx; ctx->m_conn = item.conn;.rmutex_lock (&ctx->m_conn->cmutex); 소유권을 공개(publish)한다:conn->worker = this; conn->context = ctx;— 이제 다른 워커 스레드의SEND_PACKET/RELEASE_PACKET도conn->context를 통해 이 컨텍스트를 찾을 수 있다.m_events.add_descriptor (fd, EPOLLET|EPOLLIN|EPOLLRDHUP, ctx). 실패하면 →worker/context를 null로 되돌리고, unlock한 뒤m_removed_context.push_back (ctx),return false.m_context.insert (ctx). 중복이면(.second == false) →worker/context를 null로 되돌리고, unlock한 뒤m_events.remove_descriptor (fd)로 3단계를 되돌리고, removed 목록에 넣은 뒤return false.- Unlock. 통계를 초기화한다(
OPEND_NS,LAST_ACTIVE_NS = m_timens,LAST_MOVED_NS = 0,MOVE_COUNT = 0);CLIENT_NUM을 증가시킨다. - 방어적 처리: 새 클라이언트에 대해 “이론적으로는 있을 수 없는” 경우지만, 만약
m_status == HIBERNATING이면eventfd_starttimer로 타이머 휠을 재시작한다 — 순서가 어긋난 큐 때문에 살아 있는 클라이언트가 파킹된 워커에 발이 묶이는 일이 없도록 하기 위해서다.return true.
불변식 4-E (소유권 공개 순서).
conn->worker/conn->context는cmutex아래에서, 그리고 fd가 epoll에 합류하기 전에 설정되며,m_context삽입도 같은 락 아래에서 이뤄진다. 클라이언트 fd가 epoll 이벤트를 낼 수 있게 되는 순간부터,conn->context는 이미 살아 있고 등록이 끝난 컨텍스트를 가리킨다 — 등록이 실패하면 3~4단계가 모두 롤백되므로, 절반만 설치된 클라이언트가 살아남는 일은 없다.
m_context는 워커 전용 명부(roster)이며, 코디네이터는 이를 직접 건드리지 않는다. 컨텍스트 해체와 워커별 프리리스트(freelist)는 7장에서 다룬다.
4.9 챕터 요약 — 핵심 정리
섹션 제목: “4.9 챕터 요약 — 핵심 정리”- 스레드 하나, 블로킹 지점 하나.
run은 오직epoll_wait에서만 블로킹한다. 에지 트리거 논블로킹 소켓 덕분에 어느 한 커넥션도 리액터를 멈춰 세우지 못하고(불변식 4-A),m_exhausted가 비어 있지 않을 때는 대기가 폴링(타임아웃 0)으로 전환되어 예산이 소진된 컨텍스트들을 다시 찾아간다. - 세 종류의 fd, 균일한 콜백. 클라이언트 소켓,
m_eventfd,m_timerfd모두data.ptr에context *를 싣고 다닌다.run은 두 제어용 fd와 비교해서 이들을 구분하고, 그 배치의 모든 클라이언트 IO가 끝난 뒤에야 둘 다eventfd_handler로 미룬다. - eventfd는 초인종이지 우편함이 아니다.
notify는 병합되는(coalescing) 카운터를 쓰며(포화된EAGAIN도 성공으로 친다), 메시지 자체는 큐에 있고 소비자는exchange (0, acquire)로 카운트를 스냅샷한다 — 이 덕분에 웨이크업은 손실 없이 안전하고(4-C) 드레인은 프로듀서 기아 없이 상한이 걸리며(4-B),enqueue의fetch_add (release)가 이 짝을 완성한다. - 동기 호출자는
message_blocker를 타고 대기한다.enqueue_and_notify는 enqueue 전에handle->m을 잠근다(4-D).START/SHUTDOWN_CLIENT/SEND_PACKET만이 이를 시그널하며,SEND_PACKET은Pending경로에서는 실제 전송이 끝날 때까지 시그널을 미룬다. - 핸들러는 사라진 컨텍스트를 방어하고, QUEUE 타이머는 스스로 해제된다.
send_packet/release_packet/new_client는 먼저conn->context를 찾아보고 null이면 그만둔다.new_client는 fd가 epoll에 들어가기 전에cmutex아래에서 소유권을 공개하며, 실패하면 롤백한다(4-E). 지연된 종료는m_has_retry와 함께 1 msQUEUE타이머를 다시 무장시키고, 대기 중인 재시도가 더 이상 없으면eventfd_handler가 이를 제거하므로 공격적인 폴링이 영구화되지는 않는다.
Chapter 5: 송수신 예산(Budget)과 IO 제한
섹션 제목: “Chapter 5: 송수신 예산(Budget)과 IO 제한”4장에서는 epoll 루프의 구조를 확립했다. 하나의 커넥션 워커가 수백 개의 소켓을
소유하고, 깨어날 때마다 준비된(ready) 파일 디스크립터 목록을 순회하며
handle_reception과 handle_transmission을 호출한다. 이 설계에는 명백한
실패 모드가 하나 있다. 소켓을 계속 읽기 가능(readable) 상태로 유지하는
클라이언트 — 대용량 LOAD, 폭주하는 결과 집합(result-set) 페치, 바이트를
영원히 조금씩 흘려보내는 슬로우로리스(slow-loris) — 는 커널 버퍼가 비워질
때까지 handle_reception 안에 머물며 소켓을 비워낼 수 있고, 그동안 같은
워커가 소유한 다른 모든 커넥션은 대기하게 된다. 리액터는 “많은 커넥션을
하나의 스레드가 담당한다”는 설계(cubrid-thread-manager.md의 리액터 패턴
동기 참고)를 “하나의 탐욕스러운 커넥션이 형제 커넥션들을 굶긴다”는 상황으로
뒤집어 버린다.
이 장이 답하는 질문은 다음과 같다: 하나의 워커 안에서 바쁜 커넥션 하나가
다른 커넥션들을 굶기지 못하게 막는 장치는 무엇인가? 답은 매 epoll 틱마다
부과되는 커넥션별 바이트 예산(byte budget), 예산이 소진되는 즉시 워커를
양보하게 만드는 부분 진행(partial-progress) 프로토콜, 그리고 양보한
커넥션이 다시 실행되기 전에 다른 모든 준비된 fd가 먼저 서비스됨을 보장하는
2단계 **재무장(re-arm)**이다. 이 장에서는 예산 검사의 모든 분기, 재큐잉이냐
계속 진행이냐를 가르는 결정, 그리고 커넥션이 도중에 죽었을 때 예산 관련
장부를 일관되게 유지해 주는 세 개의 종료 경로 헬퍼(is_wait_required,
has_remaining_tasks, purge_stale_contexts)를 하나하나 따라가 본다. 풀
크기 조정과 C10K 배경은 cubrid-thread-worker-pool.md에서 다루며, 이 장은
그 위에 얹힌 틱 단위 IO 규율을 다룬다.
5.1 두 가지 예산 파라미터
섹션 제목: “5.1 두 가지 예산 파라미터”각 워커는 생성자에서 두 개의 서버 파라미터를 한 번만 읽어 평범한
size_t 멤버로 캐시해 둔다 — 틱마다 다시 읽지 않으므로, 공정성 검사 비용은
레지스터 비교 수준에 그친다.
// worker::worker -- src/connection/connection_worker.cppm_recv_budget = static_cast<size_t> (prm_get_integer_value (PRM_ID_CSS_RECV_BUDGET_PER_CONNECTION));m_send_budget = static_cast<size_t> (prm_get_integer_value (PRM_ID_CSS_SEND_BUDGET_PER_CONNECTION));m_exhausted.reserve (128); /* <- the yielded-context sidebar, sized for churn */prm_Def[] 항목이 값의 허용 범위를 고정한다(멤버 순서는 default_value, value, upper_limit, lower_limit이다):
| Parameter | Default | Upper | Lower | Meaning of the value |
|---|---|---|---|---|
recv_budget_per_connection | 16 KB | 1 GB | 0 | 한 틱당 한 커넥션에 대해 recv()할 수 있는 최대 바이트 수 |
send_budget_per_connection | 32 KB | 1 GB | 0 | 한 틱당 한 커넥션에 대해 sendmsg()할 수 있는 최대 바이트 수 |
송신 기본값이 수신 기본값의 두 배인 이유는, 응답이 대체로 요청보다 크고 송신 경로에는 응답 파싱 비용이 없어 더 느슨한 상한을 두어도 되기 때문이다.
불변식 (0은 무제한을 의미한다). 예산이
0이면 제한이 비활성화된다. 모든 계정 처리 지점은limit > 0 && consumption >= limit으로 가드하는데 —limit == 0이면 좌변에서 short-circuit이 일어나 해당 커넥션은 예산을 이유로 절대 양보되지 않는다.lower_limit이0이므로 운영자는 두 예산 중 하나를 합법적으로0으로 설정해 공정성 도입 이전의 “EAGAIN까지 밀어붙여 비운다” 동작을 그대로 되살릴 수 있다. 이 가드를consumption >= limit으로 “단순화”하지 말 것 — 그렇게 하면0이 “한 바이트마다 양보”로 뒤바뀌어 워커가 멈춰 버린다.
이 값들을 더 키우기 전에 기억해 둘 폭(width) 불일치가 하나 있다:
m_send_budget은 size_t이지만, transmitter::fill (int fd, int limit)은
**int**를 받는다. 1 GB라는 upper_limit은 부호 있는 32비트 int
(0x40000000) 안에 들어가므로 현재 범위에서는 안전하지만, upper_limit을
INT_MAX보다 크게 올리면 fill이 음수 limit을 보게 되어 송신 측 제한이
조용히 무력화된다.
5.2 exhausted_context — 양보한 커넥션의 기록
섹션 제목: “5.2 exhausted_context — 양보한 커넥션의 기록”커넥션이 예산을 다 쓰면 워커는 그 커넥션을 그냥 잊어버릴 수 없다 — 다음
틱에 정확히 그 지점부터 재개할 수 있도록 recv, send, 혹은 둘 다 중
어느 쪽에 아직 처리할 작업이 남아 있는지 기억해야 한다. 그 기억 장치가
exhausted_context이며, 컨텍스트 id를 키로 하는 맵에 저장된다.
// worker::exhausted_context -- src/connection/connection_worker.hppstruct exhausted_context{ bool prepared; /* <- has this record survived one full loop yet? */ uint32_t events; /* <- EPOLLIN and/or EPOLLOUT still owed */ context *ctx; /* <- the connection to resume */};// ...std::unordered_map<uint64_t, exhausted_context> m_exhausted; /* keyed by ctx->m_id */| Field | Role | Why it exists |
|---|---|---|
prepared | 한 틱 지연 래치(latch). 삽입 시 false이며, 첫 번째 handle_exhausted 통과에서 true로 뒤집히고 이번에는 해당 레코드를 건너뛴다 | 방금 양보한 커넥션이 재시도되기 전에 다른 모든 준비된 fd에 대해 최소 한 번의 전체 epoll 패스가 이루어짐을 보장한다 — 굶주림 방지의 핵심 지연 장치 |
events | 아직 갚아야 할 IO 방향의 비트마스크(EPOLLIN, EPOLLOUT, 혹은 둘 다) | 한 커넥션이 같은 틱에서 recv와 send를 모두 소진할 수 있으며, 각 방향은 완료될 때 독립적으로 클리어된다 |
ctx | IO가 지연된 context(7장)를 가리키는 역포인터 | 재개 대상; 재추가 시 맵 키와 대조 검증된다 |
이 맵은 fd나 포인터가 아니라 ctx->m_id(단조 증가하는 컨텍스트별 id, 7장)로
키가 매겨진다. 이 점이 중요한데, fd는 커널에 의해, 컨텍스트 포인터는
프리리스트에 의해 재사용되지만 m_id는 프로세스 수명 동안 유일하므로,
오래된(stale) 엔트리가 방금 재사용된 커넥션과 별칭(alias)을 이룰 일이
절대 없다.
불변식 (살아 있는 컨텍스트당 맵 엔트리 하나).
handle_exhausted_add_context는 삽입 전에m_id를 조회한다. 히트가 나면it->ctx == ctx를 assert하고 새 방향을events에 OR로 접어 넣을 뿐, 두 번째 레코드를 만들지 않는다. 한 커넥션에 레코드가 두 개 있으면handle_exhausted가 같은ctx를 한 틱에 두 번 처리해 이중 차감을 하거나, 더 나쁘면 컨텍스트가 해제된 뒤에도 재개를 시도할 수 있다. 이 단일 엔트리 규칙 덕분에 §5.7의 close 경로에 있는m_exhausted.erase (ctx->m_id)가 완전한 제거가 된다.
flowchart LR
subgraph W["worker (한 스레드)"]
MAP["m_exhausted<br/>unordered_map<uint64_t, exhausted_context>"]
RB["m_recv_budget"]
SB["m_send_budget"]
end
E1["exhausted_context<br/>prepared / events / ctx"]
C1["context (7장)<br/>m_id, m_recv, m_send"]
MAP -->|"값"| E1
E1 -->|"ctx"| C1
MAP -.->|"키 = ctx->m_id"| C1
그림 5-1. m_exhausted는 컨텍스트 id를 그 커넥션이 아직 갚아야 할 IO
방향과 한 틱 지연되었는지 여부를 기억하는 레코드에 매핑한다.
5.3 drain과 fill 내부에서의 예산 차감
섹션 제목: “5.3 drain과 fill 내부에서의 예산 차감”예산을 소비하는 주체는 워커가 아니라 IO 프리미티브다. receiver::drain은
recv 상태 머신을 실행하며(바이트를 태스크로 바꾸는 과정은 8장에서 다룬다)
로컬 consumption을 누적하고, 상태가 진행될 때마다 한도를 다시 검사한다.
// receiver::receive_in_allocated -- src/connection/receiver.cppm_received += bytes;consumption += bytes; /* <- charge the tick */// ... condensed: parse the now-complete packet ...if (limit > 0 && consumption >= limit) { return result::BudgetExhausted; /* <- stop; do not loop for more */ }return result::Ok;drain의 디스패치 루프는 BudgetExhausted를 종단 상태로 취급하여, 먼저
메트릭을 기록한 다음 그대로 위로 반환한다.
// receiver::drain -- src/connection/receiver.cppcase result::BudgetExhausted: m_stats->add (statistics::context::RECV_BUDGET_HIT, 1); /* <- observability, Ch 11 */ [[fallthrough]];case result::Pending:case result::Error:case result::PeerReset: return status;송신 측도 이를 그대로 미러링한다. transmitter::fill은 msghdr의 iovec
배열을 순회하며, 한도를 sendmsg 이후가 아니라 이전에 검사한다 — 그래서
지불할 예산이 없는 syscall은 애초에 발행하지 않는다.
// transmitter::fill -- src/connection/transmitter.cppwhile (msg->msg_iovlen) { if (limit > 0 && consumption >= limit) { m_stats->add (statistics::context::SEND_BUDGET_HIT, 1); return result::BudgetExhausted; } bytes = ::sendmsg (fd, msg, MSG_NOSIGNAL); // ... condensed: advance iov_base/iov_len by bytes, consumption += bytes ... }return result::Ok; /* <- iovec fully drained; nothing left to send */불변식 (예산 검사는 프레임 경계에서만 이루어지며, syscall 도중에는 결코 이루어지지 않는다). 두 프리미티브 모두 진행 중인 recv/sendmsg를 중단시키지 않는다 — 한도 검사는 커널 호출 사이에서만 이루어진다. 따라서 하나의 syscall이 소켓 버퍼 하나만큼의 바이트를 초과해 예산을 넘어설 수 있다. 예산이 제한하는 것은 틱을 독점하는 반복 횟수이지, 정확한 바이트 수가 아니다. 이는 의도된 설계다. 부분 syscall을 처리하려면 리시버(8장)가 갖고 있지 않은 재시작 가능한 프레이밍이 필요하기 때문이다.
Ok(더 이상 남은 것이 없음)와BudgetExhausted(작업이 남았는데 한도를 넘음) 두 상태 모두Pending(EAGAIN, 커널 버퍼가 비워짐)과는 구분된다. §5.4의 재큐잉 결정은 바로 이 세 갈래 구분에 좌우된다.
5.4 재큐잉이냐 계속 진행이냐의 결정
섹션 제목: “5.4 재큐잉이냐 계속 진행이냐의 결정”handle_reception과 handle_transmission은 각각 불리언 in_exhausted를
받는다 — run의 새 epoll 패스에서 호출될 때는 false,
handle_exhausted의 지연 재실행에서 호출될 때는 true다. 이 플래그가
재큐잉 여부를 가르는 전부다. 수신 측을 보면:
// worker::handle_reception -- src/connection/connection_worker.cppio_status = ctx->m_recv.m_receiver.drain (ctx->m_conn->fd, m_recv_budget);if (io_status == result::PeerReset || io_status == result::Error) { ctx->m_send.m_transmitter.empty (); this->handle_connection_close (ctx); /* <- socket is dead: close, do not requeue */ return io_status; }assert (io_status == result::Pending || io_status == result::BudgetExhausted);
if (!in_exhausted && io_status == result::BudgetExhausted) { handle_exhausted_add_context (ctx, EPOLLIN); /* <- yield: remember to resume recv */ }// still dispatch whatever WAS parsed this tick:if (ctx->m_recv.m_receiver.get_result ()->empty ()) { return io_status; }// ... condensed: for each parsed packet -> handle_packet (Ch 8) ...여기서 읽어낼 것은 세 가지다. 첫째, 부분 진행은 결코 버려지지 않는다:
예산에 도달했더라도 drain이 이미 파싱해 둔 온전한 패킷들은 같은 호출
안에서 handle_packet으로 넘겨진다. 커넥션이 양보하는 것은 워커이지, 이미
받아 둔 데이터가 아니다. 둘째, 재큐잉은 !in_exhausted일 때만 일어난다 —
이미 exhausted 목록에서 온 호출은 스스로를 다시 추가하지 않는다. 그저
BudgetExhausted를 반환하고 다음 틱까지 맵에 남아 있을 뿐이다(events
비트는 그대로 켜져 있다). 셋째, Pending은 결코 재큐잉되지 않는다. 이는
커널 버퍼가 비었다는 뜻이므로, 더 많은 바이트가 도착하면 epoll의 레벨/엣지
트리거가 자연스럽게 워커를 깨울 것이다.
handle_transmission에는 네 번째 상태인 Ok가 추가되는데, 이는 송신이
완료될 수도 있기 때문이다.
// worker::handle_transmission -- src/connection/connection_worker.cppstatus = ctx->m_send.m_transmitter.fill (ctx->m_conn->fd, m_send_budget);if (status == result::PeerReset || status == result::Error) { /* close, return */ }assert (status == result::Ok || status == result::Pending || status == result::BudgetExhausted);if (status == result::Ok) { this->wakeup_blocked_worker (ctx->m_send.m_blocker); /* <- reply fully flushed */ // ... condensed: if CONN_CLOSING -> handle_connection_close; else rearm EPOLLIN ... ctx->m_send.m_transmitter.clear (); }else if (!in_exhausted && status == result::BudgetExhausted) { handle_exhausted_add_context (ctx, EPOLLOUT); /* <- yield: resume send */ }return status;flowchart TD
A["handle_reception / handle_transmission(ctx, in_exhausted)"]
A --> B["drain / fill이 예산에 따라 차감됨"]
B --> C{"io_status?"}
C -->|"PeerReset / Error"| D["handle_connection_close<br/>반환 (재큐잉 없음)"]
C -->|"Ok (송신만 해당)"| E["블로커 깨움, clear<br/>EPOLLIN 재무장 또는 종료"]
C -->|"Pending"| F["파싱된 패킷 디스패치<br/>반환 (epoll이 다시 깨움)"]
C -->|"BudgetExhausted"| G{"in_exhausted?"}
G -->|"true"| H["m_exhausted에 남음<br/>events 비트 유지"]
G -->|"false"| I["handle_exhausted_add_context<br/>EPOLLIN / EPOLLOUT 설정"]
I --> J["파싱된 패킷 디스패치, 반환"]
그림 5-2. 틱 단위 IO 호출의 모든 종단 상태와, 그것이 커넥션을 exhausted 목록에 재큐잉하는지 여부.
5.5 2단계 재무장(re-arm)과 busy-wait 틱
섹션 제목: “5.5 2단계 재무장(re-arm)과 busy-wait 틱”handle_exhausted_add_context는 이 맵을 키우는 유일한 작성자(writer)다.
완전히 새로운 레코드에는 prepared = false를 설정하고, 기존 레코드에는
events를 OR로 접어 넣는다.
// worker::handle_exhausted_add_context -- src/connection/connection_worker.cppif (m_exhausted.find (ctx->m_id) == m_exhausted.end ()) { m_exhausted[ctx->m_id].prepared = false; /* <- must survive one loop before resume */ m_exhausted[ctx->m_id].events = event; m_exhausted[ctx->m_id].ctx = ctx; }else { assert (m_exhausted[ctx->m_id].ctx == ctx); m_exhausted[ctx->m_id].events |= event; /* <- recv+send owed at once */ }run 루프는 두 지점에서 공정성 회로를 닫는다. 첫째, exhausted 목록이
비어 있지 않을 때는 epoll wait 타임아웃이 논블로킹으로 붕괴한다. 그래서
워커는 갚아야 할 작업이 있는 동안 epoll_wait에서 잠드는 대신 계속
스핀한다.
// worker::run -- src/connection/connection_worker.cppnfds = m_events.wait (events.data (), events.size (), m_exhausted.empty () ? TIMEOUT_INFINITE : TIMEOUT_NOWAIT);// ... condensed: for each of nfds ready fds -> handle_reception / handle_transmission ...if (m_exhausted.size () > 0) { if (!this->handle_exhausted ()) { return false; } /* <- after the fresh fds */ }여기서 핵심은 순서다: 매 틱마다 새 epoll 집합을 먼저 서비스하고,
그다음에 exhausted 목록을 처리한다. 지난 틱에 양보한 탐욕스러운 커넥션은
방금 준비된 형제 커넥션을 앞지를 수 없다. handle_exhausted는 이어서
prepared를 통해 두 번째 지연을 적용한다.
// worker::handle_exhausted -- src/connection/connection_worker.cppfor (auto it = m_exhausted.begin (); it != m_exhausted.end (); ) { if (!it->second.prepared) { it->second.prepared = true; /* <- first sighting: arm, but skip this tick */ it++; continue; } ctx = it->second.ctx; if (it->second.events & EPOLLIN) { status = this->handle_reception (ctx, true); /* <- in_exhausted = true */ if (status == result::ClosedConnection || status == result::PeerReset) { it = m_exhausted.erase (it); continue; } /* <- gone: drop record */ if (status == result::Error) { return false; } /* <- fatal: kill worker */ if (status == result::Pending) /* <- recv fully drained now */ { it->second.events &= ~EPOLLIN; /* <- clear the owed direction */ if (!it->second.events) { it = m_exhausted.erase (it); continue; } } /* BudgetExhausted again: fall through, EPOLLIN stays set, retry next tick */ } if (it->second.events & EPOLLOUT) { status = this->handle_transmission (ctx, true); // ... symmetric: erase on Closed/PeerReset, return false on Error ... if (status == result::Ok || status == result::Pending) { it->second.events &= ~EPOLLOUT; if (!it->second.events) { it = m_exhausted.erase (it); continue; } } } it++; }레코드 하나에 대한 모든 종료 경로를 따라가 보자: (a) 첫 통과 —
prepared가 false→true로 뒤집히고 continue, 레코드는 손대지 않음;
(b) 커넥션이 죽음 — erase하고 넘어감; (c) Error — false를
반환, run이 워커를 종료시킴; (d) 방향이 완료됨(recv는 Pending,
send는 Ok/Pending) — 해당 비트를 클리어하고, events가 0이 되면
erase; (e) 예산을 다시 소진함 — 어느 분기에도 걸리지 않아 비트가
그대로 남고, it++되어 다음 틱에도 여전히 한 예산어치만큼 제한된 채
재시도된다. it = m_exhausted.erase (it)라는 관용구는 unordered_map을
순회하며 삭제하는 올바른 패턴이다(erase가 다음 유효 이터레이터를
반환한다).
불변식 (커넥션당 틱당 작업량은 유한하다). 어느 한 커넥션이 서비스받는 두 시점 사이에는, 다른 모든 준비된 fd에 대한 최소 한 번의 전체 패스(순서 보장)에 더해, 첫 소진 시 한 틱의 추가 지연(
prepared)이 존재한다. 어떤 커넥션도 워커가 형제 커넥션들에게 돌아서기 전에recv_budget + send_budget바이트를 초과하는 소켓 IO를 소비할 수 없다. 이 둘 중 하나라도 어기면 — exhausted 목록을 새 fd보다 먼저 서비스하거나,prepared의 건너뛰기를 생략하면 — 이 장이 막으려는 굶주림이 다시 돌아온다.
5.6 종료 경로 헬퍼: is_wait_required와 has_remaining_tasks
섹션 제목: “5.6 종료 경로 헬퍼: is_wait_required와 has_remaining_tasks”송신 예산의 플러시 대응물은 커넥션이 종료될 때 등장한다: 워커는 클라이언트가
아직 받아야 할 바이트를 버려서는 안 된다. handle_connection_close 안의 두
프레디케이트가 이를 관장한다.
is_wait_required는 트랜잭션 인덱스가 아직 할당되지 않은 커넥션(아직
boot_client_register 안에 있는 클라이언트)을 위해 50ms를 재울 가치가
있는지 결정한다.
// worker::is_wait_required -- src/connection/connection_worker.cppbool worker::is_wait_required (context *ctx){ if (ctx->m_conn->fd == cdc_Gl.conn.fd) /* <- the change-data-capture pseudo-connection */ { return false; /* <- CDC has no boot handshake: never wait */ } return true; /* <- every real client: wait once, then re-read tran index */}결과는 둘뿐이다. 유일한 특수 케이스는 클라이언트 부트 핸드셰이크를 전혀
거치지 않는 CDC 전역 커넥션으로, 이 경우 트랜잭션 인덱스를 기다리는 것은
순전히 아무 의미 없는 50ms 정지에 지나지 않는다. 일반적인 클라이언트는
모두 true를 반환하며, 호출자는 tran_index/client_id를 다시 읽기
전에 한 번 잠든다.
has_remaining_tasks는 송신 플러시 게이트다 — 응답이 아직 버퍼에 남아
있어 close를 재시도해야 하는지를 결정한다.
// worker::has_remaining_tasks -- src/connection/connection_worker.cppbool worker::has_remaining_tasks (context *ctx){ this->handle_message_queue_by_index (queue_type::IMMEDIATE); /* <- drain queued RELEASE_PACKET first */ if (ctx->m_ignore < ignore_level::IGNORE_ALL && !ctx->m_send.m_transmitter.empty ()) { return true; /* <- graceful close: unsent bytes remain, retry later */ } return false; /* <- forced close (IGNORE_ALL) OR send buffer empty: proceed */}분기는 세 가지다. 먼저 IMMEDIATE 큐를 서비스하는데, 대기 중인
RELEASE_PACKET 메시지가 트랜스미터가 곧 들여다볼 메모리를 해제할 수
있기 때문이다. 그다음: 정상(graceful) 종료(m_ignore < IGNORE_ALL)에서
트랜스미터가 비어 있지 않으면 true를 반환하여, handle_connection_close를
retry 경로로 보내 응답을 한 번 더 플러시하도록 시도한다. 강제(forced)
종료(m_ignore == IGNORE_ALL, §5.4에서 Error/PeerReset 시 설정됨)는
&&를 short-circuit시켜 즉시 false를 반환한다 — 상대가 이미 사라졌으므로
남은 송신은 포기된다.
5.7 purge_stale_contexts — 종료 시 예산 맵 정리
섹션 제목: “5.7 purge_stale_contexts — 종료 시 예산 맵 정리”handle_connection_close는 컨텍스트를 그 자리에서 즉시 해제하지 않는다.
ctx->m_removed = true로 표시하고 m_removed_context에 덧붙일 뿐이다.
handle_message_queue, ha_close_all_connections, finalize의 끝에서
호출되는 지연된 리퍼(reaper)에서야 컨텍스트가 살아 있는 집합과 exhausted
맵 양쪽 모두에서 빠져나간다.
// worker::purge_stale_contexts -- src/connection/connection_worker.cppcoordinator::message message;message.type = coordinator::message_type::RETURN_TO_POOL;message.resource = m_removed_context; /* <- hand the batch to the coordinator (Ch 6) */
for (context *ctx : m_removed_context) { if (m_context.erase (ctx) == 0) { er_log_conn (__FILE__, __LINE__, "... context not found\n"); continue; /* <- already gone: skip the exhausted scrub */ } m_exhausted.erase (ctx->m_id); /* <- THE critical line: drop any owed IO */ }m_removed_context.clear ();m_coordinator->enqueue (std::move (message));if (!m_coordinator->notify ()) { assert_release (false); }컨텍스트마다 분기는 둘이다. m_context.erase가 0을 반환하면 그 컨텍스트는
애초에 이 워커의 살아 있는 집합에 없었던 것이고(이중 제거 레이스) — 로그를
남기고 continue하여 exhausted 정리를 건너뛴다. 그렇지 않으면
m_exhausted.erase (ctx->m_id)로 지연된 IO 레코드를 제거한다. 이 else
분기는 겉보기보다 더 중요하다: 이것이 없다면, 예산을 다 쓰고(§5.4) 그 후
닫힌 커넥션이 코디네이터가 곧 프리리스트(7장)로 회수할 메모리를 가리키는
ctx를 지닌 채 매달린 exhausted_context를 남기게 된다. 다음
handle_exhausted가 그것을 역참조하게 될 것이다. 컨텍스트가 워커에서
갑작스럽게 빠져나가는 곳이면 어디든 이와 동일한 한 줄짜리 정리가 등장한다
— handle_message_queue_handoff_client도 이를 실행한다 — 그리고 바로 이
때문에 §5.2의 단일 엔트리 불변식이 m_id 기준 erase를 완전한 것으로
만들어 준다.
불변식 (exhausted 레코드는 자신의 컨텍스트보다 오래 살아남지 않는다). 컨텍스트를
m_context에서 제거하는 모든 경로는 같은 임계 구역 안에서ctx->m_id를m_exhausted에서도 함께 지운다(워커 스레드 하나뿐이므로 락은 필요 없다 — 두 맵 모두 워커 전용이다). exhausted 맵은 오직 이 워커가 소유한 살아 있는 컨텍스트만을 참조할 수 있으며, 이를 어기면 바로 다음 틱에서 use-after-free가 발생한다.
5.8 챕터 요약 — 핵심 정리
섹션 제목: “5.8 챕터 요약 — 핵심 정리”-
캐시된 두 예산이 틱당 IO를 제한한다.
recv_budget_per_connection(16 KB)과send_budget_per_connection(32 KB)은 한 번만 읽혀m_recv_budget/m_send_budget에 저장되며,drain과fill은 로컬consumption을 차감하고limit > 0 && consumption >= limit이 되면BudgetExhausted를 반환한다. 예산이0이면 제한이 비활성화된다. -
예산 검사는 프레임 경계에서 이루어지므로, 정확한 바이트가 아니라 반복 횟수를 제한한다. 진행 중인 syscall은 결코 중단되지 않으며, 소켓 버퍼 하나만큼의 초과는 예상되고 의도된 동작이다.
-
부분 진행은 보존된다. 예산에 도달하면 양보하는 것은 워커이지 커넥션의 데이터가 아니다 — 이미 파싱된 패킷은 커넥션이 지연되기 전에 같은 호출 안에서 디스패치된다.
-
exhausted_context는 갚아야 할 방향을 기억한다. 불멸의ctx->m_id로 키가 매겨지며,events(아직 갚아야 할 EPOLLIN/EPOLLOUT)와prepared(한 틱 지연 래치)를 담는다. 살아 있는 컨텍스트당 엔트리 하나라는 규칙이 강제된다. -
공정성은 순서와 지연의 조합에서 나온다.
run은 exhausted 목록보다 먼저 새 epoll 집합을 서비스하고, 목록이 비어 있지 않은 동안에는TIMEOUT_NOWAIT로 스핀한다.handle_exhausted는prepared를 통해 각 레코드를 한 틱 건너뛴다. 이 둘이 합쳐져 커넥션당 틱당 작업량이 유한함을 보장한다. -
재큐잉 게이트는
in_exhausted다. 새 패스에서의 호출은BudgetExhausted에서 재큐잉하고, 이미 exhausted 목록에서 온 호출은 다시 추가하지 않고events비트를 다음 틱을 위해 켜 둔다.Pending은 결코 재큐잉되지 않으며,Ok(송신만 해당)는 완료되어 클리어된다. -
종료 경로 헬퍼들이 장부의 일관성을 지킨다.
is_wait_required는 CDC 커넥션을 특수 케이스로 처리하고,has_remaining_tasks는 트랜스미터에 미송신 바이트가 남아 있는 동안 정상 종료를 재시도하되 강제(IGNORE_ALL) 종료는 그대로 진행시키며,purge_stale_contexts는 모든 제거 시점에ctx->m_id를m_exhausted에서 지워 어떤 지연 레코드도 자신의 컨텍스트보다 오래 살아남지 않게 한다.
Chapter 6: 코디네이터의 배치, 리밸런싱, 오토 스케일
섹션 제목: “Chapter 6: 코디네이터의 배치, 리밸런싱, 오토 스케일”모든 연결 워커(connection worker)는 STATISTICS 메시지를 전용 스레드 하나, 즉
coordinator로 보낸다. 이 장에서는 다음 질문에 답한다: 이 스레드는 신규
연결을 어디에 배치할지, 과부하 워커에서 언제 연결을 옮길지, 워커를 언제
추가하거나 줄일지를 어떻게 결정하는가? 세 가지 결정 모두 스칼라 값 하나 —
워커 점수(score) — 와 그 위에 얹힌 작은 상태 머신으로 귀결된다. 함께 보는
cubrid-thread-manager.md의 *Score-based connection assignment
(CBRD-26424)*와 Auto scaling (CBRD-26406) 절이 “왜”를 설명한다면, 여기서는
산술식과 모든 분기를 그대로 추적한다. 구조체 필드(m_scaling,
m_scaling_statistics, m_task_statistics, worker_statistics)는 2장에서
표로 정리했으므로, 이 장에서는 제어 흐름을 좌우하는 것만 다시 짚는다.
6.1 제어 루프 — coordinator::run과 세 개의 타이머
섹션 제목: “6.1 제어 루프 — coordinator::run과 세 개의 타이머”coordinator::run은 정확히 세 개의 fd — 임의의 스레드가 message를 큐에
넣었을 때 깨어나는 m_eventfd, 병합된 주기 틱인 m_timerfd, 디버그 제어
소켓인 m_ctrlfd — 를 도는 단일 소비자(single-consumer) epoll 루프다.
연결 소켓은 전혀 건드리지 않는다 — 그것들은 워커 쪽에 있다.
// coordinator::run -- src/connection/coordinator.cppwhile (!m_stop) { nfds = m_events.wait (events.data (), events.size (), TIMEOUT_INFINITE); if (nfds < 0) { if (errno == EINTR) { continue; } /* <- signal, retry */ assert_release (false); continue; /* <- other error: fatal in debug */ } m_timens = this->get_monotonic_ns (); /* <- one clock read per loop */ for (i = 0; i < nfds; i++) if (events[i].events & EPOLLIN) { if (events[i].data.fd == m_eventfd) { eventfd_clear (m_eventfd); handle_message_queue (); } else if (events[i].data.fd == m_timerfd) { /* dispatch timers, below */ } else if (events[i].data.fd == m_ctrlfd) { handle_controller (); } } }m_timerfd는 등록된 모든 타이머 중 가장 짧은 지연시간으로 세팅되어
있으므로, 물리적으로 한 번 틱이 울려도 실제로는 가장 빠른 핸들러 하나만
마감이 됐다는 뜻일 수 있다. 각 핸들러는 자신의 last_time 대비로 다시
게이팅된다:
// coordinator::run (timerfd branch) -- src/connection/coordinator.cppfor (j = 0; j < static_cast<int> (timer_type::TYPE_COUNT); j++) { if (!m_timer_handler[j].valid) { continue; } if (m_timens - m_timer_handler[j].last_time > static_cast<uint64_t> (m_timer_handler[j].latency)) { if (!m_timer_handler[j].function ()) { return false; } /* <- handler asked to stop */ m_timer_handler[j].last_time = m_timens; /* <- rearm this handler only */ } }eventfd_clear (m_timerfd); /* <- drain the timerfd counter last */생성자에서 설치되는 세 핸들러는 다음과 같다:
timer_type | 지연시간(latency) | 핸들러 | 역할 |
|---|---|---|---|
STATISTICS | LOW_LATENCY 1초 | statistics_update | 새 카운터를 EWMA와 점수에 접어 넣는다(fold) |
REBALANCING | MEDIUM_LATENCY 5초 | statistics_rebalancing | 가장 뜨거운 연결 하나를 최댓값→최솟값 워커로 옮긴다 |
SCALING | HIGH_LATENCY 60초 | statistics_scaling | 오토 스케일 트라이얼 머신을 한 스텝 진행시킨다 |
불변식 (단일 소비자 coordinator 상태).
m_statistics,m_scaling,m_scaling_statistics,m_migrating은 오직 coordinator 스레드에서만 —run내부의handle_message_queue(eventfd) 또는 타이머 핸들러에서만 — 변경된다. 프로듀서는 락-프리(lock-free)m_queue+m_queue_size만 건드린다. 이 장에서 어떤 것도 락을 잡지 않는 이유가 바로 이것이다: 작성자가 하나뿐이기 때문이다.
flowchart TD
W["epoll wait TIMEOUT_INFINITE"]
N{"nfds < 0"}
E{"errno == EINTR"}
T["m_timens = get_monotonic_ns"]
L["준비된 fd마다 반복"]
EV["eventfd\nclear + handle_message_queue"]
TM["timerfd\nlast_time으로 각 핸들러 게이팅"]
CT["ctrlfd\nhandle_controller"]
W --> N
N -->|예| E
E -->|예| W
E -->|아니오| W
N -->|아니오| T --> L
L --> EV --> W
L --> TM --> W
L --> CT --> W
Figure 6-1. coordinator::run — 깨어날 때마다 클럭을 한 번 읽고, 준비된
fd들을 각 핸들러로 분배한다. 타이머는 경과 시간으로 스스로를 게이팅한다.
6.2 원시 카운터를 하나의 숫자로 — 점수(score)
섹션 제목: “6.2 원시 카운터를 하나의 숫자로 — 점수(score)”배치, 리밸런싱, 스케일링은 모두 worker_statistics::m_score로 순위를
매기며, 이 값은 워커의 통계가 바뀔 때마다 statistics_update_score가 다시
계산한다. 두 매크로가 가중치를 매기는데, 둘 다 VAL_TO_SCORE (w, m, s) = w * s / m 위에 세워져 있다 — 기준 크기 m으로 정규화한 샘플 s에 가중치 w를
곱하는 형태다:
// score macros -- src/connection/coordinator.cpp#define VAL_TO_SCORE(w, m, s) ((w) * static_cast<double> (s) / (m))#define EVAL_WORKER(mq, rmutex) (VAL_TO_SCORE (25, 3.5, (mq)) + VAL_TO_SCORE (500, 1, (rmutex)))#define EVAL_CONTEXT(bytes, budget) (VAL_TO_SCORE (50, 1000, (bytes)) + VAL_TO_SCORE (10, 1, (budget)))// coordinator::statistics_update_score -- src/connection/coordinator.cppm_statistics[worker].m_score = 1 * static_cast<double> (m_statistics[worker].m_client_num) / 1 + /* <- one point per client */ EVAL_WORKER (EWMA_WORKER (MQ_COMPLETED), EWMA_WORKER (BLOCKED_RMUTEX)) + EVAL_CONTEXT (EWMA_CONTEXT (BYTES_IN_TOTAL) + EWMA_CONTEXT (BYTES_OUT_TOTAL), EWMA_CONTEXT (RECV_BUDGET_HIT) + EWMA_CONTEXT (SEND_BUDGET_HIT));세 가지 가산적 압력이 있다: headcount(m_client_num), 워커 압력
(완료된 MQ 작업이 25/3.5, 게다가 recv-mutex 블로킹이 무겁게 500 — 블록된
워커는 비용이 크다), 그리고 컨텍스트 압력(in+out 바이트가 50/1000, 게다가
송수신 budget-hit 빈도가 10). EWMA_* 피연산자는 지수가중이동평균
(exponentially-weighted moving average)이므로, 점수는 순간 스파이크가 아니라
지속된 부하를 추적한다. STATISTICS 타이머 본문이 이 폴드(fold)를
구동한다:
// coordinator::statistics_update -- src/connection/coordinator.cppthis->handle_message_queue (); /* <- absorb STATISTICS messages queued since last tick */this->statistics_update_task (); /* <- fold the global task-worker counters */워커별 EWMA 폴딩은 handle_message_queue_statistics(7장/11장)에서
일어나며, 이 함수가 statistics_update_connection을 호출한 뒤
statistics_update_score를 호출한다. 그 커널이 statistics_EWMA다:
// coordinator::statistics_EWMA (scalar overload) -- src/connection/coordinator.cppdiff = 0;if (current > prev) { diff = static_cast<double> (current - prev); } /* <- counters only rise; clamp */acc = acc * (1 - alpha) + diff * (alpha / (time_delta * 1e-6)); /* <- rate per us, alpha = EWMA_ALPHA */prev = current;statistics_update_connection은 m_last_updated로 분기한다: 첫 샘플
(== 0)은 누산기(accumulator)와 “이전” 슬롯 양쪽에 원시 값을 그대로
복사한다(0으로부터의 유령 델타를 만들지 않기 위해서다). 이후의 모든 샘플은
m_sum을 리셋하고, 워커 메트릭과 각 컨텍스트 메트릭을 폴드한 다음, 모든
컨텍스트의 스무딩된 값의 합으로 m_sum을 다시 만든다 — 이 m_sum이 바로
EVAL_CONTEXT가 읽는 값이다.
불변식 (첫 샘플 스파이크 금지). 카운터의 첫 관측값은 0으로부터의 델타여서는 안 된다. 그렇지 않으면 갓 생긴 워커가 거대한 가짜 비율(rate)을 보이게 된다.
m_last_updated(그리고statistics_update_task의 병렬 게이지인requested.second)가 첫 폴드를 게이팅한다. 이를 깨면 새 워커마다 과부하로 보여 신규 연결을 밀어내게 된다.
병합된 체크아웃 기준으로 덧붙이자면: css_get_task_stats는 스텁이다
(server_support.c에서 본문이 주석 처리되어 있다). 따라서
m_task_statistics는 0만 폴드하며, 이후 scale_selection에 들어가는 태스크
완료 항(task-completion term)은 그 배관(plumbing)이 들어오기 전까지는 죽어
있다(상위 문서의 Cross-check Notes 참고).
6.3 배치 — statistics_find_score_extremes
섹션 제목: “6.3 배치 — statistics_find_score_extremes”배치는 하나의 투-익스트림 패스(two-extreme pass) 중 최솟값 쪽이다:
// coordinator::statistics_find_score_extremes -- src/connection/coordinator.cppmax = 0; min = 0;for (i = 1; i < m_current_worker; i++) /* <- only ACTIVE workers; index 0 seeds both */ { if (m_statistics[i].m_score < m_statistics[min].m_score) { min = i; } else if (m_statistics[i].m_score >= m_statistics[max].m_score) { max = i; } }return { min, max };두 가지 미묘한 점이 있다. 경계는 m_max_worker가 아니라 m_current_worker다:
현재 카운트보다 위에서 동면 중인(hibernated) 워커는 보이지 않으므로, 신규
클라이언트가 잠든 워커에 배치되는 일은 없다. else if는 한 번의 순회에서
어떤 워커도 min이면서 동시에 max가 되지 않게 하며, >=는 최댓값 동점을 더
높은 인덱스 쪽으로 편향시키고 <는 최솟값에서 더 낮은 인덱스를 유지시킨다 —
결정론적 동점 처리(tie-break)다. 신규 클라이언트 배치
(handle_message_queue_new_client, 7장)는 이 함수를 호출하여 클라이언트를
min에게 넘기고, m_client_num을 올린 뒤 점수를 다시 계산한다. “신규
연결이 어디로 가는가”의 전부가 바로 이것이다: 점수가 가장 낮은 활성
워커.
coordinator::random_bit(정적 mt19937 위에서 도는 bernoulli(0.5))는
원래 의도된 무작위 동점 처리였지만, 병합된 체크아웃에서는 호출자가
없다 — 결정론적인 >=/<가 그 자리를 대신한다. 만약 실제 동점 무작위화를
연결한다면, 그 제너레이터가 함수-로컬 static이라는 점에 유의하라. 이것이
안전한 이유는 random_bit 역시 오직 coordinator 스레드에서만 실행되기
때문이다.
6.4 리밸런싱 — statistics_rebalancing과 transfer_connection
섹션 제목: “6.4 리밸런싱 — statistics_rebalancing과 transfer_connection”5초짜리 REBALANCING 타이머는 마이그레이션 비용을 치를 가치가 있을 때만,
최대 한 개의 연결을 가장 뜨거운 워커에서 가장 차가운 워커로 옮긴다:
// coordinator::statistics_rebalancing -- src/connection/coordinator.cppconstexpr double threshold = 0.2;std::tie (min, max) = statistics_find_score_extremes ();diff = m_statistics[max].m_score - m_statistics[min].m_score;if (diff <= m_statistics[max].m_score * threshold) { return true; } /* <- within 20%: leave it */
score = -1; id = 0;for (auto &stats : m_statistics[max].m_contexts) { target = EVAL_CONTEXT (/* this context's smoothed bytes + budget hits */); if (target <= diff / 2 && score < target) { score = target; id = stats.first; } /* <- biggest that fits */ }if (id != 0) { this->transfer_connection (id, max, min); }분기: (1) 점수 차가 20% 이내면 → 그대로 반환, 아무것도 움직이지 않는다.
(2) 아니라면 뜨거운 워커의 컨텍스트들을 훑어, 자신의 EVAL_CONTEXT가
diff / 2 아래에 여전히 들어맞는 것 중 가장 큰 연결을 찾는다 — 이 diff/2가
오버슈트 방지 상한선이다. 절반보다 많이 옮기면 min이 새로운 max가 되어
버리기 때문이다. (3) 아무것도 조건을 만족하지 못하면(id가 0으로 남는
경우, 예를 들어 거대한 연결 하나가 지배하는 경우) 아무것도 옮기지 않는다.
transfer_connection이 실제 인계(hand-off)를 수행한다(이 함수는
scale_down과 컨트롤러의 CLIENT_MOVE에서도 도달한다):
// coordinator::transfer_connection -- src/connection/coordinator.cppif (m_migrating.find (id) != m_migrating.end ()) { return false; } /* <- already in flight, skip */m_migrating.insert (id);auto stats = m_statistics[from].m_contexts.find (id);m_statistics[to].m_contexts.emplace (stats->first, /* copy both EWMA slots to destination */);/* the stats in worker[from] are removed when the worker responds. */request.type = connection::worker::message_type::HANDOFF_CLIENT;request.id = stats->first; request.worker_ptr = workers[to].get (); request.worker_index = to;workers[from]->enqueue (queue_type::LAZY, std::move (request)); /* <- tell SOURCE to hand off */if (!workers[from]->notify ()) { assert_release (false); }낙관적 복사 후 확인(optimistic-copy-then-confirm) 방식이다. 목적지 맵은
즉시 통계를 받지만(누산기와 이전 메트릭 양쪽의 복사본), 원본 쪽 복사본은
워커가 HANDOFF_REPLY(7장)로 응답할 때만 지워진다. 이 응답이 양쪽의
m_client_num을 뒤집고, 인계가 실패했다면(!transferred) 추측적으로 만들어
둔 목적지 복사본을 지워 되돌린다. 메시지는 소스의 LAZY 큐(5장)에 실린다:
인계는 레이턴시에 민감한 작업이 아니기 때문이다.
불변식 (연결당 인계는 한 번만).
m_migrating은 연결 id로 키가 매겨진 인플라이트(in-flight) 집합이다.transfer_connection은 같은 id의 두 번째 마이그레이션을 거부한다. 이 집합에서 항목이 지워지는 곳은 오직handle_message_queue_handoff_reply뿐이다. 이를 깨면, 리밸런스가 아직 진행 중인 사이에scale_down이 컨텍스트를 순회하면서 같은 연결에 대해HANDOFF_CLIENT를 두 번 내보내m_client_num을 이중으로 세는 일이 생길 수 있다.
flowchart TD
R["statistics_rebalancing (5초)"]
X["점수 극값 찾기 -> min,max"]
D{"diff <= max_score * 0.20"}
S["max.contexts 스캔\ndiff/2 이하 중 가장 큰 EVAL_CONTEXT 선택"]
F{"id != 0"}
T["transfer_connection id max->min"]
M{"id가 m_migrating에 있는가"}
C["대상에 통계 복사\nHANDOFF_CLIENT를 소스 LAZY로"]
Z["반환, 이동 없음"]
R --> X --> D
D -->|균형| Z
D -->|아니오| S --> F
F -->|맞는 것 없음| Z
F -->|있음| T --> M
M -->|진행 중| Z
M -->|신규| C
Figure 6-2. statistics_rebalancing — 틱마다 하나의 제한된 이동만
허용하며, 20% 밴드, diff/2 적합 상한, 그리고 인플라이트 집합으로
게이팅된다.
6.5 오토 스케일링 — 시행-관찰(trial-and-observe) 상태 머신
섹션 제목: “6.5 오토 스케일링 — 시행-관찰(trial-and-observe) 상태 머신”coordinator는 “정답” 워커 개수를 계산할 수 없으므로 실험한다. 두 개의
열거형(enum)이 이를 이끈다: m_status(STABLE/DRAINING/EXPANDING)는
물리적 상태 — 워커가 마이그레이션 도중인가 — 이고,
m_scaling_statistics.status(STABLE/TRIAL)는 실험 상태 — 지금
프로빙 중인가 — 이다. 실험 관련 필드는(2장에서 가져오면):
| Field | Role |
|---|---|
status | STABLE은 유휴, TRIAL은 앞으로 이만큼의 틱 동안 프로빙 중 |
window_size | 최대 프로브 길이. auto_scaling_window_size 기본값 4 |
history | 프로브별 {scale, score} 샘플, TRIAL 틱마다 하나씩 |
direction | 이번 트라이얼이 어느 방향으로 스텝하는지 (UP/DOWN) |
count | 이번 트라이얼에서 남은 틱 수 |
previous_direction | 직전에 완료된 트라이얼의 방향 |
previous_scale | 직전 트라이얼이 시작될 때의 워커 개수 |
아밍(Arming) — scale_trial(스케일링이 STABLE일 때 호출되며, 각
트라이얼이 끝난 뒤 다음 트라이얼을 잇기 위해서도 호출된다):
// coordinator::scale_trial -- src/connection/coordinator.cppm_scaling_statistics.history.clear ();if (m_scaling_statistics.previous_scale == m_current_worker) /* <- last trial changed nothing */ direction = (previous_direction == DOWN) ? UP : DOWN; /* <- so try the other way */else direction = m_scaling_statistics.previous_direction; /* <- it helped, keep going */if (direction == DOWN) count = min (m_current_worker - m_min_worker, window_size);else count = min (m_max_worker - m_current_worker, window_size);if (m_scaling_statistics.count == 0) /* <- no headroom this way */ { previous_direction = flip (previous_direction); status = STABLE; }else { m_scaling_statistics.status = TRIAL; }직전 트라이얼이 시작 지점에서 끝났다면(성과 없음) 방향을 뒤집고, 그렇지
않으면 유지한다. count는 남은 여유분(headroom)과 window_size 둘 다에
클램프된다. 여유분이 0이면 previous_direction을 뒤집은 뒤 STABLE로
중단하여, 다음 scale_trial이 반대 방향을 프로브하게 한다.
스테핑(Stepping) — statistics_scaling(60초짜리 SCALING 타이머):
// coordinator::statistics_scaling -- src/connection/coordinator.cppif (m_scaling_statistics.status == scaling_status::STABLE) { this->scale_trial (); return true; }assert (m_scaling_statistics.status == scaling_status::TRIAL);bytes_inout = /* sum of BYTES_IN_TOTAL + BYTES_OUT_TOTAL over all workers' m_sum */;m_scaling_statistics.history.push_back ({ m_current_worker, VAL_TO_SCORE (50, 1000, bytes_inout) + m_task_statistics.completed.first * 2 }); /* <- throughput score */m_scaling_statistics.count--;if (m_scaling_statistics.count == 0) { m_scaling_statistics.previous_scale = m_current_worker; selected = this->scale_selection (); if (selected < m_current_worker) { previous_direction = DOWN; scale_down (); scale_trial (); } else if (selected > m_current_worker) { previous_direction = UP; for (...) scale_up (); scale_trial (); } else { m_scaling_statistics.status = STABLE; } /* <- current won */ }else { if (m_scaling_statistics.direction == DOWN) { this->scale_down (); } else { this->scale_up (); } }분기: STABLE 틱은 트라이얼을 아밍하고 그대로 반환한다. TRIAL 틱은 현재
카운트와 스루풋 점수(throughput score)(전체 바이트 50/1000에 스무딩된
태스크 완료율의 두 배를 더한 값)를 history에 기록한 뒤 count를
감소시킨다. 틱이 남아 있으면 direction으로 한 스텝 나아간다. 이번이 마지막
틱이었다면(count == 0), scale_selection이 기록된 스케일 중 점수가 가장
좋은 것을 골라 그쪽으로 움직인다 — scale_down, scale_up의 반복, 또는
현재 값이 이미 이겼다면 STABLE로 떨어뜨린다 — 그런 다음 scale_trial로
다시 아밍한다.
// coordinator::scale_selection -- src/connection/coordinator.cppmax_score = max over history of stats.score;for (auto &stats : history) if (max_score * 0.95 < stats.score) candidates.push_back (stats.scale);if (candidates.size () != 0) return candidates[uniform_int (0, size-1)]; /* <- random among top 5% */return m_current_worker; /* <- empty history: stay put */물리적 스텝. scale_up은 다음 동면 중인 워커를 깨운다:
// coordinator::scale_up -- src/connection/coordinator.cppif (m_current_worker >= m_max_worker || m_status != status::STABLE) { return false; } /* <- no room / busy */m_scaling.draining_worker = -1;m_status = status::EXPANDING;m_statistics[m_current_worker].m_score = 0; /* <- zero score => wins placement, absorbs new clients *//* enqueue AWAKEN (LAZY) to workers[m_current_worker]; notify */m_current_worker++;m_scaling.last_expand_ns = get_monotonic_ns ();m_status = status::STABLE;새 워커의 점수를 m_current_worker를 증가시키기 전에 0으로 만드는 것은,
다음번 statistics_find_score_extremes의 min이 새 워커를 가리키도록
유도해서 그 워커를 빠르게 채우기 위해서다. scale_down은 유일한 2단계짜리
연산이다:
// coordinator::scale_down -- src/connection/coordinator.cppif (m_current_worker <= m_min_worker || m_status != status::STABLE) { return false; }m_current_worker--; /* <- worker now invisible to placement */for (auto &stats : m_statistics[m_current_worker].m_contexts) /* <- evict every connection */ { std::tie (newhome, std::ignore) = statistics_find_score_extremes (); transfer_connection (stats.first, m_current_worker, newhome); }m_scaling.draining_worker = m_current_worker;m_status = status::DRAINING; /* <- stay DRAINING until it empties */먼저 감소시키므로 배치와 익스트림 스캔(i < m_current_worker)이 사라질
워커를 더 이상 대상으로 삼지 않게 된다. 그런 다음 모든 연결을 현재
점수-최소 워커로 인계한다. 이 과정은 동기적으로 끝날 수 없다 — 워커가 각
인계를 확인해줘야 하므로 — 그래서 DRAINING에 머문다. 완료는 비동기적으로,
handle_message_queue_statistics에서 이뤄진다. 드레이닝 중인 워커의 다음
리포트가 빈 컨텍스트 집합을 보이면 scale_down_finish가 실행된다:
// coordinator::scale_down_finish -- src/connection/coordinator.cpp/* enqueue HIBERNATE (LAZY) to workers[draining_worker]; notify */m_statistics[draining_worker].m_score = 0; /* ...clear m_core, m_client_num, m_sum, m_worker, m_contexts... */m_scaling.last_drain_ns = get_monotonic_ns ();m_scaling.draining_worker = -1;m_status = status::STABLE;불변식 (오직 STABLE에서만 스케일).
scale_up/scale_down둘 다m_status == STABLE이 아니면 시작을 거부하며, 각자는 인계가 진행 중인 동안 상태를 non-STABLE로 남겨 둔다.scale_up은 동기적으로STABLE을 복원하지만,scale_down은scale_down_finish를 기다린다. 따라서scale_down을 발행한 트라이얼은 드레인이 끝나기 전까지 다음 스텝을 발행할 수 없다 —m_status게이트가 구조적 변경을 직렬화하는 셈이다. 이를 없애면, 두 번째scale_down이 아직 드레이닝 중인 워커를 인계 대상으로 골라버릴 수 있다.
stateDiagram-v2 [*] --> STABLE STABLE --> TRIAL: scale_trial \n count > 0 STABLE --> STABLE: scale_trial \n count == 0 여유 없음 TRIAL --> TRIAL: tick \n count > 0 up 또는 down 스텝 TRIAL --> STABLE: count == 0 \n selected == current TRIAL --> TRIAL: count == 0 \n selected 다르면 재아밍
Figure 6-3. m_scaling_statistics.status — 실험 상태 머신. 각 TRIAL
틱은 history 샘플을 하나씩 추가하고, count == 0이 되면 가장 점수가
좋은 스케일로 커밋한 뒤 재아밍한다.
flowchart TD
S["statistics_scaling (60초)"]
Q{"scaling status"}
A["scale_trial로 다음 트라이얼 아밍"]
REC["history 기록\nscore = bytes + 2*completed\ncount--"]
C{"count == 0"}
STEP{"direction"}
SU["scale_up"]
SD["scale_down"]
SEL["scale_selection\n상위 5% 밴드에서 선택"]
CMP{"selected vs current"}
DN["dir=DOWN; scale_down; 재아밍"]
UP["dir=UP; scale_up xN; 재아밍"]
ST["status = STABLE"]
S --> Q
Q -->|STABLE| A
Q -->|TRIAL| REC --> C
C -->|아니오| STEP
STEP -->|UP| SU
STEP -->|DOWN| SD
C -->|예| SEL --> CMP
CMP -->|작음| DN
CMP -->|큼| UP
CMP -->|같음| ST
Figure 6-4. statistics_scaling — 모든 분기. STABLE 틱은 트라이얼을
아밍하고, TRIAL 틱은 샘플을 기록한 뒤 한 스텝 더 나아가거나, count == 0
이면 가장 점수가 좋은 스케일로 커밋한다.
6.6 챕터 요약 — 핵심 정리
섹션 제목: “6.6 챕터 요약 — 핵심 정리”- 스레드 하나, 숫자 하나.
coordinator::run은 eventfd/timerfd/ctrlfd를 도는 락-프리 단일 소비자 epoll 루프다. 모든 결정은m_score로 워커 순위를 매기며, coordinator의 모든 상태는 오직 여기서만 변경된다 — 이 장 전체가 락 없이 돌아갈 수 있는 이유가 되는 불변식이다. - 점수 = headcount + 워커 압력 + 컨텍스트 압력, 각각 EWMA로 스무딩되며
(
VAL_TO_SCORE/EVAL_WORKER/EVAL_CONTEXT),BLOCKED_RMUTEX가 500이라는 무게로 가중되어 멈춰선 워커가 부하를 가장 강하게 밀어낸다. 첫 샘플의 폴딩은m_last_updated로 게이팅된다. - 배치는
statistics_find_score_extremes의 min 쪽이며,[0, m_current_worker)범위만 스캔하므로 잠든 워커는 도달 불가능하다.>=/<가 현재의 결정론적 동점 처리이고,random_bit은 이름만 존재할 뿐 연결되어 있지 않다. - 리밸런싱은 5초마다 최대 한 개의 연결만 옮기며, 20% 밴드를 넘어설
때만 동작하고, 오버슈트를 피하기 위해
diff/2아래에서 가장 큰 컨텍스트를 고른다.transfer_connection은 목적지로 낙관적으로 복사하고HANDOFF_REPLY가 올 때만 원본을 지우며, 이는m_migrating으로 보호된다. - 스케일링은 시행-관찰 방식이다.
scale_trial은 방향을 고른다 (직전 트라이얼이 성과 없었다면 뒤집고, 그렇지 않으면 유지),count를 여유분과window_size(기본 4)에 클램프한다.statistics_scaling은 틱마다 스루풋 점수를 기록하고,count == 0이 되면scale_selection의 상위 5% 밴드를 통해 최선의 스케일로 커밋한다. - 두 종류의 상태가 구조 변경을 직렬화한다.
m_status(STABLE/DRAINING/EXPANDING)가 물리적 변경을 게이팅한다.scale_up은 동기적으로 STABLE을 복원하고 새 워커의 점수를 0으로 만들어 신규 연결을 흡수하게 하는 반면,scale_down은 비워진 워커의 다음 리포트에서scale_down_finish가 실행될 때까지 DRAINING에 머문다. - 알려진 비활성 경로:
css_get_task_stats는 스텁 상태이므로, 점수와 스케일링 스루풋 메트릭 양쪽에 들어가는 태스크 완료 항은 0만 폴드한다 — 가중치를 튜닝하기 전에 이것부터 복원해야 한다.
Chapter 7: 컨텍스트 생명주기와 워커별 프리리스트
섹션 제목: “Chapter 7: 컨텍스트 생명주기와 워커별 프리리스트”이 장이 답하려는 질문은 이렇다: connection::context는 핫 패스에서 new/delete를 전혀 건드리지 않고 어떻게 할당되고 재활용되는가? 앞선 장들(“The connection reactor”, “Why a freelist”)이 커넥션당 객체가 접속/해제 폭주 상황에서 왜 범용 할당자를 건드려서는 안 되는지를 설명했다면, 이 장은 그 방법을 다룬다 — 소유권이 정확히 어떻게 넘어가는지, claim/retire의 모든 분기, 그리고 컨텍스트를 지연 반환하는 종료 프로토콜까지. 세 협력자가 이 작업을 나눠 맡는다:
- **
pool**은 메모리를 소유한다.freelist노드로 이루어진 LIFO 스택이며, 각 노드는 하나의context를 품고 있다.claim_context/retire_context를 통해 노출된다. - **
coordinator**는 claim/retire를 호출하는 유일한 스레드이며, 자신의 생애주기 내내 pool의m_mutex를 쥐고 있다(initialize끝에서lock_resource,finalize시작에서release_resource). 그래서 프리리스트는 설계상 단일 기록자(single-writer)이고, 노드별 잠금은 아예 존재하지 않는다. - **
worker**는 컨텍스트를 절대 직접 할당하지 않는다.NEW_CLIENT메시지를 통해 이미 claim된 컨텍스트를 건네받아m_context로스터에 추적하고, 커넥션이 죽으면 그 포인터를m_removed_context에 잠시 쌓아 두었다가 일괄RETURN_TO_POOL로 되돌려 보낸다.
즉 “워커별 프리리스트”라는 이름은 사실 두 개의 층을 가리킨다 — 코디네이터가 직렬화하는 공유 메모리 풀, 그리고 워커마다 따로 두는 로스터 + retire 대기 버퍼.
7.1 세 개의 구조체
섹션 제목: “7.1 세 개의 구조체”프리리스트 노드. 노드는 사실상 context에 next 포인터 하나를 붙인 것인데, context를 의도적으로 맨 앞에 둔다:
// pool::freelist -- src/connection/connection_pool.hppstruct freelist { context m_context; /* THIS MUST BE THE FIRST -- so reinterpret_cast<freelist*>(&m_context) round-trips */ freelist *m_next; freelist (std::size_t capacity) : m_context (capacity), m_next (nullptr) {} bool prepare () { return m_context.prepare (); } /* <- allocates the receiver backing store */};// ... the freelist control block, an anonymous struct member of pool ...struct { freelist *m_head; std::size_t m_max; std::size_t m_claim; } m_freelist;| Field | 역할 | 존재 이유 |
|---|---|---|
freelist::m_context | 재활용되는 context 페이로드 | 맨 앞에 두어서 retire_context가 순수한 context*만 가지고도 오프셋 0의 reinterpret_cast로 노드를 복원할 수 있게 한다. |
freelist::m_next | 침습적(intrusive) LIFO 링크 | 별도 컨테이너 없이 빈 노드들을 실로 꿰듯 연결한다; 노드가 m_head에 걸려 있는 동안에만 유효하다. |
m_freelist.m_head | 프리 스택의 맨 위 | nullptr이면 예비 재고가 바닥났다는 뜻 — claim은 이때 new로 대체된다. |
m_freelist.m_max | 보유 상한 = max_connections * 1.1 | 이 값을 넘으면 retire되는 노드는 풀에 넣지 않고 바로 해제한다. 그래야 일시적 스파이크가 RSS를 영구히 부풀리지 않는다. |
m_freelist.m_claim | 현재 나가 있는 컨텍스트 수 | m_max와 비교해 재활용할지 해제할지 결정하며, finalize_freelist에서 == 0임을 assert한다. |
connection::context 구조체 — 실제로 재활용되는 객체다. 필드를 하나씩 보면:
// connection::context -- src/connection/connection_context.hppstruct context { css_conn_entry *m_conn; /* THIS MUST BE THE FIRST */ int m_worker; uint64_t m_id; ignore_level m_ignore; bool m_removed; struct { state m_state; receiver m_receiver; NET_HEADER m_header; int m_request_id; bool m_command; } m_recv; struct { transmitter m_transmitter; std::shared_ptr<message_blocker> m_blocker; } m_send; statistics::metrics<statistics::context> m_stats;};| Field | 역할 | 존재 이유 |
|---|---|---|
m_conn | css_conn_entry로의 역방향 포인터 | 레거시 엔진과의 다리 역할; m_conn->context가 다시 이곳을 가리켜서 양쪽 모두가 서로를 복원할 수 있다. 계약상 첫 번째 멤버여야 한다. |
m_worker | 소유 워커의 인덱스 | claim 시점에 coordinator가 찍어 준다; RETURN_TO_POOL이 올바른 워커의 통계 버킷을 감소시킬 수 있게 한다. |
m_id | 프로세스 전역에서 유일한 컨텍스트 id | coordinator의 워커별 통계 맵과 워커의 m_exhausted 맵을 여는 키; 워커 간 인계에도 살아남는다. |
m_ignore | ERR/HUP 억제 수준 | 죽어가는 커넥션이 그래도 전송 버퍼를 비워야 하는지를 결정한다(아래 역할 매트릭스 참고). |
m_removed | ”이미 정리됨” 플래그 | 중복 SHUTDOWN_CLIENT가 이중으로 닫지 않도록 하는 멱등성 가드. |
m_recv.m_state | 수신 하위 상태(HEADER/DATA/ERROR) | handle_packet에서 어떤 패킷 핸들러를 쓸지 고른다; 8장에서 자세히 다룬다. |
m_recv.m_receiver | 소유하는 수신 버퍼/파서 | 부분적으로 도착한 패킷을 보관한다; 배킹 스토어는 prepare()가 할당하고, reset 시 해제가 아니라 재활용된다. |
m_recv.m_header | 파싱된 고정 크기 패킷 헤더(NET_HEADER) | 9개 필드로 이루어진 struct packet_header(type/version/host_id/transaction_id/request_id/db_error/function_code/flags/buffer_size); 기본값은 DEFAULT_HEADER_DATA로 초기화된다. |
m_recv.m_request_id | 진행 중인 요청의 id | 커맨드 헤더와 그 데이터 본문을 짝짓는다. |
m_recv.m_command | ”커맨드 패킷이 조립 중” 플래그 | 데이터 본문이 완성되면 백엔드 풀에 태스크를 넣을지 여부를 가른다. |
m_send.m_transmitter | 소유하는 송신 큐 + iovec 상태 | 나가는 바이트를 버퍼링한다; 재활용 전에 비워지거나 버려진다. |
m_send.m_blocker | 선택적 message_blocker 핸들 | 태스크 워커가 블로킹 전송을 요청하면, 이 조건 변수가 바이트가 나가거나 커넥션이 죽는 시점에 그 워커를 깨운다. |
m_stats | 컨텍스트별 메트릭 | 통계 틱마다 coordinator로 스냅샷되며, 재활용 시 초기화된다. |
불변식 — 겹겹이 쌓인 두 개의 “첫 번째 멤버” 계약.
m_conn은context의 첫 멤버이고,m_context는freelist의 첫 멤버다.m_conn이 오프셋 0에 있어서 C 쪽 코드는context*와 그css_conn_entry*를 서로 바꿔 다룰 수 있고,m_context가 오프셋 0에 있어서retire_context는reinterpret_cast<freelist*>(ctx)한 번으로 노드 헤더에 닿는다. 둘 중 하나라도 순서를 바꾸면 캐스트는 조용히 엉뚱한 바이트를 읽는다 — 프리리스트 링크가 깨지거나,css가 쓰레기 값을 역참조하게 된다.
두 개의 열거형. state { HEADER, DATA, ERROR }는 수신 상태 기계다(그 전이는 8장 소관). ignore_level : uint8_t { DONT_IGNORE = 0, IGNORE_ALL }는 작은 순서 척도다 — 코드가 <로 비교하므로, 훗날 그 사이에 중간 단계가 끼어들 여지도 있다. 그 의미는 종료 단계에 따라 달라진다:
m_ignore | 정상 커넥션에서 | 종료 중에는 |
|---|---|---|
DONT_IGNORE | 평범한 I/O; 에러는 그대로 드러난다 | has_remaining_tasks가 여전히 전송 버퍼가 비워지기를 기다린 뒤에야 닫는다. |
IGNORE_ALL | EPOLLERR/RDHUP, PeerReset, 프로토콜 오류에서 설정됨 | 비우기 과정을 건너뛴다 — 상대가 이미 사라졌으므로 has_remaining_tasks가 즉시 false를 반환한다. |
flowchart TD
subgraph POOL["pool - 코디네이터가 직렬화"]
H["m_freelist.m_head"] --> N1["freelist 노드<br/>m_context : context<br/>m_next"]
N1 --> N2["freelist 노드<br/>m_context : context<br/>m_next = null"]
end
subgraph WK["worker - 스레드별"]
R["m_context : context* 집합"]
RM["m_removed_context : context* 벡터"]
end
CO["coordinator<br/>생애 내내 m_mutex 보유"]
CO -- "claim / retire" --> H
CO -- "NEW_CLIENT ctx" --> R
RM -- "RETURN_TO_POOL 일괄 전송" --> CO
N1 -. "css_conn_entry.context" .-> R
Figure 7-1. 컨텍스트는 pool 프리리스트 노드 안에 살고 있으며, coordinator가 그것을 워커의 로스터로 옮겼다가 다시 되돌린다.
7.2 획득: claim_context와 고갈 시 대체 경로
섹션 제목: “7.2 획득: claim_context와 고갈 시 대체 경로”claim_context는 오직 coordinator에서만 실행된다(coordinator는 이미 m_mutex를 쥐고 있으며, 그 사실을 assert가 문서화한다). 분기를 전부 살펴보면:
// pool::claim_context -- src/connection/connection_pool.cppcontext *pool::claim_context () { freelist *head; assert (m_mutex_holder == std::this_thread::get_id ()); /* <- caller must own m_mutex */ head = m_freelist.m_head; if (head) { m_freelist.m_head = m_freelist.m_head->m_next; } /* pop the LIFO */ else { head = new freelist (32 * 1024); /* <- reserve drained: grow */ if (!head->prepare ()) { /* allocate the receiver backing store */ delete head; return nullptr; /* <- allocation failure: caller must cope with null */ } } m_freelist.m_claim++; return &head->m_context;}- 예비 재고 적중(
m_head != nullptr): 맨 위 노드를 pop하고m_head를m_next로 전진시킨다. 할당이 전혀 없다 — 이것이 핫 패스다. pop된 노드의m_next는 이제 낡은 값이지만 해가 없다(다음 retire 때 덮어써진다). - 예비 재고 고갈(
m_head == nullptr):new freelist (32 * 1024)가 노드를 하나 생성하는데, 그context는 32 KiB 용량의 receiver 객체를 갖고 있고, 뒤이어prepare()가 그 receiver의 배킹 버퍼를 실제로 할당한다. 이곳이 핫 패스에서 할당이 일어날 수 있는 유일한 지점이며, 그것도 살아있는 커넥션 수가 미리 데워둔(pre-warmed) 예비 재고를 넘어설 때뿐이다(initialize_freelist가 미리m_max개의 노드를 심어 두고, 각각에 대해서도prepare()를 호출해 둔다). prepare()실패(증설 중 메모리 부족):delete head; return nullptr.m_claim은 증가시키지 않는다 — 그래야 카운트가 정직하게 유지된다. 프리리스트가 null을 반환하는 유일한 지점이다.- 성공하는 두 분기는 모두
m_claim++을 하고&head->m_context를 반환한다. 노드의 소유권은 여전히 pool에 남아 있고, 호출자는 빌려온context*만 받는다.
null 반환 이후의 흐름. 유일한 호출자는 coordinator::handle_message_queue_new_client다. 이 함수는 결과를 확인하고, nullptr이면 들어온 커넥션을 css_free_conn으로 정리하고 클라이언트를 조용히 버린다 — 고갈과 할당 실패가 겹친 경우의 최종 결착이다:
// coordinator::handle_message_queue_new_client -- src/connection/coordinator.cppctx = m_parent->claim_context ();if (!ctx) { css_free_conn (item.conn); return true; } /* failed to allocate: just ignore */// ... else: pick worker, stamp ctx->m_worker/m_id, enqueue NEW_CLIENT ...성공한 경우 coordinator는 아직 워커의 손이 닿기 전에 여전히 낡은 상태로 남아 있는 신원 필드를 찍어 넣는다(request.ctx->m_worker = worker; request.ctx->m_id = id++;).
flowchart TD
A["claim_context - m_mutex 보유 중"] --> B{"m_freelist.m_head?"}
B -- "non-null" --> C["head = m_head<br/>m_head = m_head->m_next"]
B -- "null - 고갈됨" --> D["head = new freelist 32K"]
D --> P{"head->prepare?"}
P -- "false" --> Q["delete head<br/>return nullptr"]
P -- "true" --> E
C --> E["m_claim++"]
E --> F["return &head->m_context"]
Q -. "호출자" .-> G["coordinator: css_free_conn; 클라이언트 버림"]
Figure 7-2. claim_context: pop, 증설, 혹은 null 실패 — 마지막 경우는 coordinator의 if (!ctx)가 처리한다.
7.3 반환: retire_context, 순환, 그리고 축소 분기
섹션 제목: “7.3 반환: retire_context, 순환, 그리고 축소 분기”retire는 claim의 거울상이다. context 포인터로부터 노드를 재구성하고, context를 세정(scrub)한 다음, 그 노드가 예비 재고로 복귀할지 아니면 소멸할지를 결정한다:
// pool::retire_context -- src/connection/connection_pool.cppvoid pool::retire_context (context *ctx) { freelist *head; assert (m_mutex_holder == std::this_thread::get_id ()); head = reinterpret_cast<freelist *> (ctx); /* <- valid only because m_context is first */ head->m_context.reset (); /* scrub all fields before reuse */ if (m_freelist.m_claim > m_freelist.m_max) { delete head; } /* over ceiling: shrink */ else { head->m_next = m_freelist.m_head; m_freelist.m_head = head; } /* push back: rotate */ m_freelist.m_claim--;}- 노드 복원:
reinterpret_cast<freelist*>(ctx)— 오프셋 이동이 없는 캐스트이며,m_context가 맨 앞에 있기 때문에(7.1의 불변식) 정확히 합법적이다. - 세정:
head->m_context.reset ()(7.4절)이 모든 필드를 생성 시점의 기본값으로 되돌려서, 다음 claim한 쪽이 깨끗한 객체를 보게 한다. 이 덕분에 재사용이 소멸자/생성자 왕복을 건너뛸 수 있다. - 축소 분기(
m_claim > m_max): 살아있는 개체 수가 보유 상한을 넘어섰으므로 이 노드는 잉여다 —delete head. - 순환 분기(else):
m_head에 push한다(LIFO). “순환”이란 바로 이 claim에서의 pop과 retire에서의 push가 맞물려 도는 주기를 말한다 — 같은 노드들이 계속 돌고 돌기 때문에, 안정 상태에서의 잦은 접속/해제는 할당자를 전혀 건드리지 않는다. - 두 분기 모두
m_claim--을 한다.
불변식 —
m_claim은 밖으로 나가 있는 컨텍스트 수를 정확히 센다.claim_context가 성공할 때마다+1,retire_context가 실행될 때마다-1이 되며, 둘 다 coordinator에서 단일 스레드로만 일어나므로 원자적 연산이 필요 없다.finalize_freelist는m_claim == 0을 assert한다; 컨텍스트가 하나라도 새면 카운트가 어긋나서 종료 시점에 이 assert가 걸린다.m_claim은 축소 여부를 결정하는 유일한 입력값이기도 하다 — 이 값이 어긋나면 메모리가 새거나(영영 축소되지 않거나), 혹은 매 주기마다new/delete를 반복하며 요동친다.
initialize_freelist는 예비 재고를 미리 만들어 둔다(m_max = max_connections * 1.1개의 노드, 각각 new freelist (32*1024) 다음 prepare(); 어느 하나라도 prepare()가 실패하면 그 노드를 delete하고 false를 반환한다). finalize_freelist는 claim 카운트가 0임을 assert한 뒤 m_head를 순회하며 모든 노드를 delete한다.
flowchart TD
A["retire_context - m_mutex 보유 중"] --> B["head = context 포인터를 freelist*로 reinterpret_cast"]
B --> C["head->m_context.reset"]
C --> D{"m_claim > m_max?"}
D -- "yes - 상한 초과" --> E["delete head"]
D -- "no" --> F["head->m_next = m_head<br/>m_head = head"]
E --> G["m_claim--"]
F --> G
Figure 7-3. retire_context: 세정한 다음, 축소하거나 순환시킨다.
7.4 context::reset — 재사용을 위한 세정
섹션 제목: “7.4 context::reset — 재사용을 위한 세정”재사용은 reset이 생성 시점 상태를 정확히 복원하는 데 달려 있다 — 스칼라 값을 다시 채우고, 소유하고 있는 I/O 장치를 리셋하고, 통계를 초기화한다:
// connection::context::reset -- src/connection/connection_context.cppvoid context::reset () { m_conn = nullptr; m_worker = -1; m_id = 0; m_ignore = ignore_level::DONT_IGNORE; m_removed = false; m_recv.m_state = state::HEADER; m_recv.m_receiver.reset (); /* buffers recycled, not freed */ m_recv.m_header = DEFAULT_HEADER_DATA; /* {0,0,0,NULL_TRAN_INDEX,0,0,0,0,0} */ m_recv.m_request_id = -1; m_recv.m_command = false; m_send.m_transmitter.clear (); m_send.m_blocker = nullptr; /* drop shared_ptr ref */ m_stats.reset ();}이는 두 생성자와 필드 단위로 정확히 대응한다. 두 생성자 모두 같은 스칼라 값을 설정하고 m_recv.m_header = DEFAULT_HEADER_DATA로 초기화한다; 차이는 오직 receiver/transmitter의 크기를 잡는 방식뿐이다 — 용량을 받는 생성자는 receiver (capacity, &m_stats) / transmitter (&m_stats)를 쓰고, 기본 생성자는 receiver () / transmitter ()를 쓴다. reset이 해제 대신 m_recv.m_receiver.reset ()을 호출하기 때문에, 재활용된 context는 32 KiB 버퍼를 데운 채로 유지한다 — 바이트 버퍼조차 할당자를 건드리지 않는다. m_send.m_blocker를 nullptr로 떨어뜨리는 것은 블록된 태스크의 핸들에 대한 마지막 shared_ptr 참조를 해제하는 일이다; close가 retire에 이를 즈음이면 그 태스크는 이미 깨어난 뒤다(7.5절).
7.5 종료 프로토콜: handle_connection_close, 재시도 헬퍼, 그리고 지연 반환
섹션 제목: “7.5 종료 프로토콜: handle_connection_close, 재시도 헬퍼, 그리고 지연 반환”컨텍스트가 워커를 떠나는 유일한 경로는 handle_connection_close다. 이 서브시스템에서 분기가 가장 많은 함수로, 트랜잭션을 잠재우고, I/O를 비우거나 버리고, 리액터를 블로킹하는 대신 지연 메시지를 통해 스스로를 재시도할 수 있어야 한다. 모든 분기를 빠짐없이 살펴보면:
- 멱등성 가드:
if (ctx->m_removed)— 이미 정리된 상태다. 대기자가 있으면 깨우고(wakeup_blocked_worker (handle)), 로그를 남기고,return true. 이 덕분에 중복된SHUTDOWN_CLIENT도 안전하게 처리된다. 그 뒤assert_release (ctx->m_conn). - 등록 중인 클라이언트에 대한 조기 재시도. 상태를 건드리기 전에 다섯 개의 조건을 함께 검사한다:
m_status != TERMINATING그리고!css_is_shutdowning_server ()그리고requires_client_info (ctx)그리고ctx->m_conn->get_tran_index () == NULL_TRAN_INDEX그리고is_registering_client (ctx). 모두 참이면, 그 클라이언트는 아직boot_client_register안에 있는 상태(트랜잭션 인덱스가 아직 없음)이지만 대기 중인 요청이나 진행 중인 태스크가 있다(has_pending_request() || has_working_task()). 지금 닫으면 등록 과정과 경합하게 되므로,rmutex아래에서ctx->m_conn->stop_talk = true를 설정해(등록 경로가 중단하도록 신호를 보냄)return retry_connection_close (ctx, is_retry, handle)— 지금은 미루고 등록이 끝나기를 기다린다.requires_client_info가 false를 반환하는 유일한 경우는 CDC 내부 fd(ctx->m_conn->fd == cdc_Gl.conn.fd)뿐인데, 이 fd는 기다릴 클라이언트가 없다. - 상태 전환:
rmutex아래에서CONN_OPEN→CONN_CLOSING;status를 스냅샷한다. - 트랜잭션 인덱스 획득(
start_connection_close,tran_index_lock아래에서):tran_index/client_id를 읽고, 유효하면css_set_thread_info (..., NET_SERVER_SHUTDOWN)으로 워커의m_entry를 이 conn에 묶는다.tran_index == NULL_TRAN_INDEX이면{-1,-1}을 반환한다. 이 경우requires_client_info (ctx)가 참이라면, 재시도는 이미 건너뛴 상태였으므로(2단계가 발동하지 않았으므로) 등록 과정이 트랜잭션 인덱스를 절대 공개하지 않을 것이다 — 그래서client_id = ctx->m_conn->client_id로 복구하고 재시도 없이 닫는다. 여기엔 sleep도, 재조회도 없다.tran_index_lock을 해제한다. css_end_server_request; entry의 상태를TS_CHECK로 설정한다(잠재적으로 무한히 도는xtran_wait_server_active_trans를 끊어낸다);session_p가 설정돼 있으면ssession_stop_attached_threads.- 재시도가 아닐 때의 깨우기:
if (!is_retry)→net_server_wakeup_workers가 진행 중인 태스크 스레드들을 인터럽트한다. 재시도 패스에서는 이미 인터럽트된 상태이므로 건너뛴다. - 활성 워커 관문:
net_server_active_workers (...) > 0→ 이 conn을 위해 아직 돌고 있는 태스크 스레드가 있다는 뜻 →goto retry. - 잔여 작업 관문:
has_remaining_tasks (ctx)는 먼저 IMMEDIATE 큐를 비우고(큐에 있는RELEASE_PACKET이 receiver 메모리를 해제할 수도 있다), 그 다음 —m_ignore < IGNORE_ALL일 때만 — transmitter가 비어 있지 않으면 true를 반환한다 →goto retry.IGNORE_ALL상태의 컨텍스트는 이 단계를 건너뛴다: 죽은 상대에게 보내지 못한 바이트는 그냥 버린다. - 정리(남은 작업이 없을 때), 소스 코드 순서 그대로:
m_events.remove_descriptor (fd);tran_index != NULL_TRAN_INDEX이면net_server_conn_down; 아래의end_connection_close; 그 다음cmutex아래에서(rmutex_lock을 통해)m_conn->worker와m_conn->context를 null로 만든다; 그 다음에야m_send.m_transmitter.clear ();css_shutdown_socket (fd),fd = INVALID_SOCKET; 종료 대기자(handle)와m_send.m_blocker가 있으면 깨운다;css_prepare_shutdown_conn. - 지연 반환:
ctx->m_removed = true; m_removed_context.push_back (ctx);— 이 시점에 컨텍스트를 retire하지 않는다; 일괄 처리되는purge_stale_contexts를 위해 잠시 쌓아 둘 뿐이다.m_stats.sub (CLIENT_NUM, 1);return true. retry:레이블:end_connection_close;return retry_connection_close (ctx, true, handle).
9단계의 null 설정은 transmitter.clear ()보다 먼저 일어난다. 그래야 어떤 메시지 핸들러도, transmitter가 지워지고 있는 컨텍스트에 새 작업을 묶지 못한다.
불변식 —
m_conn->context는 컨텍스트가 retire 대기열에 오르기 전에cmutex아래에서 null이 된다. 9단계는cmutex아래에서m_conn->context = nullptr을 설정한다; 경합할 가능성이 있는 모든 메시지 핸들러(send_packet,release_packet,shutdown_client)는 같은cmutex아래에서m_conn->context를 다시 읽고 null이면 중단한다(7.6절). 포인터가 지워진 이후로는 어떤 새 작업도 그 컨텍스트에 묶이지 않으므로, 나중에retire_context를 위해 대기열에 올려도 동시 사용과 경합할 수 없다.
공유 재시도 헬퍼. 2단계와 retry: 레이블 둘 다 retry_connection_close로 흘러들어 간다. 이 함수는 리액터를 절대 블록하지 않는다: SHUTDOWN_CLIENT 메시지를 만들어(conn, ctx, m_id, m_ignore, retry, 그리고 대기자 handle을 담아) enqueue (queue_type::LAZY, ...)하고, m_has_retry = true로 설정한 다음, handle_message_queue에 묶인 LOW_LATENCY QUEUE 타이머를 건다. 그래야 스핀 없이도 재시도가 곧 발화한다. eventfd_addtimer의 반환값이 그대로 리턴된다.
// worker::end_connection_close -- src/connection/connection_worker.cppvoid worker::end_connection_close () { pthread_mutex_lock (&m_entry->tran_index_lock); css_set_thread_info (m_entry, -1, 0, -1, -1); /* detach client/tran from this entry */ m_entry->conn_entry = NULL; m_entry->m_status = cubthread::entry::status::TS_RUN; pthread_mutex_unlock (&m_entry->tran_index_lock);}end_connection_close는 양쪽 출구 모두에서 실행된다 — 정리(9단계)든 재시도(11단계)든, 어느 쪽이든 워커의 entry는 다음으로 넘어가기 전에 이 클라이언트 행세를 멈춰야 하기 때문이다. tran_index_lock을 쥐는 이유는 entry가 스레드 매니저의 전역 목록에 살아 있어서 다른 경로들도 그것을 들여다볼 수 있기 때문이다.
purge_stale_contexts — 일괄 반환. 메인 루프와 ha_close_all_connections는 처리를 마친 뒤에 이 함수를 호출한다. 그래서 retire는 close 한 번마다가 아니라 틱(tick) 한 번마다 일어난다:
// worker::purge_stale_contexts -- src/connection/connection_worker.cppmessage.type = coordinator::message_type::RETURN_TO_POOL;message.resource = m_removed_context; /* copy the batch to the coordinator */for (context *ctx : m_removed_context) { if (m_context.erase (ctx) == 0) { /* drop from the live roster */ er_log_conn (...); continue; /* not in roster: log, skip local cleanup */ } m_exhausted.erase (ctx->m_id); /* also drop any IO-budget deferral */}m_removed_context.clear ();m_coordinator->enqueue (std::move (message));if (!m_coordinator->notify ()) { assert_release (false); }분기: m_context.erase가 0을 반환하면 그 포인터는 애초에 로스터에 없었던 것이다(버그이거나 중복 스테이징) — 로그를 남기고 continue하지만, 이미 복사해 둔 message.resource 안에는 그 포인터가 그대로 남아 있다. 실제로 메모리가 되돌아가는 곳은 coordinator 쪽의 handle_message_queue_return_to_pool이다: 컨텍스트마다 m_statistics[m_worker].m_client_num을 감소시키고, 그 컨텍스트의 통계(m_id 키)를 모든 워커 맵에서 지운 다음, css_free_conn (ctx->m_conn)과 retire_context (ctx)(7.3절)를 호출한다. 컨텍스트가 프리리스트에 다시 들어가는 곳은 오직 그곳뿐이다 — coordinator에서, m_mutex 아래에서.
flowchart TD
A["handle_connection_close ctx"] --> B{"ctx->m_removed?"}
B -- yes --> Z["대기자 깨움; return true"]
B -- no --> C{"TERMINATING 아님, shutdown 중 아님<br/>requires_client_info<br/>tran_index == NULL<br/>is_registering_client 모두 참?"}
C -- yes --> RH["stop_talk = true<br/>retry_connection_close"]
C -- no --> D["status -> CONN_CLOSING<br/>start_connection_close"]
D --> E{"tran_index < 0?"}
E -- "yes 이고 requires_client_info" --> E2["client_id = m_conn->client_id<br/>재시도 없이 닫음"]
E -- no --> F["end_server_request; TS_CHECK"]
E2 --> F
F --> G{"active_workers > 0?"}
G -- yes --> RT["retry:"]
G -- no --> H{"has_remaining_tasks?"}
H -- yes --> RT
H -- no --> I["fd 제거; conn_down; end_connection_close<br/>cmutex 아래서 m_conn->worker/context null화<br/>transmitter.clear; 소켓 shutdown; 대기자 깨움"]
I --> J["m_removed=true<br/>m_removed_context.push_back<br/>CLIENT_NUM--"]
RT --> RH
Figure 7-4. handle_connection_close: 모든 관문은 지연 스테이징이나 자기 재시도로 이어질 뿐, 블로킹 스핀으로는 결코 이어지지 않는다.
7.6 널 컨텍스트 가드 (CBRD-26412)
섹션 제목: “7.6 널 컨텍스트 가드 (CBRD-26412)”9단계가 m_conn->context를 null로 만들고 나면, close보다 먼저 큐에 들어가 있던 메시지가 나중에 도착해서 이미 컨텍스트가 사라진 conn을 참조할 수 있다. 세 개의 핸들러 — handle_message_queue_send_packet, handle_message_queue_release_packet, handle_message_queue_shutdown_client — 는 cmutex 아래에서 m_conn->context를 다시 읽어 컨텍스트를 복구하고, 가장 먼저 null 여부를 확인한다:
// worker::handle_message_queue_shutdown_client -- src/connection/connection_worker.cppr = rmutex_lock (m_entry, &item.conn->cmutex);ctx = reinterpret_cast<context *> (item.conn->context);if (ctx == nullptr) { /* <- context already cleared by a prior close */ rmutex_unlock (m_entry, &item.conn->cmutex); er_log_conn (..., "context is already cleared for conn = %p", item.conn); this->wakeup_blocked_worker (item.waiter_handle); return true; /* drop the stale request, do not deref null */}send_packet 버전은 null 분기에서 추가로 item.deleter ()를 실행하고(큐에 넣으려던 버퍼는 그래도 해제해야 하니까) item.waiter_handle을 깨운다; release_packet은 그냥 로그만 남기고 반환한다. 이 가드가 없다면 핸들러는 null 포인터를 reinterpret_cast해서 그대로 역참조했을 것이다 — SEND/RELEASE/SHUTDOWN이 동시에 일어난 close와의 경합에서 진 경우마다 use-after-close 크래시가 났을 것이다. 9단계가 그 포인터를 지울 때 쓴 것과 같은 cmutex 아래에서 다시 읽음으로써 그 틈을 닫는다: 핸들러는 살아있는 컨텍스트이거나 확정적인 nullptr 둘 중 하나만 보게 되며, 결코 어중간하게 찢긴 값을 보지 않는다.
이를 리액터 자신의 제어용 fd 할당과 대비해 보면: eventfd_register는 내부 컨텍스트를 맨 new context ()로 만들고 delete로 해제하며, 마스터 커넥터도 마찬가지로 new/delete를 쓴다. 이들은 프로세스당 하나뿐인 제어 fd이지 클라이언트마다 생기는 핫 패스 객체가 아니므로, 의도적으로 프리리스트를 우회한다.
7.7 챕터 요약 — 핵심 정리
섹션 제목: “7.7 챕터 요약 — 핵심 정리”- 핫 패스에서
new/delete가 없다.context는 미리 데워둔freelist노드를 pop해서 claim되고, 다시 push해서 retire된다; 할당자는 예비 재고가 고갈되었을 때(claim이 증설하며,nullptr로 실패할 수도 있다)나 살아있는 개체 수가m_max = max_connections * 1.1을 넘었을 때(retire가 축소한다)만 건드려진다. - 겹겹이 쌓인 두 개의 “첫 번째 멤버” 계약.
context::m_conn이 오프셋 0에 있어서css_conn_entry와 다리를 놓고,freelist::m_context가 오프셋 0에 있어서retire_context가reinterpret_cast로 노드를 복원할 수 있다. 둘 중 하나라도 순서를 바꾸면 캐스트가 망가진다. - 프리리스트는 단일 기록자이지, 노드마다 잠기는 것이 아니다. 오직 coordinator만 pool의
m_mutex를 쥔 채로 claim/retire를 호출한다;m_claim은 평범한 카운터이며 종료 시점에 0임을 assert한다. - 재활용은 곧 세정이다.
context::reset은 모든 필드를 생성 시점의 기본값으로 되돌리고(m_header = DEFAULT_HEADER_DATA), receiver/transmitter 버퍼는 해제하지 않고 재활용해서 32 KiB 저장소를 데운 채로 유지한다. - 종료는 지연되고 스스로 재시도한다.
handle_connection_close는 리액터를 절대 블록하지 않는다: 아직 등록 중인 클라이언트, 활성 태스크 스레드, 살아있는 상대에 대한 비어 있지 않은 전송 버퍼 — 이 모두가 공유 헬퍼retry_connection_close(LAZYSHUTDOWN_CLIENT+ 저지연 타이머)로 흘러들어 간다; 실제 retire는purge_stale_contexts→RETURN_TO_POOL로 일괄 처리된다. m_removed와cmutex아래에서의 null화가 경합을 막는다. 컨텍스트 포인터는 스테이징되기 전에(그리고 transmitter가 지워지기 전에)cmutex아래에서 지워진다; CBRD-26412 가드는 같은 잠금 아래에서 그것을 다시 읽어, null을 역참조하는 대신 낡은SEND/RELEASE/SHUTDOWN요청을 그냥 버린다.ignore_level이 비우기냐 버리기냐를 가른다.DONT_IGNORE는 close가 전송 버퍼를 기다리게 만들고,IGNORE_ALL은has_remaining_tasks를 단락시켜서 죽은 커넥션이 즉시 정리되게 한다.
Chapter 8: 바이트에서 태스크로
섹션 제목: “Chapter 8: 바이트에서 태스크로”4장부터 7장까지는 커넥션 워커(리액터)가 소켓을 비우고 epoll 조각들로부터 하나의 완전한 요청을 재조립하는 과정을 따라갔다. 이번 장은 그 이야기의 매듭을 짓는다. 요청을 완전히 받아들인 뒤, 그것이 어떻게 리액터를 벗어나 백엔드 태스크 워커 풀에서 실행 가능한 작업이 되는가 — 그것도 리액터가 쿼리 실행을 기다리며 블록되는 일 없이?
이 경계는 의도적으로 얇게 설계되어 있다. 래퍼 하나(push_task_into_worker_pool), 프리 함수 하나(css_push_server_task), 힙에 할당되는 태스크 하나(css_server_task), 그리고 범용 풀 인큐 경로(push_task_on_core → execute_on_core → core_impl::execute_task)가 전부다. 리액터는 thread_worker_pool_impl.hpp를 절대 링크하지 않는다 — 풀 전체가 css_push_server_task라는 심볼 하나 뒤에 숨어 있는 셈이다. 이런 분리를 택한 재설계의 근거는 컴패니언 문서 Two pools, one process에 담겨 있다. 이번 장에서는 그 경계선을 한 줄 한 줄 따라가며, 풀이 정지되었을 때의 거부 처리, 풀이 가득 찼을 때의 인큐, 그리고 별도의 wakeup_blocked_worker 백프레셔 핸드셰이크까지 살펴본다.
8.1 두 개의 호출 지점
섹션 제목: “8.1 두 개의 호출 지점”리액터의 두 상태가 요청을 디스패치 가능하다고 선언하며, 둘 다 같은 래퍼를 거친다. 데이터 바디가 없는 커맨드 헤더는 즉시 푸시되고, 바디가 있는 헤더는 바디가 도착할 때까지 미뤄진다.
// worker::handle_command_header_packet -- src/connection/connection_worker.cppif (ntohl (header->buffer_size) > 0) ctx->m_recv.m_command = true; /* <- defer; wait for the data packet */else this->push_task_into_worker_pool (ctx); /* <- e.g. LOG_CHECKPOINT: dispatch now */// worker::handle_data_packet -- src/connection/connection_worker.cppif (ctx->m_recv.m_command) { this->push_task_into_worker_pool (ctx); ctx->m_recv.m_command = false; /* <- exactly one dispatch per command/body pair */ }NEXT_STATE (ctx, m_recv, HEADER); /* <- reactor rearms immediately, does not wait */m_command은 이중 디스패치를 막는 가드다 (헤더가 세팅하고, 데이터가 소비하며 지운다). NEXT_STATE는 같은 턴 안에서 곧바로 수신 상태 머신을 재무장하므로, 리액터는 방금 푸시한 태스크를 기다리는 법이 없다.
8.2 worker::push_task_into_worker_pool — 한 줄짜리 경계
섹션 제목: “8.2 worker::push_task_into_worker_pool — 한 줄짜리 경계”// worker::push_task_into_worker_pool -- src/connection/connection_worker.cppvoid worker::push_task_into_worker_pool (context *ctx){ css_push_server_task (*ctx->m_conn);}이 함수가 짧다는 것 자체가 설계 의도다. ctx->m_conn(CSS_CONN_ENTRY &)을 역참조해서 C 링키지 함수인 css_push_server_task로 그대로 넘길 뿐이다. 리액터 클래스 cubconn::connection::worker는 cubthread::worker_pool이나 css_server_task를 어디에서도 직접 언급하지 않는다 — 풀과 관련해 이 클래스가 아는 심볼은 이것 하나뿐이다. 반환값도 없고, 에러 체크도 없고, 대기도 없다. 호출은 인큐 한 번만큼의 시간에 끝나며, 이는 §8.6의 경계 불변식이 기계적으로 구현된 모습이다.
8.3 css_push_server_task — 카운터 갱신 후 디스패치
섹션 제목: “8.3 css_push_server_task — 카운터 갱신 후 디스패치”// css_push_server_task -- src/connection/server_support.cvoid css_push_server_task (CSS_CONN_ENTRY &conn_ref){ // note: cores are partitioned by connection index ... to avoid pushing tasks to cores that are full. // some of those tasks may belong to threads holding locks ... conn_ref.add_pending_request (); /* <- ++pending_request_count (atomic) */ conn_ref.add_working_task (); /* <- ++working_task_count (atomic) */ thread_get_manager ()->push_task_on_core (css_Server_request_worker_pool, new css_server_task (conn_ref), static_cast<size_t> (conn_ref.idx), conn_ref.in_method);}먼저 두 개의 원자적 카운터가 증가한다. 둘 다 CSS_CONN_ENTRY 위에 std::atomic<size_t>로 존재하며, 오직 멤버 함수를 통해서만 건드려진다.
| 카운터 | 증가시키는 쪽 (리액터) | 감소시키는 쪽 (풀) | 역할 |
|---|---|---|---|
pending_request_count | add_pending_request | start_request | 수신은 됐지만 아직 실행이 시작되지 않은 요청의 수. |
working_task_count | add_working_task | end_working_task | 현재 실행 중인 태스크의 수. 이 값이 0으로 떨어지는 것이 CONN_CLOSING 커넥션의 정리(teardown) 진행 여부를 결정하는 게이트가 된다. |
두 카운터는 푸시가 일어나기 전에 리액터 쪽에서 먼저 증가한다. 그래서 풀은 커넥션 카운터가 0인 채로 살아있는 태스크를 마주칠 일이 없다.
코어는 라운드로빈이 아니라 커넥션 인덱스로 선택된다. conn_ref.idx가 그대로 core_hash가 되는데, 코드 주석이 그 이유를 밝히고 있다 — 락을 쥔 태스크가 하필 가득 찬 코어에 해싱되면, 무관한 락 대기자들이 큐가 전부 비워질 때까지 발이 묶일 수 있다. 그래서 한 커넥션의 모든 요청은 하나의 코어에 고정된다. (반면 css_push_external_task가 쓰는 평범한 push_task는 get_next_core가 라운드로빈으로 골라주는 코어를 그대로 받아들인다.)
css_server_task는 힙에 할당되며, 소유권은 이 시점부터 풀로 넘어간다 — 이제부터는 풀이 이 객체를 delete할 책임을 진다(§8.4). 마지막 인자인 conn_ref.in_method(메서드/SP 콜백 채널이면 true)는 풀의 is_temp 플래그가 되어, §8.5의 ‘워커 없음’ 분기를 임시 스레드 쪽으로 유도한다. 그래야 콜백이 가득 찬 코어 뒤에서 발이 묶이지 않는다.
8.4 css_server_task — 태스크 객체와 스레드-엔트리 인계
섹션 제목: “8.4 css_server_task — 태스크 객체와 스레드-엔트리 인계”// css_server_task -- src/connection/server_support.cclass css_server_task : public cubthread::entry_task{public: css_server_task (void) = delete; /* <- must carry a connection */ css_server_task (CSS_CONN_ENTRY &conn) : m_conn (conn) {} void execute (context_type &thread_ref) override final; // retire not overwritten; task is automatically deletedprivate: CSS_CONN_ENTRY &m_conn; /* <- only state: which connection this runs for */};cubthread::entry_task는 task<entry>의 별칭이므로, context_type은 곧 cubthread::entry — 풀이 스레드마다 하나씩 갖는 THREAD_ENTRY다. 이 태스크가 갖는 상태는 참조 m_conn 하나뿐이다. retire를 오버라이드하지 않아 execute 이후 베이스 클래스의 delete this를 그대로 물려받는다 — 이것이 §8.3의 new와 짝을 이루는 부분이며, 푸시한 쪽이 이후로 절대 이 포인터를 다시 건드려서는 안 되는 이유다.
execute는 커넥션을 풀 스레드에 결속시키고 요청을 실행한다.
// css_server_task::execute -- src/connection/server_support.cthread_ref.conn_entry = &m_conn; /* <- graft connection onto this pool thread */session_p = thread_ref.conn_entry->session_p;m_conn.start_request (); /* <- --pending_request_count */if (session_p != NULL) { thread_ref.private_lru_index = session_get_private_lru_idx (session_p); pgbuf_thread_variables_init (&thread_ref); }thread_ref.m_status = cubthread::entry::status::TS_RUN;pthread_mutex_lock (&thread_ref.tran_index_lock);(void) css_internal_request_handler (thread_ref, m_conn); /* <- the actual query dispatch */thread_ref.conn_entry = NULL;thread_ref.m_status = cubthread::entry::status::TS_FREE;if (m_conn.end_working_task () == 0 && m_conn.status == CONN_CLOSING) /* <- last task out of a closing conn */ css_request_shutdown_conn (&m_conn, /*DONT_IGNORE*/, false, 0 /* no wait */);else css_wakeup_handler (&m_conn); /* <- otherwise nudge the reactor to flush replies */end_working_task는 감소 이후의 카운트 값을 반환한다. 여기서 갈리는 두 갈래: 값이 0이 되었고 커넥션이 CONN_CLOSING 상태라면, 이 태스크가 마지막으로 빠져나간 태스크였다는 뜻이므로 스스로 셧다운을 개시한다(풀 스레드가 정리 작업으로 블록되지 않도록 no wait로). 그렇지 않다면 css_wakeup_handler를 호출해 리액터의 메시지 큐를 찔러, 핸들러가 만들어낸 응답들이 플러시되도록 한다.
불변식 — 한 요청의 카운터 쌍은 정확히 한 번의 풀 실행을 감싼다. 증가는 푸시 전에 리액터에서 정확히 한 번, 감소는 execute 안에서 풀 쪽에서 정확히 한 번 일어난다. 그리고 푸시는 실행보다 happens-before 관계에 있으므로(풀 뮤텍스 안의 release/acquire) 두 스레드에서 그 값들이 서로 보이는 것이 보장된다. 만약 증가를 둘 다 하지 않고 푸시하거나, execute를 두 번 실행하면 working_task_count는 결코 0으로 돌아오지 않는다 — 그러면 CONN_CLOSING 상태의 커넥션이 css_request_shutdown_conn에 끝내 도달하지 못한 채 새어버린다(leak).
flowchart TB
subgraph reactor["리액터 스레드"]
W["push_task_into_worker_pool"] --> C["css_push_server_task"]
C -->|"new"| T["css_server_task<br/>CSS_CONN_ENTRY & 보유"]
C -->|"카운터 증가"| CE["CSS_CONN_ENTRY<br/>pending_request_count<br/>working_task_count"]
end
C -->|"push_task_on_core, 소유권 이전"| POOL["요청 풀<br/>core = conn.idx % ncores"]
subgraph backend["백엔드 풀 스레드"]
POOL -->|"execute (entry &)"| EX["css_server_task::execute<br/>conn_entry 바인딩, 핸들러 실행"]
EX -->|"delete this"| GONE["태스크 소멸(retire)"]
end
T -.->|"참조"| CE
EX -.->|"감소"| CE
Figure 8-1 — 한 번의 푸시는 풀이 소유하고 커넥션을 참조하는 힙 css_server_task를 만든다. 풀 스레드는 이를 cubthread::entry에 결속시켜 핸들러를 실행하고, 카운터를 감소시킨 뒤 스스로를 삭제한다.
8.5 push_task_on_core → execute_on_core → execute_task — 모든 분기
섹션 제목: “8.5 push_task_on_core → execute_on_core → execute_task — 모든 분기”push_task_on_core는 매니저 쪽 파사드로, 가드 분기 하나만 갖는다.
// manager::push_task_on_core -- src/thread/thread_manager.cppif (worker_pool_arg == NULL) { exec_p->execute (get_entry ()); exec_p->retire (); } /* <- inline; SA_MODE / null pool only */else { check_not_single_thread (); worker_pool_arg->execute_on_core (exec_p, core_hash, method_mode); }cub_server에서는 풀이 항상 null이 아니므로 제어는 execute_on_core로 넘어간다. 이 함수는 순수한 산술 연산이다 — 인덱스를 슬롯 번호로 환산해 그대로 넘길 뿐이다: core_index = core_hash % m_cores.size (); m_cores[core_index]->execute_task (work_arg, is_temp);.
실제 분기 로직은 전부 core_impl::execute_task에 몰려 있다.
// worker_pool_impl<Stats>::core_impl::execute_task -- src/thread/thread_worker_pool_impl.hppif (!m_parent_pool->is_running ()) /* branch 1: pool stopped */ { task_p->retire (); return; } /* <- delete and drop silently */wrapped_task task_ref (task_p);std::unique_lock<std::mutex> ulock (m_workers_mutex); /* <- guards m_available_workers */if (!m_available_workers.empty ()) /* branch 2: idle worker parked */ { refp = static_cast<worker_impl *> (m_available_workers.back ()); m_available_workers.pop_back (); ulock.unlock (); /* <- unlock before notify */ refp->assign_task (std::move (task_ref)); }else if (is_temp) /* branch 3: no worker, temp task */ { ulock.unlock (); execute_task_as_temp (std::move (task_ref)); }else /* branch 4: no worker, normal task */ m_task_queue.push (std::move (task_ref));- 분기 1 — 풀이 정지된 경우 (거부).
is_running()은!m_stopped이다(std::atomic<bool>이며 셧다운 시에 뒤집힌다). 태스크는 retire되어 버려진다 — 이미 수신된 요청을 폐기하는 유일한 경로이며, 오직 셧다운 상황에서만 도달한다. §8.3에서 증가시킨 카운터는 의도적으로 되돌리지 않는다. 이 경우 커넥션 자체가 통째로 정리(teardown)되기 때문이다. - 분기 2 — 유휴 워커가 있는 경우. 대기 중인
worker_impl을 하나 꺼내고m_workers_mutex를 풀어준 다음assign_task를 호출한다. 락 해제 후 notify하는 순서 덕분에 임계 구역이 짧게 유지되고, 깨어난 스레드가 곧바로 뮤텍스에 걸려 블록되는 일이 없다. - 분기 3 — 워커가 없고 임시 태스크인 경우.
is_temp가 참이면(in_method에서 유래)execute_task_as_temp가 자기만의 OS 스레드를 가진 일회용 워커를 즉석에서 띄운다. 이 덕분에 상시 워커가 전부 바쁜 상황에서도 메서드/SP 콜백은 계속 진행할 수 있다 — 재진입성 SP가 자기 자신과 데드락에 빠지는 것을 막는 안전장치다. - 분기 4 — 워커가 없고 일반 태스크인 경우 (풀이 가득 참). 태스크는
m_task_queue로 이동된다. 풀이 가득 찼을 때는 큐에 쌓아둘 뿐, 푸시한 쪽을 블록시키지도 태스크를 버리지도 않는다. 상시 워커가 현재 태스크를 끝내면 큐에서 다음 것을 꺼내간다. 리액터의 푸시가 과부하 상황에서도 논블로킹으로 남을 수 있는 비결이 바로 이것이다 — 백프레셔가 큐 깊이로 치환되는 것이다.
assign_task는 워커 전용 슬롯으로의 인계를 마무리하고, 지연 스레드 생성 정책을 적용한다.
// worker_pool_impl<Stats>::core_impl::worker_impl::assign_task -- src/thread/thread_worker_pool_impl.hppstd::unique_lock<std::mutex> ulock (m_task_mutex);m_wrapped_task.emplace (std::move (task_ref)); /* <- m_task_mutex guards this optional slot */if (m_is_temp) { m_has_thread = true; start_thread (); }if (m_has_thread) { ulock.unlock (); m_task_cv.notify_one (); } /* <- wake parked worker (Ch 10) */else { m_has_thread = true; ulock.unlock (); start_thread (); } /* <- first task: create OS thread */OS 스레드는 한 슬롯에 첫 태스크가 배정될 때만 생성된다. 그 이후로는 슬롯이 자기 스레드를 계속 유지하고, 배정될 때마다 m_task_cv에 notify만 보내면 된다. 이 조건 변수의 반대편에서 도는 실행 루프는 10장에서 다룬다.
flowchart TB
S["execute_task (task, is_temp)"] --> R{"pool is_running?"}
R -- 아니요 --> RJ["retire; 폐기; return"]
R -- 예 --> L["m_workers_mutex 잠금"]
L --> A{"available worker?"}
A -- 예 --> P["pop; unlock;<br/>assign_task -> notify"]
A -- 아니요 --> TE{"is_temp?"}
TE -- 예 --> TT["unlock;<br/>임시 스레드 생성"]
TE -- 아니요 --> Q["m_task_queue.push<br/>(풀 가득 참: 큐에 적재, 블록하지 않음)"]
Figure 8-2 — core_impl::execute_task의 분기 트리. 풀 정지에 따른 거부(왼쪽)가 태스크를 버리는 유일한 경로이고, 풀이 가득 찬 경우(오른쪽 아래)는 리액터를 블록시키는 대신 큐에 적재한다.
8.6 경계 불변식 — 리액터는 실행을 기다리며 블록되지 않는다
섹션 제목: “8.6 경계 불변식 — 리액터는 실행을 기다리며 블록되지 않는다”불변식 — 리액터 스레드는 요청 실행을 기다리며 대기하는 법이 없다. push_task_into_worker_pool은 최악의 경우에도 인큐 한 번(§8.5 분기 4)이 끝나면 곧바로 리턴하며, 리액터 스레드 위에서 css_internal_request_handler를 실행하는 일은 결코 없다. 실행은 오로지 요청 풀 스레드 안, css_server_task::execute 내부에서만 일어난다. 리액터가 받는 유일한 피드백은 비동기적이다(css_wakeup_handler가 자신의 메시지 큐에 알림을 남기는 것). 이 원칙이 깨진다면 — 예를 들어 SERVER_MODE에서 핸들러를 인라인으로 실행한다면 — 느린 쿼리 하나가 epoll 루프 전체를 얼려버리고, 같은 리액터 워커에 다중화된 다른 모든 커넥션까지 함께 멈춰버린다. 이것이 바로 리액터/백엔드 분리가 막고자 하는 헤드오브라인 블로킹이다(컴패니언: Why two pools).
8.7 wakeup_blocked_worker와 백프레셔 핸드셰이크
섹션 제목: “8.7 wakeup_blocked_worker와 백프레셔 핸드셰이크”§8.2~8.5에서 본 태스크 푸시는 발사 후 망각(fire-and-forget) 방식이다 — css_server_task는 블로커를 전혀 지니지 않는다. 하지만 일부 제어 오퍼레이션(소켓 버퍼가 가득 찬 SEND_PACKET, START, SHUTDOWN_CLIENT)은 리액터가 실제로 처리를 마칠 때까지 요청을 보낸 스레드가 기다려야 한다. 이 핸드셰이크는 message_blocker를 거치며, wakeup_blocked_worker가 이를 풀어준다.
// worker::wakeup_blocked_worker -- src/connection/connection_worker.cppvoid worker::wakeup_blocked_worker (std::shared_ptr<message_blocker> handle){ if (handle) /* <- null handle = fire-and-forget, nothing to wake */ { std::lock_guard<std::mutex> lock (handle->m); handle->done = true; /* <- set predicate under the lock */ handle->cv.notify_one (); }}message_blocker는 세 개의 필드로 이루어진 랑데부 객체로, shared_ptr로 공유되어 양쪽 중 어느 쪽이 먼저 끝나든 그보다 오래 살아남는다.
| Field | Role | Why it exists |
|---|---|---|
m (std::mutex) | done을 보호하며 cv와 짝을 이룬다. | 프레디케이트를 세팅하는 시점과 대기 조건을 확인하는 시점을 직렬화하여, 그 사이로 웨이크업이 새어나가지 못하게 한다. |
cv (std::condition_variable) | 대기/알림 채널. | 블록된 스레드가 스핀 대신 잠들 수 있게 한다. |
done (bool) | 프레디케이트: 요청이 처리되었는가. | 진짜 웨이크업과 spurious wakeup(가짜 깨어남)을 구분한다. cv.wait이 이 값을 재확인한다. |
대기하는 쪽 로직은 enqueue_and_notify에 있다. wait_time이 0이 아니면 블로커를 만들고, 인큐하기 전에 먼저 락을 잡는다 — 이것이 lost-wakeup(놓친 웨이크업)을 막는 장치다.
// worker::enqueue_and_notify -- src/connection/connection_worker.cppif (wait_time) { handle = std::make_shared<message_blocker> (); handle->done = false; lock = std::unique_lock<std::mutex> (handle->m); /* <- hold m before the item is visible */ item.waiter_handle = handle; }this->enqueue (type, std::move (item));if (!this->notify ()) { ... return false; } /* <- notify failed: give up, never wait */if (wait_time) { if (wait_time < 0) handle->cv.wait (lock, [&] { return handle->done; }); else handle->cv.wait_for (lock, std::chrono::seconds (wait_time), [&] { return handle->done; }); }인큐 이전부터 대기 직전까지 m을 계속 쥐고 있다는 것은, 그 사이 메시지를 처리하고 wakeup_blocked_worker를 호출하려는 리액터가 있다면 대기자가 cv 위에 완전히 자리 잡을 때까지 m에서 블록된다는 뜻이다. 프레디케이트 람다는 spurious wakeup을 걸러낸다. 실패 분기도 중요하다. notify()가 실패하면 메시지는 큐에 들어갔지만 아무도 신호를 보낼 수 없는 상태가 되므로, 함수는 대기하지 않고 곧장 false를 반환한다 — 아무도 풀어주지 않을 블로커를 기다린다면 영원히 멈춰버리기 때문이다.
리액터는 기다리던 결과가 확정되는 모든 지점에서 wakeup_blocked_worker를 호출한다. 그리고 결정적으로, 모든 정리(teardown) 경로에서도 반드시 호출하여 대기자가 고아처럼 남겨지는 일이 없도록 한다. handle_connection_close는 close 핸들과 send 블로커를 모두 풀어준다.
// worker::handle_connection_close -- src/connection/connection_worker.cppthis->wakeup_blocked_worker (handle); /* <- release the close waiter */this->wakeup_blocked_worker (ctx->m_send.m_blocker); /* <- and any thread blocked on an unsent packet */send 블로커는 SEND_PACKET 메시지가 디큐될 때 컨텍스트로 옮겨진다(ctx->m_send.m_blocker = std::move (item.waiter_handle)). 그래서 전송이 성공하든, epoll 재무장이 실패하든, 혹은 그 아래에서 커넥션이 닫혀버리든, 정확히 한 곳의 wakeup_blocked_worker (ctx->m_send.m_blocker) 호출이 대기자를 풀어준다. 이 동기식 밸브는 쿼리를 실어 나르는 fire-and-forget 태스크 푸시와는 별개이면서도, 서로를 보완하는 관계에 있다.
8.8 css_get_task_stats — 비활성화된 훅
섹션 제목: “8.8 css_get_task_stats — 비활성화된 훅”// css_get_task_stats -- src/connection/server_support.cvoid css_get_task_stats (UINT64 *stats_out){// css_Server_request_worker_pool->get_task_stats (stats_out);}본문이 주석 처리되어 있다 — 현재 리비전에서 이 훅은 아무 일도 하지 않는 스텁으로, stats_out을 전혀 건드리지 않는다. 따라서 호출자가 버퍼를 미리 0으로 채워두지 않으면 stale한(오래된) 바이트를 그대로 읽게 된다. 태스크 메트릭을 연결하려는 사람은 이 접근자가 죽어 있다는 사실을 알아야 한다. SHOW JOB QUEUES가 실제로 쓰는 코어별 실시간 스캔은 css_wp_core_job_scan_mapper/get_stats 경로를 거치며, 실제로 관리되고 있는 원자적 프리(free) 통계는 11장에서 다룬다.
8.9 챕터 요약 — 핵심 정리
섹션 제목: “8.9 챕터 요약 — 핵심 정리”- 경계는 네 번의 얇은 홉으로 이루어진다. 요청이 완성되면 한 줄짜리
push_task_into_worker_pool→css_push_server_task→new css_server_task→push_task_on_core순으로 호출이 이어진다. 리액터는 풀 내부 요소를 단 하나도 직접 언급하지 않는다. - 태스크의 소유권은 풀에 있다.
css_server_task는CSS_CONN_ENTRY &하나만 들고 있으며retire를 오버라이드하지 않는다. 그래서css_push_server_task의new와 베이스 클래스의delete this가 이 태스크의 생애를 앞뒤로 감싼다. - 카운터는 정확히 한 번의 실행을 감싼다.
add_pending_request/add_working_task는 푸시 전 리액터에서,start_request/end_working_task는execute안에서 풀 쪽이 실행한다.working_task_count가 0에 도달하는 것이 곧CONN_CLOSING커넥션이 정리될 수 있는 조건이다. execute_task는 네 개의 분기를 갖는다. 풀 정지 → 폐기(셧다운 상황에서만), 유휴 워커 있음 → 배정 후 notify, 워커 없음+임시 → 임시 스레드 생성, 워커 없음+일반 → 큐잉. 태스크를 버리는 것은 정지 분기뿐이며, 풀이 가득 찬 분기는 푸시한 쪽을 블록시키는 대신 큐에 쌓는다.- 경계 불변식은 기계적으로 보장된다. 푸시는 최악의 경우에도 인큐 한 번 뒤에 리턴하며 핸들러를 인라인으로 실행하는 법이 없다. 그래서 느린 쿼리 하나가 여러 커넥션을 다중화하는 epoll 루프를 얼려버릴 수 없다.
wakeup_blocked_worker는 별도의 동기식 채널이다. 반드시 블록해야 하는 제어/송신 경로를 위해message_blocker(뮤텍스 + cv +done)와 인큐 전 락 확보가 lost-wakeup의 틈을 막고, 정리(teardown) 경로는 항상 이를 호출해 대기자가 고아로 남지 않게 한다. 쿼리 푸시는 이 채널을 전혀 사용하지 않는다.css_get_task_stats는 스텁이다 — 본문이 주석 처리되어 있으며, 태스크 통계는 이 접근자가 아니라 코어별 스캔에서 얻어진다.
Chapter 9: 백엔드 풀 엔진 I: 구조와 디스패치
섹션 제목: “Chapter 9: 백엔드 풀 엔진 I: 구조와 디스패치”1~8장의 connection reactor는 바이트를 task로 바꾼 뒤 백엔드 풀의
execute()를 호출한다. 이 장은 그 풀 내부를 연다. 독자가 던질 질문은
구체적이다 — 백엔드 풀 안에서 작업은 어떻게 코어들에 나뉘어
배정되고, 유휴 워커에게 넘겨지는가? 답은 3단 중첩 구조다.
worker_pool_impl이 코어들을 소유하고, 각 core_impl이
워커들을 소유하며, 각 worker_impl은 OS 스레드 하나와 컨텍스트
하나를 소유한다 — 여기에 코어를 고르고 워커를 고르는 두 단계의
선택이 더해진다. 10장은 워커의 run() 루프에서 이야기를 이어받는다.
이 장은 task가 워커의 m_wrapped_task에 안착하는 지점에서 멈춘다.
연관 문서: 풀이 코어 단위로 샤딩되는 이유(단일 큐의 캐시 라인 경합, C10K, 풀 크기 설계)는
cubrid-thread-worker-pool.md의 “Core partitioning” 절에서 다룬다. 재설계가 이 엔진을 그대로 두는 이유는cubrid-thread-manager.md의 “The two-layer split” 절을 참고한다.
모든 것이 template <bool Stats>로 템플릿화되어 있다. 이 불리언
하나가 통계 장치를 실체(worker_pool<true>)로 만들거나,
if constexpr로 소거되는 빈 베이스로 만든다. 이후 본문에서는
<Stats> 접미사를 생략한다.
9.1 3단 파티션 구조
섹션 제목: “9.1 3단 파티션 구조”그림 9-1은 소유 관계의 골격이다 — 누가 누구를 unique_ptr로
소유하는지(포함 관계)에, 리프가 루트에 도달할 수 있게 하는 두 개의
비소유 역참조 포인터(m_parent_pool, m_parent_core)를 더한 것이다.
flowchart TD WP["worker_pool_impl<Stats><br/>m_cores : vector<unique_ptr<core>><br/>m_max_workers, m_round_robin_counter, m_stopped"] C0["core_impl #0<br/>m_workers / m_available_workers / m_task_queue<br/>m_workers_mutex"] C1["core_impl #1<br/>..."] W0["worker_impl<br/>m_context_p / m_wrapped_task<br/>m_task_cv / m_task_mutex / m_has_thread"] W1["worker_impl<br/>..."] WP -->|소유| C0 WP -->|소유| C1 C0 -->|소유| W0 C0 -->|소유| W1 W0 -.->|m_parent_core| C0 C0 -.->|m_parent_pool| WP
그림 9-1 — 소유 관계(실선)와 역참조(점선) 엣지.
풀은 코어를 선택하고(9.6), 코어는 워커를 선택하거나 task를
큐에 넣으며(9.7), 워커는 실행 중인 스레드를 소유한다. 뮤텍스는 두
계층에 각각 하나씩 존재한다 — core_impl::m_workers_mutex는
코어별 리스트들을 지키고, worker_impl::m_task_mutex는 워커
하나의 핸드오프 슬롯을 지킨다. 이 둘의 범위를 겹치지 않게 유지하는
것이 정확성의 전부다.
불변조건 — 워커/코어 파티션은 초기화 시점에 고정된다. 모든 워커는 평생 하나의 코어에 속한다.
assign_workers_to_cores는 앞의remainder개 코어에는quotient + 1개를, 나머지 코어에는quotient개를 배정하므로, 코어별 워커 수의 합은m_max_workers와 같다. 런타임에 움직이는 것은 task뿐이다. 이 합이 깨지면get_round_robin_core_hash의 비례 디스패치가 왜곡된다.
9.2 worker_pool_impl — 루트
섹션 제목: “9.2 worker_pool_impl — 루트”// worker_pool_impl<Stats> -- src/thread/thread_worker_pool_impl.hpptemplate <bool Stats>class worker_pool_impl : public worker_pool{ std::vector<std::unique_ptr<core>> m_cores; /* the cores, owned */ std::size_t m_max_workers; /* cap across all cores */ std::atomic<bool> m_stopped; /* one-way latch */ std::atomic<std::size_t> m_round_robin_counter; /* dispatch cursor */};| Field | Role | Why it exists |
|---|---|---|
m_cores | 소유하는 코어들의 벡터; 인덱스가 “코어 해시” 도메인이 된다 | 워커 집합을 나누는 중간 계층; 풀과 함께 코어가 소멸하도록 unique_ptr 사용 |
m_max_workers | 전체 동시-워커 상한(코어별 합) | 스레드 수의 상한이자 라운드로빈 커서의 wrap 모듈러스(9.6) |
m_stopped | stop_execution 전까지 false, 이후 영원히 true | execute_task가 읽는 is_running() 게이트; 어떤 프로듀서가 보더라도 래치를 놓치지 않도록 atomic |
m_round_robin_counter | [0, m_max_workers) 범위를 도는 단조 증가 디스패치 커서 | 기본 코어 선택 정책; atomic CAS로 동시 execute() 호출이 lock-free가 된다 |
베이스 클래스(worker_pool, thread_worker_pool.hpp)의 필드 네
개가 이후 논의에서 중요하다 — m_name(스레드 이름),
m_entry_manager(컨텍스트를 발급·회수, 7장),
m_pool_threads(초기화 시점에 미리 시작할지 첫 task에서 시작할지),
m_idle_timeout(10장의 condvar 대기 상한).
불변조건 —
m_stopped은 단방향 래치다.stop_execution은m_stopped.exchange(true)를 수행하며, 이전 값이false였음을 본 쪽이 teardown의 소유자가 된다. 이 값은 다시 풀리지 않으므로execute_task는 “실행 중이 아님”을 종결 상태로 취급하고 task를 retire한다. 경합 중인 프로듀서가 오래된false를 읽고 task 하나를 enqueue하더라도, 그 task는 나중에retire_queued_tasks가 버린다 — 안전하며, 누수가 아니다.
9.3 core_impl — 중간 계층
섹션 제목: “9.3 core_impl — 중간 계층”// worker_pool_impl<Stats>::core_impl -- src/thread/thread_worker_pool_impl.hpptemplate <bool Stats>class worker_pool_impl<Stats>::core_impl : public worker_pool::core{ std::vector<std::unique_ptr<worker>> m_workers; /* pooled workers, owned */ std::vector<worker *> m_available_workers; /* idle & ready (non-owning) */ std::queue<wrapped_task> m_task_queue; /* overflow when none idle */ mutable std::mutex m_workers_mutex; /* guards the three above */
std::vector<std::unique_ptr<worker>> m_temp_workers; /* throwaway SP/method workers */ std::vector<std::unique_ptr<worker>> m_free_temp_workers; /* finished temps, pending join */ mutable std::mutex m_temp_workers_mutex;/* guards the two temp lists */};| Field | Role | Why it exists |
|---|---|---|
m_workers | 이 코어에 고정 배정된 풀링 워커 명단 | 소유하는 저장소; initialize 이후로는 크기가 바뀌지 않는다 |
m_available_workers | 현재 유휴 상태이고 사용 가능한 워커들을 가리키는 raw 포인터 | execute_task가 팝하는 프리 리스트; m_workers가 소유하므로 raw로 둔다 |
m_task_queue | 유휴 워커가 없을 때 도착한 task들의 FIFO | 프로듀서를 블로킹하지 않고 버스트를 흡수한다 |
m_workers_mutex | 위 세 리스트를 직렬화한다 | 워커 팝과 task 푸시를 원자적으로 만든다; const 관찰자도 잠글 수 있도록 mutable |
m_temp_workers | 중첩된 SP/method task를 위한 비풀링 워커 | task 안에서 실행되는 task가 실행 중인 워커를 가로채면 안 된다; 임시 스레드가 대신 실행한다(9.7) |
m_free_temp_workers | 종료 후 여기 대기 중인 임시 워커들 | 소멸을 지연시킴 — 자신의 스레드가 실행 중인 동안에는 임시 워커가 자기 자신의 unique_ptr을 지울 수 없다 |
m_temp_workers_mutex | 두 임시 리스트를 모두 보호한다 | 별도 락으로 두어 임시 워커의 잦은 생성/소멸이 핫 경로인 풀링 워커 경로와 경합하지 않게 한다 |
worker_pool::core에서 상속받은 것: m_parent_pool(is_running()과
entry manager에 도달)과 m_pool_threads.
불변조건 — 가용 리스트는 멤버십의 상한이다.
m_available_workers.size() <= m_workers.size()는 항상 성립한다.get_task_or_become_available과become_available모두push_back뒤 이를assert한다. 워커 하나는 최대 한 번만 등장한다 — 배정 전에 팝되고(9.7), 스레드가 다시 유휴 상태로 돌아올 때만 재푸시된다(10장). 이중 등재를 허용하면 두 task가 워커 하나의 단일m_wrapped_task를 두고 충돌하게 된다.
9.4 worker_impl — 스레드를 소유하는 리프
섹션 제목: “9.4 worker_impl — 스레드를 소유하는 리프”// worker_pool_impl<Stats>::core_impl::worker_impl -- src/thread/thread_worker_pool_impl.hpptemplate <bool Stats>class worker_pool_impl<Stats>::core_impl::worker_impl : public worker_pool::core::worker{ context_type *m_context_p; /* execution context, lives with the thread */ std::optional<wrapped_task> m_wrapped_task; /* the one pending/current task */ std::condition_variable m_task_cv; /* wake on assign or stop */ std::mutex m_task_mutex; /* guards m_wrapped_task / m_has_thread / m_stop */ bool m_stop; /* pool asked this worker to stop */ bool m_has_thread; /* an OS thread is live for this worker */ bool m_is_temp; /* throwaway temp worker */ stats_base m_stats; /* empty base unless Stats */};| Field | Role | Why it exists |
|---|---|---|
m_context_p | 이 스레드에 묶인 thread_entry 컨텍스트를 가리키는 포인터 | task들 사이에서 재사용됨; init_run에서 생성되고 finish_run에서 회수됨(7장/10장) |
m_wrapped_task | 배정된 단일 task를 담는 std::optional | 핸드오프 슬롯; has_value()가 condvar가 대기하는 “일이 있다” 조건이다 |
m_task_cv | 파킹된 스레드가 잠드는 condition variable | assign을 스핀이 아닌 wake로 바꾼다; stop 시에도 깨어난다 |
m_task_mutex | 핸드오프를 위한 워커별 락 | 범위가 워커 하나이므로, 서로 다른 워커로의 assign은 절대 경합하지 않는다 |
m_stop | 풀이 teardown할 때 true로 설정 | condvar 조건을 깨뜨려 스레드가 대기 대신 종료하게 한다 |
m_has_thread | OS 스레드가 이 워커를 서비스하는 동안 true | assign_task에서 wake냐 spawn이냐를 가르는 false→true→false 전환 |
m_is_temp | 비풀링 임시 워커임을 표시 | 임시 경로는 condvar를 건너뛴다: 한 번 실행하고 free-temp 리스트에 스스로 파킹 |
m_stats | stats_base — Stats==false일 때 비어 있음 | 꺼져 있을 때는 EBO로 크기가 0인 베이스; 켜져 있을 때는 실제 statset |
상속받은 필드: m_parent_core(큐, entry manager, 풀 이름에 도달).
불변조건 — 워커당 task는 하나.
assign_task는emplace전에!m_wrapped_task.has_value()를 assert한다. 워커가 task를 받는 경우는m_available_workers에서 팝된 직후뿐이거나(다른 프로듀서가 같은 워커를 다시 노릴 수 없다), 큐에 쌓인 task라면 자신의run루프 안에서뿐이다. 이optional을 어기면 task 하나를 놓치게 된다.
9.5 wrapped_task와 stats 계열
섹션 제목: “9.5 wrapped_task와 stats 계열”wrapped_task는 raw task_type *를 감싸는 move-only 봉투다.
페이로드는 컴파일 타임에 선택되므로, stats를 쓰지 않는 빌드는
타이밍 정보를 전혀 들고 다니지 않는다.
// worker_pool_impl<Stats>::wrapped_task -- src/thread/thread_worker_pool_impl.hppstruct task_only { task_type *task; };struct task_with_stats { task_type *task; cubperf::time_point time; };using inner_type = typename std::conditional_t<Stats, task_with_stats, task_only>;// ...inner_type m_inner; /* the only data member */| Field / type | Role | Why it exists |
|---|---|---|
task_only::task | 타이밍 없는 raw task 포인터 | Stats==false일 때 봉투 전체 |
task_with_stats::task | 동일한 포인터 | Stats==true일 때의 페이로드 |
task_with_stats::time | enqueue 시각(cubperf::time_point) | stats가 큐-실행 사이의 지연을 측정할 수 있게 함 |
inner_type | conditional_t<Stats, ...> 별칭 | 런타임 분기 없이 컴파일 타임에 레이아웃을 결정 |
m_inner | 유일한 멤버 | wrapped_task를 얇고 이동 가능한 래퍼로 유지 |
불변조건 — move-only이며 이중 retire는 없다. 복사 생성자/대입은
= delete이고, move 생성자는 원본을 null로 만들며 소멸자는m_inner.task == nullptr을 assert한다. 그래서 살아 있는 봉투는 task를 정확히 한 번만 소유하며,retire()는task->retire()뒤에task를 null로 만들어 두 번째 retire를 assert가 잡아내는 no-op으로 만든다. task를 소비하는 모든 경로는std::move를 사용한다.
stats 계열은 서로 협력하는 세 타입으로 이루어진다. stats는
메트릭 분류 체계와 헬퍼들을 담은, 전부 static인 정책 클래스다(각각
if constexpr (Stats)로 감싸져 있다). stats_base는 워커별
저장소로, worker_pool<true> 특수화가 이를 채우고 1차 템플릿은
비워 둔다. 축적 과정은 11장이 해부한다. 여기서는 false 빌드에서
m_stats가 아무 비용도 들지 않는다는 것만 알면 된다.
| Field / type | Role | Why it exists |
|---|---|---|
stats::id | enum class id : cubperf::stat_id, start_thread부터 retire_context까지(0–7) 값을 갖고 마지막에 type_count sentinel | 8개 생애주기 메트릭에 이름을 붙임; type_count가 정의 배열의 크기를 정함 |
stats::statdef | static const cubperf::statset_definition — id마다 COUNTER_AND_TIMER 쌍 하나 | 컴파일 타임 메트릭 레지스트리(이벤트마다 카운트 하나와 지속시간 하나) |
stats_base(1차 템플릿) | 빈 클래스 — Stats==false일 때 EBO로 크기 0 | non-stats 빌드에서 m_stats를 공짜로 만듦 |
stats_base<true>::statset | cubperf::statset *, 소유; stats::destroy에서 delete됨 | 살아 있는 워커별 카운터/타이머 저장소 |
stats_base<true>::time | cubperf::time_point 클록 기준점 | 11장이 축적하는 “마지막 이벤트 이후 경과 시간” 델타의 기준점 |
9.6 코어 선택 — execute와 get_round_robin_core_hash
섹션 제목: “9.6 코어 선택 — execute와 get_round_robin_core_hash”execute는 단 두 줄이다 — 코어 인덱스를 고르고, 그 코어로
디스패치한다.
// worker_pool_impl<Stats>::execute -- src/thread/thread_worker_pool_impl.hppvoid worker_pool_impl<Stats>::execute (task_type *work_arg){ execute_on_core (work_arg, get_next_core ()); /* policy -> core index */}// execute_on_core:core_index = core_hash % m_cores.size (); /* fold hash into range */m_cores[core_index]->execute_task (work_arg, is_temp);get_next_core는 virtual이며, 기본값은
get_round_robin_core_hash다(6장의 서브클래스들이 이 정책을
오버라이드한다). 커서는 m_cores.size()가 아니라
m_max_workers에서 wrap한다 — 이것은 의도적이다.
// worker_pool_impl<Stats>::get_round_robin_core_hash -- src/thread/thread_worker_pool_impl.hppwhile (true) { index = m_round_robin_counter; next_index = index + 1; if (next_index == m_max_workers) /* <- wrap on TOTAL workers, not core count */ { next_index = 0; } if (m_round_robin_counter.compare_exchange_strong (index, next_index)) { break; /* <- won the CAS, index is mine */ } /* lost the race: CAS reloaded `index`, retry */ }return index; /* caller does index % m_cores.size() */그림 9-2는 CAS 루프의 두 분기를 모두 추적한다.
flowchart TD
A["index = m_round_robin_counter 읽기"] --> B["next = index + 1"]
B --> C{"next == m_max_workers ?"}
C -->|예| D["next = 0 (wrap)"]
C -->|아니오| E["next 유지"]
D --> F{"CAS(counter, index -> next) ?"}
E --> F
F -->|성공| G["index 반환"]
F -->|경합 실패| A
G --> H["execute_on_core: core = index % m_cores.size()"]
그림 9-2 — execute_on_core로 이어지는 get_round_robin_core_hash의
CAS 루프.
커서의 주기는 m_max_workers이고 접는(fold) 모듈러스는
m_cores.size()이므로, 각 코어는 자신의 워커 수에 비례해
방문된다 — 예를 들어 워커 15개를 코어 4개에 나누면, 0..14를
% 4로 접었을 때 코어 0–2는 사이클당 4번씩, 코어 3은 3번
방문된다:
| Cursor 0..14 | % 4 sequence | Core 0 | Core 1 | Core 2 | Core 3 |
|---|---|---|---|---|---|
| 한 주기 전체 | 0 1 2 3 0 1 2 3 0 1 2 3 0 1 2 | 4 | 4 | 4 | 3 |
불변조건 — 디스패치는 코어 크기에 비례한다. 커서 주기
m_max_workers와 접기 모듈러스m_cores.size()는 각 코어에 자신의 워커 비중만큼의 task 몫을 준다 — 단, 이는m_max_workers가 코어별 워커 수의 합(9.1)일 때에만 성립한다.m_cores.size()에서 wrap했다면 균등하게 나뉘어 작은 코어가 과부하될 것이다. 강한(strong) CAS는 각 인덱스를 정확히 한 번만 차지하게 한다; 가시성 순서는 무관하므로seq_cst는 부수적일 뿐이다.
9.7 코어 내부의 디스패치 — core_impl::execute_task
섹션 제목: “9.7 코어 내부의 디스패치 — core_impl::execute_task”코어가 정해지면, execute_task는 거부 검사를 거친 뒤 task를 세
목적지 중 하나로 보낸다 — 유휴 워커, 새로 만든 임시 스레드,
오버플로 큐. 그림 9-3은 모든 분기를 빠짐없이 담고 있다.
flowchart TD
S["execute_task(task_p, is_temp)"] --> A{"parent pool의 is_running() ?"}
A -->|아니오| R["task_p->retire(); return"]
A -->|예| B["task 래핑; m_workers_mutex 잠금"]
B --> C{"m_available_workers 비어 있음 ?"}
C -->|아니오| D["refp = back(); pop_back()<br/>unlock; refp->assign_task(move)"]
C -->|예| E{"is_temp ?"}
E -->|예| F["unlock; execute_task_as_temp(move)"]
E -->|아니오| G["m_task_queue.push(move)<br/>(락을 쥔 채로)"]
그림 9-3 — core_impl::execute_task의 목적지들.
// worker_pool_impl<Stats>::core_impl::execute_task -- src/thread/thread_worker_pool_impl.hppassert (task_p != nullptr);if (!m_parent_pool->is_running ()) { task_p->retire (); /* REJECT: pool stopping, never enqueue */ return; }wrapped_task task_ref (task_p);std::unique_lock<std::mutex> ulock (m_workers_mutex);if (!m_available_workers.empty ()) { refp = static_cast<worker_impl *> (m_available_workers.back ()); m_available_workers.pop_back (); ulock.unlock (); /* release BEFORE assign to avoid lock nesting */ refp->assign_task (std::move (task_ref)); /* FAST: hand to idle worker */ }else { if (is_temp) { ulock.unlock (); /* drop m_workers_mutex before spawning temp */ execute_task_as_temp (std::move (task_ref)); /* TEMP path */ } else { m_task_queue.push (std::move (task_ref)); /* QUEUE path, lock held */ } }인라인 주석이 짚어주는 미묘한 지점이 둘 있다. fast 경로는
assign_task(워커의 m_task_mutex를 잡는다) 이전에
m_workers_mutex를 풀어서 두 락이 절대 중첩되지 않게 한다 — 코어
대 워커 데드락이 없다는 뜻이다. queue 경로는 m_workers_mutex를
쥔 채로 push하므로, enqueue는 같은 락 아래에서 큐를 확인하는
워커의 get_task_or_become_available(10장)과의 사이에서 원자적이다.
temp 경로는 일회용 worker_impl을 통해 중첩된 SP/method
task를 처리하므로, 자신이 막고 있는 워커의 뒤에서 기다리는 일이
없다.
불변조건 — task는 정확히 하나의 락 보유 구간 아래에서 소유된다. 워커-팝과 큐-푸시는
m_workers_mutex아래에서 서로 배타적이다. 여기에get_task_or_become_available이 같은 락 아래 프리 리스트에 추가하기 전에 큐를 먼저 확인한다는 점이 더해지면, task와 유휴 워커가 서로를 놓치는 창(window)은 존재하지 않는다. lost-wakeup은 이 공유 락으로 닫힌다.
9.8 워커에게 넘기기 — assign_task와 become_available
섹션 제목: “9.8 워커에게 넘기기 — assign_task와 become_available”assign_task(wrapped_task &&)는 task가 코어에서 워커로 건너가는
지점이다 — task를 배치한 다음, 파킹된 스레드를 깨우거나(wake)
새 스레드를 생성한다(spawn). 선택은 m_has_thread에 달려
있다.
// worker_impl::assign_task(wrapped_task &&) -- src/thread/thread_worker_pool_impl.hppstd::unique_lock<std::mutex> ulock (m_task_mutex);assert (!m_wrapped_task.has_value ());m_wrapped_task.emplace (std::move (task_ref)); /* place the task */if (m_is_temp) { m_has_thread = true; assert (m_context_p == nullptr); start_thread (); /* temp: always a fresh thread */ }if (m_has_thread) { ulock.unlock (); /* WAKE: notify outside the lock */ m_task_cv.notify_one (); }else { m_has_thread = true; ulock.unlock (); assert (m_context_p == nullptr); start_thread (); /* SPAWN: first task, new thread */ }temp 분기는 항상 start_thread한다(뒤이은 notify_one은 무해한
no-op이다 — 임시 스레드는 m_task_cv에서 절대 대기하지 않는다,
10장). wake 분기는 notify_one 전에 unlock하여, 깨어난
스레드가 방금 쥐고 있던 뮤텍스에 곧바로 다시 걸리지 않게 한다.
spawn 분기는 락 아래에서 m_has_thread를 뒤집고, unlock한 뒤
worker_impl::run을 실행하는 detached 스레드를 start_thread한다.
prestart 오버로드인 assign_task(void)는 task를 전혀
배정하지 않는다 — 그저 살아 있는 파킹된 스레드 하나를 보장할
뿐이다. 이는 풀링 스레드를 미리 시작하는 데 쓰인다:
m_pool_threads가 설정되어 있을 때 initialize_workers에서,
그리고 스레드가 없는 가용 워커를 모두 미리 시작시키는 warmup에서.
// worker_impl::assign_task(void) -- src/thread/thread_worker_pool_impl.hppassert (!m_wrapped_task.has_value ());assert (!m_is_temp); /* never prestart a temp */if (m_has_thread) { ulock.unlock (); /* already has a thread: nothing to do */ }else { m_has_thread = true; ulock.unlock (); assert (m_context_p == nullptr); start_thread (); /* spawn a thread that will block waiting */ }미리 시작된 스레드는 task 없이 run에 진입하고, 큐가 비어
있음을 확인한 뒤 파킹한다 — 그래서 첫 실제 task는 핫 경로에서
스레드 생성 지연을 치르는 대신, 값싼 wake 경로를 타게 된다.
become_available은 정반대의 마이크로 연산으로, 정지 중인
워커(m_stop이 설정된)가 teardown 회계를 일관되게 유지하기 위해
여전히 가용성을 재선언해야 할 때 쓰인다:
// core_impl::become_available -- src/thread/thread_worker_pool_impl.hppstd::unique_lock<std::mutex> ulock (m_workers_mutex);m_available_workers.push_back (&worker_arg);assert (m_available_workers.size () <= m_workers.size ());get_task_or_become_available(10장)과 달리 이 함수는 큐를 먼저
확인하지 않는다 — 정지 중인 스레드는 더 이상 일을 원하지
않으며, 오직 파티션 불변조건이 의존하는 pop/push 회계를 균형
있게 유지하려 할 뿐이다.
불변조건 —
m_has_thread의 false→true는 일회성 spawn 게이트다.m_task_mutex아래에서, 정확히 하나의 경로만 이를 false→true로 뒤집고 곧바로start_thread한다. 역방향 전환은 오직 파킹된 스레드가 비어 있는 채로 타임아웃될 때get_new_task에서만 일어난다. 그래서 워커는 살아 있는 스레드를 절대 두 개 이상 갖지 않으며, 스레드가 이미 존재하는 동안 배정되는 task는 항상 wake로 처리된다. 뮤텍스 규율이 깨지면 두 스레드가 하나의m_wrapped_task를 두고 경합하며 비울 수 있다.
9.9 챕터 요약 — 핵심 정리
섹션 제목: “9.9 챕터 요약 — 핵심 정리”- 3단 계층, 2단계 선택.
worker_pool_impl은 코어들을,core_impl은 워커들을,worker_impl은 스레드+컨텍스트 하나를 소유한다. 디스패치는 코어 고르기(get_next_core) 다음 워커 고르기-또는-큐잉(execute_task)의 순서다. - 파티션은 정적이며, 움직이는 것은 task뿐이다.
assign_workers_to_cores는 각 워커를 평생 하나의 코어에 고정한다(앞의remainder개 코어가 하나씩 더 받는다); 코어별 개수의 합은m_max_workers다. - 라운드로빈은 균등이 아니라 비례다. 커서는
m_max_workers에서 wrap하고index % m_cores.size()로 접히므로, 각 코어의 task 몫은 그 워커 몫과 같다 — 더 작은 코어는 한 사이클에 한 번 건너뛰어진다. execute_task는 목적지가 네 곳, 락은 하나. 거부, fast(유휴 워커, unlock 후 assign), temp(일회용 스레드에서 실행되는 중첩 SP/method), queue — 그리고 워커-팝과 task-푸시는m_workers_mutex아래에서 원자적이어서 lost-wakeup 창을 닫는다.- 핸드오프는
m_has_thread에 따른 wake-or-spawn이다.assign_task는 워커의optional을 채운 뒤, 파킹된 스레드를notify_one하거나 새 스레드를start_thread한다; task 없는 오버로드를 통한 prestart는 spawn 지연을 핫 경로 밖으로 밀어낸다. - 서로 겹치지 않는 두 뮤텍스 범위.
m_workers_mutex는 코어별 리스트를,m_task_mutex는 워커 하나의 핸드오프를 지킨다;execute_task는 워커에 들어가기 전에 코어 락을 풀어서, 두 락이 절대 중첩되지 않는다. Stats는 컴파일 타임에 공짜다.wrapped_task와m_stats는worker_pool<false>에서std::conditional_t와 빈stats_base를 통해 타이밍 없는 레이아웃으로 축소된다; 11장이 실제 동작 경로를 다룬다.
Chapter 10: 백엔드 풀 엔진 II — 워커 실행 루프
섹션 제목: “Chapter 10: 백엔드 풀 엔진 II — 워커 실행 루프”9장은 push 쪽 흐름을 따라갔다. 이 장은 워커 스레드 자신의 시점에서 pull 쪽을
따라간다: 새로 spawn된 std::thread가 실행하는 함수 worker_impl::run과 그것이
거쳐가는 모든 것 — 컨텍스트 브래킷(init_run / finish_run), “태스크를 하나 더
실행할지” 아니면 “이 스레드를 retire할지”를 가르는 condition-variable wait,
temp 워커의 단발성 경로, 풀 teardown, 그리고 worker_pool_task_capper admission
gate까지 다룬다.
모든 구조체는 9장에서 이미 소개했다; 이 장의 이야기는 동일한 worker_impl
필드들이 상태를 오가며 움직이는 과정이므로, 10.1절은 행렬표로 시작한다.
연결 참고: 풀이 유휴 스레드를 retire하고 필요 시 다시 spawn하는 이유는
cubrid-thread-worker-pool.md문서의 “cubthread::worker_pool— partitioned pool with per-core queues” 절에서 설명한다; temp 워커가 stored-procedure 재귀 호출 상황을 처리하는 이야기는cubrid-thread-manager.md문서의 “Task worker pool sizing” 절에 있다. 이 장은 그 과정의 한 줄 한 줄에 해당하는 HOW다.
10.1 워커 생명주기와 이를 좌우하는 필드들
섹션 제목: “10.1 워커 생명주기와 이를 좌우하는 필드들”worker_impl은 스레드가 아니라 재사용 가능한 슬롯이다; 생애 동안 이 슬롯은
0개, 1개, 또는 여러 개의 OS 스레드를 차례로 소유할 수 있다. 이 순환을 인코딩하는
필드는 네 개다.
| 필드 | INACTIVE (스레드 없음) | RUNNING (태스크 실행 중) | WAITING (태스크 대기 중) | RETIRING |
|---|---|---|---|---|
m_has_thread | false | true | true | get_new_task에서 true→false로 바뀜 |
m_wrapped_task | nullopt | 현재 태스크를 보유 | 할당 전까지 nullopt | nullopt |
m_context_p | nullptr | 유효한 entry * | 유효한 entry * | finish_run에서 유효값→nullptr로 |
m_stop | false | 경우에 따라 true | stop 시 true로 바뀜 | true 또는 timeout |
m_is_temp | 생성 시 고정됨 — temp 슬롯은 결코 WAITING에 들어가지 않는다; 태스크 하나를 실행하고 곧바로 소멸한다 |
불변식 — m_has_thread는 슬롯당 단일 스레드를 보장하는 래치다. 오직
m_task_mutex 아래에서만 쓰여지며, context가 retire되기 전에 false로
설정된다 — 그래야 동시에 실행되는 assign_task가 false를 보고 죽어가는
스레드에 notify하는 대신 새 스레드를 spawn한다; 이 순서를 어기면 두 스레드가
하나의 m_context_p를 두고 경합하게 된다.
stateDiagram-v2
[*] --> Inactive
Inactive --> Starting : assign_task가\nm_has_thread를 true로 설정\nstart_thread 호출
Starting --> InitRun : run 진입\ninit_run이 컨텍스트 생성
InitRun --> Executing : execute_current_task 실행
Executing --> GetNewTask : 태스크 retire\ncontext recycle
GetNewTask --> Executing : 큐에 있던 태스크\n또는 cv가 태스크와 함께 깨어남
GetNewTask --> Retiring : 비어있고 timeout\n또는 m_stop
Retiring --> Inactive : m_has_thread false\nfinish_run이 context retire
InitRun --> TempExec : m_is_temp
TempExec --> TempFinish : 태스크 하나 실행\nfinish_run
TempFinish --> [*] : register_free_temp_list\n스레드 종료
Figure 10-1. worker_impl 슬롯 생명주기. Pooled는 Executing↔GetNewTask
사이를 순환하고, temp는 단발성 분기를 탄다.
10.2 worker_impl::run — 스레드 진입점
섹션 제목: “10.2 worker_impl::run — 스레드 진입점”run은 start_thread에서 std::thread에 전달되는 함수이며, m_is_temp에 따라
두 가지 형태 중 하나로 갈라진다.
// worker_impl::run -- src/thread/thread_worker_pool_impl.hppvoid worker_pool_impl<Stats>::core_impl::worker_impl::run (void){ os::resources::cpu::clearaffinity (); /* <- drop the spawner's CPU pin */ pthread_setname_np (pthread_self (), ... get_name ().c_str ()); /* <- name for top/gdb */ init_run (); /* <- create the entry context */ if (m_is_temp) { execute_current_task (); /* <- temp always arrives WITH a task */ finish_run (); /* <- retire context, self-park */ return; /* <- thread dies; no loop, no wait */ } if (!m_wrapped_task.has_value ()) { if (get_new_task ()) /* <- prestart path: warmup / pool_threads */ { assert (m_wrapped_task.has_value ()); } } if (m_wrapped_task.has_value ()) { do { execute_current_task (); } while (get_new_task ()); /* <- get_new_task false is the ONLY loop exit */ } // else: never got a task (prestarted, then instant timeout/stop)}clearaffinity는 OS 스케줄링을 자유롭게 풀어준다; init_run은 항상 실행된다.
temp 분기는 태스크 하나를 실행하고 곧바로 return한다(절대 기다리지 않는다).
pooled 경로는 태스크 없이 먼저 시작할 수 있는데(warmup / pool_threads),
이 경우 get_new_task를 한 번 호출한다; 이후 루프는 get_new_task가 false를
반환할 때까지 돈다 — 이 시점에는 이미 내부적으로 finish_run이 호출된 뒤이므로
(10.5절), run은 그대로 함수 끝에 도달해 falls off하고 스레드가 종료된다.
10.3 init_run / finish_run — 컨텍스트 브래킷
섹션 제목: “10.3 init_run / finish_run — 컨텍스트 브래킷”각 스레드는 정확히 하나의 context create/retire 쌍으로 자신의 작업을 감싼다.
// worker_impl::init_run -- src/thread/thread_worker_pool_impl.hppvoid ...::worker_impl::init_run (void){ assert (m_has_thread); if (!m_is_temp) /* <- debug: a running pooled slot must be off the available list */ static_cast<core_impl *> (m_parent_core)->check_worker_not_available (*this); // ... stats ... m_context_p = &m_parent_core->get_entry_manager ().create_context (); /* <- claim entry */}
// worker_impl::finish_run -- src/thread/thread_worker_pool_impl.hppvoid ...::worker_impl::finish_run (void){ assert (!m_wrapped_task.has_value ()); /* <- never retire context with a task pending */ assert (m_context_p != nullptr); m_parent_core->get_entry_manager ().retire_context (*m_context_p); m_context_p = nullptr; if (m_is_temp) static_cast<core_impl *> (m_parent_core)->register_free_temp_list (this); /* <- self-park */}create_context는 풀에 있는 cubthread::entry를 하나 확보하고 매니저의
on_create 훅을 실행한다(트랜잭션 워커의 경우 트랜잭션 인덱스를 할당한다).
불변식 — context의 생명주기는 스레드의 생명주기와 같다. m_context_p는
정확히 init_run과 finish_run 사이의 구간에서만 non-null이다; 태스크와 태스크
사이에는 retire되는 게 아니라 recycle된다(10.4절) — 훨씬 저렴하다. 곧 스레드가
종료될 temp 슬롯은 스스로를 delete할 수 없으므로, 나중에 스윕될 수 있도록
register_free_temp_list(10.6절)를 통해 스스로를 파킹한다.
10.4 execute_current_task와 태스크 retire
섹션 제목: “10.4 execute_current_task와 태스크 retire”// worker_impl::execute_current_task -- src/thread/thread_worker_pool_impl.hppvoid ...::worker_impl::execute_current_task (void){ assert (m_wrapped_task.has_value ()); m_wrapped_task->execute (*m_context_p); /* <- task->execute(entry&) */ retire_current_task (); /* <- task->retire(); reset optional */ m_parent_core->get_entry_manager ().recycle_context (*m_context_p); /* <- cheap reset */ if (m_is_temp == false) static_cast<core_impl *> (m_parent_core)->free_all_temp_list (); /* <- reclaim dead temps */}retire_current_task는 m_wrapped_task->retire()를 호출하고(기본 구현은
delete this) optional을 reset하는데, 이것이 finish_run의 pre-task assert를
만족시킨다 — 태스크는 temp 경로에서도 execute 이후 항상 retire된다. 마지막의
free_all_temp_list 호출 덕분에 temp 정리를 위한 별도의 reaper가 필요 없다:
pooled 워커가 작업을 끝낼 때마다 자신이 속한 core의 free-temp 무덤을 비워준다.
temp 슬롯 자신은 이 호출을 건너뛴다(자신은 회수되는 대상이지 회수하는 쪽이
아니기 때문이다).
10.5 get_new_task — 워커 condition에서의 대기
섹션 제목: “10.5 get_new_task — 워커 condition에서의 대기”pooled 워커가 블로킹되는 유일한 지점이자, 슬롯이 retire를 결정하는 곳이다.
// worker_impl::get_new_task -- src/thread/thread_worker_pool_impl.hppbool ...::worker_impl::get_new_task (void){ assert (!m_wrapped_task.has_value ()); std::unique_lock<std::mutex> ulock (m_task_mutex, std::defer_lock); /* <- not yet held */ if (!m_stop) { std::optional<wrapped_task> queued_task = static_cast<core_impl *> (m_parent_core)->get_task_or_become_available (*this); if (queued_task.has_value ()) { m_wrapped_task.emplace (std::move (*queued_task)); return true; /* <- fast path: drained the queue */ } ulock.lock (); if (!m_wrapped_task.has_value () && !m_stop) condvar_wait (m_task_cv, ulock, ... get_idle_timeout (), [this] { return m_wrapped_task.has_value () || m_stop; }); } else { static_cast<core_impl *> (m_parent_core)->become_available (*this); /* <- re-advertise */ ulock.lock (); } if (!m_wrapped_task.has_value ()) { m_has_thread = false; /* <- latch: next assign_task must spawn a new thread */ finish_run (); /* <- retire context BEFORE releasing the slot */ return false; } ulock.unlock (); /* <- else: a pusher populated m_wrapped_task */ return true; /* <- wakeup_with_task */}get_task_or_become_available(9장)은 큐에 있는 태스크를 원자적으로 반환하거나,
큐가 비어 있으면 이 슬롯을 m_available_workers에 밀어넣고 nullopt를
반환한다 — 그래서 wait 경로에 들어갈 때는 이미 이 슬롯이 “가능”으로 광고된
상태이고, pusher가 언제든 m_wrapped_task를 세팅할 수 있다. 바로 이 때문에
람다는 m_task_mutex 아래에서 다시 확인한다.
stateDiagram-v2
[*] --> CheckStop
CheckStop --> GetOrAvail : m_stop 아님
CheckStop --> BecomeAvail : m_stop, 정지 중
GetOrAvail --> EmplaceReturn : 큐에서 태스크 발견
GetOrAvail --> LockMutex : 큐가 비어 있음, available list에 등록됨
LockMutex --> Wait : 태스크 없고 stop 아님
LockMutex --> CheckTask : wait 생략
BecomeAvail --> CheckTask : mutex lock
Wait --> CheckTask : 태스크, stop, 또는 idle_timeout
CheckTask --> Retire : m_wrapped_task 비어있음
CheckTask --> RunTask : m_wrapped_task 설정됨
EmplaceReturn --> [*] : found_in_queue\ntrue 반환
Retire --> [*] : m_has_thread false\nfinish_run\nfalse 반환
RunTask --> [*] : wakeup_with_task\ntrue 반환
Figure 10-2. worker_impl::get_new_task. false로 끝나는 두 경로(빈 큐에서의
idle-timeout, 혹은 stop) 모두 retire 노드를 거쳐 나간다; true로 끝나는 모든
경로는 m_wrapped_task가 채워진 채로 빠져나간다.
분기는 다섯 가지다: (1) 정지 중이 아니고, 큐가 비어 있지 않음 — 빠른 경로,
블로킹 없음. (2) 정지 중이 아니고, 큐가 비어 있음 — 이제 슬롯이 available
list에 올라간 상태; mutex를 잡고, 그래도 태스크가 없으면 get_idle_timeout()으로
시간을 제한한 m_task_cv에 대해 condvar_wait한다(무한 timeout이면 wait로,
아니면 wait_for로 귀결된다). (3) 이미 m_stop이 설정됨 — 대기하지 않는다;
teardown 집계가 이 슬롯을 볼 수 있도록 become_available로 다시 광고한 뒤
lock을 잡는다. (4) wait 이후, 태스크 없음 — retire한다: m_has_thread = false, finish_run, return false. (5) wait 이후, 태스크 있음 —
unlock하고 return true.
불변식 — retire 여부의 판단은 m_task_mutex 아래에서 이루어진다.
retire할지 실행할지를 가르는 m_wrapped_task 읽기와 m_has_thread = false
쓰기는 assign_task가 잡는 것과 같은 mutex를 공유한다 — 그래서 “워커가 죽기로
결정하는 것”과 “pusher가 작업을 배정하는 것”이 서로 직렬화되고, 이미 retire를
확정한 슬롯에 태스크가 얹히는 일이 없다.
10.6 temp 워커: 생성과 free-temp 핸드오프
섹션 제목: “10.6 temp 워커: 생성과 free-temp 핸드오프”temp 워커는 재귀 실행 상황 — 예를 들어 실행 중인 태스크(stored procedure 등)가
그 자체로 워커를 하나 더 필요로 하는 경우 — 을 위해 존재한다. 이런 경우 일반
큐에서 블로킹하면 deadlock이 날 수 있다. 생성 코드는 execute_task_as_temp
(dispatch 쪽, 9장)에 있다.
// core_impl::execute_task_as_temp -- src/thread/thread_worker_pool_impl.hppvoid ...::core_impl::execute_task_as_temp (wrapped_task &&task_ref){ auto w = allocate_worker (true); /* <- is_temp = true */ w->set_parent_core (*this); std::lock_guard<std::mutex> ulock (m_temp_workers_mutex); m_temp_workers.push_back (std::move (w)); /* <- core owns the temp slot */ static_cast<worker_impl *> (m_temp_workers.back ().get ())->assign_task (std::move (task_ref));}
// core_impl::register_free_temp_list -- src/thread/thread_worker_pool_impl.hppvoid ...::core_impl::register_free_temp_list (worker *w){ std::unique_lock<std::mutex> ulock (m_temp_workers_mutex); auto it = std::find_if (m_temp_workers.begin (), m_temp_workers.end (), [w] (const std::unique_ptr<worker> &p) { return p.get () == w; }); assert (it != m_temp_workers.end ()); m_free_temp_workers.push_back (std::move (*it)); /* <- move ownership, active -> free */ m_temp_workers.erase (it);}
// core_impl::free_all_temp_list -- src/thread/thread_worker_pool_impl.hppvoid ...::core_impl::free_all_temp_list (){ std::unique_lock<std::mutex> ulock (m_temp_workers_mutex); m_free_temp_workers.clear (); /* <- destroys the parked unique_ptrs */}temp 슬롯에 대한 assign_task는 m_has_thread = true로 설정하고 무조건
start_thread()를 호출한다(notify할 기존 스레드가 없기 때문이다); 새로 뜬
스레드가 실행되어 m_is_temp를 만나고, 한 번 실행한 뒤 finish_run의 temp
꼬리 부분이 unique_ptr를 m_temp_workers에서 m_free_temp_workers로
move한다 — 이 객체는 그 스레드가 아직 finish_run 안에 있기 때문에 살아있는
상태로 유지된다. 이후 pooled 워커의 free_all_temp_list가 free list를
clear()해서 이들을 파괴한다.
불변식 — temp 슬롯은 반드시 다른 스레드에 의해서만 파괴된다. 스스로
파킹하는 동작과, pooled 워커에서만 실행되는 clear()(m_is_temp == false
가드) 조합이 어떤 스레드도 자기 자신의 스택 밑에서 delete this를 실행하지
않도록 보장한다; 두 리스트 모두 m_temp_workers_mutex로 보호된다.
10.7 Teardown: pool, core, worker에 걸친 stop_execution
섹션 제목: “10.7 Teardown: pool, core, worker에 걸친 stop_execution”shutdown은 위에서 아래로 fan-out되고, 아래에서 위로 확인된다.
// worker_pool_impl::stop_execution -- src/thread/thread_worker_pool_impl.hppvoid worker_pool_impl<Stats>::stop_execution (void){ if (m_stopped.exchange (true)) return; /* <- another caller already owns the stop */ // ... 30s(release)/60s(debug) timeout, 10ms spin ... while (true) { bool has_running_workers = false; for (const auto &it : m_cores) has_running_workers |= it->stop_execution (); /* <- notify every worker */ if (!has_running_workers) break; /* <- everyone quiesced */ if (now () > timeout) { assert (false); break; } /* <- gave up; something is wedged */ std::this_thread::sleep_for (time_spin_sleep); } for (const auto &it : m_cores) /* <- queues now safe to drain */ static_cast<core_impl *> (it.get ())->retire_queued_tasks ();}불변식 — teardown은 정확히 하나의 스레드만 수행한다. m_stopped.exchange (true)는 이전 값을 반환하므로, 가장 먼저 호출한 스레드만 false를 보고 전체
spin/retire 과정을 한 번 실행하며, 이후에 호출하는 스레드들은 그냥 return한다.
is_running()은 !m_stopped를 읽으므로, 플래그가 뒤집히는 순간 execute_task
(9장)는 새 태스크를 거부하기 시작한다. 루프는 모두 조용해진 sweep에서
정상적으로 break되거나, timeout에서 assert(false)와 함께 비정상적으로
break된다(release 빌드에서는 컴파일되어 사라진다). 큐는 quiescence(모두 정지)
이후에만 drain된다.
// worker_impl::stop_execution -- src/thread/thread_worker_pool_impl.hppbool ...::worker_impl::stop_execution (void){ context_type *context_p = m_context_p; /* <- plain read, deliberately unsynchronized */ bool has_thread = false; if (context_p != nullptr) m_parent_core->get_entry_manager ().stop_execution (*context_p); /* <- wake a long task */ std::unique_lock<std::mutex> ulock (m_task_mutex); if (m_has_thread) has_thread = true; /* <- report still-running to the pool loop */ m_stop = true; ulock.unlock (); if (!m_is_temp) m_task_cv.notify_one (); /* <- kick a waiting pooled thread */ return has_thread;}분기: (1) context가 살아 있으면 매니저의 stop_execution 훅을 호출해 실행
깊숙한 곳에서 블로킹된 태스크(예: lock wait)를 인터럽트한다. (2) lock 아래에서
읽은 m_has_thread가 풀 쪽의 재시도 여부를 결정한다. (3) get_new_task가
확인하는 것이 바로 m_stop = true다. (4) notify_one은 temp 워커에 대해서는
생략된다 — temp 슬롯은 m_task_cv에서 절대 기다리지 않기 때문이다.
m_context_p 읽기는 의도적으로 lock-free다; retire와 경합하는 관찰은 이미
알려져 있고 용인되는 사항이다.
stateDiagram-v2
[*] --> Exchange
Exchange --> [*] : true였음, 다른 스레드가 이미 소유
Exchange --> Sweep : false였음
Sweep --> Notify : 각 core의 stop_execution 호출
Notify --> CheckRunning : m_stop 설정\npooled면 notify_one
CheckRunning --> CheckTimeout : has_thread 남아있음
CheckRunning --> Drain : 모두 정지됨
CheckTimeout --> Sweep : 초과 안 됨\n10ms sleep
CheckTimeout --> Drain : 초과됨\nassert false
Drain --> [*] : core별 retire_queued_tasks
Figure 10-3. Teardown fan-out. 풀은 모든 워커가 has_thread == false를
보고하거나 timeout이 발동할 때까지 재-sweep한다; 그 다음에야 큐가 drain된다.
10.8 core_impl::retire_queued_tasks — 밀린 작업 drain하기
섹션 제목: “10.8 core_impl::retire_queued_tasks — 밀린 작업 drain하기”quiescence(모두 정지) 이후에도, 큐에 남았지만 한 번도 dispatch되지 못한 태스크들은 여전히 heap에 할당된 태스크 객체를 붙들고 있다. 이들은 실행되어서는 안 되고 retire되어야 한다.
// core_impl::retire_queued_tasks -- src/thread/thread_worker_pool_impl.hppvoid ...::core_impl::retire_queued_tasks (void){ std::unique_lock<std::mutex> ulock (m_workers_mutex); /* <- same mutex the queue uses */ while (!m_task_queue.empty ()) { wrapped_task queued_task = std::move (m_task_queue.front ()); m_task_queue.pop (); queued_task.retire (); /* <- task->retire(); frees the task, no execute */ }}조건 없이 비워질 때까지 drain한다. m_workers_mutex(push 쪽 lock)를 잡고
있는 것과 m_stopped == true라는 조건이 합쳐져, 어떤 execute_task도 새
태스크를 경합해 들여보낼 수 없다. move된 이후의 wrapped_task 소멸자는 내부
포인터가 null인지 assert하므로, 경계에서 double-free를 잡아낸다.
10.9 admission cap — worker_pool_task_capper
섹션 제목: “10.9 admission cap — worker_pool_task_capper”풀은 결코 pusher를 블로킹하지 않는다: execute_task는 제한 없이 큐에 쌓는다.
worker_pool_task_capper는 진행 중인 작업량을 워커 수만큼으로 제한하는
opt-in 정문 역할을 하며, capped_task 데코레이터를 통해 back-pressure를 건다.
// worker_pool_task_capper -- src/thread/thread_worker_pool_taskcap.cppworker_pool_task_capper::worker_pool_task_capper (worker_pool *worker_pool) /* ... */{ m_tasks_available = m_max_tasks = worker_pool->get_worker_count (); /* <- cap = pool size */}bool worker_pool_task_capper::try_task (task<entry> *task){ std::unique_lock<std::mutex> ulock (m_mutex); if (m_tasks_available == 0) return false; /* <- non-blocking: caller keeps the task */ execute (task); return true;}void worker_pool_task_capper::push_task (task<entry> *task){ std::unique_lock<std::mutex> ulock (m_mutex); m_cond_var.wait (ulock, [&] { return (m_tasks_available > 0); }); /* <- block for a permit */ execute (task);}void worker_pool_task_capper::execute (task<entry> *task) /* <- private, under m_mutex */{ m_tasks_available--; /* <- consume a permit */ m_worker_pool->execute (new capped_task (*this, task)); /* <- wrap and dispatch */}void worker_pool_task_capper::end_task (){ std::unique_lock<std::mutex> ulock (m_mutex); m_tasks_available++; /* <- return the permit */ ulock.unlock (); m_cond_var.notify_all (); /* <- wake all push_task waiters */}void worker_pool_task_capper::capped_task::execute (entry &ctx){ m_nested_task->execute (ctx); /* <- real work, on a real worker context */ m_capper.end_task (); /* <- permit returned exactly once, post-execute */}worker_pool_task_capper::capped_task::~capped_task (){ m_nested_task->retire (); } /* <- free the wrapped task when the wrapper is retired */try_task와 push_task는 cap이 다 찬 분기에서만 다르게 동작한다: try_task는
false를 반환하고 소유권을 호출자에게 남겨두며, push_task는 end_task가
permit을 하나 풀어줄 때까지 m_cond_var에서 블로킹한다. 둘 다 내부적으로
private execute를 호출한다.
불변식 — permit 보존 법칙이 동시성을 m_max_tasks로 제한한다.
m_tasks_available은 worker_count에서 시작해 admission 시점에 감소하고
completion 시점에 증가하며(둘 다 m_mutex 아래에서), 항상 [0, m_max_tasks]
범위에 머문다. 따라서 동시에 진행 중인 capped_task는 최대 m_max_tasks개를
넘지 않고, 풀의 큐는 결코 자라지 않는다; permit은 nested 태스크가 실행을 마친
이후, 그러나 wrapper가 retire되기 이전에 반환된다.
한 가지 미묘한 점: 이 cap은 생성 시점에 get_worker_count()로부터 고정되며,
이후 다시 읽지 않는다. 폭이 변하는 Phase-2 elastic pool에 대해서는
cubrid-thread-manager.md 문서의 “Elastic worker pool” 절을 참고하라.
10.10 챕터 요약 — 핵심 정리
섹션 제목: “10.10 챕터 요약 — 핵심 정리”worker_impl은 스레드가 아니라 재사용 가능한 슬롯이다.run이 스레드 본체이고,m_has_thread(오직m_task_mutex아래에서만 쓰여짐)는 pusher에게 기존 스레드에 notify할지 새 스레드를 spawn할지를 알려주는 래치다.init_run/finish_run은 스레드 생명주기당 정확히 하나의 context를 감싼다, 반면recycle_context는 태스크와 태스크 사이에 그 context를 저렴하게 reset한다; pooled 루프는do execute_current_task while get_new_task형태다.get_new_task는 유일한 블로킹 지점이자 retire를 결정하는 곳이다. 큐에 있던 태스크를 반환하거나, idle timeout으로 제한된m_task_cv에서 대기하거나, 아무것도 찾지 못하면m_has_thread = false로 설정하고finish_run을 호출한 뒤false를 반환한다.- temp 워커는 단발성이며 스스로 파킹한다.
execute_task_as_temp가 생성하며, 태스크 하나를 실행한 뒤m_free_temp_workers에 등록된다; 이후 pooled 워커가free_all_temp_list를 통해 이들을 회수하므로, 어떤 스레드도 자기 자신에 대해delete this를 실행하지 않는다. - teardown은 멱등이며 확인 절차를 거친다.
m_stopped.exchange(true)가 하나의 stopper를 선출하고, 풀은 모든 core가has_thread == false를 보고하거나 timeout이 assert될 때까지 재-sweep하며, 그 다음retire_queued_tasks가 밀린 작업을 drain한다. worker_pool_task_capper는 풀에 없는 back-pressure를 더해준다.worker_count로 제한된 permit 카운터가 admission을 관문처럼 통제한다;try_task는 빠르게 실패하고,push_task는 블로킹하며,capped_task::execute는 실제 작업이 끝난 직후 permit을 반환한다.- 모든 분기는 태스크나 context를 정확히 한 번씩 retire한다. 태스크
retire는
execute이후,retire_queued_tasks안, 그리고~capped_task에서 일어난다; context retire는 스레드당 한 번finish_run에서 일어난다.m_wrapped_task와m_context_p에 대한 assert들이 이 규칙을 지키는 가드레일이다.
Chapter 11: 원자 연산 없는 통계와 관측성
섹션 제목: “Chapter 11: 원자 연산 없는 통계와 관측성”커넥션 리액터(connection reactor)는 바이트를 옮기는 동안 perfmon 원자 변수에
단 한 번도 손대지 않는다. 대신 부하는 **소유자별 평범한 정수 배열(per-owner
plain-integer array)**에 기록되고, 느린 타이머 주기마다 값이 그대로 복사되어
메시지에 실리며, 단일 스레드로 동작하는 코디네이터가 이를 EWMA 모델로 녹여낸다.
이 장은 이 파이프라인을 필드 단위로 따라간다: cubconn::statistics의 값
타입, coordinator::worker_statistics의 행(row), 그 전송 경로
(statistics_metrics_to_coordinator)와 소비자 측 분기, 그리고 두 개의
운영자용 표면(operator surface) — statistics_print 오버레이와 SQL
SHOW JOB QUEUES 뷰 — 를 거쳐, 파이프라인이 스텁(stub)으로 남아 있는 유일한
지점인 css_get_task_stats에서 끝맺는다.
연관 문서: 재설계가 리액터의 핫 패스에서 perfmon 원자 변수를 포기한 이유 — false sharing, 캐시 라인 경합, C10K 팬아웃(fan-out) — 는
cubrid-thread-manager.md의 §“Observability without contention” (CBRD-26191)에서 논증한다. 이 장은 그 방법을 다룬다. 대조 대상이 되는 백엔드 요청 풀은cubrid-thread-worker-pool.md의 §“Worker pool statistics”를 참고.
11.1 값 타입 — cubconn::statistics
섹션 제목: “11.1 값 타입 — cubconn::statistics”connection_statistics.hpp는 두 개의 enum class 키 집합과 고정 크기 카운터
배열 템플릿 metrics<T, VT> 하나를 정의한다. 맵도, 해싱도, 락도 없다 —
통계값 “쓰기”는 그냥 array[static_cast<size_t>(key)] += value다.
// cubconn::statistics::worker -- src/connection/connection_statistics.hppenum class worker : std::uint8_t{ PACKET_COUNT, CLIENT_NUM, /* network */ MQ_REQUESTED, /* message queue */ MQ_COMPLETED, MQ_NEW_CLIENT, MQ_HANDOFF_CLIENT, MQ_TAKEOVER_CLIENT, MQ_SHUTDOWN_CLIENT, MQ_SEND_PACKET, MQ_RELEASE_PACKET, BLOCKED_RMUTEX, /* us */ STATS_COUNT, /* array size */ NA /* "no stat" sentinel */};context 키 (커넥션 단위): BYTES_IN_TOTAL, BYTES_OUT_TOTAL(바이트);
OPEND_NS, LAST_ACTIVE_NS, LAST_MOVED_NS(ns 단위 wall-clock 마크);
MOVE_COUNT; RECV_BUDGET_HIT, SEND_BUDGET_HIT(5장의 IO-bound 플래그);
STATS_COUNT는 배열 크기를 정한다.
worker 키 (워커 스레드 단위, 위에서 본 것): BLOCKED_RMUTEX
(css_conn_entry::rmutex에 블록된 마이크로초)와 CLIENT_NUM(게이지 —
adopt 시 add, close 시 sub)을 제외하면 모두 단조 증가 카운터다.
MQ_*들은 8장의 메시지 큐 동사(verb)들을 그대로 반영한다.
배열 템플릿:
// metrics<T,VT> -- src/connection/connection_statistics.hpptemplate <class T, typename VT = std::uint64_t>class metrics { // ... condensed: copy ctor, move ctor, cross-VT converting ctor ... inline void add (T key, VT value); /* array[key] += value — plain, non-atomic */ inline void sub (T key, VT value); inline VT get (T key); private: VT m_values[static_cast<std::size_t> (T::STATS_COUNT)]; /* the whole state */};| Field | 역할 | 존재 이유 |
|---|---|---|
m_values[T::STATS_COUNT] | 전체 카운터 상태, enumerator당 슬롯 하나 | 고정 배열이므로 O(1) add, 할당 없음, memcpy 한 번으로 끝나는 캐시 친화적 블록 |
템플릿의 VT 매개변수는 수치 타입이다: 워커는 기본값인 std::uint64_t
(원시 카운터)를 쓰고, 코디네이터는 EWMA 누산기를 위해 metrics<T, double>을
인스턴스화한다. 변환 생성자와 operator-/operator*(둘 다
metrics<T,double>을 반환)는 오직 원시 uint64 샘플을 §11.4의 double
EWMA 연산으로 끌어올리기 위해서만 존재한다.
불변식 — 단일 기록자이므로 원자 연산이 필요 없다. 각 metrics 인스턴스는
정확히 하나의 기록 스레드만 가진다. context::m_stats는 현재 그 컨텍스트를
소유한 워커만 건드린다(해당 컨텍스트의 receiver와 transmitter가 그 안으로
되돌아가는 원시 포인터 metrics<context>*를 들고 있다 — receiver::m_stats,
transmitter::m_stats); 워커 자신의 m_stats는 그 워커의 리액터 루프만
건드린다. 어느 두 스레드도 같은 배열에 쓰지 않으므로 add/sub는 평범한
+=로 충분하다 — std::atomic도, 펜스(fence)도, 락도 없다. metrics 하나를
여러 스레드가 공유하게 만드는 순간, 재설계가 걷어냈던 lost-update /
false-sharing 비용이 소리 없이 되살아난다.
불변식 — STATS_COUNT가 크기를 정하고, NA는 절대 인덱스로 쓰이지 않는다.
배열 크기는 센티널 STATS_COUNT가 정하며, 이 값은 반드시 실제로 할당된
마지막 enumerator로 남아 있어야 한다. worker는 이름 하나를 더 추가하는데,
NA는 STATS_COUNT 너머에 있다 — 애초부터 배열 범위 밖이며, 8장의
디스패치 테이블에서 “이 메시지 타입에는 대응하는 카운터가 없다”는 표시
({ handler, statistics::worker::NA })로만 쓰인다. NA를 add에 넘기면
배열 한 칸을 넘어서 써버린다. 디스패치 루프는 이를 다음과 같이 막는다:
if (handler[...].second != statistics::worker::NA) m_stats.add (...).
11.2 핫 패스에서의 기록 — 카운터가 실제로 움직이는 곳
섹션 제목: “11.2 핫 패스에서의 기록 — 카운터가 실제로 움직이는 곳”모든 증가 연산은 이미 다른 일을 하고 있는 경로 안에 인라인으로 들어앉아 있으므로, 비용은 L1 캐시에 상주하는 배열에 대한 덧셈 한 번뿐이다.
// worker + context hot-path adds -- connection_worker.cpp / receiver.cpp / transmitter.cppm_stats.add (statistics::worker::PACKET_COUNT, ...->size ()); /* worker: after a drain */m_stats.add (statistics::worker::BLOCKED_RMUTEX, us); /* worker: rmutex block time */m_stats->add (statistics::context::BYTES_IN_TOTAL, bytes); /* context: per recv */m_stats->add (statistics::context::RECV_BUDGET_HIT, 1); /* context: BudgetExhausted, Ch 5 */m_stats->add (statistics::context::BYTES_OUT_TOTAL, bytes); /* context: per send */m_stats->add (statistics::context::SEND_BUDGET_HIT, 1); /* context: send budget hit */컨텍스트의 생애주기 마크는 add가 아니라 set이다: adopt 시점에
ctx->m_stats.set (OPEND_NS/LAST_ACTIVE_NS, m_timens)를 호출하고, 패킷을
처리할 때마다 LAST_ACTIVE_NS를 다시 찍으며, 핸드오프(hand-off) 때는
MOVE_COUNT를 올리고 CLIENT_NUM을 옮긴다(내주는 쪽은 sub, 받는 쪽은
add). 이 중 어느 것도 동기화되지 않는다.
11.3 코디네이터의 행 — coordinator::worker_statistics
섹션 제목: “11.3 코디네이터의 행 — coordinator::worker_statistics”코디네이터는 워커 슬롯당 행 하나씩을 담은
std::vector<worker_statistics> m_statistics를 유지한다. 하나의 행은 원시
스냅샷들로부터 구축되는 모델이지 — 워커와 메모리를 공유하는 일은 결코 없다.
// coordinator::worker_statistics -- src/connection/coordinator.hppstruct worker_statistics { double m_score; /* placement scalar */ double m_core; /* CPU utilization 0..1 */ uint64_t m_last_cpu_time; uint32_t m_client_num; uint64_t m_last_updated; statistics::metrics<statistics::context, double> m_sum; /* rollup of all contexts */ std::pair<statistics::metrics<statistics::worker, double>, statistics::metrics<statistics::worker>> m_worker; /* {accumulated EWMA, previous raw} */ std::unordered_map<uint64_t, std::pair<statistics::metrics<statistics::context, double>, statistics::metrics<statistics::context>>> m_contexts; /* per-connection {EWMA, prev} */};| Field | 역할 | 존재 이유 |
|---|---|---|
m_score | 배치/재조정/스케일 로직이 워커 순위를 매기는 데 쓰는 단일 스칼라 | 6장은 벡터가 아니라 비교 가능한 숫자 하나가 필요하다 |
m_core | 마지막 샘플 이후 워커가 소모한 코어의 비율 | (cpu_time_ns 델타) / (wall 델타); CPU 항 |
m_last_cpu_time | 직전 CLOCK_THREAD_CPUTIME_ID 측정값 | m_core 델타 계산의 기준선 |
m_client_num | 이 워커의 살아 있는 커넥션 수 | 즉시성 게이지, 샘플의 CLIENT_NUM을 그대로 복사 |
m_last_updated | 마지막으로 수락한 샘플의 wall-clock ns | ”이 워커를 본 적 있는가?” 플래그(§11.4) 겸 EWMA delta 계산의 기준 |
m_sum | 살아 있는 모든 컨텍스트의 누적 EWMA 지표 합 | 읽을 때마다 컨텍스트를 다시 순회하지 않고도 워커 단위 바이트/버짓 합계를 제공 |
m_worker.first | 워커 카운터의 누적 EWMA(double) | 평활화된 부하 신호 |
m_worker.second | 직전 원시 워커 샘플(uint64) | 다음 델타 계산의 피감수 |
m_contexts[id].first | 한 커넥션의 컨텍스트 카운터 누적 EWMA | 재조정(rebalance)이 옮길 단일 커넥션을 고르는 근거(6장) |
m_contexts[id].second | 직전 원시 컨텍스트 샘플 | 커넥션 단위 델타 계산의 기준 |
불변식 — 누산기/직전값 쌍은 항상 함께 움직인다. m_worker와 각
m_contexts 항목에서 .first는 평활화된 double이고 .second는 마지막
원시 uint64 샘플이다. EWMA는 diff = current - previous로 전진하므로,
.second는 수락된 모든 샘플에서 current로 다시 세팅되어야 한다 —
그러지 않으면 다음 델타가 이중으로 계산된다. 짝이 되는 .second 없이
.first만 존재하면 델타는 0을 기준으로 계산되어 한 번 튀어 오른다. 소비자
측이 이 둘을 항상 맞물려 유지한다(§11.4).
그림 11-1은 소유권이 넘어가는 지점을 보여준다 — 워커가 소유한 메모리가
코디네이터가 소유한 메모리로 바뀌는 유일한 지점은 바로 message다.
flowchart LR
subgraph W["워커 스레드 — 단일 기록자"]
ws["worker m_stats\nmetrics<worker>"]
cs["context m_stats\nmetrics<context> 커넥션당"]
end
subgraph M["coordinator::message — 이동(move)됨"]
mw["statistics.worker\n{index, metrics 사본}"]
mc["statistics.contexts\n{id, metrics 사본}의 vector"]
end
subgraph C["코디네이터 스레드 — 단일 판독자"]
row["m_statistics[index]\nworker_statistics 행"]
end
ws -->|복사| mw
cs -->|복사| mc
mw -->|std::move로 enqueue| row
mc -->|std::move로 enqueue| row
그림 11-1 — 지표 데이터 경로. 스레드 경계를 넘는 것은 복사본뿐이며, 가변 상태를 공유하는 일은 없다.
11.4 전송 — worker::statistics_metrics_to_coordinator
섹션 제목: “11.4 전송 — worker::statistics_metrics_to_coordinator”워커 시작 시 등록되는 MEDIUM_LATENCY(5초) 타이머가 이 함수를 발화시킨다;
이 함수가 하는 일의 전부는 값으로 스냅샷을 뜨고, 이동으로 enqueue하는
것뿐이다.
// worker::statistics_metrics_to_coordinator -- src/connection/connection_worker.cppbool worker::statistics_metrics_to_coordinator () { coordinator::message message; message.type = coordinator::message_type::STATISTICS; message.statistics.cpu_time_ns = get_time_ns (CLOCK_THREAD_CPUTIME_ID); /* CPU burned */ message.statistics.time_ns = get_time_ns (CLOCK_MONOTONIC); /* wall clock */ message.statistics.worker.first = m_index; /* which slot */ message.statistics.worker.second = m_stats; /* copy of counters */ message.statistics.contexts.reserve (m_context.size ()); for (context *ctx : m_context) /* only LIVE contexts */ message.statistics.contexts.emplace_back (ctx->m_id, ctx->m_stats); /* copy each */ m_coordinator->enqueue (std::move (message)); /* MPSC, non-blocking */ return true;}이 함수에는 오류 분기가 없다 — 언제나 true를 반환한다. 정작 중요한
분기는 루프의 개수(cardinality)다: m_context는 현재 소유 중인 컨텍스트만
담고 있으므로, 휴면(hibernating) 상태이거나 드레인(drain) 중인 워커는
비어 있는 contexts 벡터를 복사하게 되고, 그 결과는 고스란히 소비자
쪽에 전달된다. enqueue는 코디네이터의 tbb::concurrent_queue(다중 생산자)에
밀어 넣고 m_queue_size를 올린다; 이 호출은 결코 블록되지 않으므로,
코디네이터가 느려져도 리액터가 멈추는 일은 없다.
소비자 측 함수인 handle_message_queue_statistics에 모든 분기가 몰려 있다.
// coordinator::handle_message_queue_statistics -- src/connection/coordinator.cppindex = item.statistics.worker.first;delta = item.statistics.time_ns - m_statistics[index].m_last_updated;m_statistics[index].m_core = static_cast<double> (item.statistics.cpu_time_ns - m_statistics[index].m_last_cpu_time) / delta;this->statistics_update_connection (delta, item.statistics.worker, item.statistics.contexts);m_statistics[index].m_last_cpu_time = item.statistics.cpu_time_ns;m_statistics[index].m_last_updated = item.statistics.time_ns;this->statistics_update_score (index);if ((m_status == status::DRAINING && (int) index == m_scaling.draining_worker) && item.statistics.contexts.empty ()) this->scale_down_finish ();분기 맵:
- 첫 샘플 (
statistics_update_connection내부에서 검사하는m_last_updated == 0):else분기가 원시 샘플로.first와.second를 시딩(seed)한다 — EWMA는 적용되지 않는다;m_core의delta는 이번 한 번만 0을 기준선으로 계산되며 다음 틱에서 폐기된다. - 후속 샘플:
m_sum.reset()을 호출한 뒤statistics_EWMA가m_worker와 각m_contexts[id]를 전진시킨다(각 누산기는 존재함이assert되어.first/.second쌍을 유지한다). - 컨텍스트 롤업:
m_sum은 모든m_contexts[id].first로부터 매번 다시 합산된다 — 매 틱마다 새로 리듀스(reduce)하므로, 떠난 커넥션은 자연스럽게 빠진다. - DRAINING + 빈 컨텍스트: 드레인 중인 워커(6장)가 컨텍스트 0개를
보고하면 →
scale_down_finish()가 그 슬롯을 퇴역시킨다 — 빈 컨텍스트 전송이 실제로 의미를 갖는 유일한 지점이다. - 그 외의 경우: 그대로 통과하며, 모델만 갱신되고 스케일링 동작은 없다.
flowchart TD
A["5초 타이머 발화\nstatistics_metrics_to_coordinator"] --> B["cpu_time, wall time 스냅샷,\nm_stats + 각 ctx의 m_stats 복사"]
B --> C["std::move로 enqueue, m_queue_size 증가"]
C --> D["코디네이터가 큐를 비움\nhandle_message_queue_statistics"]
D --> E{"m_last_updated == 0 ?"}
E -->|예| F[".first와 .second 시딩\nEWMA 없음"]
E -->|아니오| G["m_sum 리셋, worker + contexts EWMA 전진"]
F --> H["contexts 전체에 대해 m_sum 재합산"]
G --> H
H --> I["statistics_update_score index"]
I --> J{"DRAINING이고 draining_worker이며\ncontexts가 비어 있는가 ?"}
J -->|예| K["scale_down_finish"]
J -->|아니오| L["완료"]
그림 11-2 — 전송과 그 분기를 남김없이 처리하는 소비자.
평활화 커널은 원시 카운터 델타를 단위 시간당 비율로 바꾼 다음, 이를 블렌딩한다.
// coordinator::statistics_EWMA -- src/connection/coordinator.cppdiff = (current > prev) ? static_cast<double> (current - prev) : 0; /* guard counter reset */acc = acc * (1 - alpha) + diff * (alpha / (time_delta * 1e-6)); /* alpha = 0.06 */prev = current; /* keep .second paired */이어서 statistics_update_score는 클라이언트 수, 워커 EWMA
(MQ_COMPLETED, BLOCKED_RMUTEX), 컨텍스트 EWMA(바이트, 버짓 히트)를
EVAL_WORKER/EVAL_CONTEXT 매크로를 통해 m_score 하나로 블렌딩한다 —
6장이 순위를 매기는 데 쓰는 바로 그 단일 숫자다.
11.5 개발자용 오버레이 — coordinator::statistics_print
섹션 제목: “11.5 개발자용 오버레이 — coordinator::statistics_print”오직 controller 디버그 소켓(handle_controller_request의
control_type::SHOW_STATS)을 통해서만 도달하는 이 함수는 전체 화면 터미널
대시보드를 그린다. 이미 만들어진 모델을 락 없이 읽는데 — 코디네이터 스레드
자신에서 실행되기 때문이다.
// coordinator::statistics_print -- src/connection/coordinator.cppprintf ("\033[2J\033[H"); /* clear + home */for (i = 0; i < m_max_worker; i++) { if (m_statistics[i].m_contexts.size () == 0) continue; /* skip idle/unallocated slots */ printf ("------ worker %d (%d) ------\n", (int) i, (int) m_statistics[i].m_contexts.size ()); core += m_statistics[i].m_core; printf ("SCORE: %lf\n", m_statistics[i].m_score); // ... condensed: per-worker detail block is #if-commented out ... mq_completed += m_statistics[i].m_worker.first.get (statistics::worker::MQ_COMPLETED); bytes_in += m_statistics[i].m_sum.get (statistics::context::BYTES_IN_TOTAL); bytes_out += m_statistics[i].m_sum.get (statistics::context::BYTES_OUT_TOTAL);}분기: 워커별 루프는 컨텍스트 0개인 슬롯을 모두 건너뛴다(continue) —
유휴, 휴면, 또는 아직 할당된 적 없는 워커는 출력되지도,
core/bytes_in/bytes_out에 기여하지도 않는다. 요약 블록은 조건 없이
항상 실행된다. 수정하기 전에 알아둘 만한 나눗셈 위험이 두 군데 있다:
core / m_max_worker(안전하다, m_max_worker >= 1이 보장됨)와
m_task_statistics.depth.first / m_task_statistics.workers * 100인데,
후자의 분모는 PRM_ID_TASK_WORKER로서 0이 아니라고 가정되어 있다. 상태
표시줄은 STABLE / DRAINING / EXPANDING에 대한 중첩 삼항 연산자다. 이
표면은 개발자용 도구이지 운영자용 API가 아니다 — 원시 컨트롤 소켓이
구동하는, ANSI 이스케이프가 섞인 stdout일 뿐이다. 프로덕션 관측성은
§11.6에서 다룬다.
11.6 운영자용 표면 — SHOW JOB QUEUES와 태스크 스텁
섹션 제목: “11.6 운영자용 표면 — SHOW JOB QUEUES와 태스크 스텁”SQL에서 보이는 뷰는 SHOW JOB QUEUES이며, 컬럼은 metadata_of_job_queues에
선언되어 있다.
| Column | Source | 현재의 의미 |
|---|---|---|
Jobq_index | core index | 백엔드 요청 풀의 core를 재해석한 값(job queue는 더 이상 존재하지 않는다) |
Num_total_workers | wp_core.get_worker_count() | 해당 core의 워커 수 |
Num_busy_workers | mapper를 통해 실시간으로 집계 | tran_index가 non-null인 워커 |
Num_connection_workers | 하드코딩된 0 | 리액터 워커는 별도의 풀에 살고 있다 |
// css_wp_core_job_scan_mapper -- src/connection/server_support.c(void) db_make_int (&vals[val_index++], (int) core_index);(void) db_make_int (&vals[val_index++], (int) wp_core.get_worker_count ());int busy_count = 0;wp_core.map_running_contexts (stop_mapper, css_wp_worker_get_busy_count_mapper, busy_count);(void) db_make_int (&vals[val_index++], (int) busy_count);(void) db_make_int (&vals[val_index++], 0); /* connection workers reported here as 0 */주목할 규율 하나: SHOW JOB QUEUES는 백엔드 요청 풀(9~10장)을 보고할
뿐, 커넥션 리액터는 보고하지 않는다 — 리액터의 부하 모델(§11.3)은 아직 SQL
표면을 갖고 있지 않다. 그리고 이 레거시 뷰조차도 핫 패스 카운터를 피한다:
css_job_queues_start_scan은 요청마다 증가시키는 방식이 아니라, 스캔
시점에 살아 있는 컨텍스트를 매핑하여 tran_index != NULL_TRAN_INDEX로부터
바쁨(busyness) 여부를 도출한다.
태스크 통계 피드는 현재 스텁 상태다. LOW_LATENCY 틱마다
statistics_update_task는 css_get_task_stats(stats)를 호출하고 그 결과를
m_task_statistics(requested/started/completed/depth)에 접어 넣어,
statistics_print 요약과 오토스케일 점수(statistics_scaling의
m_task_statistics.completed.first * 2) 양쪽에 공급한다. 그런데:
// css_get_task_stats -- src/connection/server_support.cvoid css_get_task_stats (UINT64 *stats_out) {// css_Server_request_worker_pool->get_task_stats (stats_out); /* <- commented out */}실제 호출은 주석 처리되어 있어서 stats_out은 손대지 않은 채로 남고,
statistics_update_task는 스택에 우연히 남아 있던 값을 읽는다: 첫 틱은
쓰레기값 혹은 0으로 .second를 시딩하고, 이후 틱들은 그 값을 기준으로
EWMA를 돌린다. 그래서 이 연결이 다시 붙기 전까지는
requested/started/completed/depth 오버레이 라인과 스케일링 점수의 태스크
항은 의미가 없다 — §11.4의 바이트/mq 신호는 살아 있지만, 태스크 신호는
죽은 코드다.
대조적으로, 백엔드 풀에는 살아 있는 perfmon 경로가 그대로 남아 있다:
css_get_thread_stats → worker_pool_impl::get_stats →
core_impl::get_stats로 이어지는 이 경로는 m_workers_mutex를 잡고 각
워커의 cubperf::stat_value 배열을 누적한다. 이 락-앤-perfmon 패턴이야말로
§11.1이 리액터에서 피하려 했던 바로 그것이다 — 여기서 용인되는 이유는 오직
이 통계들이 패킷 단위가 아니라 er_log_stats를 위해 읽히기 때문이다.
11.7 챕터 요약 — 핵심 정리
섹션 제목: “11.7 챕터 요약 — 핵심 정리”- 단일 기록자 소유권 덕분에 원자 연산이 필요 없다.
metrics<T,VT>는 정확히 하나의 스레드(컨텍스트를 소유한 워커, 혹은 워커 자신의 루프)만이 기록하는 평범한 고정 배열이므로,add/sub는 동기화되지 않은+=로 충분하다 — “핫 패스에는 perfmon을 두지 않는다”는 규율 전체가 여기서 나온다(§11.1). - 스레드 경계는 오직 ‘복사 후 이동’으로만 넘는다.
statistics_metrics_to_coordinator는 5초 주기로 카운터 배열을 move-onlymessage에 복사한 뒤 논블로킹으로 enqueue하며, 코디네이터는 그것의 유일한 판독자다(그림 11-1, §11.4). - 코디네이터가 보관하는 것은 카운터가 아니라 모델이다.
worker_statistics는 워커와 각 컨텍스트에 대해doubleEWMA(.first)와 직전 원시 샘플(.second)을 짝지어 두고, 여기에m_sum롤업과 파생값m_score를 더한다(§11.3). - 소비자 측 분기는 하나도 허투루 없다. 첫 샘플 시딩 대 EWMA 전진,
컨텍스트별 롤업, 그리고
scale_down_finish를 촉발하는 DRAINING+빈 컨텍스트 분기가 모두handle_message_queue_statistics의 분기들이다 (§11.4, 그림 11-2). - 운영자용 표면은 둘이지만, 대상 풀은 서로 다르다.
statistics_print는 리액터 모델을 코디네이터 스레드에서 그려내는 ANSI 대시보드다(컨텍스트 0개인 슬롯은 건너뜀);SHOW JOB QUEUES는 백엔드 풀의 core들을 보고하며, 바쁨 여부는 온디맨드 컨텍스트 매핑으로 도출하고 커넥션 워커는0으로 보고한다(§11.5~§11.6). css_get_task_stats는 스텁이다. 실제 호출이 주석 처리되어 있어서,m_task_statistics와 오토스케일 점수의 태스크 항은 현재 아무 작용도 하지 않는다 — 태스크 신호를 신뢰하려면 먼저 이 연결을 되살려야 한다 (§11.6).- 옛 perfmon 경로는 비용이 저렴한 곳에서는 그대로 남아 있다.
worker_pool_impl::get_stats는 여전히 락을 잡고 백엔드 풀의cubperf값을 읽는다; 이번 재설계가 원자 연산을 걷어낸 곳은 패킷 단위 리액터 경로뿐이며, 그 외 모든 곳은 아니다(§11.6).
Chapter 12: Phase 2 자료구조 — 탄력적 풀과 동시성 슬롯
섹션 제목: “Chapter 12: Phase 2 자료구조 — 탄력적 풀과 동시성 슬롯”출처 범위: 이 장의 모든 심볼은 아직
develop에 머지되지 않은 PR #7323 (feature/worker_pool_elastic) 에서 가져온 것이다. 말미의 POSHINTS 블록에 있는 줄 번호는 이 PR 워크트리 기준이며develop과는 일치하지 않는다.
Phase 1(9~11장)은 동시성을 워커 스레드의 개수로 제한했다. 그러나 실행
중인 태스크가 논리적 대기(락, 크리티컬 섹션, SP 왕복 호출 등)에
걸리는 순간 이 결합은 깨진다 — 스레드는 살아 있지만 유휴 상태인데도
여전히 동시성 몫을 차지하기 때문이다. Phase 2는 두 번째 통화(通貨)인
동시성 슬롯(concurrency slot) 을 도입한다. 이제 실행 가능한 동시성은
= 보유 중인 슬롯 수이며, 스레드 수는 슬롯 수를 넘어 초과 확보
(overcommit) 될 수 있다. 그래야 논리적 대기에 걸려 멈춰 선 스레드가
자신의 슬롯을 새 스레드에게 넘겨줄 수 있다.
이 장은 순수하게 구조만 다룬다 — 새로 등장하는 모든 객체의 모든
필드를 짚는다. 흐름(획득, 반납, 슬롯 데몬, 논리적 대기와의 연결)은
13~14장이 다루며, 왜 이렇게 설계했는가는 별도 문서
cubrid-thread-manager.md의 §“Elastic worker pool (CBRD-26684)”,
§“Concurrency slots and the per-core slot pool (CBRD-26685 / 26689)”,
§“The slot daemon — soft/hard wait and inter-core transfer
(CBRD-26686 / 26691)“에 있다. 이 장에는 두 갈래의 타입 계열이 등장한다.
하나는 템플릿화된 풀 계열(worker_pool_elastic<Stats>,
core_elastic, worker_elastic)로, Phase 1 엔진을 태스크마다 슬롯을
얹어 나르도록 다시 입힌 것이다. 다른 하나는 템플릿이 아닌 슬롯
계열(concurrency_slot, concurrency_slot_pool, 게시자/구독자 쌍,
concurrency_slot_daemon)로 concurrency_slot.hpp/.cpp에 있으며,
Stats에 대해 전혀 알지 못한 채 타입 소거된 void *를 통해서만 다시
연결된다.
12.1 worker_pool_elastic<Stats> — 슬롯을 아는 풀
섹션 제목: “12.1 worker_pool_elastic<Stats> — 슬롯을 아는 풀”// worker_pool_elastic -- src/thread/thread_worker_pool_elastic.hpptemplate <stats_t Stats>class worker_pool_elastic final : public worker_pool_impl<Stats>{ friend class manager; public: using unique_slot = std::unique_ptr<concurrency_slot>; /* <- ownership token */ // ... condensed: worker, task_type, wrapped_task, stats aliases ... class core_elastic; private: std::atomic<std::size_t> m_current_worker; /* placed in same cache line */ std::atomic<std::size_t> m_max_concurrency; std::atomic<std::size_t> m_max_worker;};final로 봉인되어 있고, Phase 1의 worker_pool_impl<Stats>(9장)를
상속한다. 비타입 템플릿 매개변수 stats_t Stats(on/off)는 컴파일
타임에 원자적 연산 없는 통계 정책(11장)을 선택한다 — 슬롯 관련 로직은
두 값 모두에 대해 중복 생성되며, 그래서 .cpp의 dynamic_cast들은 항상
<stats_t::on>과 <stats_t::off> 양쪽을 모두 시도한다. 별칭 unique_slot = std::unique_ptr<concurrency_slot>는 13장 전체에 걸쳐 이어지는 모든
전달 지점을 관통하는 소유권 토큰이다.
| 필드 | 역할 | 존재 이유 |
|---|---|---|
m_current_worker | 모든 코어에 걸친 살아있는 워커 스레드 수의 원자적 카운트 | 전역 초과 확보 예산; reserve_available_worker가 스레드를 스폰하기 전에 이 값을 CAS로 증가시킨다(13장). 이 주소는 참조를 통해 모든 core_elastic에 공유된다 |
m_max_concurrency | 원자적 목표값 = 실행 가능한 슬롯의 총합 = 발행된 슬롯의 총합 | 동시성 상한선; adjust_runtime_parameter가 코어별로 나눠 분배한다 |
m_max_worker | 전체 살아있는 스레드 수에 대한 원자적 하드 캡 | 슬롯 수를 넘어 스레드가 초과 확보되도록 허용한다 — 슬롯을 쥔 스레드가 블록되어도 다른 스레드에게 슬롯을 넘길 수 있게 하기 위함 |
불변조건 — m_max_worker >= m_max_concurrency.
adjust_runtime_parameter의 assert (max_worker >= max_concurrency)로
강제된다. 슬롯은 실행 가능한 작업량을 제한하지만, 워커는 슬롯 수를
넘어설 수 있어야 논리적으로 대기 중인 워커의 슬롯이 결코 놀지 않는다.
이를 어기면 블록된 워커의 슬롯을 재사용할 수 없게 되어, 탄력적 동작이
다시 스레드 수에 묶인 동시성으로 퇴화한다.
불변조건 — m_current_worker는 0이 아니라 max_concurrency로
시드된다. 생성자는 m_current_worker (max_concurrency)로 초기화하고,
initialize는 정확히 m_max_concurrency개의 워커를 만든다. 그래서
기동 시점에는 살아있는 스레드 수와 슬롯 수가 같고, 원자 변수는 이미
그 현황을 정확히 반영한다. 0에서 시작하면 최초의 초과 확보 계산이
잘못될 것이다.
12.2 core_elastic — 코어별 슬롯 소유자
섹션 제목: “12.2 core_elastic — 코어별 슬롯 소유자”// core_elastic -- src/thread/thread_worker_pool_elastic.hpptemplate <stats_t Stats>class worker_pool_elastic<Stats>::core_elastic final : public worker_pool_impl<Stats>::core_impl{ friend class worker_pool_elastic; class worker_elastic; // ... condensed: adjust_workers, execute_task, get_task_and_slot_or_become_available ... concurrency_slot_pool m_slots; std::size_t m_max_concurrency; // per core std::atomic<std::size_t> &m_current_worker; // global, not hard cap std::atomic<std::size_t> &m_max_worker; stats_base m_retired_stats;};각 코어는 concurrency_slot_pool(m_slots)을 하나씩 품고 있으며,
m_slots (this, this->m_core_mutex)로 생성된다. 두 번째 인자가
의미하는 바는, 슬롯 풀이 자신만의 뮤텍스를 소유하지 않고 코어의
m_core_mutex를 빌려 쓴다는 것이다. 즉 슬롯 상태와 워커 상태가 하나의
락을 공유한다.
| 필드 | 역할 | 존재 이유 |
|---|---|---|
m_slots | 코어별 concurrency_slot_pool | 이 코어의 태스크가 동시에 몇 개까지 실행될 수 있는지를 통제한다 |
m_max_concurrency | 풀 전체의 max_concurrency 중 이 코어에 배정된 몫(이 코어의 목표 슬롯 수) | adjust_runtime_parameter가 전역 목표값을 코어들에 분배한다(몫 + 나머지) |
m_current_worker | 풀 전역 살아있는-스레드 원자 변수에 대한 참조 | 초과 확보는 코어별이 아니라 전역으로 제한되므로, 바쁜 코어 하나가 풀 전체의 스레드 예산을 빌려 쓸 수 있다 |
m_max_worker | 풀 전역 스레드 캡 원자 변수에 대한 참조 | reserve_available_worker의 CAS 루프에서 읽힌다 |
m_retired_stats | 다운스케일 중 제거된 워커들의 누적 stats_base | 워커가 삭제되어도 통계는 살아남아야 한다; get_retire_if_excess는 죽어가는 워커의 카운터를 삭제 직전에 여기로 접어 넣는다 |
불변조건 — 두 원자 변수는 값이 아니라 참조로 보유된다.
m_current_worker와 m_max_worker는 풀의 멤버를 그대로 가리킨다
(allocate_core에서 전달됨). 코어마다 별도 복사본을 두면 각 코어가
독립적으로 max_worker개의 스레드를 스폰할 수 있게 되어, 전역 캡이
core_count배만큼 뚫려버린다.
12.3 worker_elastic — 슬롯을 나르는 워커
섹션 제목: “12.3 worker_elastic — 슬롯을 나르는 워커”// worker_elastic -- src/thread/thread_worker_pool_elastic.hpptemplate <stats_t Stats>class worker_pool_elastic<Stats>::core_elastic::worker_elastic final : public worker_pool_impl<Stats>::core_impl::worker_impl{ friend class core_elastic; // ... condensed: assign_task, run, execute_current_task, get_new_task ... unique_slot m_slot; // guarded by m_task_mutex};worker_elastic은 Phase 1의 worker_impl에 딱 하나의 필드만
더한다 — 현재 태스크에 묶인 슬롯이다. assign_task는 m_task_mutex
아래에서 wrapped_task와 unique_slot을 함께 워커로 옮기고,
execute_current_task는 실행 직전에 m_slot을 스레드 엔트리
(m_context_p->m_slot)로 옮긴다.
| 필드 | 역할 | 존재 이유 |
|---|---|---|
m_slot | 할당된 태스크와 짝지어진 concurrency_slot; m_task_mutex로 보호됨 | 워커는 슬롯을 쥐고 있을 때만 실행된다; 슬롯을 큐가 아니라 워커에 두는 이유는, 그래야 태스크가 깨어날 때 슬롯도 함께 따라다니기 때문이다 |
불변조건 — 태스크와 슬롯은 함께 있거나 함께 없다. get_new_task의
모든 대기 조건식과 사후조건은
(!m_wrapped_task.has_value () && !m_slot) || (m_wrapped_task.has_value () && m_slot)를 단언한다. 워커는 결코 슬롯 없이 태스크만 쥐어서는 안
되고(무제한 실행이 되어버린다), 태스크 없이 슬롯만 쥐어서도 안 된다
(동시성이 새어나간다). 이것이 Phase 2 전체에서 가장 무겁게 짊어져야 할
단 하나의 불변조건이며, “동시성 == 보유 슬롯 수”를 문자 그대로 참으로
만들어 주는 장치다.
12.4 concurrency_slot — 동시성 토큰
섹션 제목: “12.4 concurrency_slot — 동시성 토큰”슬롯 자체는 작은 객체다. 그 가치는 누가, 얼마 동안 쥐고 있는가에 있다.
// concurrency_slot -- src/thread/concurrency_slot.hppclass concurrency_slot{ friend class concurrency_slot_pool; // ... condensed: reset, return_to_pool, start_waiting, has_wait_expired ... concurrency_slot_pool *const m_owner_pool; /* fixed at construction */ concurrency_slot_pool *m_holder_pool; bool m_wait; // soft wait std::chrono::time_point<std::chrono::steady_clock> m_wait_since; // guarded by the holder pool mutex (core mutex)};| 필드 | 역할 | 존재 이유 |
|---|---|---|
m_owner_pool | const — 슬롯을 발행한 풀 | 빌려간 슬롯은 결국 원래 주인에게 돌아가야 한다; const로 두어 슬롯의 생애 동안 원래 소속 주소가 절대 바뀌지 않게 한다 |
m_holder_pool | 현재 이 슬롯을 빌려주고 있는 풀 | 데몬이 코어 사이에 슬롯을 옮긴 뒤에는 소유자와 달라진다; 획득 시 set_holder_pool이 이를 찍는다 |
m_wait | 소프트 대기 플래그; 보유자가 논리적 대기에 들어갈 때 start_waiting이 설정 | 슬롯이 데몬에 의해 탈취 가능 상태임을 표시한다 |
m_wait_since | 대기가 시작된 steady_clock 타임스탬프 | has_wait_expired가 50 ms 임계값과 비교해 하드 탈취 여부를 결정한다 |
불변조건 — 소유자는 불변, 보유자는 이동 가능하다. m_owner_pool은
const이고 m_holder_pool은 변경 가능하므로, 코어 사이를 오가며
빌려진 슬롯도 늘 자기 집으로 돌아가는 길을 안다. return_to_pool은
먼저 m_owner_pool->return_slot(...)에 슬롯을 건네보고, 그다음
m_holder_pool에 건네보고, 마지막에는 강제로 소유자에게 되돌린다 —
이 3단계 배수(排水) 과정이 전이(transfer) 상황에서도 각 풀의
m_slot_count를 정확하게 유지시켜 준다. 이 구분이 무너지면 코어 간
대여가 종료 시점에 슬롯을 누수시킨다.
12.5 concurrency_slot_pool — 코어별 슬롯 은행
섹션 제목: “12.5 concurrency_slot_pool — 코어별 슬롯 은행”// concurrency_slot_pool -- src/thread/concurrency_slot.hppclass concurrency_slot_pool : public concurrency_slot_subscriber{ // ... condensed: try_acquire_slot, acquire_slot, release_slot, borrow_surplus_slots, get_score ... static constexpr std::size_t SLOT_SURPLUS_THRESHOLD = 2; // must be > 1 const void *m_parent; std::queue<std::unique_ptr<concurrency_slot>> m_available_slots; bool m_surplus; std::chrono::time_point<std::chrono::steady_clock> m_surplus_since; std::size_t m_slot_count; std::size_t m_target_count; std::list<entry *> m_wait_queue; // entries waiting to acquire a slot std::mutex *m_mutex; // the owning core mutex};이 풀은 여유 슬롯을 FIFO 큐에 은행처럼 쌓아두고, 슬롯이 모자란 스레드는
대기 목록에 세워둔다. 동시에 concurrency_slot_subscriber이기도
하다(§12.6) — 생성자가 concurrency_slot_daemon::get_publisher ()를
구독자 베이스 클래스에 넘기므로, 모든 코어의 풀은 단 하나의 전역
데몬에 자동으로 등록된다.
| 필드 | 역할 | 존재 이유 |
|---|---|---|
SLOT_SURPLUS_THRESHOLD | static constexpr = 2 | 풀이 “잉여” 상태로 간주되기 위한 최소 여유 슬롯 수; > 1이어야 데몬이 빌려가더라도 풀이 항상 자기 몫으로 슬롯 하나는 남겨두게 된다 |
m_parent | 소유 core_elastic을 가리키는 const void * 역참조 | 이 파일은 템플릿이 아니므로 Stats를 볼 필요가 없도록 타입을 소거해 둔 것; wakeup_workers/has_queued_task에서 dynamic_cast로 core_elastic<on/off>로 되돌려 캐스팅한다 |
m_available_slots | 여유 unique_slot의 FIFO queue | 은행 그 자체; try_acquire_slot이 앞에서 꺼내고 release_slot이 뒤에 넣는다 |
m_surplus | available >= SLOT_SURPLUS_THRESHOLD인 동안 true | 데몬이 대여를 고려하기 전에 확인하는 값싼 플래그 |
m_surplus_since | 잉여 조건이 시작된 시점 | borrow_surplus_slots는 잉여 상태가 2초 넘게 지속된 뒤에야 슬롯을 훔쳐가며, 잦은 진동을 억제한다 |
m_slot_count | 현재 이 풀에 물리적으로 있는 슬롯 수(소유 + 대여) | 다운스케일(adjust_concurrency)은 목표치에 도달할 때까지 자기 소유 슬롯을 파괴한다 |
m_target_count | 원하는 슬롯 수 = 해당 코어의 m_max_concurrency | m_slot_count와의 괴리가 재구성 중 생성/파괴를 유도한다 |
m_wait_queue | acquire_slot에 블록된 스레드 엔트리들의 list<entry *> | 논리적 대기의 주차장; release_slot은 풀린 슬롯을 맨 앞 대기자에게 넘긴다 |
m_mutex | 소유 코어의 m_core_mutex를 가리키는 std::mutex * | 슬롯 풀 상태는 코어 락을 공유한다 — 별도 뮤텍스도, 락 순서 위험도 없다 |
불변조건 — 슬롯 풀의 상태는 코어 뮤텍스로 보호된다. m_mutex는
소유된 뮤텍스가 아니라 포인터이며, ulock 오버로드들은 모두
assert (ulock.owns_lock ())를 단언한다. 이미 코어 락을 쥐고 있는
호출자(예: core_elastic::execute_task)는 그 락을 그대로 전달하므로,
디스패치 결정과 슬롯 획득이 하나의 원자적 임계 구역이 된다. 전용
뮤텍스를 두었다면 디스패치는 고정된 순서로 두 개의 락을 잡아야 했을
것이다.
불변조건 — m_parent(코어)와 구독자가 물려받은 m_identifier(풀)는
서로 다른 void *이다. m_parent는 코어 포인터로
(adjust_workers/has_queued_task에 도달한다), 상속받은
m_identifier(§12.6)는 워커 풀 포인터로(데몬의 그룹핑 키다). 둘을
혼동하면 웨이크업이 잘못 라우팅되거나 풀이 잘못 그룹핑된다.
12.6 게시자 / 구독자 — 데몬이 풀들을 찾는 방법
섹션 제목: “12.6 게시자 / 구독자 — 데몬이 풀들을 찾는 방법”데몬은 각 코어를 가리키는 포인터를 직접 들고 있지 않고도 활성 슬롯 풀 전부를 순회할 수 있어야 한다. 게시자/구독자 패턴은 이 의존 방향을 뒤집는다 — 풀이 초기화 시점에 스스로 등록하므로, 데몬은 그저 맵을 훑기만 하면 된다.
// concurrency_slot_subscriber / publisher -- src/thread/concurrency_slot.hppclass concurrency_slot_subscriber{ concurrency_slot_publisher *m_publisher; void *m_identifier; // currently used only as a worker_pool type};
class concurrency_slot_publisher{ friend class concurrency_slot_subscriber; std::map<void *, std::vector<concurrency_slot_subscriber *>> m_subscribers; std::mutex m_mutex;};| 구조체 | 필드 | 역할 | 존재 이유 |
|---|---|---|---|
concurrency_slot_subscriber | m_publisher | 구독/해지할 대상 데몬 게시자 | 생성 시 한 번 설정됨; ~subscriber가 RAII 정리를 위해 deactivate를 호출한다 |
m_identifier | 그룹핑 키 = 소유 worker_pool 포인터; activate 전까지 nullptr | 게시자가 한 풀에 속한 코어별 구독자들을 한데 묶을 수 있게 한다 | |
concurrency_slot_publisher | m_subscribers | 식별자(워커 풀)에서 그 벡터(코어별 슬롯 풀들)로의 map | 데몬의 순회 대상: 각 풀에 대해 그 풀에 속한 모든 코어의 슬롯 은행 |
m_mutex | 구독자 맵을 보호 | subscribe/unsubscribe/traverse 모두 이를 잠근다 |
등록 절차는 concurrency_slot.cpp에서 이루어진다.
concurrency_slot_pool::initialize가 워커 풀 포인터를 인자로
activate (identifier)를 호출하면, activate는 m_identifier를
저장하고 m_publisher->subscribe (identifier, this)를 호출해 이 풀을
m_subscribers[identifier]에 덧붙인다. 그래서 하나의 키
(worker_pool_elastic)가 자신의 N개 코어별 풀들을 한데 묶는다.
traverse (Func)는 m_mutex를 잠그고 그룹별로
func (identifier, subscribers)를 호출한다 — 14장의 데몬이 한 풀의
코어들을 함께 재조정할 때 사용하는 진입점이다.
불변조건 — 구독자는 최대 한 번만 등록된다. 풀이 이미 등록되어
있으면 subscribe는 assert_release (false)를 던지고, deactivate는
멱등적이다(m_identifier가 null이 아닌지로 가드된다). 이중 등록이
일어나면 데몬이 같은 코어의 슬롯을 한 틱에 두 번 훔쳐서 재분배하게
된다.
12.7 concurrency_slot_daemon — 게시자 싱글턴
섹션 제목: “12.7 concurrency_slot_daemon — 게시자 싱글턴”// concurrency_slot_daemon -- src/thread/concurrency_slot.hppclass concurrency_slot_daemon : public concurrency_slot_publisher{ public: static void initialize (); static concurrency_slot_publisher *get_publisher (); private: static concurrency_slot_daemon &get_instance (); // Meyers singleton daemon *m_daemon;};이 데몬은 그 자체가 게시자다(concurrency_slot_publisher를 상속한다).
get_instance()(함수-지역 static)를 통해 도달하는, 프로세스 전역
싱글턴이다. get_publisher()는 &get_instance()를 반환한다 — 모든
슬롯 풀이 구독하는 바로 그 포인터이며, 그래서 모든 코어별 풀이 하나의
맵 아래로 모인다.
| 필드 | 역할 | 존재 이유 |
|---|---|---|
m_daemon | 50 ms looper 위에서 concurrency_slot_daemon_task를 실행하는 cubthread::daemon * | 소프트/하드 대기 상태의 슬롯을 훔쳐 한 풀의 코어들 사이에서 재조정하는 주기적 엔진(14장); create_daemon 전까지 nullptr |
REGISTER_DAEMON (concurrency_slot_daemon)이 initialize/finalize를
매니저의 데몬 생애주기에 연결한다. 풀들은 initialize에서 구독하고
~concurrency_slot_subscriber에서 구독을 해지한다. 데몬은 그 중심에
자리한 안정적인 싱글턴이다.
12.8 thread_entry 확장 — 실행 중인 태스크의 슬롯이 머무는 곳
섹션 제목: “12.8 thread_entry 확장 — 실행 중인 태스크의 슬롯이 머무는 곳”태스크가 실행되는 동안 그 슬롯은 스레드 엔트리(cubthread::entry)
위에 놓인다. 그래야 논리적 대기 코드(락 매니저, 크리티컬 섹션)가 워커
풀에 대해 아무것도 알지 못한 채로도 슬롯을 찾아서 해제할 수 있다. 이
필드들은 클라이언트 모듈을 위해 엔트리를 void *로 타입 소거하기만
하는 thread_compat.hpp가 아니라 thread_entry.hpp에 있다.
// cubthread::entry additions -- src/thread/thread_entry.hppthread_resume_suspend_status resume_status; /* resume status */// ... condensed ...#if defined (SERVER_MODE) /* concurrency slot held by the entry; only set for workers from an elastic worker pool */ std::unique_ptr<cubthread::concurrency_slot> m_slot;#endif| 필드 | 역할 | 존재 이유 |
|---|---|---|
m_slot | 실행 중인 태스크가 쥐고 있는 슬롯; SERVER_MODE 전용, 탄력적이지 않은 스레드에서는 nullptr | execute_current_task가 실행 직전에 워커의 슬롯을 이리로 옮긴다; 락/CSS 대기 경로가 소프트 대기 시 여기서 이를 해제해 다른 스레드가 실행될 수 있게 한다 |
resume_status | thread_resume_suspend_status — 스레드가 왜 중단/재개되었는가 | 슬롯 대기는 기존의 중단/재개 메커니즘을 재사용한다; acquire_slot이 THREAD_CONCURRENCY_SLOT_SUSPENDED를 설정하고 이전 값을 저장했다가 이후 복원한다 |
slot_waits (event_stat 안에 있음) | 이 스레드가 슬롯 대기로 블록된 누적 시간, struct timeval | 슬로우 쿼리 추적용; 엔트리별 event_stats에서 lock_waits/latch_waits 옆에 자리한다 |
이 열거형은 추가 전용이다(이미 부여된 의미를 절대 바꿔서는 안 된다). 그래서 Phase 2는 자신의 상태들을 맨 끝에 덧붙인다.
// thread_resume_suspend_status (tail) -- src/thread/thread_entry.hpp THREAD_CONCURRENCY_SLOT_SUSPENDED = 25, THREAD_CONCURRENCY_SLOT_RESUMED = 26, THREAD_SLEEP_FUNC_SUSPENDED = 27| 값 | 의미 | 설정 주체 |
|---|---|---|
THREAD_CONCURRENCY_SLOT_SUSPENDED = 25 | 스레드가 슬롯을 기다리며 슬롯 풀의 m_wait_queue에 세워진 상태 | pthread_cond_wait 이전에 acquire_slot이 설정 |
THREAD_CONCURRENCY_SLOT_RESUMED = 26 | 풀린 슬롯이 이 대기자에게 건네짐; 깨워야 함 | release_slot이 thread_wakeup_already_had_mutex를 통해 설정 |
THREAD_SLEEP_FUNC_SUSPENDED = 27 | 슬립 헬퍼 내부에서 중단됨(Phase 2에 인접한 대기) | 논리적 대기 연결부(14장) |
불변조건 — 슬롯 대기는 중첩되며 반드시 resume_status를 복원해야
한다. acquire_slot은 호출자의 resume_status(예:
THREAD_LOCK_RESUMED)를 THREAD_CONCURRENCY_SLOT_SUSPENDED로 덮어쓰기
전에 저장해 두었다가, 종료 시 복원한다. 슬롯 대기는 바깥쪽 락/CSS
대기에 부수적으로 딸린 것이다. THREAD_RESUME_DUE_TO_INTERRUPT가 위로
새어나가면, 바깥쪽 대기는 자원이 실제로는 부여되었는데도 결코 부여받지
못한 것으로 오인하게 되어 락 정리의 소유권이 망가진다. 인터럽트 자체는
소실되지 않는다 — 스레드/트랜잭션 인터럽트 상태를 통해 별도로 추적된다
(14장).
12.9 구조체 관계도
섹션 제목: “12.9 구조체 관계도”그림 12-1은 하나의 풀 안에서의 소유 관계를 보여주고, 그림 12-2는 데몬이 게시자를 통해 모든 풀에 도달하는 경로를 보여준다.
flowchart TB
subgraph POOL["worker_pool_elastic<Stats>"]
WPE["m_current_worker<br/>m_max_concurrency<br/>m_max_worker<br/>(원자 변수)"]
subgraph CORE["core_elastic (코어별)"]
CE["m_max_concurrency<br/>&m_current_worker<br/>&m_max_worker<br/>m_retired_stats"]
SLOTS["m_slots :<br/>concurrency_slot_pool"]
WK["worker_elastic<br/>m_slot"]
end
end
ENTRY["cubthread::entry<br/>m_slot / resume_status"]
SLOT["concurrency_slot<br/>소유 풀 + 보유 풀"]
WPE -->|참조로| CE
CORE --> SLOTS
CORE --> WK
SLOTS -->|발행 / 은행 보관| SLOT
WK -->|할당 시 함께 전달| SLOT
WK -->|실행 시 이동| ENTRY
ENTRY -->|실행 중 보유| SLOT
그림 12-1. 슬롯의 여정: m_slots가 발행하고, worker_elastic이
나르며, 실행을 위해 entry로 옮겨졌다가, 소유 풀로 되돌아간다.
flowchart TB DAEMON["concurrency_slot_daemon<br/>(싱글턴, publisher를 상속)"] MAP["m_subscribers:<br/>worker_pool 포인터 → 벡터"] P1["코어 0 슬롯 풀<br/>(subscriber)"] P2["코어 1 슬롯 풀<br/>(subscriber)"] PN["코어 N 슬롯 풀<br/>(subscriber)"] DAEMON --> MAP P1 -->|activate/subscribe| MAP P2 -->|activate/subscribe| MAP PN -->|activate/subscribe| MAP DAEMON -->|풀별로 traverse| P1 DAEMON -->|풀별로 traverse| P2 DAEMON -->|풀별로 traverse| PN
그림 12-2. 게시/구독: 각 concurrency_slot_pool은 자신이 속한 워커
풀의 포인터를 키로 하여 스스로 구독하고, 데몬은 맵을 traverse하며
한 풀의 코어들을 함께 재조정한다.
12.10 챕터 요약 — 핵심 정리
섹션 제목: “12.10 챕터 요약 — 핵심 정리”- 동시성은 스레드 수가 아니라 슬롯 수다.
worker_pool_elastic<Stats>는 세 개의 원자 변수(m_current_worker,m_max_concurrency,m_max_worker)를 더하며,m_max_worker >= m_max_concurrency라는 불변조건 덕분에 스레드는 슬롯 수를 넘어 초과 확보될 수 있다. core_elastic은 코어별concurrency_slot_pool을 소유하고, 두 개의 스레드 예산 원자 변수를 참조로 들고 있어 초과 확보를 전역 단위로 제한한다;m_retired_stats는 다운스케일된 워커들의 카운터를 보존한다.- 태스크-슬롯 짝짓기 불변조건이 Phase 2의 심장이다.
worker_elastic(그리고 그것이 실행되는entry)은 태스크와 슬롯을 함께 쥐거나 둘 다 놓거나 둘 중 하나만 한다 — 이로써 “실행 가능 == 보유 슬롯 수”가 문자 그대로 참이 된다. concurrency_slot은 불변인m_owner_pool, 이동 가능한m_holder_pool, 그리고 소프트 대기 플래그(m_wait/m_wait_since)를 가진다. 이 덕분에 데몬은 대기 중인 스레드로부터 슬롯을 훔쳐 코어 사이로 빌려주면서도, 그 슬롯이 여전히 자기 집으로 돌아가는 길을 찾을 수 있다.concurrency_slot_pool은 소유 코어의 뮤텍스를m_mutex로 공유하며, 여유 슬롯을m_available_slots에 은행처럼 쌓아두고, 슬롯이 모자란 스레드를m_wait_queue에 세워두며,m_parent를 통해 자신의 코어에 도달한다.- 게시자/구독자 패턴은 데몬-풀 간 의존 방향을 뒤집는다. 각 풀은
자신이 속한
worker_pool포인터를 키로 하여 단 하나의concurrency_slot_daemon에 스스로 구독하고, 데몬은 50 ms마다 그 맵을traverse한다. - 실행 중인 태스크의 슬롯은
cubthread::entry::m_slot에 머물며, 슬롯 대기는 새로운 열거값(THREAD_CONCURRENCY_SLOT_SUSPENDED/RESUMED)을 가지고resume_status를 재사용한다. 바깥쪽 대기의 상태를 저장했다가 복원함으로써 중첩된 락/CSS 대기가 계속 올바르게 유지된다.
Chapter 13: 2단계 흐름 — 슬롯 획득·반환과 탄력적 실행 루프
섹션 제목: “Chapter 13: 2단계 흐름 — 슬롯 획득·반환과 탄력적 실행 루프”12장에서는 2단계의 구조를 정리했다. 이 장에서는 런타임을 추적한다 — 작업이 실행을 위해 슬롯을 획득하는 방식, 슬롯을 반환하는 방식, 그리고 탄력적 워커 루프가 9-10장의 기본 core_impl / worker_impl 루프와 정확히 어느 줄에서 어떻게 달라지는지를 다룬다. 모든 코드는 아직 병합되지 않은 PR #7323(feature/worker_pool_elastic)의 것이며, 줄 번호는 해당 워크트리 기준이다. 왜 이렇게 설계했는지는 함께 제공되는 “Elastic worker pool: the redesign narrative” 문서에 있다 — 여기서는 어떻게 동작하는지를 추적한다.
13.1 슬롯의 두 소유자와 획득 인터페이스
섹션 제목: “13.1 슬롯의 두 소유자와 획득 인터페이스”concurrency_slot은 일종의 허가 토큰이다. 이것을 쥐고 있다는 것은 “지금 이 코어에서 요청을 실행해도 좋다”는 뜻이다. 이 장의 모든 흐름은 이 슬롯의 back-pointer들에 의해 좌우된다.
| Field | 역할 | 존재 이유 |
|---|---|---|
m_owner_pool (const) | 슬롯을 생성한 풀; 절대 바뀌지 않는다. | 풀은 축소 시 자신이 만든 슬롯만 파괴하므로, 각 코어의 m_slot_count는 항상 정확하게 유지된다. 빌려준 슬롯도 항상 자신의 홈을 알고 있다. |
m_holder_pool | 현재 슬롯을 보유 중인 풀; 획득 시 set_holder_pool로 설정되고, reset으로 해제된다. | 빌려온 슬롯(owner != holder)을 반환 시 필요로 하는 쪽으로 돌려보낸다. |
m_wait / m_wait_since | start_waiting이 설정하는 소프트 대기 플래그와 타임스탬프; has_wait_expired는 50ms 이후 true가 된다. | 재조정 데몬(14장)이 논리적 대기에 머물러 있는 스레드에서 슬롯을 훔쳐올 수 있게 한다. |
획득 진입점은 두 가지이며, 이 구분 자체가 2단계의 핵심이다: try_acquire_slot(논블로킹, 여유 슬롯이 없으면 nullptr를 반환 — 디스패처 경로)와 acquire_slot(entry *)(블로킹, 슬롯이 건네질 때까지 호출자를 재운다 — 14장의 논리적 대기 경로)이다. 각각 락을 직접 잡는 얇은 오버로드와 실제 std::unique_lock &를 받는 오버로드를 갖는다.
13.2 try_acquire_slot와 acquire_slot
섹션 제목: “13.2 try_acquire_slot와 acquire_slot”try_acquire_slot(ulock)는 단순하다. 비어 있으면 nullptr; 그렇지 않으면 m_available_slots.front()를 꺼내 set_holder_pool(this)를 호출하고 check_surplus_slots를 실행한 뒤 반환한다. acquire_slot(thread_p, ulock)는 블로킹 경로다.
// concurrency_slot_pool::acquire_slot -- src/thread/concurrency_slot.cpp if (!m_available_slots.empty ()) /* FAST: hand slot immediately */ { thread_p->m_slot = std::move (m_available_slots.front ()); m_available_slots.pop (); thread_p->m_slot->set_holder_pool (this); check_surplus_slots (); return true; } m_wait_queue.push_back (thread_p); /* SLOW: enqueue self, sleep */ ulock.unlock (); saved_status = thread_p->resume_status; /* <- remember OUTER wait's result */ thread_p->resume_status = THREAD_CONCURRENCY_SLOT_SUSPENDED; while (thread_p->resume_status == THREAD_CONCURRENCY_SLOT_SUSPENDED) { pthread_cond_wait (&thread_p->wakeup_cond, &thread_p->th_entry_lock); }대기가 끝나면 정확히 하나의 깨어난 이유만 성립한다.
THREAD_CONCURRENCY_SLOT_RESUMED— 다른 스레드의release_slot이 이미thread_p->m_slot에 슬롯을 심어두고 대기자를 큐에서 뺐다.resume_status = saved_status로 복원하고true를 반환한다. 별도의 dequeue는 필요 없다.THREAD_RESUME_DUE_TO_INTERRUPT— 취소로 깨어난 경우다.saved_status를 복원하고 다시 락을 잡은 뒤:thread_p->m_slot이 설정되어 있으면(슬롯이 건네진 직후 인터럽트가 발생한 경쟁 상황)release_slot으로 되돌려주고false를 반환한다. 그렇지 않으면m_wait_queue에서 자신을std::find/erase로 제거하고(없다면 동시 실행 중인release_slot이 이미 stale 항목으로 제거한 것이다)false를 반환한다.
불변식 13-C (바깥쪽 대기 결과는 내부 슬롯 대기를 거쳐도 보존된다). 슬롯 대기는 LOCK/CSS 대기 안에 중첩된 보조 대기일 뿐이다.
saved_status는 대기 전에 캡처되고 모든 반환 경로에서 복원된다.THREAD_RESUME_DUE_TO_INTERRUPT가 위로 새어나가면 LOCK/CSS는 실제로는 자원이 승인되었음에도 결코 승인되지 않은 것으로 오인하여 각자의 wakeup 소유권을 망가뜨리게 된다. 인터럽트 자체는 사라지지 않는다 — 별도의 스레드/트랜잭션 인터럽트 상태에 그대로 남아 있다.
stateDiagram-v2 [*] --> CheckSlots : acquire_slot CheckSlots --> Return_true : 여유 슬롯 있음, pop 후 holder 설정 CheckSlots --> Waiting : 비어있음, 자신을 큐에 넣고 상태 저장 후 cond_wait Waiting --> Return_true : SLOT_RESUMED Waiting --> HandedBack : INTERRUPT, m_slot 설정됨 Waiting --> EraseSelf : INTERRUPT, m_slot null HandedBack --> Return_false : release_slot EraseSelf --> Return_false : wait_queue에서 제거
Figure 13-1. 분기를 모두 포함한 concurrency_slot_pool::acquire_slot.
13.3 release_slot — 축소, 저장, 또는 전달
섹션 제목: “13.3 release_slot — 축소, 저장, 또는 전달”release_slot(slot, ulock)은 해제된 슬롯의 운명을 결정한다. 세 가지 결과가 순서대로 시도된다.
// concurrency_slot_pool::release_slot -- src/thread/concurrency_slot.cpp slot->reset (); if (slot->m_owner_pool == this && m_target_count < m_slot_count) { slot.reset (); m_slot_count--; check_surplus_slots (); return; } /* SHRINK */ while (true) { if (m_wait_queue.empty ()) /* STORE */ { m_available_slots.emplace (std::move (slot)); break; } waiter = m_wait_queue.front (); m_wait_queue.pop_front (); /* HAND OFF */ ulock.unlock (); waiter->lock (); /* core mutex dropped BEFORE entry lock */ if (waiter->resume_status == THREAD_CONCURRENCY_SLOT_SUSPENDED) { waiter->m_slot = std::move (slot); waiter->m_slot->set_holder_pool (this); thread_wakeup_already_had_mutex (waiter, THREAD_CONCURRENCY_SLOT_RESUMED); waiter->unlock (); ulock.lock (); break; } waiter->unlock (); ulock.lock (); /* waiter already INTERRUPTED: retry next */ } check_surplus_slots ();- Shrink(축소) 는 슬롯이
this의 소유이고 풀이 목표치를 초과한 경우에만(adjust_concurrency가m_target_count를 낮춘 경우) 발동한다. 이때 슬롯은 풀에 되돌리지 않고 그대로 삭제된다 — 동시성 축소의 지연(lazy) 절반에 해당한다(§13.5). - Store(저장) — 대기자가 없으면
m_available_slots에 밀어 넣는다. - Hand off(전달) — 맨 앞 대기자를 꺼내고, 코어 뮤텍스를 놓은 뒤 그 대기자의
th_entry_lock을 잡아resume_status를 다시 확인한다. 여전히SLOT_SUSPENDED라면 슬롯을 심고THREAD_CONCURRENCY_SLOT_RESUMED로 깨운다(§13.2의 1번 분기가 깨어나는 지점이다). 이미 인터럽트된 상태라면 건너뛰고 다음 대기자를 시도한다 — 슬롯은 반드시 어딘가에 정착하거나 저장되어야 한다.
불변식 13-F (락 순서: 코어 뮤텍스가 entry lock보다 먼저 풀린다).
release_slot은 두 락을 동시에 쥐지 않는다 —waiter->lock()전에ulock.unlock()을 하고,waiter->unlock()이후에 다시 락을 잡는다. 코어 뮤텍스를 쥔 채로th_entry_lock을 잡으면 획득 경로와의 락 역전(lock inversion)이 재발한다.
RTTI 브리지. concurrency_slot_pool은 템플릿에 무관하게 동작해야 하지만 템플릿화된 코어를 호출해야 하므로, m_parent를 void *로 저장해 두고 런타임에 다운캐스트한다. wakeup_workers(ulock)는 m_parent를 core_elastic<stats_t::on/off>의 각 인스턴스화로 dynamic_cast해 해당 코어의 adjust_workers(ulock)를 호출하고(14장 참고), has_queued_task(ulock)도 동일한 두 갈래 캐스트로 코어에게 !m_task_queue.empty ()를 묻는다. 두 함수 모두 세 번째, fallthrough 분기를 갖는다 — 다운캐스트가 어느 인스턴스와도 맞지 않으면 wakeup_workers는 조용히 아무 일도 하지 않고, has_queued_task는 false를 반환한다. 이는 방어적 코드다 — m_parent는 항상 core_elastic이므로 사실상 도달 불가능한 분기지만, 이 덕분에 브리지가 전역(total) 함수로 유지된다.
13.4 return_to_pool와 return_slot — 소유자/보유자 3단계 전달
섹션 제목: “13.4 return_to_pool와 return_slot — 소유자/보유자 3단계 전달”작업이 끝나면 그 슬롯은 그냥 되돌려지는 것이 아니라, 가장 필요로 하는 쪽으로 라우팅된다.
// concurrency_slot::return_to_pool -- src/thread/concurrency_slot.cpp auto r = m_owner_pool->return_slot (std::move (slot), false); /* 1. offer owner */ if (r) { r = m_holder_pool->return_slot (std::move (r), false); /* 2. offer holder */ if (r) { m_owner_pool->return_slot (std::move (r), true); } } /* 3. force home */return_slot(slot, force)는 수락/거절 게이트다 — 수요 신호가 하나라도 있으면 슬롯을 소비하고, 그렇지 않으면 소유권을 std::move로 호출자에게 되돌려준다.
// concurrency_slot_pool::return_slot -- src/thread/concurrency_slot.cpp if (has_queued_task (ulock) || /* queued work here */ (m_available_slots.empty () && !m_wait_queue.empty ()) || /* blocked acquirers */ (slot->m_owner_pool == this && m_slot_count > m_target_count) || force) { release_slot (std::move (slot), ulock); wakeup_workers (ulock); return nullptr; } return slot; /* rejected */먼저 **소유자(owner)**에게 제안한다(축소된 상태라면 회수/파괴할 수 있다). 다음으로 **보유자(holder)**에게 제안한다(대기 중인 작업이 있을 수 있다). 둘 다 원하지 않으면 슬롯이 유실되지 않도록 강제로(force) 집으로 돌려보낸다 — 이 force 단계가 종료를 보장하고 m_slot_count를 보존한다. 수락되면 wakeup_workers가 발동하므로, 대기 작업이 있는 코어로 슬롯이 돌아오면 즉시 adjust_workers가 트리거된다.
stateDiagram-v2 [*] --> OfferOwner : return_to_pool OfferOwner --> Done : 소비됨 OfferOwner --> OfferHolder : 거절됨 OfferHolder --> Done : 소비됨 OfferHolder --> ForceOwner : 거절됨 ForceOwner --> Done : force는 항상 수락 Done --> [*]
Figure 13-2. return_to_pool의 소유자→보유자→강제 반환 프로토콜.
13.5 needs_slot, 잉여 슬롯, 그리고 adjust_concurrency
섹션 제목: “13.5 needs_slot, 잉여 슬롯, 그리고 adjust_concurrency”needs_slot(ulock) — 데몬의 수요 탐지 함수다: !m_wait_queue.empty () || (m_available_slots.empty () && has_queued_task (ulock)). **available_slots()**는 자체 락 없이 m_available_slots.size ()를 반환한다 — 모든 호출자가 이미 코어 뮤텍스를 쥐고 있기 때문이다. **check_surplus_slots**는 empty→surplus로 전이하는 순간에만 잉여 시각을 찍어두므로, m_surplus_since는 코어가 얼마나 오래 과잉 프로비저닝 상태였는지를 측정한다(데몬은 슬롯을 훔쳐가기 전 최소 2초를 요구한다).
// concurrency_slot_pool::check_surplus_slots -- src/thread/concurrency_slot.cpp if (m_available_slots.size () >= SLOT_SURPLUS_THRESHOLD) /* == 2 */ { if (!m_surplus) { m_surplus_since = steady_clock::now (); m_surplus = true; } } else { m_surplus = false; }**adjust_concurrency(concurrency, ulock)**는 소유 슬롯 집합을 새 목표치로 늘리거나 줄인다(PRM_ID_MAX_REQUEST_CONCURRENCY에 의해 구동된다).
// concurrency_slot_pool::adjust_concurrency -- src/thread/concurrency_slot.cpp m_target_count = concurrency; if (m_slot_count < concurrency) /* GROW */ { i = m_slot_count; m_slot_count = concurrency; for (; i < concurrency; i++) { release_slot (std::unique_ptr<concurrency_slot> (new concurrency_slot (this)), ulock); } } else if (m_slot_count > concurrency) /* SHRINK: available+owned only */ { while (m_slot_count > concurrency && !m_available_slots.empty ()) { auto s = std::move (m_available_slots.front ()); m_available_slots.pop (); if (s->m_owner_pool == this) { s.reset (); m_slot_count--; } /* destroy owned */ else { bucket.emplace_back (std::move (s)); } } /* keep borrowed */ for (auto &s : bucket) { m_available_slots.push (std::move (s)); } check_surplus_slots (); }- **Grow(확장)**은 부족분만큼 새로운 소유 슬롯을 만들어 각각을
release_slot에 통과시킨다. 그 결과 슬롯이 유휴 상태로 남지 않고m_wait_queue에 막혀 있던 대기자가 즉시 깨어난다. - **Shrink(축소)**는 available이면서 동시에 owned인 슬롯에 대해서만 즉시(eager) 처리된다. 빌려온 슬롯(owner != this)은 여기서 파괴되지 않고 bucket에 모아졌다가 다시 풀에 넣어지며, 실행 중인(in-flight) 슬롯은 아예 건드리지 않는다. 이들의 축소는 슬롯이 반환되며
m_target_count < m_slot_count를 확인하는 시점에release_slot의 shrink 분기(§13.3)를 통해 지연(lazy) 처리된다.
불변식 13-D (풀은 자신이 만든 슬롯만 파괴한다). 두 축소 경로 모두
s->m_owner_pool == this를 조건으로 걸어야만s.reset()을 수행하며, 그 덕분에 각 코어의m_slot_count는 자신이 생성한 슬롯의 개수를 정확히 유지한다.
13.6 core_elastic::execute_task — 슬롯으로 게이팅되는 디스패치
섹션 제목: “13.6 core_elastic::execute_task — 슬롯으로 게이팅되는 디스패치”기본 execute_task(9장)는 “워커가 비어 있는가?”만 묻는다. 탄력적 버전은 그보다 먼저 슬롯 게이트를 추가한다 — 슬롯이 없으면 디스패치도 없다.
// worker_pool_elastic<Stats>::core_elastic::execute_task -- src/thread/thread_worker_pool_elastic.hpp if (!this->m_parent_pool->is_running ()) { task_p->retire (); return; } /* rejected */ wrapped_task task_ref (task_p); std::unique_lock<std::mutex> ulock (this->m_core_mutex); unique_slot slot = m_slots.try_acquire_slot (ulock); if (slot) { worker_p = get_or_make_available_worker (); if (worker_p) { if (!this->m_task_queue.empty ()) /* FIFO: run oldest queued, enqueue new */ { this->m_task_queue.push_back (std::move (task_ref)); wrapped_task q = std::move (this->m_task_queue.front ()); this->m_task_queue.pop_front (); ulock.unlock (); try_execute_task_with_slot (worker_p, std::move (q), std::move (slot)); } else { ulock.unlock (); try_execute_task_with_slot (worker_p, std::move (task_ref), std::move (slot)); } return; } release_slot (std::move (slot), ulock); } /* slot but no worker (global cap) */ this->m_task_queue.push_back (std::move (task_ref)); /* fall through: queue */종결 경로는 다섯 가지다: (1) 풀이 정지됨 → retire; (2) 슬롯 + 워커 + 큐가 비어있지 않음 → 가장 오래된 작업을 실행하고 새 작업은 큐에 넣는다(FIFO); (3) 슬롯 + 워커 + 큐가 비어있음 → 새 작업을 곧바로 실행; (4) 슬롯은 있으나 워커가 없음(전역 m_max_worker 한도) → 슬롯을 반환하고 큐에 넣는다; (5) 슬롯이 없음 → 큐에 넣는다. try_execute_task_with_slot은 스레드 시작 실패를 처리한다 — assign_task가 페어를 되돌려주면 다시 락을 잡고, 작업을 push_front로 되돌리며(FIFO 유지), 슬롯을 release_slot하고, 워커를 m_available_workers에 다시 넣은 뒤 false를 반환한다.
13.7 탄력적 실행 루프 대 기본 루프
섹션 제목: “13.7 탄력적 실행 루프 대 기본 루프”worker_elastic은 세 개의 가상 함수 — get_new_task, execute_current_task, run — 그리고 available-worker 관리 로직을 오버라이드한다.
get_new_task — 작업과 슬롯은 항상 함께 이동하며, 빠른 경로가 우선이다. 탄력적 버전은 get_task_and_slot_or_become_available을 호출한다(코어 뮤텍스 하에서 try_acquire_slot을 수행하고, 슬롯이 여유가 있을 때만 작업을 dequeue하는 함수다). 값이 채워진 optional이 반환되면 — 즉 큐에 있던 작업과 여유 슬롯을 코어 뮤텍스 하에서 동시에 얻은 경우 — get_new_task는 stats::id::found_in_queue를 증가시키고 둘 다 emplace한 뒤 곧바로 true를 반환하며, CV 대기를 완전히 건너뛴다. nullopt이 반환될 때만(이 경우 워커는 m_available_workers에도 등록된다) 워커는 m_task_mutex를 잡고 대기한다.
// worker_elastic::get_new_task -- src/thread/thread_worker_pool_elastic.hpp if (queued.has_value ()) { stats::time_and_increment (m_stats, stats::id::found_in_queue); this->m_wrapped_task.emplace (std::move (queued->first)); m_slot = std::move (queued->second); return true; } /* FAST: no wait */ ulock.lock (); assert ((!this->m_wrapped_task.has_value () && !m_slot) || (this->m_wrapped_task.has_value () && m_slot)); /* INV 13-A */ if ((!this->m_wrapped_task.has_value () && !m_slot) && !this->m_stop) { condvar_wait (this->m_task_cv, ulock, /* persistent: infinite; else idle_timeout */, [this] { return (this->m_wrapped_task.has_value () && m_slot) || this->m_stop; }); }불변식 13-A (워커는 작업과 슬롯을 항상 함께 갖거나, 둘 다 갖지 않는다). 짝을 이루는 어서션
(!task && !slot) || (task && slot)은 진입 시와 대기 이후에 검사되며, CV 조건식은(task && slot) || stop이다. 이는 슬롯을 진정한 입장 허가 토큰(admission token)으로 만든다 — 워커는 실행 권한이 없는 작업으로 깨어나는 일도, 작업 없는 슬롯만으로 깨어나는 일도 없다. 이 결합은 생산자 쪽에서try_acquire_slot이 성공한 뒤에만 작업을 꺼내는 방식으로 강제된다.
m_stop 분기도 달라진다 — 탄력적 버전은 정지 중인 워커를 계속 보이게 하기 위해 통과하기 전에 become_available(*this)를 호출한다. 작업이 없는 상태로 빠져나갈 때는 m_has_thread를 지우고 finish_run(컨텍스트 retire)을 호출한 뒤 false를 반환한다. 작업을 찾은 경우에는 락을 풀고 wakeup_with_task를 증가시키고 check_worker_not_available(디버그용)을 확인한 뒤 true를 반환한다.
execute_current_task — 실행 중에는 슬롯이 스레드 엔트리에 머무른다.
// worker_elastic::execute_current_task -- src/thread/thread_worker_pool_elastic.hpp this->m_context_p->m_slot = std::move (m_slot); /* slot -> thread entry */ this->m_wrapped_task->execute (*this->m_context_p); if (this->m_context_p->m_slot) /* task may have swapped/lost it */ { this->m_context_p->m_slot->return_to_pool (std::move (this->m_context_p->m_slot)); this->m_context_p->m_slot = nullptr; }if (this->m_context_p->m_slot) 가드는 중요하다 — 논리적 대기 도중(§13.1) 슬롯을 빼앗긴 작업은 null 슬롯 상태로 돌아와 return_to_pool을 건너뛴다. 그렇지 않은 경우에는 §13.4의 3단계 전달을 거쳐 슬롯이 집으로 돌아간다. 슬롯을 thread_entry에 실어 나르는 이 구조 덕분에, 실행 중인 요청 자신이 슬롯을 대기하거나 반환할 수 있게 된다(14장).
run — 종료 시점의 회수(reap-on-exit). 탄력적 run은 기본(10장) 루프를 호출한 뒤 get_retire_if_excess(this)를 호출한다. 이 함수는 코어 뮤텍스 하에서 세 가지 게이트를 모두 통과해야만 이제는 잉여가 된 스레드를 제거한다.
// core_elastic::get_retire_if_excess -- src/thread/thread_worker_pool_elastic.hpp if (this->m_workers.size () > m_max_concurrency && !this->has_workers_snapshot_readers ()) { auto available_it = std::find (m_available_workers.begin (), m_available_workers.end (), w); if (available_it != this->m_available_workers.end ()) /* still idle, not re-selected */ { this->m_available_workers.erase (available_it); /* erase from available FIRST */ /* find w in m_workers */ stats::accumulate (w->m_stats, m_retired_stats); /* preserve counters */ this->m_workers.erase (worker_it); release_available_worker (); } }게이트 1: m_workers.size() > m_max_concurrency(이 코어가 슬롯 목표치를 초과함). 게이트 2: !has_workers_snapshot_readers()(불변식 13-E). 게이트 3: w가 여전히 m_available_workers에 남아 있어야 한다. finish_run과 이 검사 사이에 동시 실행 중인 execute_task가 큐에 쌓인 작업을 위해 w를 다시 가로챌 수 있다. 그러면 find는 end()를 반환하고(아직 선택되지 않았다는 조건이 실패) 회수는 건너뛴다 — 워커는 계속 실행된다. 세 조건이 모두 성립할 때만 코드는 m_available_workers에서 w를 먼저 지운 다음, m_workers에서 해당 unique_ptr<worker>를 찾아 지우며, 파괴 전에 통계를 m_retired_stats로 접어 넣는다.
불변식 13-E (스냅샷 리더가 살아 있는 동안에는 회수하지 않는다).
stop_execution,get_stats,map_running_contexts는m_workers에서 rawworker *를 복사해 내고m_workers_readers를 증가시키는snapshot_guard를 만든다. 살아 있는 스냅샷 아래에서unique_ptr<worker>를 지우면 그 포인터들이 댕글링(dangling)되므로,get_retire_if_excess는has_workers_snapshot_readers ()가 참이면 회수를 건너뛴다 — 해당 워커는 다음 유휴 종료 시점에 다시 시도한다. 통계는 파괴 전에 먼저m_retired_stats로 접어 넣어지므로, 관측성(11장)이 은퇴한 워커의 카운터를 잃는 일은 없다.
Available-worker 재구성 — 전역 오버커밋 상한. 기본 get_available_worker는 고정된 m_available_workers에서 pop만 한다. 탄력적 버전은 이를 get_or_make_available_worker로 감싸며, 전역 상한까지 벡터 자체를 늘릴 수 있게 한다.
// core_elastic::get_or_make_available_worker -- src/thread/thread_worker_pool_elastic.hpp worker_p = this->get_available_worker (); /* base: reuse an idle worker */ if (!worker_p && reserve_available_worker ()) /* none idle -> try to grow */ { this->m_workers.push_back (this->allocate_worker ()); worker_p = this->m_workers.back ().get (); worker_p->set_parent_core (*this); } return static_cast<worker_elastic *> (worker_p); /* nullptr if at global cap */reserve_available_worker는 공유된 m_current_worker 원자 변수에 대한 lock-free CAS 루프이며 m_max_worker로 상한이 걸려 있다 — expected >= m_max_worker이면 false를 반환한다. 두 원자 변수 모두 풀 전체에서 공유되며 참조로 모든 core_elastic에 전달되므로, 각 코어가 자신의 m_workers를 늘리더라도 전체 스레드 수의 상한은 전역적으로 유지된다. release_available_worker(fetch_sub(1))는 회수 시 허가증을 반환한다. 이는 스레드에 대한 소프트 상한으로, 코어별 m_max_concurrency가 거는 슬롯 상한과는 별개다 — 이것이 재설계의 본질이다: 스레드는 승인된 동시성을 넘어 오버커밋할 수 있고, 그 덕분에 블로킹된 스레드의 슬롯을 다른 곳으로 빌려줄 수 있다.
get_pool_size — 바뀌지 않았고, 바로 그 점이 미묘하다. worker_pool_elastic은 이를 오버라이드하지 않는다 — 생성 시점에 고정된 entry 예약 개수인 m_pool_size를 그대로 반환한다. 기본 풀에서는 이 값이 실제 워커 수와 같지만, 탄력적 풀에서는 실제 스레드 수가 동적이며(Σ core->m_workers.size (), m_max_worker로 상한) get_pool_size와 분리되어 있다. get_pool_size를 “현재 존재하는 스레드 수”로 읽던 호출자는 이제 예약 상한값을 얻게 된다 — 실제 현황을 보려면 get_runtime_stats / get_max_worker를 사용해야 한다.
stateDiagram-v2 [*] --> BaseRun : 기본 worker_impl 실행 루프 BaseRun --> GetNewTask GetNewTask --> Execute : task+slot Execute --> GetNewTask GetNewTask --> FinishRun : idle 또는 stop FinishRun --> ReapCheck : m_has_thread 초기화 ReapCheck --> Reap : max_concurrency 초과, reader 없음, 아직 available ReapCheck --> Keep : 게이트 중 하나라도 실패 Reap --> [*] Keep --> [*]
Figure 13-3. 탄력적 워커 생명주기: 기본 루프에 슬롯 결합 fetch와 3단계 게이트 reap-on-exit이 추가된 모습.
13.8 챕터 요약 — 핵심 정리
섹션 제목: “13.8 챕터 요약 — 핵심 정리”- 슬롯은 입장 허가 토큰이다. 실행 가능 상태가 되는 유일한 방법은
try_acquire_slot(논블로킹 디스패처)과acquire_slot(블로킹 논리적 대기)뿐이다.get_new_task는 작업과 슬롯을 결합시켜 워커가 허가 없이는 결코 실행되지 않게 한다(불변식 13-A). - 소유(ownership)와 보유(holding)의 구분이 모든 반환을 좌우한다.
m_owner_pool은 불변이며,return_to_pool은 소유자→보유자→강제(Figure 13-2) 순으로 제안해m_slot_count를 보존한다 — 코어는 자신이 만든 슬롯만 파괴한다(불변식 13-D). release_slot은 세 가지 운명 중 하나를 맞는다 — 목표치를 초과한 소유 슬롯을 축소하거나, 대기자가 없으면 저장하거나, 막혀 있는 획득자에게 전달한다. 이때 대기자의th_entry_lock을 잡기 전에는 항상 코어 뮤텍스를 먼저 놓는다(불변식 13-F).- 인터럽트 경쟁 상황은 명시적으로 처리된다.
acquire_slot은 모든 경로에서 바깥쪽 대기의resume_status를 복원하며(불변식 13-C), 인터럽트 직전에 건네진 슬롯도 회수한다. adjust_concurrency는 available이면서 owned인 슬롯은 즉시 축소하고, 실행 중인 슬롯은release_slot을 통해 지연 축소한다. 그 결과 동시성을 줄이는 작업이 실행 중인 작업 때문에 막히는 일이 없다.check_surplus_slots는 데몬을 위해 과잉 프로비저닝 구간의 시간을 잰다.- 스레드는 승인된 동시성을 넘어 오버커밋한다.
reserve_available_worker의 CAS 루프는 코어별m_max_concurrency슬롯 상한과 무관하게 전역m_max_worker스레드 상한을 강제한다.get_new_task의 빠른 경로는 큐에 쌓인 작업과 여유 슬롯을 함께 찾으면(found_in_queue) CV 대기를 건너뛴다.get_retire_if_excess는 목표치를 초과했고, 살아 있는snapshot_guard리더가 없으며(불변식 13-E), 워커가 여전히m_available_workers에 남아 있을 때만(그렇지 않으면 이미 재선택되어 계속 실행 중이라는 뜻이다) 잉여 스레드를 회수한다. get_pool_size는 이제 실시간 카운트가 아니라 예약 상한이다. 탄력적 풀은 이를 오버라이드하지 않은 채로 둔다 — 실시간 스레드 상태는get_runtime_stats/get_max_worker로 확인해야 한다.
Chapter 14: Phase 2 흐름 — 슬롯 데몬과 논리적 대기 연결 구조
섹션 제목: “Chapter 14: Phase 2 흐름 — 슬롯 데몬과 논리적 대기 연결 구조”12~13장에서는 elastic pool의 슬롯 구조와, 실행 중인 워커가 거치는 동기적
acquire/release/return 경로를 다뤘다. 이번 장에서는 슬롯 예산을 유동적으로
유지하는 두 가지 비동기 메커니즘으로 그 흐름을 마무리한다: 유휴 슬롯과 정체된
슬롯을 재분배하는 concurrency_slot_daemon(약 50ms 주기)과, 스레드가
락·CSS 작업 큐·PL 호출에서 블록될 때 슬롯을 내놓고 깨어날 때 다시 획득하는
**서스펜션 연결(wiring)**이다. 독자 질문: 반납된 슬롯은 어떻게 재분배되며,
락/CSS/PL 대기는 어떻게 슬롯을 내놓고 다시 획득하는가?
워크트리 참고. 모든 코드는 PR #7323
feature/worker_pool_elastic워크트리 기준이며 — 아직develop에 머지되지 않음; POSHINT 줄 번호도 이 워크트리 기준이다.상호 참조. Elastic pool의 도입 배경은
cubrid-thread-manager.md에서 다뤘다; 코어별concurrency_slot_pool필드,SLOT_SURPLUS_THRESHOLD, pub/subsubscribe/activate배선, 그리고try_acquire_slot/release_slot/return_slot/borrow_surplus_slots는 12~13장의 내용이다. 이 장은 이를 이미 알고 있다고 전제하고 진행한다.
14.1 데몬 셸과 틱(tick) 계약
섹션 제목: “14.1 데몬 셸과 틱(tick) 계약”REGISTER_DAEMON (concurrency_slot_daemon)는 그 자체로 퍼블리셔인
(concurrency_slot_daemon : concurrency_slot_publisher) 싱글턴을 등록한다.
즉 m_subscribers 맵을 소유하는 객체와, 그 맵을 순회하는 태스크를 갖는 객체가
동일하다. create_daemon은 concurrency_slot_daemon_task를 고정 50ms
looper로 감싼다:
// concurrency_slot_daemon::create_daemon -- src/thread/concurrency_slot.cpplooper loop = looper (std::chrono::milliseconds (50));m_daemon = cubthread::get_manager ()->create_daemon (loop, daemon_task, "concurrency");execute는 다섯 단계로 이루어진 계약을 수행한다: (1) 파라미터를 재조정하고,
이후 등록된 워커 풀 identifier마다 (2) 블록된 엔트리에서 50ms가 지난 슬롯을
회수하고, (3) 수요가 있을 때에만 과공급된 코어에서 잉여분을 회수하고,
(4) 재분배하고, (5) 공급받은 코어를 깨운다. 2~5단계는 모두
traverse_subscribers 콜백 안에서 실행된다.
불변식 — 강제되지 않은 슬롯 이동은 오직 틱(tick)뿐이다. 동기적인
release_slot/return_slot은 태스크가 끝나거나 대기자가 나타날 때만 슬롯을
옮긴다; 유휴 잉여분과 블록된 엔트리에 묶여 있는 슬롯은 오직 데몬만이
회수한다. 데몬이 멈추면 풀은 정적 크기로 퇴화할 뿐 절대 깨지지 않는다 — 모든
슬롯은 여전히 return_to_pool을 통해 제자리로 돌아간다.
14.2 1단계 — 파라미터 재조정과 cgroup 인식 코어 수
섹션 제목: “14.2 1단계 — 파라미터 재조정과 cgroup 인식 코어 수”check_and_propagate_parameters는 두 개의 동적 튜너블 값을 다시 읽어, 태스크가
캐시해 둔 값과 어긋난 경우에만 동작한다:
// concurrency_slot_daemon_task::check_and_propagate_parameters -- src/thread/concurrency_slot.cppif (max_request_concurrency != m_max_request_concurrency || max_request_worker != m_max_request_worker) { tune_parameters (max_request_concurrency, max_request_worker); /* clamp to sane band */ css_set_max_concurrency_and_workers (max_request_concurrency, max_request_worker); /* push to live pool */ prm_set_integer_value (PRM_ID_MAX_REQUEST_CONCURRENCY, max_request_concurrency); /* write back clamped */ /* ...set worker, update m_ caches... */ }tune_parameters는 두 값을 모두 [core_count, CSS_MAX_CLIENT_COUNT] 범위로
클램프하고 worker >= concurrency를 강제한다:
// concurrency_slot_daemon_task::tune_parameters -- src/thread/concurrency_slot.cppstd::size_t core_count = system_core_count ();max_request_concurrency = std::min (std::max (max_request_concurrency, core_count), max_client_count);max_request_worker = std::min (std::max (max_request_worker, core_count), max_client_count);if (max_request_worker < max_request_concurrency) { max_request_worker = max_request_concurrency; }system_core_count는 hardware_concurrency가 아니라
os::resources::cpu::effective ().adjusted_max를 반환한다. effective()
(resources.cpp의 지연 초기화된 static)는 프로세스 affinity, 커널
online 값, 그리고 cgroup CPU 제한(cpu.cfs_quota_us/cpu.max,
cgroup.cpp가 읽음)의 교집합을 구한 뒤 내림하고 >= 1로 클램프한다:
// cpu::effective (correction & adjustment) -- src/base/resources.cppctx.adjusted_max = static_cast<std::size_t> (std::floor (ctx.max)); /* min of affinity, online, cgroup quota */if (ctx.adjusted_max <= 0) { ctx.adjusted_max = 1; }따라서 64코어 호스트에서 cpu.max=2로 고정된 컨테이너라면 core_count == 2가
된다. 기본값: max_request_concurrency = system_core_count()*3(소스 주석에
“adjusted based on CBRD-26636”이라 적혀 있음), 상한은 CSS_MAX_CLIENT_COUNT;
max_request_worker = CSS_MAX_CLIENT_COUNT — 이 튜너블들은 CBRD-26688 /
CBRD-26690 / CBRD-26977에서 온 것이다. css_set_max_concurrency_and_workers는
adjust_runtime_parameter(12장)를 통해 실행 중인 변경 사항을 전파한다.
14.3 traverse_subscribers와 identifier별 본문
섹션 제목: “14.3 traverse_subscribers와 identifier별 본문”traverse_subscribers는 베이스의 traverse로 위임하며, 이 함수는 순회가 끝날
때까지 퍼블리셔 뮤텍스를 쥔 채로 맵 엔트리마다 한 번씩 콜백을 호출한다:
키는 워커 풀 identifier, 값은 그 풀에 속한 코어별 concurrency_slot_pool
구독자들이다.
// concurrency_slot_publisher::traverse -- src/thread/concurrency_slot.hppstd::lock_guard<std::mutex> lock (m_mutex);for (auto &subset : m_subscribers) { func (subset.first, subset.second); } /* (identifier, subs) */불변식 — 구독자 집합은 한 identifier의 틱 동안 고정된다. 퍼블리셔 뮤텍스가
traverse 전체에 걸쳐 유지되므로, 수확(harvest) 도중에는 어떤 코어도
subscribe/unsubscribe할 수 없고 distribute_slots가 해체 중인 풀에 슬롯을
먹일 일도 없다. 이는 각 풀의 코어 뮤텍스(아래에서 풀 단위로 다시 획득함)와는
별개의 락이다. execute의 람다는 subs를 값으로(auto subs) 받는다 —
자유롭게 clear하고 재구성할 수 있는 사적인 복사본이다.
flowchart TD
A["execute 틱"] --> B["check_and_propagate_parameters"]
B --> C["identifier별 traverse_subscribers"]
C --> D["steal_from_entries_if_excess<br/>블록된 엔트리에서 50ms 경과 슬롯 회수"]
D --> E{"has_slot_demand?"}
E -->|yes| F["steal_from_cores_if_excess<br/>코어별 borrow_surplus_slots"]
E -->|no| G["점수 기반 distribute_slots"]
F --> G
G --> H["공급받은 코어에 wakeup_workers"]
H --> C
execute 틱 한 번; D–H는 identifier마다 반복된다.has_slot_demand는 3단계를 게이팅한다 — 어떤 풀이든 needs_slot이 참이면
(대기 큐가 비어있지 않거나, 가용 리스트가 비어있는데 큐에 태스크가 있으면 —
13장) true를 반환한다.
14.4 소프트웨이트 승격과 엔트리로부터의 슬롯 회수
섹션 제목: “14.4 소프트웨이트 승격과 엔트리로부터의 슬롯 회수”2단계는 슬롯이 낭비될 만큼 오래 블록된 엔트리로부터 슬롯을 회수하는데, 이는 슬롯 위에서 이루어지는 2단계 **소프트→하드 승격(promotion)**을 통해 이루어진다. 락 서스펜션은 슬롯을 반납하지 않고 — 대신 소프트웨이팅으로 표시한다:
// concurrency_slot::start_waiting / has_wait_expired -- src/thread/concurrency_slot.cppvoid concurrency_slot::start_waiting () { m_wait = true; m_wait_since = std::chrono::steady_clock::now (); }void concurrency_slot::stop_waiting () { m_wait = false; }bool concurrency_slot::has_wait_expired (const std::chrono::time_point<std::chrono::steady_clock> &now){ constexpr auto threshold = std::chrono::milliseconds (50); return m_wait && (now - m_wait_since >= threshold); }데몬은 실행 중인 모든 컨텍스트를 순회하며 만료된 슬롯을 회수함으로써 소프트를 하드로 승격시킨다:
// concurrency_slot_daemon_task::steal_from_entries_if_excess -- src/thread/concurrency_slot.cppauto func = [&slots, &now] (entry &thread_ref, bool &stop) { thread_ref.lock (); if (thread_ref.m_status == cubthread::entry::status::TS_WAIT && thread_ref.m_slot && thread_ref.m_slot->has_wait_expired (now)) { slots.emplace_back (std::move (thread_ref.m_slot)); thread_ref.m_slot = nullptr; } /* steal out */ thread_ref.unlock ();};pool->map_running_contexts (func); /* stats on/off both dispatched */불변식 — 서스펜드된 엔트리의 m_slot은 스레드 엔트리 락으로 보호된다.
start_waiting/stop_waiting은 th_entry_lock 하에서 실행되고(아래의
후크들), 데몬도 m_status/m_slot을 읽고 슬롯을 꺼내기 전에
thread_ref.lock ()을 잡는다. 양쪽이 같은 락을 쓰므로 → 데몬과 깨어나는
스레드가 같은 슬롯을 동시에 차지하는 일은 절대 없다. TS_WAIT 검사는 두 번째
보호막이다: 슬롯을 쥐고 있는 TS_RUN 엔트리는 건너뛴다. 50ms 임계값 자체가
설계 의도다 — 데몬 한 주기보다 짧은 락 대기는 슬롯을 지키고, 그보다 길면
잃는다.
stateDiagram-v2 [*] --> Held : 태스크 시작 시 acquire_slot Held --> SoftWait : LOCK 서스펜드 start_waiting SoftWait --> Held : 50ms 이전 깨어남, stop_waiting SoftWait --> Stolen : 데몬 has_wait_expired 50ms 시점 Stolen --> Reacquired : 깨어나 보니 m_slot null, acquire_slot 블록 Held --> Returned : CSS 서스펜드 즉시 슬롯 반납 Returned --> Reacquired : 깨어나서 acquire_slot 호출 Reacquired --> [*]
14.5 코어 간 잉여 이전과 점수 기반 분배
섹션 제목: “14.5 코어 간 잉여 이전과 점수 기반 분배”3단계는 수요가 있을 때만 유휴 잉여분을 거둬들인다: 각 코어는
borrow_surplus_slots(13장: SLOT_SURPLUS_THRESHOLD까지, 잉여 상태로
2000ms가 지난 뒤에만)를 통해 슬롯을 내놓는다. 4단계 distribute_slots는
모든 코어에 점수를 매겨 내림차순으로 정렬한 뒤, 그 순서를 라운드로빈으로
돌며 슬롯을 나눠준다 — 점수는 부족분에 가중치를 둔다:
// concurrency_slot_pool::get_score -- src/thread/concurrency_slot.cppreturn -static_cast<float> (m_available_slots.size ()) + m_wait_queue.size () * 2.0f; /* neediest = highest */// concurrency_slot_daemon_task::distribute_slots -- src/thread/concurrency_slot.cppsubs.clear ();std::sort (scores.begin (), scores.end (), [] (const auto &a, const auto &b) { return a.second > b.second; });while (!slots.empty ()) { auto slot = std::move (slots.back ()); slots.pop_back (); auto pool = static_cast<concurrency_slot_pool *> (scores[i++ % scores.size ()].first); pool->release_slot (std::move (slot)); /* Ch 13: store or hand to a waiter */ if (std::find (subs.begin (), subs.end (), ...) == subs.end ()) { subs.push_back (pool); } /* record fed pool */}distribute_slots는 subs를 실제로 슬롯을 받은 풀만 남도록 재구성하므로,
5단계의 wakeup_workers는 정확히 그 코어들만 건드린다. release_slot은
13장에서 다룬 루틴이다(저장하거나, 대기 중인 대기자에게 건네며, owner-vs-holder
축소 규칙을 지킨다).
불변식 — 한 틱 안에서 슬롯이 유실되는 일은 없다. 2~3단계에서 회수된 것은
모두 4단계에서 배치되거나(execute가 slots.empty ()를 단언한다), 소유
코어가 m_target_count를 초과할 때만 release_slot 내부에서 파괴된다.
여기서 슬롯의 holder는 바뀔 수 있지만(owner는 평생 고정) — 이것이 코어
A에서 만들어진 슬롯이 코어 B에서 작업을 실행하게 되는 방식이며,
return_to_pool의 owner-then-holder 라우팅은 여전히 슬롯을 제자리로
돌려보낸다.
14.6 서스펜션 연결 — CSS 하드 반납 대 LOCK 소프트웨이트
섹션 제목: “14.6 서스펜션 연결 — CSS 하드 반납 대 LOCK 소프트웨이트”레거시 C 블로킹 프리미티브들은 모든 논리적 대기를, pthread_cond_wait 루프
앞의 thread_prepare_suspension과 그 뒤의 thread_prepare_resumption으로
감싼다. 서스펜션은 엔트리를 TS_WAIT로 전환하고, 슬롯이 관리되는 엔트리라면
블록되는 이유에 따라 분기한다:
// thread_prepare_suspension -- src/thread/thread_entry.cppif (thread_p->m_slot) switch (suspended_reason) { case THREAD_CSS_QUEUE_SUSPENDED: /* long idle wait: give slot back now */ holder = thread_p->m_slot->get_holder_pool (); { auto slot = std::move (thread_p->m_slot); thread_p->m_slot = nullptr; slot->return_to_pool (std::move (slot)); } break; case THREAD_LOCK_SUSPENDED: /* short-ish: keep slot, mark soft-wait */ holder = thread_p->m_slot->get_holder_pool (); thread_p->start_waiting (); break; default: break;}| Suspend reason | Slot action | Rationale |
|---|---|---|
THREAD_CSS_QUEUE_SUSPENDED | 즉시 return_to_pool(하드 반납) | CSS 큐에 대기 중인 핸들러는 무기한 유휴 상태가 될 수 있으므로, 슬롯을 쥐고 있으면 실제 작업이 굶주리게 된다 |
THREAD_LOCK_SUSPENDED | start_waiting(슬롯 유지) | 락 대기는 대체로 짧으므로, 빠른 재개를 위해 슬롯을 유지하고 50ms 후에는 데몬이 회수하도록 둔다 |
entry::start_waiting/stop_waiting은 SERVER_MODE에서만 동작하는,
m_slot으로 한 줄 위임하는 함수다. 두 경우 모두 holder는 슬롯이 움직이기
전에 캡처된다 — resumption은 m_slot이 이미 null이 된 뒤에도 풀 포인터가
필요하기 때문이다.
14.7 재개(resumption) 연결과 웨이크업 루프
섹션 제목: “14.7 재개(resumption) 연결과 웨이크업 루프”바깥쪽 대기 루프가 빠져나오면(조건: resume_status가 더 이상 서스펜드 사유와
같지 않을 때), thread_prepare_resumption이 슬롯을 정리한다 — holder가
설정된 경우에만 실행되며, 세 갈래로 분기한다:
// thread_prepare_resumption -- src/thread/thread_entry.cppif (holder) { if (timedout || thread_p->resume_status == THREAD_RESUME_DUE_TO_INTERRUPT || thread_p->resume_status == THREAD_RESUME_DUE_TO_SHUTDOWN) { /* branch 0: aborting */ if (thread_p->m_slot) { holder->release_slot (std::move (thread_p->m_slot)); thread_p->m_slot = nullptr; } } else { if (thread_p->m_slot) { thread_p->stop_waiting (); } /* branch 1: soft-wait survived */ else { holder->acquire_slot (thread_p); } /* branch 2: reacquire, may block */ }}thread_p->m_status = status; /* restore pre-wait TS_RUN/TS_CHECK */- 분기 0(타임아웃/인터럽트/셧다운). 대기가 풀리는 중이다. 슬롯이 아직
쥐어져 있다면(아직 만료되지 않은 LOCK 소프트웨이트)
release_slot으로 돌려준다; 재획득을 위해 블록하는 일은 절대 없다. 이미 도난당했거나 CSS로 반납된 상태라면 할 일이 없다. - 분기 1(정상 재개, 슬롯 유지). LOCK인 경우에만 해당하며, 대기 시간이
50ms 미만이라 데몬이 슬롯을 훔쳐가지 않았다.
stop_waiting이m_wait를 지운다; 비용 없이 재개된다. - 분기 2(정상 재개, 슬롯 소실). CSS(서스펜드 시점에 반납됨)와, 데몬이
슬롯을 훔쳐간 LOCK 케이스;
acquire_slot이 슬롯을 다시 건네받을 때까지 블록한다(13장의 대기 큐 경로).
불변식 — 중첩된 슬롯 대기가 원래의 재개 결과를 덮어써서는 안 된다.
acquire_slot은 THREAD_CONCURRENCY_SLOT_SUSPENDED를 키로 하는 또 하나의
pthread_cond_wait를 실행하지만, 그 주위에서 resume_status를 저장했다가
복원한다(13장). 그렇지 않으면 락 매니저가 이미 기록해 둔
THREAD_LOCK_RESUMED가 덮어써져, lock_suspend가 락이 허가되지 않았다고
잘못 결론 내리게 된다. 인터럽트 자체는 유실되지 않는다 — 상위의 취소 경로를
위해 트랜잭션 인터럽트 상태에 그대로 남아 있다.
thread_suspend_wakeup_and_unlock_entry는 타임아웃이 없는 드라이버로
(timedout = false를 넘기므로 분기 0에 도달하려면 인터럽트/셧다운이
필요하다), 타임아웃 변형은 error == ER_CSS_PTHREAD_COND_TIMEDOUT을 넘기며
순수 타임아웃만으로도 분기 0에 도달할 수 있다.
// thread_suspend_wakeup_and_unlock_entry -- src/thread/thread_entry.cppthread_prepare_suspension (thread_p, status, suspended_reason, start_time, holder);while (thread_p->resume_status == suspended_reason) { pthread_cond_wait (&thread_p->wakeup_cond, &thread_p->th_entry_lock); } /* outer wait */thread_prepare_resumption (thread_p, status, suspended_reason, false, start_time, holder);thread_p->unlock ();14.8 세 가지 호출 지점
섹션 제목: “14.8 세 가지 호출 지점”락 매니저(논리적 대기). lock_suspend는 락 대기 관련 장부를 기록한 뒤
thread_suspend_wakeup_and_unlock_entry (..., THREAD_LOCK_SUSPENDED)를
호출한다 — 이 호출 하나가 슬롯 인계 전체이며, 소프트웨이트 경로
(14.6–14.7)를 그대로 따른다. 깨어나면 resume_status를 읽는다:
THREAD_LOCK_RESUMED면 허가됨(분기 1/2에 따라 슬롯을 다시 손에 쥔 상태),
THREAD_RESUME_DUE_TO_INTERRUPT면 정리됨(분기 0이 이미 유지 중이던 슬롯을
놓아준 상태). 락 코드는 슬롯을 직접 건드리지 않는다.
네트워크 wake-eligible 스위치. net_server_wakeup_workers는 트랜잭션의
서스펜드된 워커를 인터럽트하며, resume_status를 기준으로 분기한다:
// net_server_wakeup_workers -- src/communication/network_sr.cswitch (suspended_p->resume_status) { case THREAD_CSECT_READER_SUSPENDED: case THREAD_CSECT_WRITER_SUSPENDED: case THREAD_CSECT_PROMOTER_SUSPENDED: case THREAD_LOCK_SUSPENDED: case THREAD_JOB_QUEUE_SUSPENDED: wakeup_now = false; break; /* CS / lock waits NOT interrupt-woken here */ case THREAD_CSS_QUEUE_SUSPENDED: case THREAD_CONCURRENCY_SLOT_SUSPENDED: /* Phase-2 slot wait: eligible */ /* ...other idle waits... */ wakeup_now = true; break; /* ...resumed states -> false... */}if (wakeup_now) { thread_wakeup_already_had_mutex (suspended_p, THREAD_RESUME_DUE_TO_INTERRUPT); }Phase 2에서 추가된 것은 “지금 깨움” 그룹에 들어간
THREAD_CONCURRENCY_SLOT_SUSPENDED다: acquire_slot 내부에 머물러 있는
스레드(resumption 분기 2)는 인터럽트 가능해야 한다. 그렇지 않으면 클라이언트의
취소 요청이 슬롯 기아 상태 뒤에 걸려 멈춰버릴 수 있다. THREAD_LOCK_SUSPENDED는
여전히 “깨우지 않음”으로 남는다 — 그 슬롯은 데몬이 회수하는 소프트웨이트이기
때문이다.
PL 프로세스 외부 호출. Java PL 서버에서 블록되는 저장 프로시저는 그대로
두면 왕복하는 내내 슬롯 하나를 고정시키게 된다. 그래서 read_data_from_java는
블로킹 수신 앞뒤로 슬롯을 반납했다가 다시 획득한다:
// read_data_from_java -- src/sp/pl_execution_stack_context.hpp#if defined (SERVER_MODE) auto *holder = thread_concurrency_slot_release (m_thread_p); /* give slot back before blocking recv */#endif int error = conn->receive_buffer (b, &interrupt_func, 500);#if defined (SERVER_MODE) thread_concurrency_slot_acquire (m_thread_p, holder); /* reacquire (may block) after recv */#endifthread_manager.hpp의 thread_concurrency_slot_release는 엔트리를 잠그고,
m_slot을 꺼내 return_to_pool한 뒤 holder 풀을 반환한다;
thread_concurrency_slot_acquire는 holder가 설정돼 있으면 TS_WAIT 하에서
acquire_slot으로 다시 블록한다. LOCK과 달리 PL은 명시적인 release/reacquire
쌍을 사용하는데, PL 호출은 원래 오래 걸릴 것으로 예상되므로 50ms 동안 슬롯을
붙잡고 있어 봤자 의미가 없기 때문이다.
14.9 챕터 요약 — 핵심 정리
섹션 제목: “14.9 챕터 요약 — 핵심 정리”- 데몬만이 강제되지 않은 슬롯 이동자다. 50ms
looper가 파라미터를 재조정하고, 블록된 엔트리에서 만료된 슬롯을 회수하고, (수요가 있을 때) 과공급 코어에서 유휴 잉여분을 회수하고, 점수 기준으로 재분배하고, 공급받은 코어를 깨운다. - 소프트→하드 승격은 LOCK 정책이다.
start_waiting이m_wait_since를 찍고,has_wait_expired가 50ms 시점에 승격시키며,steal_from_entries_if_excess는 대기가 만료된TS_WAIT엔트리에서만 슬롯을 뽑아간다 — 엔트리 락에 의해 깨우는 쪽과 순서가 맞춰진다. - CSS/PL은 즉시 반납하고, LOCK은 낙관적으로 유지한다. 서스펜션은
THREAD_CSS_QUEUE_SUSPENDED에 대해서는return_to_pool하고,THREAD_LOCK_SUSPENDED에 대해서는 소프트웨이트로만 표시한다; PL은 명시적인thread_concurrency_slot_release/_acquire쌍을 사용한다. - 재개(resumption)는 세 갈래다. 중단(abort)은 재획득 없이 유지 중이던
슬롯을 놓아준다; 슬롯을 유지한 정상 재개는
stop_waiting을 호출한다 (비용 없음); 슬롯이 없는 정상 재개는acquire_slot에서 블록하는데, 이 함수는resume_status를 저장/복원해 허가된THREAD_LOCK_RESUMED가 훼손되지 않도록 한다. - 분배는 부족분 기준으로 이루어지며 아무것도 흘리지 않는다. 코어들은
get_score(-available + 2*waiters) 기준으로 정렬되고, 슬롯은 가장 궁핍한 순서로 라운드로빈 배분되며,slots.empty ()가 단언된다; 슬롯의 holder는 코어를 넘나들 수 있지만, 귀환 라우팅을 위한 owner는 고정된다. - 코어 수는 cgroup을 인식한다.
tune_parameters는 튜너블 값을[system_core_count(), CSS_MAX_CLIENT_COUNT]범위로 클램프하며worker >= concurrency를 지킨다;system_core_count는adjusted_max— affinity, online, cgroup CPU 쿼터의 교집합을 내림한 값 — 이므로 컨테이너 제한이 그대로 반영된다.
이 리비전 시점의 위치 힌트
섹션 제목: “이 리비전 시점의 위치 힌트”아래 줄 번호는 2026-07-08 시점에 관찰한 값이다. 심볼 이름이 정식 앵커이며 줄 번호는 시간이 지나면 어긋나는 힌트다.
| Symbol | File | Line |
|---|---|---|
cpu::effective | PR:src/base/resources.cpp | 192 |
PRM_ID_MAX_REQUEST_WORKER | PR:src/base/system_parameter.c | 5212 |
PRM_ID_MAX_REQUEST_CONCURRENCY | PR:src/base/system_parameter.c | 5231 |
net_server_wakeup_workers | PR:src/communication/network_sr.c | 928 |
css_set_max_concurrency_and_workers | PR:src/connection/server_support.c | 2913 |
read_data_from_java | PR:src/sp/pl_execution_stack_context.hpp | 154 |
concurrency_slot_subscriber::activate | PR:src/thread/concurrency_slot.cpp | 53 |
concurrency_slot_publisher::subscribe | PR:src/thread/concurrency_slot.cpp | 82 |
concurrency_slot::return_to_pool | PR:src/thread/concurrency_slot.cpp | 162 |
concurrency_slot::return_to_pool | PR:src/thread/concurrency_slot.cpp | 163 |
concurrency_slot::start_waiting | PR:src/thread/concurrency_slot.cpp | 179 |
concurrency_slot::stop_waiting | PR:src/thread/concurrency_slot.cpp | 186 |
concurrency_slot::has_wait_expired | PR:src/thread/concurrency_slot.cpp | 192 |
concurrency_slot::has_wait_expired | PR:src/thread/concurrency_slot.cpp | 193 |
concurrency_slot_pool::concurrency_slot_pool | PR:src/thread/concurrency_slot.cpp | 204 |
concurrency_slot_pool::adjust_concurrency | PR:src/thread/concurrency_slot.cpp | 240 |
concurrency_slot_pool::adjust_concurrency(ulock) | PR:src/thread/concurrency_slot.cpp | 248 |
concurrency_slot_pool::try_acquire_slot | PR:src/thread/concurrency_slot.cpp | 291 |
concurrency_slot_pool::try_acquire_slot(ulock) | PR:src/thread/concurrency_slot.cpp | 299 |
concurrency_slot_pool::acquire_slot | PR:src/thread/concurrency_slot.cpp | 321 |
concurrency_slot_pool::acquire_slot | PR:src/thread/concurrency_slot.cpp | 328 |
concurrency_slot_pool::acquire_slot(ulock) | PR:src/thread/concurrency_slot.cpp | 329 |
concurrency_slot_pool::release_slot | PR:src/thread/concurrency_slot.cpp | 413 |
concurrency_slot_pool::release_slot(ulock) | PR:src/thread/concurrency_slot.cpp | 421 |
concurrency_slot_pool::return_slot | PR:src/thread/concurrency_slot.cpp | 481 |
concurrency_slot_pool::borrow_surplus_slots | PR:src/thread/concurrency_slot.cpp | 500 |
concurrency_slot_pool::borrow_surplus_slots | PR:src/thread/concurrency_slot.cpp | 501 |
concurrency_slot_pool::needs_slot | PR:src/thread/concurrency_slot.cpp | 525 |
concurrency_slot_pool::needs_slot(ulock) | PR:src/thread/concurrency_slot.cpp | 533 |
concurrency_slot_pool::available_slots | PR:src/thread/concurrency_slot.cpp | 541 |
concurrency_slot_pool::get_score | PR:src/thread/concurrency_slot.cpp | 552 |
concurrency_slot_pool::check_surplus_slots | PR:src/thread/concurrency_slot.cpp | 568 |
concurrency_slot_pool::wakeup_workers | PR:src/thread/concurrency_slot.cpp | 585 |
concurrency_slot_pool::has_queued_task | PR:src/thread/concurrency_slot.cpp | 600 |
concurrency_slot_daemon_task::execute | PR:src/thread/concurrency_slot.cpp | 661 |
concurrency_slot_daemon_task::has_slot_demand | PR:src/thread/concurrency_slot.cpp | 697 |
concurrency_slot_daemon_task::steal_from_entries_if_excess | PR:src/thread/concurrency_slot.cpp | 711 |
concurrency_slot_daemon_task::steal_from_cores_if_excess | PR:src/thread/concurrency_slot.cpp | 745 |
concurrency_slot_daemon_task::distribute_slots | PR:src/thread/concurrency_slot.cpp | 762 |
concurrency_slot_daemon_task::tune_parameters | PR:src/thread/concurrency_slot.cpp | 822 |
concurrency_slot_daemon_task::check_and_propagate_parameters | PR:src/thread/concurrency_slot.cpp | 837 |
concurrency_slot_daemon::create_daemon | PR:src/thread/concurrency_slot.cpp | 900 |
concurrency_slot_subscriber | PR:src/thread/concurrency_slot.hpp | 52 |
concurrency_slot_subscriber::m_publisher | PR:src/thread/concurrency_slot.hpp | 62 |
concurrency_slot_subscriber::m_identifier | PR:src/thread/concurrency_slot.hpp | 63 |
concurrency_slot_publisher | PR:src/thread/concurrency_slot.hpp | 66 |
concurrency_slot_publisher::m_subscribers | PR:src/thread/concurrency_slot.hpp | 81 |
concurrency_slot_publisher::m_mutex | PR:src/thread/concurrency_slot.hpp | 82 |
concurrency_slot | PR:src/thread/concurrency_slot.hpp | 92 |
concurrency_slot::m_owner_pool | PR:src/thread/concurrency_slot.hpp | 116 |
concurrency_slot::m_holder_pool | PR:src/thread/concurrency_slot.hpp | 117 |
concurrency_slot::m_wait | PR:src/thread/concurrency_slot.hpp | 119 |
concurrency_slot::m_wait_since | PR:src/thread/concurrency_slot.hpp | 120 |
concurrency_slot_pool | PR:src/thread/concurrency_slot.hpp | 125 |
concurrency_slot_pool::SLOT_SURPLUS_THRESHOLD | PR:src/thread/concurrency_slot.hpp | 163 |
SLOT_SURPLUS_THRESHOLD | PR:src/thread/concurrency_slot.hpp | 163 |
concurrency_slot_pool::m_parent | PR:src/thread/concurrency_slot.hpp | 165 |
concurrency_slot_pool::m_available_slots | PR:src/thread/concurrency_slot.hpp | 167 |
concurrency_slot_pool::m_surplus | PR:src/thread/concurrency_slot.hpp | 169 |
concurrency_slot_pool::m_surplus_since | PR:src/thread/concurrency_slot.hpp | 170 |
concurrency_slot_pool::m_slot_count | PR:src/thread/concurrency_slot.hpp | 172 |
concurrency_slot_pool::m_target_count | PR:src/thread/concurrency_slot.hpp | 173 |
concurrency_slot_pool::m_wait_queue | PR:src/thread/concurrency_slot.hpp | 175 |
concurrency_slot_pool::m_mutex | PR:src/thread/concurrency_slot.hpp | 178 |
concurrency_slot_daemon | PR:src/thread/concurrency_slot.hpp | 190 |
concurrency_slot_daemon::m_daemon | PR:src/thread/concurrency_slot.hpp | 210 |
concurrency_slot_publisher::traverse | PR:src/thread/concurrency_slot.hpp | 217 |
concurrency_slot_publisher::traverse | PR:src/thread/concurrency_slot.hpp | 218 |
concurrency_slot_daemon::traverse_subscribers | PR:src/thread/concurrency_slot.hpp | 229 |
entry::start_waiting | PR:src/thread/thread_entry.cpp | 472 |
entry::stop_waiting | PR:src/thread/thread_entry.cpp | 478 |
thread_suspend_wakeup_and_unlock_entry | PR:src/thread/thread_entry.cpp | 525 |
thread_suspend_timeout_wakeup_and_unlock_entry | PR:src/thread/thread_entry.cpp | 553 |
thread_prepare_suspension | PR:src/thread/thread_entry.cpp | 598 |
thread_prepare_resumption | PR:src/thread/thread_entry.cpp | 660 |
event_stat::slot_waits | PR:src/thread/thread_entry.hpp | 115 |
thread_resume_suspend_status | PR:src/thread/thread_entry.hpp | 146 |
THREAD_CONCURRENCY_SLOT_SUSPENDED | PR:src/thread/thread_entry.hpp | 173 |
THREAD_CONCURRENCY_SLOT_RESUMED | PR:src/thread/thread_entry.hpp | 174 |
THREAD_SLEEP_FUNC_SUSPENDED | PR:src/thread/thread_entry.hpp | 175 |
cubthread::entry::resume_status | PR:src/thread/thread_entry.hpp | 258 |
cubthread::entry::m_slot | PR:src/thread/thread_entry.hpp | 333 |
entry::m_slot | PR:src/thread/thread_entry.hpp | 333 |
thread_concurrency_slot_release | PR:src/thread/thread_manager.hpp | 630 |
thread_concurrency_slot_acquire | PR:src/thread/thread_manager.hpp | 657 |
worker_pool_elastic | PR:src/thread/thread_worker_pool_elastic.hpp | 54 |
worker_pool_elastic::unique_slot | PR:src/thread/thread_worker_pool_elastic.hpp | 65 |
worker_pool_elastic::m_current_worker | PR:src/thread/thread_worker_pool_elastic.hpp | 95 |
worker_pool_elastic::m_max_concurrency | PR:src/thread/thread_worker_pool_elastic.hpp | 97 |
worker_pool_elastic::m_max_worker | PR:src/thread/thread_worker_pool_elastic.hpp | 98 |
core_elastic | PR:src/thread/thread_worker_pool_elastic.hpp | 107 |
core_elastic::m_slots | PR:src/thread/thread_worker_pool_elastic.hpp | 155 |
core_elastic::m_max_concurrency | PR:src/thread/thread_worker_pool_elastic.hpp | 158 |
core_elastic::m_current_worker | PR:src/thread/thread_worker_pool_elastic.hpp | 161 |
core_elastic::m_max_worker | PR:src/thread/thread_worker_pool_elastic.hpp | 162 |
core_elastic::m_retired_stats | PR:src/thread/thread_worker_pool_elastic.hpp | 164 |
worker_elastic | PR:src/thread/thread_worker_pool_elastic.hpp | 173 |
worker_elastic::m_slot | PR:src/thread/thread_worker_pool_elastic.hpp | 197 |
worker_pool_elastic::worker_pool_elastic | PR:src/thread/thread_worker_pool_elastic.hpp | 209 |
worker_pool_elastic::adjust_runtime_parameter | PR:src/thread/thread_worker_pool_elastic.hpp | 239 |
core_elastic::core_elastic | PR:src/thread/thread_worker_pool_elastic.hpp | 319 |
worker_pool_elastic::core_elastic::execute_task | PR:src/thread/thread_worker_pool_elastic.hpp | 403 |
worker_pool_elastic::core_elastic::get_task_and_slot_or_become_available | PR:src/thread/thread_worker_pool_elastic.hpp | 484 |
worker_pool_elastic::core_elastic::get_retire_if_excess | PR:src/thread/thread_worker_pool_elastic.hpp | 509 |
core_elastic::reserve_available_worker | PR:src/thread/thread_worker_pool_elastic.hpp | 578 |
worker_pool_elastic::core_elastic::reserve_available_worker | PR:src/thread/thread_worker_pool_elastic.hpp | 579 |
worker_pool_elastic::core_elastic::release_available_worker | PR:src/thread/thread_worker_pool_elastic.hpp | 599 |
worker_pool_elastic::core_elastic::get_or_make_available_worker | PR:src/thread/thread_worker_pool_elastic.hpp | 607 |
worker_pool_elastic::core_elastic::try_execute_task_with_slot | PR:src/thread/thread_worker_pool_elastic.hpp | 624 |
worker_elastic::get_new_task | PR:src/thread/thread_worker_pool_elastic.hpp | 703 |
worker_elastic::run | PR:src/thread/thread_worker_pool_elastic.hpp | 789 |
worker_elastic::execute_current_task | PR:src/thread/thread_worker_pool_elastic.hpp | 799 |
system_core_count | PR:src/thread/thread_worker_pool_impl.cpp | 39 |
worker_pool_impl::get_pool_size | PR:src/thread/thread_worker_pool_impl.hpp | 691 |
core_impl::has_workers_snapshot_readers | PR:src/thread/thread_worker_pool_impl.hpp | 1236 |
core_impl::get_available_worker | PR:src/thread/thread_worker_pool_impl.hpp | 1244 |
worker_impl::run | PR:src/thread/thread_worker_pool_impl.hpp | 1523 |
lock_suspend | PR:src/transaction/lock_manager.c | 2270 |
epoll::epoll | src/base/epoll.cpp | 37 |
epoll::wait | src/base/epoll.cpp | 54 |
epoll::add_descriptor | src/base/epoll.cpp | 59 |
TIMEOUT_INFINITE | src/base/epoll.hpp | 33 |
TIMEOUT_NOWAIT | src/base/epoll.hpp | 37 |
cubsocket::epoll | src/base/epoll.hpp | 42 |
os::resources::cpu::context | src/base/resources.hpp | 45 |
PRM_NAME_CSS_RECV_BUDGET_PER_CONNECTION | src/base/system_parameter.c | 791 |
PRM_ID_CSS_AUTO_SCALING_WINDOW_SIZE | src/base/system_parameter.c | 5243 |
PRM_ID_CSS_RECV_BUDGET_PER_CONNECTION | src/base/system_parameter.c | 5259 |
PRM_ID_CSS_SEND_BUDGET_PER_CONNECTION | src/base/system_parameter.c | 5271 |
net_server_start | src/communication/network_sr.c | 1058 |
css_initialize_server_interfaces | src/communication/network_sr.c | 1137 |
result | src/connection/buffer.hpp | 43 |
context::context | src/connection/connection_context.cpp | 71 |
context::context(capacity) | src/connection/connection_context.cpp | 73 |
context::context() | src/connection/connection_context.cpp | 95 |
context::reset | src/connection/connection_context.cpp | 119 |
context::prepare | src/connection/connection_context.cpp | 121 |
context::reset | src/connection/connection_context.cpp | 126 |
thread_watcher | src/connection/connection_context.hpp | 38 |
message_blocker | src/connection/connection_context.hpp | 45 |
master::context | src/connection/connection_context.hpp | 80 |
connection::state | src/connection/connection_context.hpp | 128 |
connection::state | src/connection/connection_context.hpp | 129 |
ignore_level | src/connection/connection_context.hpp | 135 |
connection::ignore_level | src/connection/connection_context.hpp | 136 |
connection::context | src/connection/connection_context.hpp | 141 |
connection::context | src/connection/connection_context.hpp | 142 |
context::m_id | src/connection/connection_context.hpp | 152 |
context::m_recv | src/connection/connection_context.hpp | 161 |
context::m_send | src/connection/connection_context.hpp | 177 |
context::m_stats | src/connection/connection_context.hpp | 189 |
context capacity ctor | src/connection/connection_context.hpp | 191 |
DEFAULT_HEADER_DATA | src/connection/connection_defs.h | 379 |
NET_HEADER | src/connection/connection_defs.h | 391 |
in_method | src/connection/connection_defs.h | 444 |
idx | src/connection/connection_defs.h | 451 |
pending_request_count | src/connection/connection_defs.h | 515 |
pool::initialize | src/connection/connection_pool.cpp | 62 |
pool::finalize | src/connection/connection_pool.cpp | 89 |
pool::claim_context | src/connection/connection_pool.cpp | 140 |
pool::claim_context | src/connection/connection_pool.cpp | 151 |
pool::retire_context | src/connection/connection_pool.cpp | 160 |
pool::retire_context | src/connection/connection_pool.cpp | 177 |
pool::try_to_lock_resource | src/connection/connection_pool.cpp | 187 |
pool::initialize_freelist | src/connection/connection_pool.cpp | 213 |
pool::finalize_freelist | src/connection/connection_pool.cpp | 231 |
pool::initialize_freelist | src/connection/connection_pool.cpp | 246 |
pool::initialize_topology | src/connection/connection_pool.cpp | 249 |
pool::initialize_workers | src/connection/connection_pool.cpp | 269 |
pool::finalize_freelist | src/connection/connection_pool.cpp | 272 |
pool::finalize_workers | src/connection/connection_pool.cpp | 314 |
pool::initialize_coordinator | src/connection/connection_pool.cpp | 353 |
pool::start_coordinator | src/connection/connection_pool.cpp | 376 |
pool::finalize_coordinator | src/connection/connection_pool.cpp | 388 |
pool | src/connection/connection_pool.hpp | 39 |
pool::freelist | src/connection/connection_pool.hpp | 42 |
pool::claim_context | src/connection/connection_pool.hpp | 70 |
pool::m_mutex | src/connection/connection_pool.hpp | 78 |
pool::m_freelist | src/connection/connection_pool.hpp | 96 |
pool::m_freelist | src/connection/connection_pool.hpp | 101 |
css_wakeup_handler | src/connection/connection_sr.c | 3061 |
context | src/connection/connection_statistics.hpp | 32 |
statistics::context | src/connection/connection_statistics.hpp | 32 |
context (enum) | src/connection/connection_statistics.hpp | 32 |
worker | src/connection/connection_statistics.hpp | 60 |
worker (enum) | src/connection/connection_statistics.hpp | 60 |
statistics::worker::MQ_COMPLETED | src/connection/connection_statistics.hpp | 73 |
statistics::worker::BLOCKED_RMUTEX | src/connection/connection_statistics.hpp | 84 |
worker_to_string | src/connection/connection_statistics.hpp | 93 |
metrics | src/connection/connection_statistics.hpp | 111 |
metrics | src/connection/connection_statistics.hpp | 112 |
metrics::operator- | src/connection/connection_statistics.hpp | 230 |
metrics::add | src/connection/connection_statistics.hpp | 264 |
css_conn_entry::add_pending_request | src/connection/connection_support.cpp | 2803 |
css_conn_entry::start_request | src/connection/connection_support.cpp | 2809 |
css_conn_entry::add_working_task | src/connection/connection_support.cpp | 2827 |
css_conn_entry::end_working_task | src/connection/connection_support.cpp | 2833 |
REGISTER_CONNECTION connection_worker | src/connection/connection_worker.cpp | 70 |
worker::worker | src/connection/connection_worker.cpp | 75 |
m_recv_budget | src/connection/connection_worker.cpp | 92 |
m_send_budget | src/connection/connection_worker.cpp | 93 |
statistics timer register | src/connection/connection_worker.cpp | 126 |
worker::enqueue | src/connection/connection_worker.cpp | 160 |
worker::notify | src/connection/connection_worker.cpp | 182 |
worker::enqueue_and_notify | src/connection/connection_worker.cpp | 218 |
worker::push_task_into_worker_pool | src/connection/connection_worker.cpp | 288 |
worker::purge_stale_contexts | src/connection/connection_worker.cpp | 294 |
worker::wakeup_blocked_worker | src/connection/connection_worker.cpp | 320 |
worker::is_wait_required | src/connection/connection_worker.cpp | 330 |
worker::requires_client_info | src/connection/connection_worker.cpp | 330 |
worker::has_remaining_tasks | src/connection/connection_worker.cpp | 340 |
worker::is_registering_client | src/connection/connection_worker.cpp | 340 |
worker::has_remaining_tasks | src/connection/connection_worker.cpp | 345 |
worker::start_connection_close | src/connection/connection_worker.cpp | 360 |
worker::end_connection_close | src/connection/connection_worker.cpp | 380 |
worker::handle_connection_close | src/connection/connection_worker.cpp | 386 |
worker::retry_connection_close | src/connection/connection_worker.cpp | 391 |
worker::handle_connection_close | src/connection/connection_worker.cpp | 419 |
m_stats.sub CLIENT_NUM | src/connection/connection_worker.cpp | 533 |
statistics_metrics_to_coordinator | src/connection/connection_worker.cpp | 562 |
worker::eventfd_register | src/connection/connection_worker.cpp | 632 |
worker::eventfd_register | src/connection/connection_worker.cpp | 650 |
worker::eventfd_clear | src/connection/connection_worker.cpp | 658 |
worker::eventfd_starttimer | src/connection/connection_worker.cpp | 724 |
worker::eventfd_addtimer | src/connection/connection_worker.cpp | 753 |
worker::eventfd_removetimer | src/connection/connection_worker.cpp | 785 |
worker::eventfd_handler | src/connection/connection_worker.cpp | 817 |
worker::handle_message_queue_send_packet | src/connection/connection_worker.cpp | 880 |
worker::handle_message_queue_send_packet | src/connection/connection_worker.cpp | 929 |
worker::handle_message_queue_release_packet | src/connection/connection_worker.cpp | 980 |
worker::handle_message_queue_new_client | src/connection/connection_worker.cpp | 1016 |
worker::handle_message_queue_release_packet | src/connection/connection_worker.cpp | 1048 |
ctx->m_stats.set OPEND_NS | src/connection/connection_worker.cpp | 1055 |
worker::handle_message_queue_new_client | src/connection/connection_worker.cpp | 1101 |
worker::handle_message_queue_by_index | src/connection/connection_worker.cpp | 1297 |
worker::handle_message_queue_shutdown_client | src/connection/connection_worker.cpp | 1312 |
worker::handle_message_queue | src/connection/connection_worker.cpp | 1356 |
worker::handle_message_queue | src/connection/connection_worker.cpp | 1462 |
worker::handle_data_packet | src/connection/connection_worker.cpp | 1535 |
worker::handle_command_header_packet | src/connection/connection_worker.cpp | 1545 |
worker::handle_reception | src/connection/connection_worker.cpp | 1694 |
m_stats.add PACKET_COUNT | src/connection/connection_worker.cpp | 1731 |
m_stats.add BLOCKED_RMUTEX | src/connection/connection_worker.cpp | 1743 |
worker::handle_transmission | src/connection/connection_worker.cpp | 1782 |
worker::handle_exhausted_add_context | src/connection/connection_worker.cpp | 1837 |
worker::handle_exhausted | src/connection/connection_worker.cpp | 1854 |
worker::initialize | src/connection/connection_worker.cpp | 1943 |
worker::finalize | src/connection/connection_worker.cpp | 1975 |
worker::run | src/connection/connection_worker.cpp | 2007 |
worker::attach | src/connection/connection_worker.cpp | 2107 |
worker | src/connection/connection_worker.hpp | 52 |
worker::status | src/connection/connection_worker.hpp | 55 |
worker::timer_type | src/connection/connection_worker.hpp | 63 |
worker::timer_latency | src/connection/connection_worker.hpp | 74 |
worker::timer_handle | src/connection/connection_worker.hpp | 82 |
worker::exhausted_context | src/connection/connection_worker.hpp | 90 |
exhausted_context | src/connection/connection_worker.hpp | 90 |
worker::queue_type | src/connection/connection_worker.hpp | 98 |
queue_type | src/connection/connection_worker.hpp | 98 |
worker::message_type | src/connection/connection_worker.hpp | 106 |
message_type | src/connection/connection_worker.hpp | 106 |
worker::message | src/connection/connection_worker.hpp | 130 |
worker::m_parent | src/connection/connection_worker.hpp | 207 |
m_context | src/connection/connection_worker.hpp | 221 |
worker::m_timer_handler | src/connection/connection_worker.hpp | 230 |
worker::m_queue | src/connection/connection_worker.hpp | 237 |
m_queue | src/connection/connection_worker.hpp | 237 |
worker::m_queue_size | src/connection/connection_worker.hpp | 241 |
m_queue_size | src/connection/connection_worker.hpp | 241 |
m_removed_context | src/connection/connection_worker.hpp | 246 |
worker::m_exhausted | src/connection/connection_worker.hpp | 248 |
m_exhausted | src/connection/connection_worker.hpp | 248 |
m_exhausted | src/connection/connection_worker.hpp | 251 |
worker::m_stats | src/connection/connection_worker.hpp | 251 |
controller | src/connection/controller.hpp | 43 |
controller::recv | src/connection/controller.hpp | 170 |
controller::send | src/connection/controller.hpp | 193 |
EWMA_ALPHA | src/connection/coordinator.cpp | 41 |
VAL_TO_SCORE | src/connection/coordinator.cpp | 43 |
EVAL_WORKER | src/connection/coordinator.cpp | 44 |
EVAL_CONTEXT | src/connection/coordinator.cpp | 45 |
REGISTER_CONNECTION coordinator | src/connection/coordinator.cpp | 55 |
coordinator::coordinator | src/connection/coordinator.cpp | 57 |
coordinator::random_bit | src/connection/coordinator.cpp | 229 |
coordinator::transfer_connection | src/connection/coordinator.cpp | 237 |
coordinator::scale_up | src/connection/coordinator.cpp | 281 |
coordinator::scale_down_finish | src/connection/coordinator.cpp | 317 |
coordinator::scale_down | src/connection/coordinator.cpp | 348 |
coordinator::scale_trial | src/connection/coordinator.cpp | 378 |
coordinator::scale_selection | src/connection/coordinator.cpp | 415 |
coordinator::statistics_EWMA | src/connection/coordinator.cpp | 447 |
statistics_EWMA | src/connection/coordinator.cpp | 447 |
coordinator::statistics_find_score_extremes | src/connection/coordinator.cpp | 460 |
coordinator::statistics_update_score | src/connection/coordinator.cpp | 482 |
statistics_update_score | src/connection/coordinator.cpp | 482 |
coordinator::statistics_update_connection | src/connection/coordinator.cpp | 502 |
statistics_update_connection | src/connection/coordinator.cpp | 502 |
coordinator::statistics_update_task | src/connection/coordinator.cpp | 545 |
statistics_update_task | src/connection/coordinator.cpp | 545 |
coordinator::statistics_update | src/connection/coordinator.cpp | 578 |
coordinator::statistics_rebalancing | src/connection/coordinator.cpp | 586 |
coordinator::statistics_scaling | src/connection/coordinator.cpp | 629 |
statistics_scaling | src/connection/coordinator.cpp | 629 |
statistics_print | src/connection/coordinator.cpp | 697 |
coordinator::eventfd_register | src/connection/coordinator.cpp | 758 |
coordinator::eventfd_addtimer | src/connection/coordinator.cpp | 865 |
coordinator::handle_message_queue_start | src/connection/coordinator.cpp | 929 |
coordinator::handle_message_queue_new_client | src/connection/coordinator.cpp | 934 |
coordinator::handle_message_queue_new_client | src/connection/coordinator.cpp | 944 |
coordinator::handle_message_queue_return_to_pool | src/connection/coordinator.cpp | 991 |
coordinator::handle_message_queue_statistics | src/connection/coordinator.cpp | 1032 |
handle_message_queue_statistics | src/connection/coordinator.cpp | 1032 |
handle_controller_request SHOW_STATS | src/connection/coordinator.cpp | 1133 |
coordinator::initialize | src/connection/coordinator.cpp | 1192 |
coordinator::finalize | src/connection/coordinator.cpp | 1225 |
coordinator::run | src/connection/coordinator.cpp | 1240 |
coordinator::run (m_timens) | src/connection/coordinator.cpp | 1260 |
coordinator::run (timerfd dispatch) | src/connection/coordinator.cpp | 1279 |
coordinator::attach | src/connection/coordinator.cpp | 1314 |
coordinator | src/connection/coordinator.hpp | 43 |
status | src/connection/coordinator.hpp | 46 |
worker_statistics | src/connection/coordinator.hpp | 52 |
worker_statistics | src/connection/coordinator.hpp | 54 |
scaling_status | src/connection/coordinator.hpp | 75 |
scaling_status | src/connection/coordinator.hpp | 77 |
scaling_direction | src/connection/coordinator.hpp | 81 |
scaling_direction | src/connection/coordinator.hpp | 83 |
scaling_statistics | src/connection/coordinator.hpp | 89 |
timer_latency | src/connection/coordinator.hpp | 95 |
timer_type | src/connection/coordinator.hpp | 103 |
timer_handle | src/connection/coordinator.hpp | 113 |
control_type | src/connection/coordinator.hpp | 123 |
control_recv | src/connection/coordinator.hpp | 140 |
control_send | src/connection/coordinator.hpp | 148 |
message_type | src/connection/coordinator.hpp | 155 |
coordinator::message | src/connection/coordinator.hpp | 167 |
message | src/connection/coordinator.hpp | 171 |
m_timer_handler | src/connection/coordinator.hpp | 245 |
m_controller | src/connection/coordinator.hpp | 249 |
m_queue | src/connection/coordinator.hpp | 256 |
m_queue_size | src/connection/coordinator.hpp | 260 |
m_scaling | src/connection/coordinator.hpp | 261 |
m_current_worker | src/connection/coordinator.hpp | 265 |
m_migrating | src/connection/coordinator.hpp | 268 |
m_scaling_statistics | src/connection/coordinator.hpp | 269 |
m_scaling | src/connection/coordinator.hpp | 271 |
m_scaling_statistics | src/connection/coordinator.hpp | 279 |
m_task_statistics | src/connection/coordinator.hpp | 284 |
m_task_statistics | src/connection/coordinator.hpp | 294 |
m_statistics | src/connection/coordinator.hpp | 294 |
m_statistics | src/connection/coordinator.hpp | 304 |
statistics_print (decl) | src/connection/coordinator.hpp | 335 |
statistics_EWMA | src/connection/coordinator.hpp | 386 |
receiver::receive_in_allocated | src/connection/receiver.cpp | 311 |
receiver BYTES_IN_TOTAL add | src/connection/receiver.cpp | 330 |
receiver::drain | src/connection/receiver.cpp | 430 |
RECV_BUDGET_HIT | src/connection/receiver.cpp | 475 |
receiver RECV_BUDGET_HIT add | src/connection/receiver.cpp | 475 |
receiver | src/connection/receiver.hpp | 38 |
receiver::m_stats | src/connection/receiver.hpp | 64 |
css_server_task | src/connection/server_support.c | 144 |
css_job_queues_start_scan | src/connection/server_support.c | 251 |
REGISTER_WORKERPOOL | src/connection/server_support.c | 550 |
css_init | src/connection/server_support.c | 554 |
thread_create_stats_worker_pool | src/connection/server_support.c | 581 |
connections.initialize | src/connection/server_support.c | 595 |
connections.finalize | src/connection/server_support.c | 611 |
css_push_server_task | src/connection/server_support.c | 2354 |
css_server_task::execute | src/connection/server_support.c | 2376 |
css_get_thread_stats | src/connection/server_support.c | 2636 |
css_get_task_stats | src/connection/server_support.c | 2647 |
css_wp_worker_get_busy_count_mapper | src/connection/server_support.c | 2669 |
css_wp_core_job_scan_mapper | src/connection/server_support.c | 2695 |
transmitter::fill | src/connection/transmitter.cpp | 66 |
SEND_BUDGET_HIT | src/connection/transmitter.cpp | 78 |
transmitter SEND_BUDGET_HIT add | src/connection/transmitter.cpp | 78 |
transmitter BYTES_OUT_TOTAL add | src/connection/transmitter.cpp | 86 |
transmitter | src/connection/transmitter.hpp | 38 |
main | src/executables/server.c | 276 |
metadata_of_job_queues | src/parser/show_meta.c | 528 |
entry_task | src/thread/thread_entry_task.hpp | 56 |
entry_manager::create_context | src/thread/thread_entry_task.hpp | 100 |
manager::alloc_entries | src/thread/thread_manager.cpp | 82 |
manager::init_lockfree_system | src/thread/thread_manager.cpp | 115 |
manager::push_task_on_core | src/thread/thread_manager.cpp | 180 |
manager::claim_entry | src/thread/thread_manager.cpp | 234 |
manager::set_max_thread_count_from_config | src/thread/thread_manager.cpp | 266 |
cubthread::initialize | src/thread/thread_manager.cpp | 315 |
initialize_thread_entries | src/thread/thread_manager.cpp | 378 |
manager::create_worker_pool | src/thread/thread_manager.hpp | 367 |
manager::create_and_track_resource | src/thread/thread_manager.hpp | 424 |
manager::destroy_and_untrack_resource | src/thread/thread_manager.hpp | 445 |
task::retire | src/thread/thread_task.hpp | 75 |
condvar_wait | src/thread/thread_waiter.hpp | 164 |
worker_pool | src/thread/thread_worker_pool.hpp | 54 |
get_idle_timeout | src/thread/thread_worker_pool.hpp | 91 |
worker_pool::core | src/thread/thread_worker_pool.hpp | 123 |
worker_pool::core::worker | src/thread/thread_worker_pool.hpp | 178 |
system_core_count | src/thread/thread_worker_pool_impl.cpp | 39 |
worker_pool_impl | src/thread/thread_worker_pool_impl.hpp | 106 |
m_cores | src/thread/thread_worker_pool_impl.hpp | 202 |
m_max_workers | src/thread/thread_worker_pool_impl.hpp | 205 |
m_stopped | src/thread/thread_worker_pool_impl.hpp | 208 |
m_round_robin_counter | src/thread/thread_worker_pool_impl.hpp | 211 |
stats_base | src/thread/thread_worker_pool_impl.hpp | 220 |
stats_base_true | src/thread/thread_worker_pool_impl.hpp | 234 |
stats_base::statset | src/thread/thread_worker_pool_impl.hpp | 248 |
stats_base::time | src/thread/thread_worker_pool_impl.hpp | 250 |
core_impl | src/thread/thread_worker_pool_impl.hpp | 263 |
m_workers | src/thread/thread_worker_pool_impl.hpp | 319 |
m_available_workers | src/thread/thread_worker_pool_impl.hpp | 320 |
m_task_queue | src/thread/thread_worker_pool_impl.hpp | 321 |
m_workers_mutex | src/thread/thread_worker_pool_impl.hpp | 323 |
m_temp_workers | src/thread/thread_worker_pool_impl.hpp | 326 |
m_free_temp_workers | src/thread/thread_worker_pool_impl.hpp | 327 |
m_temp_workers_mutex | src/thread/thread_worker_pool_impl.hpp | 329 |
worker_impl | src/thread/thread_worker_pool_impl.hpp | 339 |
worker_impl::assign_task | src/thread/thread_worker_pool_impl.hpp | 354 |
worker_impl::assign_task_void | src/thread/thread_worker_pool_impl.hpp | 356 |
m_context_p | src/thread/thread_worker_pool_impl.hpp | 395 |
worker_impl (fields) | src/thread/thread_worker_pool_impl.hpp | 395 |
m_wrapped_task | src/thread/thread_worker_pool_impl.hpp | 396 |
m_task_cv | src/thread/thread_worker_pool_impl.hpp | 399 |
m_task_mutex | src/thread/thread_worker_pool_impl.hpp | 401 |
m_stop | src/thread/thread_worker_pool_impl.hpp | 403 |
m_has_thread | src/thread/thread_worker_pool_impl.hpp | 404 |
m_is_temp | src/thread/thread_worker_pool_impl.hpp | 406 |
m_stats | src/thread/thread_worker_pool_impl.hpp | 408 |
wrapped_task | src/thread/thread_worker_pool_impl.hpp | 417 |
wrapped_task::task_only | src/thread/thread_worker_pool_impl.hpp | 420 |
wrapped_task::task_with_stats | src/thread/thread_worker_pool_impl.hpp | 425 |
wrapped_task::inner_type | src/thread/thread_worker_pool_impl.hpp | 434 |
wrapped_task::m_inner | src/thread/thread_worker_pool_impl.hpp | 453 |
stats | src/thread/thread_worker_pool_impl.hpp | 461 |
stats::id | src/thread/thread_worker_pool_impl.hpp | 464 |
stats::statdef | src/thread/thread_worker_pool_impl.hpp | 479 |
worker_pool_impl::worker_pool_impl | src/thread/thread_worker_pool_impl.hpp | 525 |
worker_pool_impl::initialize | src/thread/thread_worker_pool_impl.hpp | 552 |
worker_pool_impl::execute | src/thread/thread_worker_pool_impl.hpp | 558 |
worker_pool_impl::execute_on_core | src/thread/thread_worker_pool_impl.hpp | 565 |
worker_pool_impl::execute_on_core | src/thread/thread_worker_pool_impl.hpp | 567 |
worker_pool_impl::is_running | src/thread/thread_worker_pool_impl.hpp | 577 |
worker_pool_impl::stop_execution | src/thread/thread_worker_pool_impl.hpp | 592 |
worker_pool_impl::stop_execution | src/thread/thread_worker_pool_impl.hpp | 594 |
worker_pool_impl::get_stats | src/thread/thread_worker_pool_impl.hpp | 670 |
worker_pool_impl::assign_workers_to_cores | src/thread/thread_worker_pool_impl.hpp | 758 |
worker_pool_impl::get_next_core | src/thread/thread_worker_pool_impl.hpp | 780 |
worker_pool_impl::get_round_robin_core_hash | src/thread/thread_worker_pool_impl.hpp | 787 |
core_impl::execute_task | src/thread/thread_worker_pool_impl.hpp | 894 |
worker_pool_impl::core_impl::execute_task | src/thread/thread_worker_pool_impl.hpp | 896 |
core_impl::warmup | src/thread/thread_worker_pool_impl.hpp | 946 |
core_impl::stop_execution | src/thread/thread_worker_pool_impl.hpp | 969 |
core_impl::retire_queued_tasks | src/thread/thread_worker_pool_impl.hpp | 998 |
core_impl::get_task_or_become_available | src/thread/thread_worker_pool_impl.hpp | 1010 |
core_impl::become_available | src/thread/thread_worker_pool_impl.hpp | 1030 |
core_impl::become_available | src/thread/thread_worker_pool_impl.hpp | 1032 |
core_impl::check_worker_not_available | src/thread/thread_worker_pool_impl.hpp | 1040 |
core_impl::check_worker_not_available | src/thread/thread_worker_pool_impl.hpp | 1042 |
core_impl::register_free_temp_list | src/thread/thread_worker_pool_impl.hpp | 1063 |
core_impl::free_all_temp_list | src/thread/thread_worker_pool_impl.hpp | 1080 |
core_impl::get_stats | src/thread/thread_worker_pool_impl.hpp | 1089 |
core_impl::initialize_workers | src/thread/thread_worker_pool_impl.hpp | 1170 |
core_impl::execute_task_as_temp | src/thread/thread_worker_pool_impl.hpp | 1194 |
worker_pool_impl::core_impl::worker_impl::assign_task | src/thread/thread_worker_pool_impl.hpp | 1267 |
worker_impl::assign_task | src/thread/thread_worker_pool_impl.hpp | 1267 |
worker_impl::stop_execution | src/thread/thread_worker_pool_impl.hpp | 1326 |
worker_impl::run | src/thread/thread_worker_pool_impl.hpp | 1387 |
worker_impl::init_run | src/thread/thread_worker_pool_impl.hpp | 1430 |
worker_impl::finish_run | src/thread/thread_worker_pool_impl.hpp | 1457 |
worker_impl::execute_current_task | src/thread/thread_worker_pool_impl.hpp | 1479 |
worker_impl::retire_current_task | src/thread/thread_worker_pool_impl.hpp | 1507 |
worker_impl::get_new_task | src/thread/thread_worker_pool_impl.hpp | 1521 |
stats::statdef | src/thread/thread_worker_pool_impl.hpp | 1673 |
worker_pool_task_capper::worker_pool_task_capper | src/thread/thread_worker_pool_taskcap.cpp | 34 |
worker_pool_task_capper::try_task | src/thread/thread_worker_pool_taskcap.cpp | 44 |
worker_pool_task_capper::push_task | src/thread/thread_worker_pool_taskcap.cpp | 60 |
worker_pool_task_capper::execute | src/thread/thread_worker_pool_taskcap.cpp | 74 |
worker_pool_task_capper::end_task | src/thread/thread_worker_pool_taskcap.cpp | 83 |
capped_task::~capped_task | src/thread/thread_worker_pool_taskcap.cpp | 106 |
capped_task::execute | src/thread/thread_worker_pool_taskcap.cpp | 112 |
worker_pool_task_capper | src/thread/thread_worker_pool_taskcap.hpp | 30 |
worker_pool_task_capper::capped_task | src/thread/thread_worker_pool_taskcap.hpp | 55 |
boot_restart_server initialize_thread_entries | src/transaction/boot_sr.c | 1638 |
cubrid-thread-manager.md— 상위 수준 짝문서(동기, 2단계 재설계 서사, JIRA/티켓 맵). 기반cubthread엔진은cubrid-thread-worker-pool.md를, Phase 2가 연동하는 논리적 대기는cubrid-lock-manager-detail.md를 함께 참고한다.- 코드 (병합된
develop, 1~11장):src/connection/{connection_worker,connection_pool,connection_context,coordinator,connection_statistics,controller,connection_support,server_support}.{cpp,hpp,c},src/base/epoll.{cpp,hpp},src/thread/{thread_worker_pool_impl,thread_worker_pool,thread_worker_pool_taskcap,thread_manager,thread_task,thread_entry_task}.{cpp,hpp}. - 코드 (PR #7323
feature/worker_pool_elastic, 미병합, 12~14장):src/thread/{concurrency_slot,thread_worker_pool_elastic,thread_entry}.{cpp,hpp},src/communication/network_sr.c,src/transaction/lock_manager.c,src/sp/pl_execution_stack_context.hpp,src/base/cgroup.cpp. - 방법론:
knowledge/methodology/code-analysis-detail-doc.md.