콘텐츠로 이동

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을 반납한다.

출처 정보: 111장은 병합된 develop 체크아웃을 추적한다. 1214장은 아직 병합되지 않은 PR #7323 (feature/worker_pool_elastic) 을 추적하며, 해당 심벌과 라인 번호는 이 브랜치 기준이므로 병합 전까지 바뀔 수 있다. PR 전용 파일의 위치 힌트 행에는 PR: 경로 접두사를 붙여 표시했다.

목차:

Ch제목상태
1데이터 구조 지도 I — 커넥션 리액터
2데이터 구조 지도 II — 코디네이터
3초기화와 메모리
4epoll 커넥션 워커 루프
5송수신 예산(Budget)과 IO 제한
6코디네이터의 배치, 리밸런싱, 오토 스케일
7컨텍스트 생명주기와 워커별 프리리스트
8바이트에서 태스크로
9백엔드 풀 엔진 I: 구조와 디스패치
10백엔드 풀 엔진 II — 워커 실행 루프
11원자 연산 없는 통계와 관측성
12Phase 2 자료구조 — 탄력적 풀과 동시성 슬롯
132단계 흐름 — 슬롯 획득·반환과 탄력적 실행 루프
14Phase 2 흐름 — 슬롯 데몬과 논리적 대기 연결 구조

Chapter 1: 데이터 구조 지도 I — 커넥션 리액터

섹션 제목: “Chapter 1: 데이터 구조 지도 I — 커넥션 리액터”

새로 수락된 커넥션은 새 프런트엔드 안에서 세 개의 장수명 C++ 객체가 이루는 삼각형으로 산다: 커넥션의 바이트 단위 상태를 담는 context, 하나의 epoll 루프 위에서 다수의 컨텍스트를 서비스하는 스레드인 worker, 그리고 컨텍스트를 할당하고 워커 벡터를 쥐고 있는 소유자 **pool**이다. 이번 장은 이 삼각형의 모든 필드에 더해 두 개의 CV(조건 변수) 프리미티브(thread_watcher, message_blocker)와 워커가 소비하는 제어 어휘(message와 그 enum들, 타이머 슬롯, 백프레셔 기록)까지 지도로 그린다. 움직임은 뒤의 장들이 추적하고, 여기서는 포인터를 정직하게 유지시키는 모양과 불변식을 먼저 고정한다.

연계 참조: 이 리액터가 존재하는지 — C10K/리액터 근거, 커넥션 워커와 백엔드 풀의 분리 — 는 짝을 이루는 cubrid-thread-manager.md(“리액터 프런트엔드”)에 있다.

소유 관계는 한 방향으로만 흐르고, 역참조는 반대 방향이다. poolworker 객체들을(std::unique_ptr로) 소유하고, context 저장소도(인트루시브 프리리스트로) 소유한다. worker는 raw pool * 하나와 자신이 서비스하는 context * 집합을 들고 있다. context는 자신을 소유한 워커의 인덱스만 기록한다 — 인덱스는 컨텍스트가 워커 사이를 옮겨 다녀도(7장) 살아남지만, raw 포인터였다면 대롱대롱 매달리게 됐을 것이다.

그림 1-1 — 리액터 삼각형: pool이 worker들과 context 저장소를 소유하고, worker와 context는 역참조만 들고 있다 Figure 1-1. 리액터 삼각형 — 소유(실선) 대 역참조·인덱스(점선). pool이 꼭짓점에서 worker 벡터와 context 프리리스트를 모두 소유하고, 밑변 두 모서리의 workercontext는 각각 raw 포인터와 정수 인덱스로 pool을 되짚는다.

불변식 — 컨텍스트는 자신의 워커에 대한 raw 포인터를 결코 소유하지 않는다. context::m_workerpool::m_workers를 가리키는 int 인덱스다. 리밸런스 시점(6장/7장)에는 이 정수 하나만 다시 쓰일 뿐, 아무것도 재포인팅되지 않으며 어떤 worker *도 워커 소멸을 넘어 살아남을 수 없다.

1.2 context — 수락된 커넥션 하나

섹션 제목: “1.2 context — 수락된 커넥션 하나”
// connection::context -- src/connection/connection_context.hpp
struct 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_workerpool::m_workers 안에서 소유 워커의 인덱스핸드오프를 견딤; 인덱스는 대롱대롱 매달릴 수 없다
m_idclaim 시점에 부여되는 단조 증가 id포인터 없이도 m_exhausted를 키잉하고 메시지를 라우팅
m_ignore오래된 ERR/HUP 이벤트를 막는 ignore_level 가드close가 결정된 뒤 뒤늦게 온 에러 이벤트는 다시 실행되지 않고 삼켜진다
m_removed정리는 됐지만 여전히 m_context에 남아 있음purge_stale_contexts의 대상 표시; 닫히는 중과 이미 사라짐을 구분
m_recv.m_statestate — HEADER/DATA/ERROR수신 측은 2단계 파서: 먼저 헤더, 그다음 헤더가 알려준 크기만큼의 본문
m_recv.m_receiverreceiver — 풀링된 재조립 버퍼 + drain 상태소켓→span 재조립을 소유
m_recv.m_header파싱된 헤더를 가리키는 span<std::byte>본문이 스트리밍되는 동안에도 헤더가 계속 주소 가능하도록 유지
m_recv.m_request_id헤더에서 나온 요청 id응답과 요청을 대응시킴
m_recv.m_command헤더가 명령 패킷이었음true면 본문이 다 도착한 시점에 백엔드 태스크가 하나 푸시된다
m_send.m_transmittertransmitter — 풀링된 송신 버퍼 + deleter부분 write 상태를 소유
m_send.m_blockershared_ptr<message_blocker>블로킹 전송을 하는 백엔드 워커가 여기서 대기; drain 시점에 시그널됨. 동시 블로킹 전송이 허용되면 벡터로 바뀔 수 있다는 메모가 소스에 있다
m_stats컨텍스트별 메트릭워커/코디네이터로 롤업됨(11장)

불변식 — m_conn이 맨 앞에 있어 context *css_conn_entry ** 와 별칭 관계를 갖는다. connection::contextmaster::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.hpp
enum class state { HEADER, DATA, ERROR };
enum class ignore_level : uint8_t { DONT_IGNORE = 0, IGNORE_ALL };

state는 수신 측의 단계를 나타낸다. ignore_leveluint8_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.hpp
struct 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.mtxactive를 보호active는 대기자들과 원자적으로 함께 움직여야 한다
thread_watcher.cv시작/종료 쿼럼을 신호워커들이 뜨고/내려가는 동안 pool이 대기
thread_watcher.active살아 있는 워커 스레드 수pool은 N개 전부가 READY에 도달하거나 전부 종료할 때까지 블록
message_blocker.mdone을 보호CV를 위한 프레디케이트 락
message_blocker.cv블록된 프로듀서에게 신호블로킹 메시지의 프로듀서가 여기서 대기
message_blocker.done프레디케이트: 워커가 이 요청을 마쳤음스퓨리어스 웨이크업과 유실된 신호를 방지

불변식 — message_blockershared_ptr을 통해 양쪽 당사자보다 오래 산다. 이것은 항상 shared_ptr로 쥐어진다(message::waiter_handlecontext::m_send.m_blocker 양쪽에서). 프로듀서와 워커가 각각 참조를 하나씩 쥐고 있으며 마지막에 놓는 쪽이 그것을 파괴한다. 그래서 프로듀서가 타임아웃된 뒤에도 워커는 cv를 시그널할 수 있고, 워커가 아직 자신의 사본을 쥐고 있는 동안에도 프로듀서는 소멸할 수 있다 — raw 포인터였다면 타임아웃된 프로듀서가 워커 밑에서 블로커를 해제해 버릴 수 있었을 것이다.

OS 스레드 하나, epoll 루프 하나, 그 위에서 다수의 컨텍스트.

// connection::worker (member layout) -- src/connection/connection_worker.hpp
pool *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_parentraw pool * 소유자컨텍스트 반환/설정 조회용; pool은 모든 워커보다 오래 산다
m_coordinatorshared_ptr<coordinator>메트릭을 보고하고 배치를 받음(6장); 여러 워커가 공유
m_watchershared_ptr<thread_watcher>pool의 쿼럼을 위해 시작/종료 시 active를 증감
m_threadrun()을 실행하는 std::threadOS 핸들; 종료 시 join
m_core고정된 CPU 코어캐시 지역성을 위한 어피니티(3장)
m_statusstatus — READY/RUNNING/HIBERNATING/TERMINATING루프와 hibernation을 이끈다(4장)
m_stop강제 정지 플래그상태와 무관하게 종료 시 루프를 끊는다
m_entrycubthread::entry *이 스레드를 레거시 스레드 매니저에 묶는다
m_context서비스 중인 unordered_set<context *>루프가 순회하는 멤버십 집합; O(1) 마이그레이션
m_indexpool::m_workers 안에서 이 워커의 인덱스context::m_worker와 대응; 라우팅/핸드오프 식별자
m_eventscubsocket::epoll클라이언트 소켓 + 제어용 fd들을 아우르는 epoll fd
m_eventfd스레드 간 웨이크업용 eventfd프로듀서가 시그널하면 epoll_wait가 깨어난다
m_timerfdepoll 집합에 들어 있는 timerfdfd 하나가 모든 주기적 타이머를 멀티플렉싱
m_timens현재 타이머 주기(ns)timerfd가 무장된 해상도를 캐시
m_timer_handlertimer_typearray<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_exhaustedmap<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.hpp
enum 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.hpp
enum 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.hpp
struct 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_CLIENTteardown 중 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.hpp
enum 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이 타이머의 주기경과 시간과 비교해 발동 여부를 결정
functionstd::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.hpp
struct exhausted_context { bool prepared; uint32_t events; context *ctx; };
필드역할존재 이유
prepared재개 준비 완료 여부갓 파킹된 것과 이미 재무장된 것을 구분
events예산 소진 시점에 대기 중이던 epoll 마스크재개 시 정확히 그 EPOLLIN/EPOLLOUT 의도를 재생
ctx멈춰 있는 컨텍스트재조회 없는 직접 재개 핸들

uint64_t m_id로(포인터가 아니라) 키잉한다는 것은, 소진된 상태에서 닫힌 컨텍스트를 이미 회수됐을지 모르는 포인터를 쫓지 않고 id로 그냥 버릴 수 있다는 뜻이다(5장).

pool은 모든 것을 소유하며, 오직 이곳만이 락 아래에서 프리리스트와 워커 벡터를 건드린다.

// connection::pool (members) -- src/connection/connection_pool.hpp
std::mutex m_mutex;
#if !defined (NDEBUG)
std::atomic<std::thread::id> m_mutex_holder { std::thread::id () };
#endif
std::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_mutexpool의 모든 가변 상태를 보호컨텍스트/워커는 여러 스레드에서 건드려짐
m_mutex_holder디버그용 현재 락 소유자디버그 빌드에서 저렴하게 올바른 스레드인지 검증
m_workersvector<unique_ptr<worker>>워커 수명의 유일한 소유자; 깔끔한 teardown
m_coordinatorshared_ptr<coordinator>모든 워커에게 건네짐(2장/6장)
m_watchershared_ptr<thread_watcher>워커들과 공유하는 시작/정지 쿼럼
m_max_connections살아 있는 커넥션 수의 하드 캡프리리스트 크기를 결정
m_max_connection_workers워커 수의 상한오토스케일 상한(6장)
m_min_connection_workers워커 수의 하한오토스케일 하한 / 항상-켜짐
m_freelist.m_head프리 컨텍스트 리스트의 headO(1) claim/retire
m_freelist.m_max할당된 컨텍스트 총수용량 산정
m_freelist.m_claim현재 claim된 컨텍스트 수m_max 대비 소진 여부를 판정

프리리스트 노드는 인트루시브 구조다:

// pool::freelist -- src/connection/connection_pool.hpp
struct 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-&gt;m_context 반환"] -.-> N0

Figure 1-2. 인트루시브 프리리스트: 각 노드는 오프셋 0에 context를 내장하며, claim/retire는 freelist *context * 사이를 캐스팅한다.

리액터 경로가 아니라 마스터-커넥터 경로에 속하는 형제 격 존재인 master::contextcontroller<RX,TX> 템플릿(controller.hpp의 유닉스 소켓 제어 채널)은, 여기서 모든 IO 호출이 반환하는 result enum — Ok, Partial, Error, Reset, Pending, BudgetExhausted, PeerReset, RefuseConnection, ClosedConnection, Aborted, Skewed(buffer.hpp) — 을 공유하며, 이는 이후 IO 분기를 다루는 장들이 기대는 개념이다.

  1. 삼각형은 poolworker를 소유하고, worker는 다수의 context를 가리키는 구조다. 소유는 unique_ptr(워커)과 인트루시브 프리리스트(컨텍스트)로 이루어지고, 역참조는 raw pool *인덱스인 context::m_worker이지 결코 worker *가 아니다.
  2. context는 커넥션당 바이트 상태 전체를 담는다m_conn 브리지, m_id/m_worker 신원, 2단계 HEADER→DATA 파서와 receiver로 이루어진 m_recv, transmitter와 선택적 블로커로 이루어진 m_send 서브구조체. 복사/이동이 불가능하며 재사용을 위해 reset된다.
  3. 레이아웃 불변식 두 가지가 하중을 지탱한다: context::m_conn이 맨 앞(css_conn_entry **와 별칭)에 있다는 것, 그리고 freelist::m_context가 맨 앞(retire_contextcontext *freelist *로 되돌려 캐스팅할 수 있게 함)에 있다는 것. 둘 중 하나라도 순서를 바꾸면 캐스트가 조용히 망가진다.
  4. 워커는 제어 메시지 소비자이지, RPC 대상이 아니다. 모든 지시는 IMMEDIATE 또는 LAZY tbb::concurrent_queue 위의 이동 전용 message이며, m_queue_size 스냅샷이 각 drain 패스를 한정해 프로듀서 기아를 막는다.
  5. timerfd 하나가 네 작업을 멀티플렉싱한다m_timer_handler[timer_type]를 통해, 활성 timer_latency의 최솟값에 맞춰 무장되며, 각 timer_handle::last_time이 자신의 발동을 통제한다.
  6. 백프레셔는 명시적 상태다: 예산을 소진한 컨텍스트는 m_id로 키잉되어 m_exhausted에 파킹되며, 다음 패스에 재생할 정확한 epoll events를 기억한다.
  7. thread_watchermessage_blocker가 유일한 두 CV 프리미티브다 — 전자는 pool의 시작/정지 쿼럼을 위해 살아 있는 워커 수를 세고, 항상 shared_ptr인 후자는 프로듀서가 특정 메시지에 대해 워커가 done을 시그널할 때까지 블록할 수 있게 한다.

Chapter 2: 데이터 구조 지도 II — 코디네이터

섹션 제목: “Chapter 2: 데이터 구조 지도 II — 코디네이터”

1장은 커넥션 리액터 — 바이트를 실어 나르는 워커별 epoll 루프 — 를 지도로 그렸다. 이번 장은 그 짝을 이루는 존재, 즉 클라이언트 소켓을 단 한 번도 직접 건드리지 않으면서도 각 커넥션을 어느 워커가 소유할지, 언제 핸드오프할지, 워커가 몇 개 있어야 하는지를 결정하는 단일 코디네이터 스레드를 지도로 그린다. 이 장은 어디까지나 필드 단위의 지도일 뿐이며, 이 상태를 읽어 들이는 알고리즘 — 스코어 계산, 리밸런싱 대상 선정, 트라이얼 기반 오토스케일링 — 은 6장에서 다룬다.

연결 고리: 왜 단일 라이터(single-writer) 제어 평면이 필요한가에 대한 논거는 별도 문서인 cubrid-thread-manager.mdAuto scaling (CBRD-26406), Score-based connection assignment, Coordinator + context freelist 절에 있다.

coordinator(cubconn::connection 소속)는 여섯 개의 기능 군집으로 멤버가 나뉘는, 특별할 것 없는 멤버 소유형 클래스다. 아래에서 필드 단위로 하나씩 걸어가며 살펴본다.

그림 2-1 — 코디네이터 소유권 맵.

그림 2-1 — 코디네이터 소유 맵: 코디네이터가 7개의 인라인 멤버 클러스터를 소유하고, 그중 셋(통계 수집·AF_UNIX 제어·MPSC 수신함)이 connection worker와 연결된다

제어 계열 필드(m_controller, control_*)는 컴파일 타임에 켜고 끌 수 있는 옵션이다. §2.7 참고.

2.2 통계 프리미티브 — metrics<T, VT>

섹션 제목: “2.2 통계 프리미티브 — metrics<T, VT>”

모든 카운터는 connection_statistics.hpp에 정의된, 고정 크기에 키 타입을 갖는 벡터다.

// metrics -- src/connection/connection_statistics.hpp
template <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::contextBYTES_IN_TOTAL, BYTES_OUT_TOTAL, OPEND_NS, LAST_ACTIVE_NS, LAST_MOVED_NS, MOVE_COUNT, RECV_BUDGET_HIT, SEND_BUDGET_HIT
statistics::workerPACKET_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 (마이크로초)

workerSTATS_COUNT 뒤에 (절대 할당되지 않는) NA를 하나 더 가지고 있으며, worker_to_string[]이 출력을 위해 worker의 각 키에 이름을 붙여 준다.

불변식 — 원시값/파생값 분리. 카운터는 VT = uint64_t로 쌓이지만 operator-/operator*metrics<T, double>을 반환한다. 코디네이터는 워커 카운터의 사본을 두 벌 들고 있다 — uint64 “이전” 스냅숏과 double EWMA 누적값. 원시 슬롯 하나에 비율(rate)을 그대로 저장하면 타입 시스템이 막는 문제, 즉 비율 계산에서 오버플로가 나거나 소수점 감쇠가 사라지는 문제가 생기기 때문이다.

2.3 worker_statistics — 워커별 성적표

섹션 제목: “2.3 worker_statistics — 워커별 성적표”

m_statisticsstd::vector<worker_statistics>이며, 커넥션 워커 하나당 한 항목씩 존재한다 — 코디네이터가 갖고 있는 “그 워커의 부하가 어느 정도인가”에 대한 모델이다.

// worker_statistics -- src/connection/coordinator.hpp
struct 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) 시각의 모노토닉 nsEWMA의 time_delta 분모가 된다
m_sum업데이트마다 계산되는 모든 컨텍스트 지표의 double맵을 순회하지 않고도 집계 뷰를 얻을 수 있다
m_workerfirst = 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의 각 쌍도 커넥션별로 동일한 규칙을 따른다.

m_statuscoordinator::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.hpp
enum 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.hpp
struct {
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;
필드역할존재 이유
statusTRIAL(변화를 측정 중) 대 STABLE(안정됨)측정 중인 상태와 정착된 상태를 구분한다
window_size트라이얼이 평균을 내는 샘플 개수판정 전에 틱마다 들쭉날쭉한 스코어를 매끄럽게 만든다
historyscaling_statistics 샘플의 한도가 있는 버퍼판정이 근거로 삼는 증거
previous_direction마지막으로 커밋된 이동의 방향진동(oscillation)이나 백오프를 감지한다
previous_scale현재 트라이얼 이전의 워커 개수트라이얼 스코어를 비교하는 기준선
direction현재 평가 중인 트라이얼의 방향이번 윈도우에서 검증 중인 가설
count이번 윈도우에서 수집된 샘플 수window_size에 도달하면 판정을 발동시킨다

이동(move) 자체의 메커니즘(통계가 아니라)은 두 번째 익명 구조체에 산다.

// m_scaling -- src/connection/coordinator.hpp
struct { 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_handlem_timer_handler

섹션 제목: “2.6 타이머 휠 — timer_handle과 m_timer_handler”

모든 주기적 작업은 하나의 timerfd가 구동한다. 코디네이터는 작업마다 fd를 하나씩 두는 대신 고정된 테이블을 통해 디스패치한다.

// timer types -- src/connection/coordinator.hpp
enum 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_handlerstd::array<timer_handle, TYPE_COUNT>이며, timer_type으로 인덱싱된다.

timer_handle 필드역할존재 이유
valid이 슬롯이 무장되어 있는가?배열 크기를 바꾸지 않고도 등록/해제할 수 있다
latency원하는 주기(ns 단위 timer_latency)해당 핸들러가 원하는 간격
functionbool() 콜백(통계/리밸런싱/스케일링 패스)타입 소거(type-erased)로 균일하게 디스패치하며, 계속 진행할지 여부를 반환한다
last_time마지막 실행의 모노토닉 nsnow - last_time >= latency일 때만 발동한다

슬롯 구성: STATISTICS는 샘플을 m_statistics/m_task_statistics에 접어 넣고, REBALANCINGCLIENT_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_controllerm_ctrlfd 필드(§2.9), 그리고 handle_controller* 메서드들 — 는 전부 #if defined (ENABLE_CONTROLLER)로 감싸여 있으며 기본 빌드에는 컴파일되지 않는다. 이 절에서 다루는 모든 내용은 오직 이 매크로가 켜졌을 때만 존재한다.

통계와 메시지는 코디네이터 안으로 흘러 들어간다. 제어 채널은 그 반대다: 특정 워커에게 보내는 명령형 커맨드와 그에 대한 응답이 밖으로 나가며, controller<RX, TX>가 감싸는 AF_UNIX SOCK_DGRAM 소켓을 통해 오간다.

// controller -- src/connection/controller.hpp
template <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 필드역할존재 이유
typeSHOW_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/sendrecvfrom/sendto가 구조체 크기만큼 정확히 옮기지 않으면 result::Error를 반환한다 — 잘려 나간 데이터그램이 부분적으로 적용되는 일은 없다. SOCK_DGRAM + SOCK_NONBLOCK 조합은 메시지 경계를 보존하면서, 유일한 코디네이터 스레드를 블로킹시키는 대신 result::Pending(공유 result enum, buffer.hpp)을 반환한다 — 그래서 여기서는 1장의 바이트 스트림 리액터가 아니라 데이터그램을 쓴다.

2.8 메시지 수신함 — message_typemessage

섹션 제목: “2.8 메시지 수신함 — message_type과 message”

코디네이터 안으로 들어오는 고빈도 비동기 경로는 메시지 큐다: 새 클라이언트, 반환되는 컨텍스트, 핸드오프 결과, 통계 배치, 종료 신호.

// message_type -- src/connection/coordinator.hpp
enum 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.hpp
struct 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_CLIENTRETURN_TO_POOLHANDOFF_REPLYSTATISTICS
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&lt;message&gt;<br/>+ m_queue_size (atomic)"]
  Q -->|단일 소비자| C["coordinator 스레드<br/>handle_message_queue"]
// m_queue / m_queue_size -- src/connection/coordinator.hpp
tbb::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_parentpool *소유하는 커넥션 pool로의 역참조
m_watchershared_ptr<thread_watcher>생존/헬스 등록
m_threadstd::thread코디네이터 자신의 OS 스레드
m_corestd::size_t이 스레드가 고정(pin)된 CPU 코어
m_statusstatus생명주기 단계(§2.4)
m_stopboolSHUTDOWN이 설정해 실행 루프를 빠져나가게 한다
m_entrycubthread::entry *스레드별 엔진 컨텍스트(별도 문서)
m_eventscubsocket::epoll코디네이터 자신의 epoll
m_eventfdint이벤트 기반 웨이크업 fd(큐/제어용)
m_timerfdint단일 타이머 fd(§2.6)
m_timensuint64_t현재 timerfd 간격(ns)
m_ctrlfdint캐시된 m_controller fd — 가드됨, ENABLE_CONTROLLER에서만(§2.7)
m_max_worker / m_min_workeruint32_t스케일 상한/하한
m_current_workeruint32_t현재 살아 있는 워커 개수; m_statistics.size()와 나란히 대응
m_migratingunordered_set<uint64_t>현재 핸드오프/테이크오버 중인 커넥션 id들
m_controllercontroller<control_recv, control_send>나가는 제어 채널 — 가드됨, ENABLE_CONTROLLER에서만(§2.7)
m_task_statistics익명 구조체풀 전체 처리량 EWMA(아래)
m_statisticsvector<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.hpp
struct {
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장)를 구동한다.

  1. 코디네이터는 여섯 개의 군집을 소유한다: 워커별 성적표 (m_statistics), 오토스케일링 트라이얼 윈도우(m_scaling_statistics
    • m_scaling), 타이머 테이블(m_timer_handler), 나가는 제어 채널 (m_controller), 들어오는 메시지 큐(m_queue), 풀 전체 처리량 (m_task_statistics).
  2. 모든 카운터는 metrics<T, VT> 고정 배열이며, 원시 uint64 이전값double EWMA 누적값을 함께 유지한다. 타입 시스템이 둘을 섞어 쓰는 것을 막는다.
  3. m_statistics[i]는 곧 워커 i이다 — 살아 있는 워커 집합과 인덱스가 나란히 대응하며, 모든 배치/리밸런싱/스케일 결정이 이 인덱스로 접근한다.
  4. 제어 채널은 컴파일 타임 옵션이다(ENABLE_CONTROLLER): 켜져 있을 때는 명령이 고정 크기 AF_UNIX 데이터그램으로 나가고, 메시지는 언제나 MPSC tbb::concurrent_queue를 통해 들어오며, m_queue_size로 한도가 정해진 채 드레인된다.
  5. message이동 전용 태그드 구조체다: type이 살아 있는 필드를 고르며, 이를 이동하면 내부에 담긴 conn과 벡터들의 소유권이 함께 넘어간다.
  6. 이동을 안전하게 지키는 멤버십/카운트 불변식은 두 가지다: 한 번에 드레인되는 워커는 최대 하나뿐이라는 것, 그리고 id는 핸드오프가 지속되는 딱 그 구간 동안만 m_migrating에 머문다는 것.
  7. 이 장은 어디까지나 구조뿐이다 — 이를 읽어 들이는 스코어 계산, 리밸런싱 대상 선정, 트라이얼 스케일링은 6장에서, m_task_statistics를 소비하는 백엔드 풀은 9–14장에서 다룬다.

이 장이 답하려는 질문은 이것이다. 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_startcss_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_entriesset_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_countm_max_threads에서 시작해, 각 풀이 자신의 REGISTER_* 매크로로 예약한 만큼 정확히 create_and_track_resource(§3.3)에 의해 차감된다. 코디네이터의 claim_entry와 각 워커의 claim_entry는 모두 같은 배열에서 소비한다. 만약 등록 시점과 pool::initialize 시점 사이에 PRM_ID_CSS_MAX_CONNECTION_WORKER가 바뀐다면, 리액터는 예약된 엔트리 수보다 더 많은 워커를 만들려 하게 되고 결국 claim_entrynullptr을 반환하게 된다 — 모든 호출자는 이를 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에 밀어 넣는다.

여기서 Resstats_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_initconnections.initialize (MAX_CONNECTIONS, max_connection_workers, min_connection_workers)를 호출한다. MAX_CONNECTIONScss_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_headfree chain의 LIFO 헤드claim_context가 여기서 팝하고, retire_context가 여기에 푸시한다
m_max상한선 = max_connections * 1.1retire_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; }

모든 노드의 context32 * 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_contextm_claim++을, 모든 retire_contextm_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_descriptorepoll_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::runevents[i].data.ptr을 다시 context*로 읽어 ctx->m_conn->fdm_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)가 충분한 코어를 제공했음을 보장하며, 워커 icores[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_initconnector를 붙이고 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_shutdownm_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_freelistm_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 이후에만 회수된다.

불변조건 — 종료 순서: 워커가 코디네이터보다 먼저. 리액터는 (active1로 떨어질 때까지 기다려) 워커를 먼저 멈추고, 그다음 (0이 될 때까지 기다려) 코디네이터를 멈춘다. 코디네이터가 워커보다 오래 살아남는 이유는, 워커의 SHUTDOWN/컨텍스트 반환이 여전히 RETURN_TO_POOL 메시지를 흘려보내고 코디네이터가 이를 다 비워내야 하기 때문이다(7장). 만약 이 순서를 뒤집어 코디네이터를 먼저 죽인다면, 코디네이터가 쥐고 있던 풀 뮤텍스가 좌초되고 진행 중이던 컨텍스트 반환들이 방치되어 m_claim이 오염되고 finalize_freelist의 assert가 걸리게 된다. 백엔드 요청 풀(css_Server_request_worker_pool)은 리액터가 정리된 이후에, css_init 말미의 destroy_worker_pool을 통해 파괴된다 — 살아 있는 클라이언트 태스크들이 자신의 태스크 엔진이 사라지기 전에 먼저 끝나야 하기 때문이다.

  1. 부팅은 css_init 안에서 이뤄지는 두 그래프의 순차 작업이다. 백엔드 요청 풀(create_worker_poolworker_pool_impl<true>::initialize)이 먼저이고, 그다음이 커넥션 리액터(pool::initialize)다. net_server_start는 이 둘보다 앞서 DB 복구와 엔트리 풀 할당의 순서를 잡아 놓는다.
  2. 엔트리 풀의 크기는 설정이 아니라 등록에 의해 정해진다. set_max_thread_count_from_configREGISTER_CONNECTION/REGISTER_WORKERPOOL 카운트(coordinator = 1, connection_worker = max_connection_worker, transaction = task_worker)에 PAD를 더해 합산하고, alloc_entries는 모든 스레드가 그로부터 claim해 가는 단 하나의 new entry[] 배열을 만든다.
  3. 풀 뮤텍스는 스코프가 아니라 바톤이다. pool::initializecss_init 스레드에서 이를 잠갔다가 풀어, 갓 스폰된 코디네이터 스레드가 평생 쥘 수 있게 한다. claim_context/retire_context는 홀더가 코디네이터임을 assert한다. 종료 시에는 try_to_lock_resource로 회수한다.
  4. 프리리스트는 max_connections * 1.1개의 32 KB 컨텍스트를 미리 할당한다. 이를 넘어서면 즉석 할당이 일어나고, retire가 다시 m_max까지 줄인다. m_claim == 0은 누수를 잡아내는 셧다운 assert다.
  5. 코디네이터와 각 워커는 각자 별도의 epoll 인스턴스를 만들고 eventfd와 timerfd를 등록한다. 코디네이터는 추가로 제어용 유닉스 소켓을 등록한다. 워커는 각 fd를 가짜 context(new int{fd}를 유사 css_conn_entry로 씀)로 감싸서 실행 루프가 wakeup과 클라이언트 I/O를 동일하게 다룰 수 있게 한다.
  6. 모든 max 워커가 처음부터 코어당 하나씩 부팅된다(assert cores.size() >= max). min_connection_worker는 오토스케일러가 줄여 가는 하한선일 뿐이다. 모든 큐는 블로킹 START로 미리 프리웜되어 시작 시점의 경쟁을 없앤다.
  7. 종료는 엄격한 순서를 따른다. 워커를 active == 1까지, 코디네이터를 active == 0까지 멈춘 뒤 바톤을 회수하고 join·해제한다 — 모든 셧다운 타임아웃 분기에 _exit(0) 가드가 걸려 있으며, 태스크 엔진은 가장 마지막에 파괴된다.

3장에서는 worker를 만들고 초기화했다. workerepoll 셋 하나, eventfd 하나, timerfd 하나, 워커별 타이머 테이블, 그리고 두 개의 tbb::concurrent_queue<message> 수신함(inbox)을 소유한다. 생성자가 만드는 std::thread의 진입점인 worker::attachinitialize (); 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.ptrcontext *를 가리키도록 등록된다: 클라이언트 소켓(EPOLLET | EPOLLIN | EPOLLRDHUP, 전송이 대기 중일 때는 EPOLLOUT도 추가), 다른 스레드가 찔러서 깨우는 카운터인 m_eventfd, 그리고 현재 활성 타이머 중 가장 짧은 지연시간에 맞춰 무장되는 **m_timerfd**다. 두 제어용 fd는 생성 시점에 각각 일회용 context로 감싸진다(eventfd_registernew context ()를 호출하고, reinterpret_cast<css_conn_entry *> (new int { fd })로 원시 fd를 저장한다). 그 덕분에 콜백 데이터는 항상 균일하게 context *가 된다. runctx->m_conn->fdm_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 인스턴스로 모여들고, runcontext에 담긴 fd 식별자를 기준으로 디먹스한다.

전체 골격은 이렇다: 스택에 놓인 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].eventsEPOLLHUP | EPOLLRDHUP | EPOLLERR 중 하나라도 있고, 동시에 그 fd가 m_eventfdm_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의 모든 분기. 수신/송신에서 발생하는 치명적 Errorm_stop 외에 유일한 종료 경로이며, 리턴하면 attachfinalize로 넘어간다.

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_clearm_eventfd에 대해 논블로킹 read 루프를 돌려(EINTR이면 재시도, EAGAIN이면 종료) 64비트 카운터를 0으로 만든다 — notifywrite와 짝을 이루는 동작이다. 그다음 handle_message_queue가 두 수신함을 모두 비우고, 재시도 타이머가 즉시 다시 발동하지 않도록 QUEUE 타이머의 last_time을 갱신한다.

Timerfd 분기(eventfds[1]). timerfd는 현재 활성 지연시간 중 가장 짧은 값 하나로(단일 주기로) 무장되므로(eventfd_starttimer), 만료되면 핸들러는 모든 슬롯을 훑으면서 각자의 경과 시간이 자신의 지연시간을 넘긴 슬롯만 발동시킨다. 어떤 콜백이든 false를 리턴하면 그 false가 그대로 전파되어 루프를 죽인다. 네 개의 슬롯은 생성자에서 설정된다:

timer_type지연시간콜백역할
HIBERNATEMEDIUM 1 shibernate_check유휴 워커를 파킹(park)
STATISTICSMEDIUM 1 sstatistics_metrics_to_coordinator코디네이터로 메트릭 전송
HAHIGH 2 sha_close_all_connectionsHA 스탠바이 상태에서 커넥션 정리
QUEUELOW 1 mshandle_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_eventfd1을 쓰며 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_workerdone을 설정하기 전에 같은 뮤텍스를 잡는다. 이렇게 앞뒤로 락을 걸어 두면, 설령 워커가 요청을 먼저 끝내 버리더라도 스레드 간 대기가 안전해진다 — 재확인되는 술어(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를 리턴하면 드레인이 중단되고, 그 falserun으로 전파되어 루프를 죽인다.

불변식 4-B (드레인 상한, 프로듀서 기아 없음). size = exchange (0, acquire)는 그 순간의 카운트를 스냅샷하면서 원자적으로 0으로 되돌린다. while (i++ < size && ...)는 드레인 도중 프로듀서들이 계속 push하더라도 최대 size개의 메시지만 처리한다. 새로 push된 메시지는 카운터를 다시 증가시키고 자기 몫의 notify를 동반하므로, 이후 패스에서 처리된다. 이렇게 한 번의 드레인이 할 일에 상한이 걸린다. acquireenqueuerelease와 짝을 이루므로, size개 메시지의 내용은 온전히 가시적(visible)이다.

4.6 handle_message_queue_send_packet — 분기 전체

섹션 제목: “4.6 handle_message_queue_send_packet — 분기 전체”

SEND_PACKET은 소유 중인 커넥션에 바이트를 송신한다. 모든 작업은 커넥션의 재귀적(recursive) cmutex 아래에서 실행되며, 이 뮤텍스는 epoll의 EPOLLOUT 경로와 공유된다.

  1. assert (item.conn); rmutex_lock (&item.conn->cmutex).
  2. ctx = item.conn->context. null이면(컨텍스트가 이미 해체됨): unlock하고, item.deleter ()가 있으면 실행하고, wakeup_blocked_worker (item.waiter_handle)를 호출한 뒤 return true — 전송은 조용히 버려진다.
  3. item.packet의 각 span을 push_for_send로 push하고, stamp ()를 찍은 다음, push_for_deleter (std::move (item.deleter))를 호출한다(버퍼는 완전히 전송된 뒤 해제된다).
  4. 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을 지정한 호출자는 자신의 바이트가 실제로 머신을 떠날 때까지 블록된다.

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장에서 다룰 “바이트에서 태스크로” 소유권 루프가 닫힌다.

NEW_CLIENT는 갓 accept된 커넥션을 설치한다(코디네이터가 미리 만들어 둔 context를 건네주며, 이는 6장에서 다룬다). 분기는 다음과 같다:

  1. assert (item.conn && item.conn->fd != -1); ctx = item.ctx; ctx->m_conn = item.conn;.
  2. rmutex_lock (&ctx->m_conn->cmutex); 소유권을 공개(publish)한다: conn->worker = this; conn->context = ctx; — 이제 다른 워커 스레드의 SEND_PACKET/RELEASE_PACKETconn->context를 통해 이 컨텍스트를 찾을 수 있다.
  3. m_events.add_descriptor (fd, EPOLLET|EPOLLIN|EPOLLRDHUP, ctx). 실패하면worker/context를 null로 되돌리고, unlock한 뒤 m_removed_context.push_back (ctx), return false.
  4. m_context.insert (ctx). 중복이면(.second == false) → worker/context를 null로 되돌리고, unlock한 뒤 m_events.remove_descriptor (fd)로 3단계를 되돌리고, removed 목록에 넣은 뒤 return false.
  5. Unlock. 통계를 초기화한다(OPEND_NS, LAST_ACTIVE_NS = m_timens, LAST_MOVED_NS = 0, MOVE_COUNT = 0); CLIENT_NUM을 증가시킨다.
  6. 방어적 처리: 새 클라이언트에 대해 “이론적으로는 있을 수 없는” 경우지만, 만약 m_status == HIBERNATING이면 eventfd_starttimer로 타이머 휠을 재시작한다 — 순서가 어긋난 큐 때문에 살아 있는 클라이언트가 파킹된 워커에 발이 묶이는 일이 없도록 하기 위해서다. return true.

불변식 4-E (소유권 공개 순서). conn->worker/conn->contextcmutex 아래에서, 그리고 fd가 epoll에 합류하기 전에 설정되며, m_context 삽입도 같은 락 아래에서 이뤄진다. 클라이언트 fd가 epoll 이벤트를 낼 수 있게 되는 순간부터, conn->context는 이미 살아 있고 등록이 끝난 컨텍스트를 가리킨다 — 등록이 실패하면 3~4단계가 모두 롤백되므로, 절반만 설치된 클라이언트가 살아남는 일은 없다.

m_context는 워커 전용 명부(roster)이며, 코디네이터는 이를 직접 건드리지 않는다. 컨텍스트 해체와 워커별 프리리스트(freelist)는 7장에서 다룬다.

  1. 스레드 하나, 블로킹 지점 하나. run은 오직 epoll_wait에서만 블로킹한다. 에지 트리거 논블로킹 소켓 덕분에 어느 한 커넥션도 리액터를 멈춰 세우지 못하고(불변식 4-A), m_exhausted가 비어 있지 않을 때는 대기가 폴링(타임아웃 0)으로 전환되어 예산이 소진된 컨텍스트들을 다시 찾아간다.
  2. 세 종류의 fd, 균일한 콜백. 클라이언트 소켓, m_eventfd, m_timerfd 모두 data.ptrcontext *를 싣고 다닌다. run은 두 제어용 fd와 비교해서 이들을 구분하고, 그 배치의 모든 클라이언트 IO가 끝난 뒤에야 둘 다 eventfd_handler로 미룬다.
  3. eventfd는 초인종이지 우편함이 아니다. notify는 병합되는(coalescing) 카운터를 쓰며(포화된 EAGAIN도 성공으로 친다), 메시지 자체는 큐에 있고 소비자는 exchange (0, acquire)로 카운트를 스냅샷한다 — 이 덕분에 웨이크업은 손실 없이 안전하고(4-C) 드레인은 프로듀서 기아 없이 상한이 걸리며(4-B), enqueuefetch_add (release)가 이 짝을 완성한다.
  4. 동기 호출자는 message_blocker를 타고 대기한다. enqueue_and_notify는 enqueue 전에 handle->m을 잠근다(4-D). START/SHUTDOWN_CLIENT/SEND_PACKET만이 이를 시그널하며, SEND_PACKETPending 경로에서는 실제 전송이 끝날 때까지 시그널을 미룬다.
  5. 핸들러는 사라진 컨텍스트를 방어하고, QUEUE 타이머는 스스로 해제된다. send_packet/release_packet/new_client는 먼저 conn->context를 찾아보고 null이면 그만둔다. new_client는 fd가 epoll에 들어가기 전에 cmutex 아래에서 소유권을 공개하며, 실패하면 롤백한다(4-E). 지연된 종료는 m_has_retry와 함께 1 ms QUEUE 타이머를 다시 무장시키고, 대기 중인 재시도가 더 이상 없으면 eventfd_handler가 이를 제거하므로 공격적인 폴링이 영구화되지는 않는다.

Chapter 5: 송수신 예산(Budget)과 IO 제한

섹션 제목: “Chapter 5: 송수신 예산(Budget)과 IO 제한”

4장에서는 epoll 루프의 구조를 확립했다. 하나의 커넥션 워커가 수백 개의 소켓을 소유하고, 깨어날 때마다 준비된(ready) 파일 디스크립터 목록을 순회하며 handle_receptionhandle_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 규율을 다룬다.

각 워커는 생성자에서 두 개의 서버 파라미터를 한 번만 읽어 평범한 size_t 멤버로 캐시해 둔다 — 틱마다 다시 읽지 않으므로, 공정성 검사 비용은 레지스터 비교 수준에 그친다.

// worker::worker -- src/connection/connection_worker.cpp
m_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이다):

ParameterDefaultUpperLowerMeaning of the value
recv_budget_per_connection16 KB1 GB0한 틱당 한 커넥션에 대해 recv()할 수 있는 최대 바이트 수
send_budget_per_connection32 KB1 GB0한 틱당 한 커넥션에 대해 sendmsg()할 수 있는 최대 바이트 수

송신 기본값이 수신 기본값의 두 배인 이유는, 응답이 대체로 요청보다 크고 송신 경로에는 응답 파싱 비용이 없어 더 느슨한 상한을 두어도 되기 때문이다.

불변식 (0은 무제한을 의미한다). 예산이 0이면 제한이 비활성화된다. 모든 계정 처리 지점은 limit > 0 && consumption >= limit으로 가드하는데 — limit == 0이면 좌변에서 short-circuit이 일어나 해당 커넥션은 예산을 이유로 절대 양보되지 않는다. lower_limit0이므로 운영자는 두 예산 중 하나를 합법적으로 0으로 설정해 공정성 도입 이전의 “EAGAIN까지 밀어붙여 비운다” 동작을 그대로 되살릴 수 있다. 이 가드를 consumption >= limit으로 “단순화”하지 말 것 — 그렇게 하면 0이 “한 바이트마다 양보”로 뒤바뀌어 워커가 멈춰 버린다.

이 값들을 더 키우기 전에 기억해 둘 폭(width) 불일치가 하나 있다: m_send_budgetsize_t이지만, transmitter::fill (int fd, int limit)은 **int**를 받는다. 1 GB라는 upper_limit은 부호 있는 32비트 int (0x40000000) 안에 들어가므로 현재 범위에서는 안전하지만, upper_limitINT_MAX보다 크게 올리면 fill이 음수 limit을 보게 되어 송신 측 제한이 조용히 무력화된다.

5.2 exhausted_context — 양보한 커넥션의 기록

섹션 제목: “5.2 exhausted_context — 양보한 커넥션의 기록”

커넥션이 예산을 다 쓰면 워커는 그 커넥션을 그냥 잊어버릴 수 없다 — 다음 틱에 정확히 그 지점부터 재개할 수 있도록 recv, send, 혹은 둘 다 중 어느 쪽에 아직 처리할 작업이 남아 있는지 기억해야 한다. 그 기억 장치가 exhausted_context이며, 컨텍스트 id를 키로 하는 맵에 저장된다.

// worker::exhausted_context -- src/connection/connection_worker.hpp
struct 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 */
FieldRoleWhy it exists
prepared한 틱 지연 래치(latch). 삽입 시 false이며, 첫 번째 handle_exhausted 통과에서 true로 뒤집히고 이번에는 해당 레코드를 건너뛴다방금 양보한 커넥션이 재시도되기 전에 다른 모든 준비된 fd에 대해 최소 한 번의 전체 epoll 패스가 이루어짐을 보장한다 — 굶주림 방지의 핵심 지연 장치
events아직 갚아야 할 IO 방향의 비트마스크(EPOLLIN, EPOLLOUT, 혹은 둘 다)한 커넥션이 같은 틱에서 recv와 send를 모두 소진할 수 있으며, 각 방향은 완료될 때 독립적으로 클리어된다
ctxIO가 지연된 context(7장)를 가리키는 역포인터재개 대상; 재추가 시 맵 키와 대조 검증된다

이 맵은 fd나 포인터가 아니라 ctx->m_id(단조 증가하는 컨텍스트별 id, 7장)로 키가 매겨진다. 이 점이 중요한데, fd는 커널에 의해, 컨텍스트 포인터는 프리리스트에 의해 재사용되지만 m_id는 프로세스 수명 동안 유일하므로, 오래된(stale) 엔트리가 방금 재사용된 커넥션과 별칭(alias)을 이룰 일이 절대 없다.

불변식 (살아 있는 컨텍스트당 맵 엔트리 하나). handle_exhausted_add_context는 삽입 전에 m_id를 조회한다. 히트가 나면 it->ctx == ctxassert하고 새 방향을 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&lt;uint64_t, exhausted_context&gt;"]
    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-&gt;m_id"| C1

그림 5-1. m_exhausted는 컨텍스트 id를 그 커넥션이 아직 갚아야 할 IO 방향과 한 틱 지연되었는지 여부를 기억하는 레코드에 매핑한다.

5.3 drainfill 내부에서의 예산 차감

섹션 제목: “5.3 drain과 fill 내부에서의 예산 차감”

예산을 소비하는 주체는 워커가 아니라 IO 프리미티브다. receiver::drain은 recv 상태 머신을 실행하며(바이트를 태스크로 바꾸는 과정은 8장에서 다룬다) 로컬 consumption을 누적하고, 상태가 진행될 때마다 한도를 다시 검사한다.

// receiver::receive_in_allocated -- src/connection/receiver.cpp
m_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.cpp
case 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::fillmsghdriovec 배열을 순회하며, 한도를 sendmsg 이후가 아니라 이전에 검사한다 — 그래서 지불할 예산이 없는 syscall은 애초에 발행하지 않는다.

// transmitter::fill -- src/connection/transmitter.cpp
while (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_receptionhandle_transmission은 각각 불리언 in_exhausted를 받는다 — run의 새 epoll 패스에서 호출될 때는 false, handle_exhausted의 지연 재실행에서 호출될 때는 true다. 이 플래그가 재큐잉 여부를 가르는 전부다. 수신 측을 보면:

// worker::handle_reception -- src/connection/connection_worker.cpp
io_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.cpp
status = 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.cpp
if (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.cpp
nfds = 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.cpp
for (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) Errorfalse를 반환, 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_requiredhas_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.cpp
bool 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.cpp
bool 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_closeretry 경로로 보내 응답을 한 번 더 플러시하도록 시도한다. 강제(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.cpp
coordinator::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_idm_exhausted에서도 함께 지운다(워커 스레드 하나뿐이므로 락은 필요 없다 — 두 맵 모두 워커 전용이다). exhausted 맵은 오직 이 워커가 소유한 살아 있는 컨텍스트만을 참조할 수 있으며, 이를 어기면 바로 다음 틱에서 use-after-free가 발생한다.

  1. 캐시된 두 예산이 틱당 IO를 제한한다. recv_budget_per_connection (16 KB)과 send_budget_per_connection(32 KB)은 한 번만 읽혀 m_recv_budget/m_send_budget에 저장되며, drainfill은 로컬 consumption을 차감하고 limit > 0 && consumption >= limit이 되면 BudgetExhausted를 반환한다. 예산이 0이면 제한이 비활성화된다.

  2. 예산 검사는 프레임 경계에서 이루어지므로, 정확한 바이트가 아니라 반복 횟수를 제한한다. 진행 중인 syscall은 결코 중단되지 않으며, 소켓 버퍼 하나만큼의 초과는 예상되고 의도된 동작이다.

  3. 부분 진행은 보존된다. 예산에 도달하면 양보하는 것은 워커이지 커넥션의 데이터가 아니다 — 이미 파싱된 패킷은 커넥션이 지연되기 전에 같은 호출 안에서 디스패치된다.

  4. exhausted_context는 갚아야 할 방향을 기억한다. 불멸의 ctx->m_id로 키가 매겨지며, events(아직 갚아야 할 EPOLLIN/EPOLLOUT)와 prepared(한 틱 지연 래치)를 담는다. 살아 있는 컨텍스트당 엔트리 하나라는 규칙이 강제된다.

  5. 공정성은 순서와 지연의 조합에서 나온다. run은 exhausted 목록보다 먼저 새 epoll 집합을 서비스하고, 목록이 비어 있지 않은 동안에는 TIMEOUT_NOWAIT로 스핀한다. handle_exhaustedprepared를 통해 각 레코드를 한 틱 건너뛴다. 이 둘이 합쳐져 커넥션당 틱당 작업량이 유한함을 보장한다.

  6. 재큐잉 게이트는 in_exhausted다. 새 패스에서의 호출은 BudgetExhausted에서 재큐잉하고, 이미 exhausted 목록에서 온 호출은 다시 추가하지 않고 events 비트를 다음 틱을 위해 켜 둔다. Pending은 결코 재큐잉되지 않으며, Ok(송신만 해당)는 완료되어 클리어된다.

  7. 종료 경로 헬퍼들이 장부의 일관성을 지킨다. is_wait_required는 CDC 커넥션을 특수 케이스로 처리하고, has_remaining_tasks는 트랜스미터에 미송신 바이트가 남아 있는 동안 정상 종료를 재시도하되 강제(IGNORE_ALL) 종료는 그대로 진행시키며, purge_stale_contexts는 모든 제거 시점에 ctx->m_idm_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.cpp
while (!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.cpp
for (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)핸들러역할
STATISTICSLOW_LATENCY 1초statistics_update새 카운터를 EWMA와 점수에 접어 넣는다(fold)
REBALANCINGMEDIUM_LATENCY 5초statistics_rebalancing가장 뜨거운 연결 하나를 최댓값→최솟값 워커로 옮긴다
SCALINGHIGH_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.cpp
m_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.cpp
this->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.cpp
diff = 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_connectionm_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.cpp
max = 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_rebalancingtransfer_connection

섹션 제목: “6.4 리밸런싱 — statistics_rebalancing과 transfer_connection”

5초짜리 REBALANCING 타이머는 마이그레이션 비용을 치를 가치가 있을 때만, 최대 한 개의 연결을 가장 뜨거운 워커에서 가장 차가운 워커로 옮긴다:

// coordinator::statistics_rebalancing -- src/connection/coordinator.cpp
constexpr 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_CONTEXTdiff / 2 아래에 여전히 들어맞는 것 중 가장 큰 연결을 찾는다 — 이 diff/2가 오버슈트 방지 상한선이다. 절반보다 많이 옮기면 min이 새로운 max가 되어 버리기 때문이다. (3) 아무것도 조건을 만족하지 못하면(id가 0으로 남는 경우, 예를 들어 거대한 연결 하나가 지배하는 경우) 아무것도 옮기지 않는다.

transfer_connection이 실제 인계(hand-off)를 수행한다(이 함수는 scale_down과 컨트롤러의 CLIENT_MOVE에서도 도달한다):

// coordinator::transfer_connection -- src/connection/coordinator.cpp
if (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장에서 가져오면):

FieldRole
statusSTABLE은 유휴, 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.cpp
m_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.cpp
if (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.cpp
max_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.cpp
if (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.cpp
if (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_downscale_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 이면 가장 점수가 좋은 스케일로 커밋한다.

  1. 스레드 하나, 숫자 하나. coordinator::run은 eventfd/timerfd/ctrlfd를 도는 락-프리 단일 소비자 epoll 루프다. 모든 결정은 m_score로 워커 순위를 매기며, coordinator의 모든 상태는 오직 여기서만 변경된다 — 이 장 전체가 락 없이 돌아갈 수 있는 이유가 되는 불변식이다.
  2. 점수 = headcount + 워커 압력 + 컨텍스트 압력, 각각 EWMA로 스무딩되며 (VAL_TO_SCORE/EVAL_WORKER/EVAL_CONTEXT), BLOCKED_RMUTEX가 500이라는 무게로 가중되어 멈춰선 워커가 부하를 가장 강하게 밀어낸다. 첫 샘플의 폴딩은 m_last_updated로 게이팅된다.
  3. 배치는 statistics_find_score_extremes의 min 쪽이며, [0, m_current_worker) 범위만 스캔하므로 잠든 워커는 도달 불가능하다. >=/<가 현재의 결정론적 동점 처리이고, random_bit은 이름만 존재할 뿐 연결되어 있지 않다.
  4. 리밸런싱은 5초마다 최대 한 개의 연결만 옮기며, 20% 밴드를 넘어설 때만 동작하고, 오버슈트를 피하기 위해 diff/2 아래에서 가장 큰 컨텍스트를 고른다. transfer_connection은 목적지로 낙관적으로 복사하고 HANDOFF_REPLY가 올 때만 원본을 지우며, 이는 m_migrating으로 보호된다.
  5. 스케일링은 시행-관찰 방식이다. scale_trial은 방향을 고른다 (직전 트라이얼이 성과 없었다면 뒤집고, 그렇지 않으면 유지), count를 여유분과 window_size(기본 4)에 클램프한다. statistics_scaling은 틱마다 스루풋 점수를 기록하고, count == 0이 되면 scale_selection의 상위 5% 밴드를 통해 최선의 스케일로 커밋한다.
  6. 두 종류의 상태가 구조 변경을 직렬화한다. m_status(STABLE/DRAINING/EXPANDING)가 물리적 변경을 게이팅한다. scale_up은 동기적으로 STABLE을 복원하고 새 워커의 점수를 0으로 만들어 신규 연결을 흡수하게 하는 반면, scale_down은 비워진 워커의 다음 리포트에서 scale_down_finish가 실행될 때까지 DRAINING에 머문다.
  7. 알려진 비활성 경로: 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 대기 버퍼.

프리리스트 노드. 노드는 사실상 context에 next 포인터 하나를 붙인 것인데, context를 의도적으로 맨 앞에 둔다:

// pool::freelist -- src/connection/connection_pool.hpp
struct 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.hpp
struct 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_conncss_conn_entry로의 역방향 포인터레거시 엔진과의 다리 역할; m_conn->context가 다시 이곳을 가리켜서 양쪽 모두가 서로를 복원할 수 있다. 계약상 첫 번째 멤버여야 한다.
m_worker소유 워커의 인덱스claim 시점에 coordinator가 찍어 준다; RETURN_TO_POOL이 올바른 워커의 통계 버킷을 감소시킬 수 있게 한다.
m_id프로세스 전역에서 유일한 컨텍스트 idcoordinator의 워커별 통계 맵과 워커의 m_exhausted 맵을 여는 키; 워커 간 인계에도 살아남는다.
m_ignoreERR/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_conncontext의 첫 멤버이고, m_contextfreelist의 첫 멤버다. m_conn이 오프셋 0에 있어서 C 쪽 코드는 context*와 그 css_conn_entry*를 서로 바꿔 다룰 수 있고, m_context가 오프셋 0에 있어서 retire_contextreinterpret_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_ALLEPOLLERR/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.cpp
context *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;
}
  1. 예비 재고 적중(m_head != nullptr): 맨 위 노드를 pop하고 m_headm_next로 전진시킨다. 할당이 전혀 없다 — 이것이 핫 패스다. pop된 노드의 m_next는 이제 낡은 값이지만 해가 없다(다음 retire 때 덮어써진다).
  2. 예비 재고 고갈(m_head == nullptr): new freelist (32 * 1024)가 노드를 하나 생성하는데, 그 context는 32 KiB 용량의 receiver 객체를 갖고 있고, 뒤이어 prepare()가 그 receiver의 배킹 버퍼를 실제로 할당한다. 이곳이 핫 패스에서 할당이 일어날 수 있는 유일한 지점이며, 그것도 살아있는 커넥션 수가 미리 데워둔(pre-warmed) 예비 재고를 넘어설 때뿐이다(initialize_freelist가 미리 m_max개의 노드를 심어 두고, 각각에 대해서도 prepare()를 호출해 둔다).
  3. prepare() 실패(증설 중 메모리 부족): delete head; return nullptr. m_claim은 증가시키지 않는다 — 그래야 카운트가 정직하게 유지된다. 프리리스트가 null을 반환하는 유일한 지점이다.
  4. 성공하는 두 분기는 모두 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.cpp
ctx = 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.cpp
void 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--;
}
  1. 노드 복원: reinterpret_cast<freelist*>(ctx) — 오프셋 이동이 없는 캐스트이며, m_context가 맨 앞에 있기 때문에(7.1의 불변식) 정확히 합법적이다.
  2. 세정: head->m_context.reset ()(7.4절)이 모든 필드를 생성 시점의 기본값으로 되돌려서, 다음 claim한 쪽이 깨끗한 객체를 보게 한다. 이 덕분에 재사용이 소멸자/생성자 왕복을 건너뛸 수 있다.
  3. 축소 분기(m_claim > m_max): 살아있는 개체 수가 보유 상한을 넘어섰으므로 이 노드는 잉여다 — delete head.
  4. 순환 분기(else): m_head에 push한다(LIFO). “순환”이란 바로 이 claim에서의 pop과 retire에서의 push가 맞물려 도는 주기를 말한다 — 같은 노드들이 계속 돌고 돌기 때문에, 안정 상태에서의 잦은 접속/해제는 할당자를 전혀 건드리지 않는다.
  5. 두 분기 모두 m_claim--을 한다.

불변식 — m_claim은 밖으로 나가 있는 컨텍스트 수를 정확히 센다. claim_context가 성공할 때마다 +1, retire_context가 실행될 때마다 -1이 되며, 둘 다 coordinator에서 단일 스레드로만 일어나므로 원자적 연산이 필요 없다. finalize_freelistm_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.cpp
void 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_blockernullptr로 떨어뜨리는 것은 블록된 태스크의 핸들에 대한 마지막 shared_ptr 참조를 해제하는 일이다; close가 retire에 이를 즈음이면 그 태스크는 이미 깨어난 뒤다(7.5절).

7.5 종료 프로토콜: handle_connection_close, 재시도 헬퍼, 그리고 지연 반환

섹션 제목: “7.5 종료 프로토콜: handle_connection_close, 재시도 헬퍼, 그리고 지연 반환”

컨텍스트가 워커를 떠나는 유일한 경로는 handle_connection_close다. 이 서브시스템에서 분기가 가장 많은 함수로, 트랜잭션을 잠재우고, I/O를 비우거나 버리고, 리액터를 블로킹하는 대신 지연 메시지를 통해 스스로를 재시도할 수 있어야 한다. 모든 분기를 빠짐없이 살펴보면:

  1. 멱등성 가드: if (ctx->m_removed) — 이미 정리된 상태다. 대기자가 있으면 깨우고(wakeup_blocked_worker (handle)), 로그를 남기고, return true. 이 덕분에 중복된 SHUTDOWN_CLIENT도 안전하게 처리된다. 그 뒤 assert_release (ctx->m_conn).
  2. 등록 중인 클라이언트에 대한 조기 재시도. 상태를 건드리기 전에 다섯 개의 조건을 함께 검사한다: 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는 기다릴 클라이언트가 없다.
  3. 상태 전환: rmutex 아래에서 CONN_OPENCONN_CLOSING; status를 스냅샷한다.
  4. 트랜잭션 인덱스 획득(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을 해제한다.
  5. css_end_server_request; entry의 상태를 TS_CHECK로 설정한다(잠재적으로 무한히 도는 xtran_wait_server_active_trans를 끊어낸다); session_p가 설정돼 있으면 ssession_stop_attached_threads.
  6. 재시도가 아닐 때의 깨우기: if (!is_retry)net_server_wakeup_workers가 진행 중인 태스크 스레드들을 인터럽트한다. 재시도 패스에서는 이미 인터럽트된 상태이므로 건너뛴다.
  7. 활성 워커 관문: net_server_active_workers (...) > 0 → 이 conn을 위해 아직 돌고 있는 태스크 스레드가 있다는 뜻 → goto retry.
  8. 잔여 작업 관문: has_remaining_tasks (ctx)는 먼저 IMMEDIATE 큐를 비우고(큐에 있는 RELEASE_PACKET이 receiver 메모리를 해제할 수도 있다), 그 다음 — m_ignore < IGNORE_ALL일 때만 — transmitter가 비어 있지 않으면 true를 반환한다 → goto retry. IGNORE_ALL 상태의 컨텍스트는 이 단계를 건너뛴다: 죽은 상대에게 보내지 못한 바이트는 그냥 버린다.
  9. 정리(남은 작업이 없을 때), 소스 코드 순서 그대로: m_events.remove_descriptor (fd); tran_index != NULL_TRAN_INDEX이면 net_server_conn_down; 아래의 end_connection_close; 그 다음 cmutex 아래에서(rmutex_lock을 통해) m_conn->workerm_conn->context를 null로 만든다; 그 다음에야 m_send.m_transmitter.clear (); css_shutdown_socket (fd), fd = INVALID_SOCKET; 종료 대기자(handle)와 m_send.m_blocker가 있으면 깨운다; css_prepare_shutdown_conn.
  10. 지연 반환: ctx->m_removed = true; m_removed_context.push_back (ctx); — 이 시점에 컨텍스트를 retire하지 않는다; 일괄 처리되는 purge_stale_contexts를 위해 잠시 쌓아 둘 뿐이다. m_stats.sub (CLIENT_NUM, 1); return true.
  11. 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.cpp
void 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.cpp
message.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: 모든 관문은 지연 스테이징이나 자기 재시도로 이어질 뿐, 블로킹 스핀으로는 결코 이어지지 않는다.

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.cpp
r = 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이지 클라이언트마다 생기는 핫 패스 객체가 아니므로, 의도적으로 프리리스트를 우회한다.

  1. 핫 패스에서 new/delete가 없다. context는 미리 데워둔 freelist 노드를 pop해서 claim되고, 다시 push해서 retire된다; 할당자는 예비 재고가 고갈되었을 때(claim이 증설하며, nullptr로 실패할 수도 있다)나 살아있는 개체 수가 m_max = max_connections * 1.1을 넘었을 때(retire가 축소한다)만 건드려진다.
  2. 겹겹이 쌓인 두 개의 “첫 번째 멤버” 계약. context::m_conn이 오프셋 0에 있어서 css_conn_entry와 다리를 놓고, freelist::m_context가 오프셋 0에 있어서 retire_contextreinterpret_cast로 노드를 복원할 수 있다. 둘 중 하나라도 순서를 바꾸면 캐스트가 망가진다.
  3. 프리리스트는 단일 기록자이지, 노드마다 잠기는 것이 아니다. 오직 coordinator만 pool의 m_mutex를 쥔 채로 claim/retire를 호출한다; m_claim은 평범한 카운터이며 종료 시점에 0임을 assert한다.
  4. 재활용은 곧 세정이다. context::reset은 모든 필드를 생성 시점의 기본값으로 되돌리고(m_header = DEFAULT_HEADER_DATA), receiver/transmitter 버퍼는 해제하지 않고 재활용해서 32 KiB 저장소를 데운 채로 유지한다.
  5. 종료는 지연되고 스스로 재시도한다. handle_connection_close는 리액터를 절대 블록하지 않는다: 아직 등록 중인 클라이언트, 활성 태스크 스레드, 살아있는 상대에 대한 비어 있지 않은 전송 버퍼 — 이 모두가 공유 헬퍼 retry_connection_close(LAZY SHUTDOWN_CLIENT + 저지연 타이머)로 흘러들어 간다; 실제 retire는 purge_stale_contextsRETURN_TO_POOL로 일괄 처리된다.
  6. m_removedcmutex 아래에서의 null화가 경합을 막는다. 컨텍스트 포인터는 스테이징되기 전에(그리고 transmitter가 지워지기 전에) cmutex 아래에서 지워진다; CBRD-26412 가드는 같은 잠금 아래에서 그것을 다시 읽어, null을 역참조하는 대신 낡은 SEND/RELEASE/SHUTDOWN 요청을 그냥 버린다.
  7. ignore_level이 비우기냐 버리기냐를 가른다. DONT_IGNORE는 close가 전송 버퍼를 기다리게 만들고, IGNORE_ALLhas_remaining_tasks를 단락시켜서 죽은 커넥션이 즉시 정리되게 한다.

4장부터 7장까지는 커넥션 워커(리액터)가 소켓을 비우고 epoll 조각들로부터 하나의 완전한 요청을 재조립하는 과정을 따라갔다. 이번 장은 그 이야기의 매듭을 짓는다. 요청을 완전히 받아들인 뒤, 그것이 어떻게 리액터를 벗어나 백엔드 태스크 워커 풀에서 실행 가능한 작업이 되는가 — 그것도 리액터가 쿼리 실행을 기다리며 블록되는 일 없이?

이 경계는 의도적으로 얇게 설계되어 있다. 래퍼 하나(push_task_into_worker_pool), 프리 함수 하나(css_push_server_task), 힙에 할당되는 태스크 하나(css_server_task), 그리고 범용 풀 인큐 경로(push_task_on_coreexecute_on_corecore_impl::execute_task)가 전부다. 리액터는 thread_worker_pool_impl.hpp를 절대 링크하지 않는다 — 풀 전체가 css_push_server_task라는 심볼 하나 뒤에 숨어 있는 셈이다. 이런 분리를 택한 재설계의 근거는 컴패니언 문서 Two pools, one process에 담겨 있다. 이번 장에서는 그 경계선을 한 줄 한 줄 따라가며, 풀이 정지되었을 때의 거부 처리, 풀이 가득 찼을 때의 인큐, 그리고 별도의 wakeup_blocked_worker 백프레셔 핸드셰이크까지 살펴본다.

리액터의 두 상태가 요청을 디스패치 가능하다고 선언하며, 둘 다 같은 래퍼를 거친다. 데이터 바디가 없는 커맨드 헤더는 즉시 푸시되고, 바디가 있는 헤더는 바디가 도착할 때까지 미뤄진다.

// worker::handle_command_header_packet -- src/connection/connection_worker.cpp
if (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.cpp
if (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.cpp
void 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::workercubthread::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.c
void 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_countadd_pending_requeststart_request수신은 됐지만 아직 실행이 시작되지 않은 요청의 수.
working_task_countadd_working_taskend_working_task현재 실행 중인 태스크의 수. 이 값이 0으로 떨어지는 것이 CONN_CLOSING 커넥션의 정리(teardown) 진행 여부를 결정하는 게이트가 된다.

두 카운터는 푸시가 일어나기 전에 리액터 쪽에서 먼저 증가한다. 그래서 풀은 커넥션 카운터가 0인 채로 살아있는 태스크를 마주칠 일이 없다.

코어는 라운드로빈이 아니라 커넥션 인덱스로 선택된다. conn_ref.idx가 그대로 core_hash가 되는데, 코드 주석이 그 이유를 밝히고 있다 — 락을 쥔 태스크가 하필 가득 찬 코어에 해싱되면, 무관한 락 대기자들이 큐가 전부 비워질 때까지 발이 묶일 수 있다. 그래서 한 커넥션의 모든 요청은 하나의 코어에 고정된다. (반면 css_push_external_task가 쓰는 평범한 push_taskget_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.c
class 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 deleted
private:
CSS_CONN_ENTRY &m_conn; /* <- only state: which connection this runs for */
};

cubthread::entry_tasktask<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.c
thread_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_coreexecute_on_coreexecute_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.cpp
if (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.hpp
if (!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.hpp
std::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.cpp
void 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로 공유되어 양쪽 중 어느 쪽이 먼저 끝나든 그보다 오래 살아남는다.

FieldRoleWhy 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.cpp
if (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.cpp
this->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.c
void 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장에서 다룬다.

  1. 경계는 네 번의 얇은 홉으로 이루어진다. 요청이 완성되면 한 줄짜리 push_task_into_worker_poolcss_push_server_tasknew css_server_taskpush_task_on_core 순으로 호출이 이어진다. 리액터는 풀 내부 요소를 단 하나도 직접 언급하지 않는다.
  2. 태스크의 소유권은 풀에 있다. css_server_taskCSS_CONN_ENTRY & 하나만 들고 있으며 retire를 오버라이드하지 않는다. 그래서 css_push_server_tasknew와 베이스 클래스의 delete this가 이 태스크의 생애를 앞뒤로 감싼다.
  3. 카운터는 정확히 한 번의 실행을 감싼다. add_pending_request/add_working_task는 푸시 전 리액터에서, start_request/end_working_taskexecute 안에서 풀 쪽이 실행한다. working_task_count가 0에 도달하는 것이 곧 CONN_CLOSING 커넥션이 정리될 수 있는 조건이다.
  4. execute_task는 네 개의 분기를 갖는다. 풀 정지 → 폐기(셧다운 상황에서만), 유휴 워커 있음 → 배정 후 notify, 워커 없음+임시 → 임시 스레드 생성, 워커 없음+일반 → 큐잉. 태스크를 버리는 것은 정지 분기뿐이며, 풀이 가득 찬 분기는 푸시한 쪽을 블록시키는 대신 큐에 쌓는다.
  5. 경계 불변식은 기계적으로 보장된다. 푸시는 최악의 경우에도 인큐 한 번 뒤에 리턴하며 핸들러를 인라인으로 실행하는 법이 없다. 그래서 느린 쿼리 하나가 여러 커넥션을 다중화하는 epoll 루프를 얼려버릴 수 없다.
  6. wakeup_blocked_worker는 별도의 동기식 채널이다. 반드시 블록해야 하는 제어/송신 경로를 위해 message_blocker(뮤텍스 + cv + done)와 인큐 전 락 확보가 lost-wakeup의 틈을 막고, 정리(teardown) 경로는 항상 이를 호출해 대기자가 고아로 남지 않게 한다. 쿼리 푸시는 이 채널을 전혀 사용하지 않는다.
  7. 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은 소유 관계의 골격이다 — 누가 누구를 unique_ptr로 소유하는지(포함 관계)에, 리프가 루트에 도달할 수 있게 하는 두 개의 비소유 역참조 포인터(m_parent_pool, m_parent_core)를 더한 것이다.

flowchart TD
  WP["worker_pool_impl&lt;Stats&gt;<br/>m_cores : vector&lt;unique_ptr&lt;core&gt;&gt;<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의 비례 디스패치가 왜곡된다.

// worker_pool_impl<Stats> -- src/thread/thread_worker_pool_impl.hpp
template <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 */
};
FieldRoleWhy it exists
m_cores소유하는 코어들의 벡터; 인덱스가 “코어 해시” 도메인이 된다워커 집합을 나누는 중간 계층; 풀과 함께 코어가 소멸하도록 unique_ptr 사용
m_max_workers전체 동시-워커 상한(코어별 합)스레드 수의 상한이자 라운드로빈 커서의 wrap 모듈러스(9.6)
m_stoppedstop_execution 전까지 false, 이후 영원히 trueexecute_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_executionm_stopped.exchange(true)를 수행하며, 이전 값이 false였음을 본 쪽이 teardown의 소유자가 된다. 이 값은 다시 풀리지 않으므로 execute_task는 “실행 중이 아님”을 종결 상태로 취급하고 task를 retire한다. 경합 중인 프로듀서가 오래된 false를 읽고 task 하나를 enqueue하더라도, 그 task는 나중에 retire_queued_tasks가 버린다 — 안전하며, 누수가 아니다.

// worker_pool_impl<Stats>::core_impl -- src/thread/thread_worker_pool_impl.hpp
template <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 */
};
FieldRoleWhy 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_availablebecome_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.hpp
template <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 */
};
FieldRoleWhy 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 variableassign을 스핀이 아닌 wake로 바꾼다; stop 시에도 깨어난다
m_task_mutex핸드오프를 위한 워커별 락범위가 워커 하나이므로, 서로 다른 워커로의 assign은 절대 경합하지 않는다
m_stop풀이 teardown할 때 true로 설정condvar 조건을 깨뜨려 스레드가 대기 대신 종료하게 한다
m_has_threadOS 스레드가 이 워커를 서비스하는 동안 trueassign_task에서 wake냐 spawn이냐를 가르는 false→true→false 전환
m_is_temp비풀링 임시 워커임을 표시임시 경로는 condvar를 건너뛴다: 한 번 실행하고 free-temp 리스트에 스스로 파킹
m_statsstats_baseStats==false일 때 비어 있음꺼져 있을 때는 EBO로 크기가 0인 베이스; 켜져 있을 때는 실제 statset

상속받은 필드: m_parent_core(큐, entry manager, 풀 이름에 도달).

불변조건 — 워커당 task는 하나. assign_taskemplace 전에 !m_wrapped_task.has_value()를 assert한다. 워커가 task를 받는 경우는 m_available_workers에서 팝된 직후뿐이거나(다른 프로듀서가 같은 워커를 다시 노릴 수 없다), 큐에 쌓인 task라면 자신의 run 루프 안에서뿐이다. 이 optional을 어기면 task 하나를 놓치게 된다.

wrapped_task는 raw task_type *를 감싸는 move-only 봉투다. 페이로드는 컴파일 타임에 선택되므로, stats를 쓰지 않는 빌드는 타이밍 정보를 전혀 들고 다니지 않는다.

// worker_pool_impl<Stats>::wrapped_task -- src/thread/thread_worker_pool_impl.hpp
struct 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 / typeRoleWhy it exists
task_only::task타이밍 없는 raw task 포인터Stats==false일 때 봉투 전체
task_with_stats::task동일한 포인터Stats==true일 때의 페이로드
task_with_stats::timeenqueue 시각(cubperf::time_point)stats가 큐-실행 사이의 지연을 측정할 수 있게 함
inner_typeconditional_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 / typeRoleWhy it exists
stats::idenum class id : cubperf::stat_id, start_thread부터 retire_context까지(0–7) 값을 갖고 마지막에 type_count sentinel8개 생애주기 메트릭에 이름을 붙임; type_count가 정의 배열의 크기를 정함
stats::statdefstatic const cubperf::statset_definitionid마다 COUNTER_AND_TIMER 쌍 하나컴파일 타임 메트릭 레지스트리(이벤트마다 카운트 하나와 지속시간 하나)
stats_base(1차 템플릿)빈 클래스 — Stats==false일 때 EBO로 크기 0non-stats 빌드에서 m_stats를 공짜로 만듦
stats_base<true>::statsetcubperf::statset *, 소유; stats::destroy에서 delete됨살아 있는 워커별 카운터/타이머 저장소
stats_base<true>::timecubperf::time_point 클록 기준점11장이 축적하는 “마지막 이벤트 이후 경과 시간” 델타의 기준점

9.6 코어 선택 — executeget_round_robin_core_hash

섹션 제목: “9.6 코어 선택 — execute와 get_round_robin_core_hash”

execute는 단 두 줄이다 — 코어 인덱스를 고르고, 그 코어로 디스패치한다.

// worker_pool_impl<Stats>::execute -- src/thread/thread_worker_pool_impl.hpp
void 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_corevirtual이며, 기본값은 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.hpp
while (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 sequenceCore 0Core 1Core 2Core 3
한 주기 전체0 1 2 3 0 1 2 3 0 1 2 3 0 1 24443

불변조건 — 디스패치는 코어 크기에 비례한다. 커서 주기 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.hpp
assert (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_taskbecome_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.hpp
std::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.hpp
assert (!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.hpp
std::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를 두고 경합하며 비울 수 있다.

  1. 3단 계층, 2단계 선택. worker_pool_impl은 코어들을, core_impl은 워커들을, worker_impl은 스레드+컨텍스트 하나를 소유한다. 디스패치는 코어 고르기(get_next_core) 다음 워커 고르기-또는-큐잉(execute_task)의 순서다.
  2. 파티션은 정적이며, 움직이는 것은 task뿐이다. assign_workers_to_cores는 각 워커를 평생 하나의 코어에 고정한다(앞의 remainder개 코어가 하나씩 더 받는다); 코어별 개수의 합은 m_max_workers다.
  3. 라운드로빈은 균등이 아니라 비례다. 커서는 m_max_workers 에서 wrap하고 index % m_cores.size()로 접히므로, 각 코어의 task 몫은 그 워커 몫과 같다 — 더 작은 코어는 한 사이클에 한 번 건너뛰어진다.
  4. execute_task는 목적지가 네 곳, 락은 하나. 거부, fast(유휴 워커, unlock 후 assign), temp(일회용 스레드에서 실행되는 중첩 SP/method), queue — 그리고 워커-팝과 task-푸시는 m_workers_mutex 아래에서 원자적이어서 lost-wakeup 창을 닫는다.
  5. 핸드오프는 m_has_thread에 따른 wake-or-spawn이다. assign_task는 워커의 optional을 채운 뒤, 파킹된 스레드를 notify_one하거나 새 스레드를 start_thread한다; task 없는 오버로드를 통한 prestart는 spawn 지연을 핫 경로 밖으로 밀어낸다.
  6. 서로 겹치지 않는 두 뮤텍스 범위. m_workers_mutex는 코어별 리스트를, m_task_mutex는 워커 하나의 핸드오프를 지킨다; execute_task는 워커에 들어가기 전에 코어 락을 풀어서, 두 락이 절대 중첩되지 않는다.
  7. Stats는 컴파일 타임에 공짜다. wrapped_taskm_statsworker_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_threadfalsetruetrueget_new_task에서 truefalse로 바뀜
m_wrapped_tasknullopt현재 태스크를 보유할당 전까지 nulloptnullopt
m_context_pnullptr유효한 entry *유효한 entry *finish_run에서 유효값→nullptr
m_stopfalse경우에 따라 truestop 시 true로 바뀜true 또는 timeout
m_is_temp생성 시 고정됨 — temp 슬롯은 결코 WAITING에 들어가지 않는다; 태스크 하나를 실행하고 곧바로 소멸한다

불변식 — m_has_thread는 슬롯당 단일 스레드를 보장하는 래치다. 오직 m_task_mutex 아래에서만 쓰여지며, context가 retire되기 전에 false로 설정된다 — 그래야 동시에 실행되는 assign_taskfalse를 보고 죽어가는 스레드에 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는 ExecutingGetNewTask 사이를 순환하고, temp는 단발성 분기를 탄다.

10.2 worker_impl::run — 스레드 진입점

섹션 제목: “10.2 worker_impl::run — 스레드 진입점”

runstart_thread에서 std::thread에 전달되는 함수이며, m_is_temp에 따라 두 가지 형태 중 하나로 갈라진다.

// worker_impl::run -- src/thread/thread_worker_pool_impl.hpp
void 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_taskfalse를 반환할 때까지 돈다 — 이 시점에는 이미 내부적으로 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.hpp
void ...::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.hpp
void ...::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_runfinish_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.hpp
void ...::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_taskm_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.hpp
bool ...::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.hpp
void ...::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.hpp
void ...::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.hpp
void ...::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_taskm_has_thread = true로 설정하고 무조건 start_thread()를 호출한다(notify할 기존 스레드가 없기 때문이다); 새로 뜬 스레드가 실행되어 m_is_temp를 만나고, 한 번 실행한 뒤 finish_run의 temp 꼬리 부분이 unique_ptrm_temp_workers에서 m_free_temp_workersmove한다 — 이 객체는 그 스레드가 아직 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.hpp
void 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.hpp
bool ...::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_onetemp 워커에 대해서는 생략된다 — 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.hpp
void ...::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.cpp
worker_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_taskpush_task는 cap이 다 찬 분기에서만 다르게 동작한다: try_taskfalse를 반환하고 소유권을 호출자에게 남겨두며, push_taskend_task가 permit을 하나 풀어줄 때까지 m_cond_var에서 블로킹한다. 둘 다 내부적으로 private execute를 호출한다.

불변식 — permit 보존 법칙이 동시성을 m_max_tasks로 제한한다. m_tasks_availableworker_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” 절을 참고하라.

  1. worker_impl은 스레드가 아니라 재사용 가능한 슬롯이다. run이 스레드 본체이고, m_has_thread(오직 m_task_mutex 아래에서만 쓰여짐)는 pusher에게 기존 스레드에 notify할지 새 스레드를 spawn할지를 알려주는 래치다.
  2. init_run / finish_run은 스레드 생명주기당 정확히 하나의 context를 감싼다, 반면 recycle_context는 태스크와 태스크 사이에 그 context를 저렴하게 reset한다; pooled 루프는 do execute_current_task while get_new_task 형태다.
  3. get_new_task는 유일한 블로킹 지점이자 retire를 결정하는 곳이다. 큐에 있던 태스크를 반환하거나, idle timeout으로 제한된 m_task_cv에서 대기하거나, 아무것도 찾지 못하면 m_has_thread = false로 설정하고 finish_run을 호출한 뒤 false를 반환한다.
  4. temp 워커는 단발성이며 스스로 파킹한다. execute_task_as_temp가 생성하며, 태스크 하나를 실행한 뒤 m_free_temp_workers에 등록된다; 이후 pooled 워커가 free_all_temp_list를 통해 이들을 회수하므로, 어떤 스레드도 자기 자신에 대해 delete this를 실행하지 않는다.
  5. teardown은 멱등이며 확인 절차를 거친다. m_stopped.exchange(true)가 하나의 stopper를 선출하고, 풀은 모든 core가 has_thread == false를 보고하거나 timeout이 assert될 때까지 재-sweep하며, 그 다음 retire_queued_tasks가 밀린 작업을 drain한다.
  6. worker_pool_task_capper는 풀에 없는 back-pressure를 더해준다. worker_count로 제한된 permit 카운터가 admission을 관문처럼 통제한다; try_task는 빠르게 실패하고, push_task는 블로킹하며, capped_task::execute는 실제 작업이 끝난 직후 permit을 반환한다.
  7. 모든 분기는 태스크나 context를 정확히 한 번씩 retire한다. 태스크 retire는 execute 이후, retire_queued_tasks 안, 그리고 ~capped_task에서 일어난다; context retire는 스레드당 한 번 finish_run에서 일어난다. m_wrapped_taskm_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”를 참고.

connection_statistics.hpp는 두 개의 enum class 키 집합과 고정 크기 카운터 배열 템플릿 metrics<T, VT> 하나를 정의한다. 맵도, 해싱도, 락도 없다 — 통계값 “쓰기”는 그냥 array[static_cast<size_t>(key)] += value다.

// cubconn::statistics::worker -- src/connection/connection_statistics.hpp
enum 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.hpp
template <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는 이름 하나를 더 추가하는데, NASTATS_COUNT 너머에 있다 — 애초부터 배열 범위 밖이며, 8장의 디스패치 테이블에서 “이 메시지 타입에는 대응하는 카운터가 없다”는 표시 ({ handler, statistics::worker::NA })로만 쓰인다. NAadd에 넘기면 배열 한 칸을 넘어서 써버린다. 디스패치 루프는 이를 다음과 같이 막는다: 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.cpp
m_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.hpp
struct 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&lt;worker&gt;"]
    cs["context m_stats\nmetrics&lt;context&gt; 커넥션당"]
  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.cpp
bool 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.cpp
index = 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 ();

분기 맵:

  1. 첫 샘플 (statistics_update_connection 내부에서 검사하는 m_last_updated == 0): else 분기가 원시 샘플로 .first.second를 시딩(seed)한다 — EWMA는 적용되지 않는다; m_coredelta는 이번 한 번만 0을 기준선으로 계산되며 다음 틱에서 폐기된다.
  2. 후속 샘플: m_sum.reset()을 호출한 뒤 statistics_EWMAm_worker와 각 m_contexts[id]를 전진시킨다(각 누산기는 존재함이 assert되어 .first/.second 쌍을 유지한다).
  3. 컨텍스트 롤업: m_sum은 모든 m_contexts[id].first로부터 매번 다시 합산된다 — 매 틱마다 새로 리듀스(reduce)하므로, 떠난 커넥션은 자연스럽게 빠진다.
  4. DRAINING + 빈 컨텍스트: 드레인 중인 워커(6장)가 컨텍스트 0개를 보고하면 → scale_down_finish()가 그 슬롯을 퇴역시킨다 — 빈 컨텍스트 전송이 실제로 의미를 갖는 유일한 지점이다.
  5. 그 외의 경우: 그대로 통과하며, 모델만 갱신되고 스케일링 동작은 없다.
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.cpp
diff = (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_requestcontrol_type::SHOW_STATS)을 통해서만 도달하는 이 함수는 전체 화면 터미널 대시보드를 그린다. 이미 만들어진 모델을 락 없이 읽는데 — 코디네이터 스레드 자신에서 실행되기 때문이다.

// coordinator::statistics_print -- src/connection/coordinator.cpp
printf ("\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에 선언되어 있다.

ColumnSource현재의 의미
Jobq_indexcore index백엔드 요청 풀의 core를 재해석한 값(job queue는 더 이상 존재하지 않는다)
Num_total_workerswp_core.get_worker_count()해당 core의 워커 수
Num_busy_workersmapper를 통해 실시간으로 집계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_taskcss_get_task_stats(stats)를 호출하고 그 결과를 m_task_statistics(requested/started/completed/depth)에 접어 넣어, statistics_print 요약과 오토스케일 점수(statistics_scalingm_task_statistics.completed.first * 2) 양쪽에 공급한다. 그런데:

// css_get_task_stats -- src/connection/server_support.c
void 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_statsworker_pool_impl::get_statscore_impl::get_stats로 이어지는 이 경로는 m_workers_mutex를 잡고 각 워커의 cubperf::stat_value 배열을 누적한다. 이 락-앤-perfmon 패턴이야말로 §11.1이 리액터에서 피하려 했던 바로 그것이다 — 여기서 용인되는 이유는 오직 이 통계들이 패킷 단위가 아니라 er_log_stats를 위해 읽히기 때문이다.

  1. 단일 기록자 소유권 덕분에 원자 연산이 필요 없다. metrics<T,VT>는 정확히 하나의 스레드(컨텍스트를 소유한 워커, 혹은 워커 자신의 루프)만이 기록하는 평범한 고정 배열이므로, add/sub는 동기화되지 않은 +=로 충분하다 — “핫 패스에는 perfmon을 두지 않는다”는 규율 전체가 여기서 나온다(§11.1).
  2. 스레드 경계는 오직 ‘복사 후 이동’으로만 넘는다. statistics_metrics_to_coordinator는 5초 주기로 카운터 배열을 move-only message에 복사한 뒤 논블로킹으로 enqueue하며, 코디네이터는 그것의 유일한 판독자다(그림 11-1, §11.4).
  3. 코디네이터가 보관하는 것은 카운터가 아니라 모델이다. worker_statistics는 워커와 각 컨텍스트에 대해 double EWMA(.first)와 직전 원시 샘플(.second)을 짝지어 두고, 여기에 m_sum 롤업과 파생값 m_score를 더한다(§11.3).
  4. 소비자 측 분기는 하나도 허투루 없다. 첫 샘플 시딩 대 EWMA 전진, 컨텍스트별 롤업, 그리고 scale_down_finish를 촉발하는 DRAINING+빈 컨텍스트 분기가 모두 handle_message_queue_statistics의 분기들이다 (§11.4, 그림 11-2).
  5. 운영자용 표면은 둘이지만, 대상 풀은 서로 다르다. statistics_print는 리액터 모델을 코디네이터 스레드에서 그려내는 ANSI 대시보드다(컨텍스트 0개인 슬롯은 건너뜀); SHOW JOB QUEUES백엔드 풀의 core들을 보고하며, 바쁨 여부는 온디맨드 컨텍스트 매핑으로 도출하고 커넥션 워커는 0으로 보고한다(§11.5~§11.6).
  6. css_get_task_stats는 스텁이다. 실제 호출이 주석 처리되어 있어서, m_task_statistics와 오토스케일 점수의 태스크 항은 현재 아무 작용도 하지 않는다 — 태스크 신호를 신뢰하려면 먼저 이 연결을 되살려야 한다 (§11.6).
  7. 옛 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.hpp
template <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장)을 선택한다 — 슬롯 관련 로직은 두 값 모두에 대해 중복 생성되며, 그래서 .cppdynamic_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_parameterassert (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.hpp
template <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_workerm_max_worker는 풀의 멤버를 그대로 가리킨다 (allocate_core에서 전달됨). 코어마다 별도 복사본을 두면 각 코어가 독립적으로 max_worker개의 스레드를 스폰할 수 있게 되어, 전역 캡이 core_count배만큼 뚫려버린다.

12.3 worker_elastic — 슬롯을 나르는 워커

섹션 제목: “12.3 worker_elastic — 슬롯을 나르는 워커”
// worker_elastic -- src/thread/thread_worker_pool_elastic.hpp
template <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_taskm_task_mutex 아래에서 wrapped_taskunique_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.hpp
class 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_poolconst — 슬롯을 발행한 풀빌려간 슬롯은 결국 원래 주인에게 돌아가야 한다; const로 두어 슬롯의 생애 동안 원래 소속 주소가 절대 바뀌지 않게 한다
m_holder_pool현재 이 슬롯을 빌려주고 있는 풀데몬이 코어 사이에 슬롯을 옮긴 뒤에는 소유자와 달라진다; 획득 시 set_holder_pool이 이를 찍는다
m_wait소프트 대기 플래그; 보유자가 논리적 대기에 들어갈 때 start_waiting이 설정슬롯이 데몬에 의해 탈취 가능 상태임을 표시한다
m_wait_since대기가 시작된 steady_clock 타임스탬프has_wait_expired가 50 ms 임계값과 비교해 하드 탈취 여부를 결정한다

불변조건 — 소유자는 불변, 보유자는 이동 가능하다. m_owner_poolconst이고 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.hpp
class 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_THRESHOLDstatic constexpr = 2풀이 “잉여” 상태로 간주되기 위한 최소 여유 슬롯 수; > 1이어야 데몬이 빌려가더라도 풀이 항상 자기 몫으로 슬롯 하나는 남겨두게 된다
m_parent소유 core_elastic을 가리키는 const void * 역참조이 파일은 템플릿이 아니므로 Stats를 볼 필요가 없도록 타입을 소거해 둔 것; wakeup_workers/has_queued_task에서 dynamic_castcore_elastic<on/off>로 되돌려 캐스팅한다
m_available_slots여유 unique_slot의 FIFO queue은행 그 자체; try_acquire_slot이 앞에서 꺼내고 release_slot이 뒤에 넣는다
m_surplusavailable >= SLOT_SURPLUS_THRESHOLD인 동안 true데몬이 대여를 고려하기 전에 확인하는 값싼 플래그
m_surplus_since잉여 조건이 시작된 시점borrow_surplus_slots는 잉여 상태가 2초 넘게 지속된 뒤에야 슬롯을 훔쳐가며, 잦은 진동을 억제한다
m_slot_count현재 이 풀에 물리적으로 있는 슬롯 수(소유 + 대여)다운스케일(adjust_concurrency)은 목표치에 도달할 때까지 자기 소유 슬롯을 파괴한다
m_target_count원하는 슬롯 수 = 해당 코어의 m_max_concurrencym_slot_count와의 괴리가 재구성 중 생성/파괴를 유도한다
m_wait_queueacquire_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.hpp
class 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_subscriberm_publisher구독/해지할 대상 데몬 게시자생성 시 한 번 설정됨; ~subscriber가 RAII 정리를 위해 deactivate를 호출한다
m_identifier그룹핑 키 = 소유 worker_pool 포인터; activate 전까지 nullptr게시자가 한 풀에 속한 코어별 구독자들을 한데 묶을 수 있게 한다
concurrency_slot_publisherm_subscribers식별자(워커 풀)에서 그 벡터(코어별 슬롯 풀들)로의 map데몬의 순회 대상: 각 풀에 대해 그 풀에 속한 모든 코어의 슬롯 은행
m_mutex구독자 맵을 보호subscribe/unsubscribe/traverse 모두 이를 잠근다

등록 절차는 concurrency_slot.cpp에서 이루어진다. concurrency_slot_pool::initialize워커 풀 포인터를 인자로 activate (identifier)를 호출하면, activatem_identifier를 저장하고 m_publisher->subscribe (identifier, this)를 호출해 이 풀을 m_subscribers[identifier]에 덧붙인다. 그래서 하나의 키 (worker_pool_elastic)가 자신의 N개 코어별 풀들을 한데 묶는다. traverse (Func)m_mutex를 잠그고 그룹별로 func (identifier, subscribers)를 호출한다 — 14장의 데몬이 한 풀의 코어들을 함께 재조정할 때 사용하는 진입점이다.

불변조건 — 구독자는 최대 한 번만 등록된다. 풀이 이미 등록되어 있으면 subscribeassert_release (false)를 던지고, deactivate는 멱등적이다(m_identifier가 null이 아닌지로 가드된다). 이중 등록이 일어나면 데몬이 같은 코어의 슬롯을 한 틱에 두 번 훔쳐서 재분배하게 된다.

12.7 concurrency_slot_daemon — 게시자 싱글턴

섹션 제목: “12.7 concurrency_slot_daemon — 게시자 싱글턴”
// concurrency_slot_daemon -- src/thread/concurrency_slot.hpp
class 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_daemon50 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.hpp
thread_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 전용, 탄력적이지 않은 스레드에서는 nullptrexecute_current_task가 실행 직전에 워커의 슬롯을 이리로 옮긴다; 락/CSS 대기 경로가 소프트 대기 시 여기서 이를 해제해 다른 스레드가 실행될 수 있게 한다
resume_statusthread_resume_suspend_status — 스레드가 왜 중단/재개되었는가슬롯 대기는 기존의 중단/재개 메커니즘을 재사용한다; acquire_slotTHREAD_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_slotthread_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-1은 하나의 풀 안에서의 소유 관계를 보여주고, 그림 12-2는 데몬이 게시자를 통해 모든 풀에 도달하는 경로를 보여준다.

flowchart TB
  subgraph POOL["worker_pool_elastic&lt;Stats&gt;"]
    WPE["m_current_worker<br/>m_max_concurrency<br/>m_max_worker<br/>(원자 변수)"]
    subgraph CORE["core_elastic (코어별)"]
      CE["m_max_concurrency<br/>&amp;m_current_worker<br/>&amp;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하며 한 풀의 코어들을 함께 재조정한다.

  1. 동시성은 스레드 수가 아니라 슬롯 수다. worker_pool_elastic<Stats>는 세 개의 원자 변수(m_current_worker, m_max_concurrency, m_max_worker)를 더하며, m_max_worker >= m_max_concurrency라는 불변조건 덕분에 스레드는 슬롯 수를 넘어 초과 확보될 수 있다.
  2. core_elastic은 코어별 concurrency_slot_pool을 소유하고, 두 개의 스레드 예산 원자 변수를 참조로 들고 있어 초과 확보를 전역 단위로 제한한다; m_retired_stats는 다운스케일된 워커들의 카운터를 보존한다.
  3. 태스크-슬롯 짝짓기 불변조건이 Phase 2의 심장이다. worker_elastic (그리고 그것이 실행되는 entry)은 태스크와 슬롯을 함께 쥐거나 둘 다 놓거나 둘 중 하나만 한다 — 이로써 “실행 가능 == 보유 슬롯 수”가 문자 그대로 참이 된다.
  4. concurrency_slot은 불변인 m_owner_pool, 이동 가능한 m_holder_pool, 그리고 소프트 대기 플래그(m_wait/m_wait_since)를 가진다. 이 덕분에 데몬은 대기 중인 스레드로부터 슬롯을 훔쳐 코어 사이로 빌려주면서도, 그 슬롯이 여전히 자기 집으로 돌아가는 길을 찾을 수 있다.
  5. concurrency_slot_pool은 소유 코어의 뮤텍스를 m_mutex로 공유하며, 여유 슬롯을 m_available_slots에 은행처럼 쌓아두고, 슬롯이 모자란 스레드를 m_wait_queue에 세워두며, m_parent를 통해 자신의 코어에 도달한다.
  6. 게시자/구독자 패턴은 데몬-풀 간 의존 방향을 뒤집는다. 각 풀은 자신이 속한 worker_pool 포인터를 키로 하여 단 하나의 concurrency_slot_daemon에 스스로 구독하고, 데몬은 50 ms마다 그 맵을 traverse한다.
  7. 실행 중인 태스크의 슬롯은 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_sincestart_waiting이 설정하는 소프트 대기 플래그와 타임스탬프; has_wait_expired는 50ms 이후 true가 된다.재조정 데몬(14장)이 논리적 대기에 머물러 있는 스레드에서 슬롯을 훔쳐올 수 있게 한다.

획득 진입점은 두 가지이며, 이 구분 자체가 2단계의 핵심이다: try_acquire_slot(논블로킹, 여유 슬롯이 없으면 nullptr를 반환 — 디스패처 경로)와 acquire_slot(entry *)(블로킹, 슬롯이 건네질 때까지 호출자를 재운다 — 14장의 논리적 대기 경로)이다. 각각 락을 직접 잡는 얇은 오버로드와 실제 std::unique_lock &를 받는 오버로드를 갖는다.

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); }

대기가 끝나면 정확히 하나의 깨어난 이유만 성립한다.

  1. THREAD_CONCURRENCY_SLOT_RESUMED — 다른 스레드의 release_slot이 이미 thread_p->m_slot에 슬롯을 심어두고 대기자를 큐에서 뺐다. resume_status = saved_status로 복원하고 true를 반환한다. 별도의 dequeue는 필요 없다.
  2. 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_concurrencym_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_parentvoid *로 저장해 두고 런타임에 다운캐스트한다. wakeup_workers(ulock)m_parentcore_elastic<stats_t::on/off>의 각 인스턴스화로 dynamic_cast해 해당 코어의 adjust_workers(ulock)를 호출하고(14장 참고), has_queued_task(ulock)도 동일한 두 갈래 캐스트로 코어에게 !m_task_queue.empty ()를 묻는다. 두 함수 모두 세 번째, fallthrough 분기를 갖는다 — 다운캐스트가 어느 인스턴스와도 맞지 않으면 wakeup_workers는 조용히 아무 일도 하지 않고, has_queued_taskfalse를 반환한다. 이는 방어적 코드다 — m_parent는 항상 core_elastic이므로 사실상 도달 불가능한 분기지만, 이 덕분에 브리지가 전역(total) 함수로 유지된다.

13.4 return_to_poolreturn_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_taskstats::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를 다시 가로챌 수 있다. 그러면 findend()를 반환하고(아직 선택되지 않았다는 조건이 실패) 회수는 건너뛴다 — 워커는 계속 실행된다. 세 조건이 모두 성립할 때만 코드는 m_available_workers에서 w먼저 지운 다음, m_workers에서 해당 unique_ptr<worker>를 찾아 지우며, 파괴 전에 통계를 m_retired_stats로 접어 넣는다.

불변식 13-E (스냅샷 리더가 살아 있는 동안에는 회수하지 않는다). stop_execution, get_stats, map_running_contextsm_workers에서 raw worker *를 복사해 내고 m_workers_readers를 증가시키는 snapshot_guard를 만든다. 살아 있는 스냅샷 아래에서 unique_ptr<worker>를 지우면 그 포인터들이 댕글링(dangling)되므로, get_retire_if_excesshas_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이 추가된 모습.

  1. 슬롯은 입장 허가 토큰이다. 실행 가능 상태가 되는 유일한 방법은 try_acquire_slot(논블로킹 디스패처)과 acquire_slot(블로킹 논리적 대기)뿐이다. get_new_task는 작업과 슬롯을 결합시켜 워커가 허가 없이는 결코 실행되지 않게 한다(불변식 13-A).
  2. 소유(ownership)와 보유(holding)의 구분이 모든 반환을 좌우한다. m_owner_pool은 불변이며, return_to_pool은 소유자→보유자→강제(Figure 13-2) 순으로 제안해 m_slot_count를 보존한다 — 코어는 자신이 만든 슬롯만 파괴한다(불변식 13-D).
  3. release_slot은 세 가지 운명 중 하나를 맞는다 — 목표치를 초과한 소유 슬롯을 축소하거나, 대기자가 없으면 저장하거나, 막혀 있는 획득자에게 전달한다. 이때 대기자의 th_entry_lock을 잡기 전에는 항상 코어 뮤텍스를 먼저 놓는다(불변식 13-F).
  4. 인터럽트 경쟁 상황은 명시적으로 처리된다. acquire_slot은 모든 경로에서 바깥쪽 대기의 resume_status를 복원하며(불변식 13-C), 인터럽트 직전에 건네진 슬롯도 회수한다.
  5. adjust_concurrency는 available이면서 owned인 슬롯은 즉시 축소하고, 실행 중인 슬롯은 release_slot을 통해 지연 축소한다. 그 결과 동시성을 줄이는 작업이 실행 중인 작업 때문에 막히는 일이 없다. check_surplus_slots는 데몬을 위해 과잉 프로비저닝 구간의 시간을 잰다.
  6. 스레드는 승인된 동시성을 넘어 오버커밋한다. 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에 남아 있을 때만(그렇지 않으면 이미 재선택되어 계속 실행 중이라는 뜻이다) 잉여 스레드를 회수한다.
  7. 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/sub subscribe/activate 배선, 그리고 try_acquire_slot/release_slot/ return_slot/borrow_surplus_slots는 12~13장의 내용이다. 이 장은 이를 이미 알고 있다고 전제하고 진행한다.

REGISTER_DAEMON (concurrency_slot_daemon)는 그 자체로 퍼블리셔인 (concurrency_slot_daemon : concurrency_slot_publisher) 싱글턴을 등록한다. 즉 m_subscribers 맵을 소유하는 객체와, 그 맵을 순회하는 태스크를 갖는 객체가 동일하다. create_daemonconcurrency_slot_daemon_task를 고정 50ms looper로 감싼다:

// concurrency_slot_daemon::create_daemon -- src/thread/concurrency_slot.cpp
looper 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.cpp
if (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.cpp
std::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_counthardware_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.cpp
ctx.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_workersadjust_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.hpp
std::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
Figure 14-1. 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.cpp
void 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.cpp
auto 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_waitingth_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 --> [*]
Figure 14-2. 논리적 대기 전반에 걸친 슬롯 생명주기.

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.cpp
return -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.cpp
subs.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_slotssubs를 실제로 슬롯을 받은 풀만 남도록 재구성하므로, 5단계의 wakeup_workers는 정확히 그 코어들만 건드린다. release_slot은 13장에서 다룬 루틴이다(저장하거나, 대기 중인 대기자에게 건네며, owner-vs-holder 축소 규칙을 지킨다).

불변식 — 한 틱 안에서 슬롯이 유실되는 일은 없다. 2~3단계에서 회수된 것은 모두 4단계에서 배치되거나(executeslots.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.cpp
if (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 reasonSlot actionRationale
THREAD_CSS_QUEUE_SUSPENDED즉시 return_to_pool(하드 반납)CSS 큐에 대기 중인 핸들러는 무기한 유휴 상태가 될 수 있으므로, 슬롯을 쥐고 있으면 실제 작업이 굶주리게 된다
THREAD_LOCK_SUSPENDEDstart_waiting(슬롯 유지)락 대기는 대체로 짧으므로, 빠른 재개를 위해 슬롯을 유지하고 50ms 후에는 데몬이 회수하도록 둔다

entry::start_waiting/stop_waitingSERVER_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.cpp
if (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_waitingm_wait를 지운다; 비용 없이 재개된다.
  • 분기 2(정상 재개, 슬롯 소실). CSS(서스펜드 시점에 반납됨)와, 데몬이 슬롯을 훔쳐간 LOCK 케이스; acquire_slot이 슬롯을 다시 건네받을 때까지 블록한다(13장의 대기 큐 경로).

불변식 — 중첩된 슬롯 대기가 원래의 재개 결과를 덮어써서는 안 된다. acquire_slotTHREAD_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.cpp
thread_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 ();

락 매니저(논리적 대기). 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.c
switch (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 */
#endif

thread_manager.hppthread_concurrency_slot_release는 엔트리를 잠그고, m_slot을 꺼내 return_to_pool한 뒤 holder 풀을 반환한다; thread_concurrency_slot_acquireholder가 설정돼 있으면 TS_WAIT 하에서 acquire_slot으로 다시 블록한다. LOCK과 달리 PL은 명시적인 release/reacquire 쌍을 사용하는데, PL 호출은 원래 오래 걸릴 것으로 예상되므로 50ms 동안 슬롯을 붙잡고 있어 봤자 의미가 없기 때문이다.

  1. 데몬만이 강제되지 않은 슬롯 이동자다. 50ms looper가 파라미터를 재조정하고, 블록된 엔트리에서 만료된 슬롯을 회수하고, (수요가 있을 때) 과공급 코어에서 유휴 잉여분을 회수하고, 점수 기준으로 재분배하고, 공급받은 코어를 깨운다.
  2. 소프트→하드 승격은 LOCK 정책이다. start_waitingm_wait_since를 찍고, has_wait_expired가 50ms 시점에 승격시키며, steal_from_entries_if_excess는 대기가 만료된 TS_WAIT 엔트리에서만 슬롯을 뽑아간다 — 엔트리 락에 의해 깨우는 쪽과 순서가 맞춰진다.
  3. CSS/PL은 즉시 반납하고, LOCK은 낙관적으로 유지한다. 서스펜션은 THREAD_CSS_QUEUE_SUSPENDED에 대해서는 return_to_pool하고, THREAD_LOCK_SUSPENDED에 대해서는 소프트웨이트로만 표시한다; PL은 명시적인 thread_concurrency_slot_release/_acquire 쌍을 사용한다.
  4. 재개(resumption)는 세 갈래다. 중단(abort)은 재획득 없이 유지 중이던 슬롯을 놓아준다; 슬롯을 유지한 정상 재개는 stop_waiting을 호출한다 (비용 없음); 슬롯이 없는 정상 재개는 acquire_slot에서 블록하는데, 이 함수는 resume_status를 저장/복원해 허가된 THREAD_LOCK_RESUMED가 훼손되지 않도록 한다.
  5. 분배는 부족분 기준으로 이루어지며 아무것도 흘리지 않는다. 코어들은 get_score(-available + 2*waiters) 기준으로 정렬되고, 슬롯은 가장 궁핍한 순서로 라운드로빈 배분되며, slots.empty ()가 단언된다; 슬롯의 holder는 코어를 넘나들 수 있지만, 귀환 라우팅을 위한 owner는 고정된다.
  6. 코어 수는 cgroup을 인식한다. tune_parameters는 튜너블 값을 [system_core_count(), CSS_MAX_CLIENT_COUNT] 범위로 클램프하며 worker >= concurrency를 지킨다; system_core_countadjusted_max — affinity, online, cgroup CPU 쿼터의 교집합을 내림한 값 — 이므로 컨테이너 제한이 그대로 반영된다.

아래 줄 번호는 2026-07-08 시점에 관찰한 값이다. 심볼 이름이 정식 앵커이며 줄 번호는 시간이 지나면 어긋나는 힌트다.

SymbolFileLine
cpu::effectivePR:src/base/resources.cpp192
PRM_ID_MAX_REQUEST_WORKERPR:src/base/system_parameter.c5212
PRM_ID_MAX_REQUEST_CONCURRENCYPR:src/base/system_parameter.c5231
net_server_wakeup_workersPR:src/communication/network_sr.c928
css_set_max_concurrency_and_workersPR:src/connection/server_support.c2913
read_data_from_javaPR:src/sp/pl_execution_stack_context.hpp154
concurrency_slot_subscriber::activatePR:src/thread/concurrency_slot.cpp53
concurrency_slot_publisher::subscribePR:src/thread/concurrency_slot.cpp82
concurrency_slot::return_to_poolPR:src/thread/concurrency_slot.cpp162
concurrency_slot::return_to_poolPR:src/thread/concurrency_slot.cpp163
concurrency_slot::start_waitingPR:src/thread/concurrency_slot.cpp179
concurrency_slot::stop_waitingPR:src/thread/concurrency_slot.cpp186
concurrency_slot::has_wait_expiredPR:src/thread/concurrency_slot.cpp192
concurrency_slot::has_wait_expiredPR:src/thread/concurrency_slot.cpp193
concurrency_slot_pool::concurrency_slot_poolPR:src/thread/concurrency_slot.cpp204
concurrency_slot_pool::adjust_concurrencyPR:src/thread/concurrency_slot.cpp240
concurrency_slot_pool::adjust_concurrency(ulock)PR:src/thread/concurrency_slot.cpp248
concurrency_slot_pool::try_acquire_slotPR:src/thread/concurrency_slot.cpp291
concurrency_slot_pool::try_acquire_slot(ulock)PR:src/thread/concurrency_slot.cpp299
concurrency_slot_pool::acquire_slotPR:src/thread/concurrency_slot.cpp321
concurrency_slot_pool::acquire_slotPR:src/thread/concurrency_slot.cpp328
concurrency_slot_pool::acquire_slot(ulock)PR:src/thread/concurrency_slot.cpp329
concurrency_slot_pool::release_slotPR:src/thread/concurrency_slot.cpp413
concurrency_slot_pool::release_slot(ulock)PR:src/thread/concurrency_slot.cpp421
concurrency_slot_pool::return_slotPR:src/thread/concurrency_slot.cpp481
concurrency_slot_pool::borrow_surplus_slotsPR:src/thread/concurrency_slot.cpp500
concurrency_slot_pool::borrow_surplus_slotsPR:src/thread/concurrency_slot.cpp501
concurrency_slot_pool::needs_slotPR:src/thread/concurrency_slot.cpp525
concurrency_slot_pool::needs_slot(ulock)PR:src/thread/concurrency_slot.cpp533
concurrency_slot_pool::available_slotsPR:src/thread/concurrency_slot.cpp541
concurrency_slot_pool::get_scorePR:src/thread/concurrency_slot.cpp552
concurrency_slot_pool::check_surplus_slotsPR:src/thread/concurrency_slot.cpp568
concurrency_slot_pool::wakeup_workersPR:src/thread/concurrency_slot.cpp585
concurrency_slot_pool::has_queued_taskPR:src/thread/concurrency_slot.cpp600
concurrency_slot_daemon_task::executePR:src/thread/concurrency_slot.cpp661
concurrency_slot_daemon_task::has_slot_demandPR:src/thread/concurrency_slot.cpp697
concurrency_slot_daemon_task::steal_from_entries_if_excessPR:src/thread/concurrency_slot.cpp711
concurrency_slot_daemon_task::steal_from_cores_if_excessPR:src/thread/concurrency_slot.cpp745
concurrency_slot_daemon_task::distribute_slotsPR:src/thread/concurrency_slot.cpp762
concurrency_slot_daemon_task::tune_parametersPR:src/thread/concurrency_slot.cpp822
concurrency_slot_daemon_task::check_and_propagate_parametersPR:src/thread/concurrency_slot.cpp837
concurrency_slot_daemon::create_daemonPR:src/thread/concurrency_slot.cpp900
concurrency_slot_subscriberPR:src/thread/concurrency_slot.hpp52
concurrency_slot_subscriber::m_publisherPR:src/thread/concurrency_slot.hpp62
concurrency_slot_subscriber::m_identifierPR:src/thread/concurrency_slot.hpp63
concurrency_slot_publisherPR:src/thread/concurrency_slot.hpp66
concurrency_slot_publisher::m_subscribersPR:src/thread/concurrency_slot.hpp81
concurrency_slot_publisher::m_mutexPR:src/thread/concurrency_slot.hpp82
concurrency_slotPR:src/thread/concurrency_slot.hpp92
concurrency_slot::m_owner_poolPR:src/thread/concurrency_slot.hpp116
concurrency_slot::m_holder_poolPR:src/thread/concurrency_slot.hpp117
concurrency_slot::m_waitPR:src/thread/concurrency_slot.hpp119
concurrency_slot::m_wait_sincePR:src/thread/concurrency_slot.hpp120
concurrency_slot_poolPR:src/thread/concurrency_slot.hpp125
concurrency_slot_pool::SLOT_SURPLUS_THRESHOLDPR:src/thread/concurrency_slot.hpp163
SLOT_SURPLUS_THRESHOLDPR:src/thread/concurrency_slot.hpp163
concurrency_slot_pool::m_parentPR:src/thread/concurrency_slot.hpp165
concurrency_slot_pool::m_available_slotsPR:src/thread/concurrency_slot.hpp167
concurrency_slot_pool::m_surplusPR:src/thread/concurrency_slot.hpp169
concurrency_slot_pool::m_surplus_sincePR:src/thread/concurrency_slot.hpp170
concurrency_slot_pool::m_slot_countPR:src/thread/concurrency_slot.hpp172
concurrency_slot_pool::m_target_countPR:src/thread/concurrency_slot.hpp173
concurrency_slot_pool::m_wait_queuePR:src/thread/concurrency_slot.hpp175
concurrency_slot_pool::m_mutexPR:src/thread/concurrency_slot.hpp178
concurrency_slot_daemonPR:src/thread/concurrency_slot.hpp190
concurrency_slot_daemon::m_daemonPR:src/thread/concurrency_slot.hpp210
concurrency_slot_publisher::traversePR:src/thread/concurrency_slot.hpp217
concurrency_slot_publisher::traversePR:src/thread/concurrency_slot.hpp218
concurrency_slot_daemon::traverse_subscribersPR:src/thread/concurrency_slot.hpp229
entry::start_waitingPR:src/thread/thread_entry.cpp472
entry::stop_waitingPR:src/thread/thread_entry.cpp478
thread_suspend_wakeup_and_unlock_entryPR:src/thread/thread_entry.cpp525
thread_suspend_timeout_wakeup_and_unlock_entryPR:src/thread/thread_entry.cpp553
thread_prepare_suspensionPR:src/thread/thread_entry.cpp598
thread_prepare_resumptionPR:src/thread/thread_entry.cpp660
event_stat::slot_waitsPR:src/thread/thread_entry.hpp115
thread_resume_suspend_statusPR:src/thread/thread_entry.hpp146
THREAD_CONCURRENCY_SLOT_SUSPENDEDPR:src/thread/thread_entry.hpp173
THREAD_CONCURRENCY_SLOT_RESUMEDPR:src/thread/thread_entry.hpp174
THREAD_SLEEP_FUNC_SUSPENDEDPR:src/thread/thread_entry.hpp175
cubthread::entry::resume_statusPR:src/thread/thread_entry.hpp258
cubthread::entry::m_slotPR:src/thread/thread_entry.hpp333
entry::m_slotPR:src/thread/thread_entry.hpp333
thread_concurrency_slot_releasePR:src/thread/thread_manager.hpp630
thread_concurrency_slot_acquirePR:src/thread/thread_manager.hpp657
worker_pool_elasticPR:src/thread/thread_worker_pool_elastic.hpp54
worker_pool_elastic::unique_slotPR:src/thread/thread_worker_pool_elastic.hpp65
worker_pool_elastic::m_current_workerPR:src/thread/thread_worker_pool_elastic.hpp95
worker_pool_elastic::m_max_concurrencyPR:src/thread/thread_worker_pool_elastic.hpp97
worker_pool_elastic::m_max_workerPR:src/thread/thread_worker_pool_elastic.hpp98
core_elasticPR:src/thread/thread_worker_pool_elastic.hpp107
core_elastic::m_slotsPR:src/thread/thread_worker_pool_elastic.hpp155
core_elastic::m_max_concurrencyPR:src/thread/thread_worker_pool_elastic.hpp158
core_elastic::m_current_workerPR:src/thread/thread_worker_pool_elastic.hpp161
core_elastic::m_max_workerPR:src/thread/thread_worker_pool_elastic.hpp162
core_elastic::m_retired_statsPR:src/thread/thread_worker_pool_elastic.hpp164
worker_elasticPR:src/thread/thread_worker_pool_elastic.hpp173
worker_elastic::m_slotPR:src/thread/thread_worker_pool_elastic.hpp197
worker_pool_elastic::worker_pool_elasticPR:src/thread/thread_worker_pool_elastic.hpp209
worker_pool_elastic::adjust_runtime_parameterPR:src/thread/thread_worker_pool_elastic.hpp239
core_elastic::core_elasticPR:src/thread/thread_worker_pool_elastic.hpp319
worker_pool_elastic::core_elastic::execute_taskPR:src/thread/thread_worker_pool_elastic.hpp403
worker_pool_elastic::core_elastic::get_task_and_slot_or_become_availablePR:src/thread/thread_worker_pool_elastic.hpp484
worker_pool_elastic::core_elastic::get_retire_if_excessPR:src/thread/thread_worker_pool_elastic.hpp509
core_elastic::reserve_available_workerPR:src/thread/thread_worker_pool_elastic.hpp578
worker_pool_elastic::core_elastic::reserve_available_workerPR:src/thread/thread_worker_pool_elastic.hpp579
worker_pool_elastic::core_elastic::release_available_workerPR:src/thread/thread_worker_pool_elastic.hpp599
worker_pool_elastic::core_elastic::get_or_make_available_workerPR:src/thread/thread_worker_pool_elastic.hpp607
worker_pool_elastic::core_elastic::try_execute_task_with_slotPR:src/thread/thread_worker_pool_elastic.hpp624
worker_elastic::get_new_taskPR:src/thread/thread_worker_pool_elastic.hpp703
worker_elastic::runPR:src/thread/thread_worker_pool_elastic.hpp789
worker_elastic::execute_current_taskPR:src/thread/thread_worker_pool_elastic.hpp799
system_core_countPR:src/thread/thread_worker_pool_impl.cpp39
worker_pool_impl::get_pool_sizePR:src/thread/thread_worker_pool_impl.hpp691
core_impl::has_workers_snapshot_readersPR:src/thread/thread_worker_pool_impl.hpp1236
core_impl::get_available_workerPR:src/thread/thread_worker_pool_impl.hpp1244
worker_impl::runPR:src/thread/thread_worker_pool_impl.hpp1523
lock_suspendPR:src/transaction/lock_manager.c2270
epoll::epollsrc/base/epoll.cpp37
epoll::waitsrc/base/epoll.cpp54
epoll::add_descriptorsrc/base/epoll.cpp59
TIMEOUT_INFINITEsrc/base/epoll.hpp33
TIMEOUT_NOWAITsrc/base/epoll.hpp37
cubsocket::epollsrc/base/epoll.hpp42
os::resources::cpu::contextsrc/base/resources.hpp45
PRM_NAME_CSS_RECV_BUDGET_PER_CONNECTIONsrc/base/system_parameter.c791
PRM_ID_CSS_AUTO_SCALING_WINDOW_SIZEsrc/base/system_parameter.c5243
PRM_ID_CSS_RECV_BUDGET_PER_CONNECTIONsrc/base/system_parameter.c5259
PRM_ID_CSS_SEND_BUDGET_PER_CONNECTIONsrc/base/system_parameter.c5271
net_server_startsrc/communication/network_sr.c1058
css_initialize_server_interfacessrc/communication/network_sr.c1137
resultsrc/connection/buffer.hpp43
context::contextsrc/connection/connection_context.cpp71
context::context(capacity)src/connection/connection_context.cpp73
context::context()src/connection/connection_context.cpp95
context::resetsrc/connection/connection_context.cpp119
context::preparesrc/connection/connection_context.cpp121
context::resetsrc/connection/connection_context.cpp126
thread_watchersrc/connection/connection_context.hpp38
message_blockersrc/connection/connection_context.hpp45
master::contextsrc/connection/connection_context.hpp80
connection::statesrc/connection/connection_context.hpp128
connection::statesrc/connection/connection_context.hpp129
ignore_levelsrc/connection/connection_context.hpp135
connection::ignore_levelsrc/connection/connection_context.hpp136
connection::contextsrc/connection/connection_context.hpp141
connection::contextsrc/connection/connection_context.hpp142
context::m_idsrc/connection/connection_context.hpp152
context::m_recvsrc/connection/connection_context.hpp161
context::m_sendsrc/connection/connection_context.hpp177
context::m_statssrc/connection/connection_context.hpp189
context capacity ctorsrc/connection/connection_context.hpp191
DEFAULT_HEADER_DATAsrc/connection/connection_defs.h379
NET_HEADERsrc/connection/connection_defs.h391
in_methodsrc/connection/connection_defs.h444
idxsrc/connection/connection_defs.h451
pending_request_countsrc/connection/connection_defs.h515
pool::initializesrc/connection/connection_pool.cpp62
pool::finalizesrc/connection/connection_pool.cpp89
pool::claim_contextsrc/connection/connection_pool.cpp140
pool::claim_contextsrc/connection/connection_pool.cpp151
pool::retire_contextsrc/connection/connection_pool.cpp160
pool::retire_contextsrc/connection/connection_pool.cpp177
pool::try_to_lock_resourcesrc/connection/connection_pool.cpp187
pool::initialize_freelistsrc/connection/connection_pool.cpp213
pool::finalize_freelistsrc/connection/connection_pool.cpp231
pool::initialize_freelistsrc/connection/connection_pool.cpp246
pool::initialize_topologysrc/connection/connection_pool.cpp249
pool::initialize_workerssrc/connection/connection_pool.cpp269
pool::finalize_freelistsrc/connection/connection_pool.cpp272
pool::finalize_workerssrc/connection/connection_pool.cpp314
pool::initialize_coordinatorsrc/connection/connection_pool.cpp353
pool::start_coordinatorsrc/connection/connection_pool.cpp376
pool::finalize_coordinatorsrc/connection/connection_pool.cpp388
poolsrc/connection/connection_pool.hpp39
pool::freelistsrc/connection/connection_pool.hpp42
pool::claim_contextsrc/connection/connection_pool.hpp70
pool::m_mutexsrc/connection/connection_pool.hpp78
pool::m_freelistsrc/connection/connection_pool.hpp96
pool::m_freelistsrc/connection/connection_pool.hpp101
css_wakeup_handlersrc/connection/connection_sr.c3061
contextsrc/connection/connection_statistics.hpp32
statistics::contextsrc/connection/connection_statistics.hpp32
context (enum)src/connection/connection_statistics.hpp32
workersrc/connection/connection_statistics.hpp60
worker (enum)src/connection/connection_statistics.hpp60
statistics::worker::MQ_COMPLETEDsrc/connection/connection_statistics.hpp73
statistics::worker::BLOCKED_RMUTEXsrc/connection/connection_statistics.hpp84
worker_to_stringsrc/connection/connection_statistics.hpp93
metricssrc/connection/connection_statistics.hpp111
metricssrc/connection/connection_statistics.hpp112
metrics::operator-src/connection/connection_statistics.hpp230
metrics::addsrc/connection/connection_statistics.hpp264
css_conn_entry::add_pending_requestsrc/connection/connection_support.cpp2803
css_conn_entry::start_requestsrc/connection/connection_support.cpp2809
css_conn_entry::add_working_tasksrc/connection/connection_support.cpp2827
css_conn_entry::end_working_tasksrc/connection/connection_support.cpp2833
REGISTER_CONNECTION connection_workersrc/connection/connection_worker.cpp70
worker::workersrc/connection/connection_worker.cpp75
m_recv_budgetsrc/connection/connection_worker.cpp92
m_send_budgetsrc/connection/connection_worker.cpp93
statistics timer registersrc/connection/connection_worker.cpp126
worker::enqueuesrc/connection/connection_worker.cpp160
worker::notifysrc/connection/connection_worker.cpp182
worker::enqueue_and_notifysrc/connection/connection_worker.cpp218
worker::push_task_into_worker_poolsrc/connection/connection_worker.cpp288
worker::purge_stale_contextssrc/connection/connection_worker.cpp294
worker::wakeup_blocked_workersrc/connection/connection_worker.cpp320
worker::is_wait_requiredsrc/connection/connection_worker.cpp330
worker::requires_client_infosrc/connection/connection_worker.cpp330
worker::has_remaining_taskssrc/connection/connection_worker.cpp340
worker::is_registering_clientsrc/connection/connection_worker.cpp340
worker::has_remaining_taskssrc/connection/connection_worker.cpp345
worker::start_connection_closesrc/connection/connection_worker.cpp360
worker::end_connection_closesrc/connection/connection_worker.cpp380
worker::handle_connection_closesrc/connection/connection_worker.cpp386
worker::retry_connection_closesrc/connection/connection_worker.cpp391
worker::handle_connection_closesrc/connection/connection_worker.cpp419
m_stats.sub CLIENT_NUMsrc/connection/connection_worker.cpp533
statistics_metrics_to_coordinatorsrc/connection/connection_worker.cpp562
worker::eventfd_registersrc/connection/connection_worker.cpp632
worker::eventfd_registersrc/connection/connection_worker.cpp650
worker::eventfd_clearsrc/connection/connection_worker.cpp658
worker::eventfd_starttimersrc/connection/connection_worker.cpp724
worker::eventfd_addtimersrc/connection/connection_worker.cpp753
worker::eventfd_removetimersrc/connection/connection_worker.cpp785
worker::eventfd_handlersrc/connection/connection_worker.cpp817
worker::handle_message_queue_send_packetsrc/connection/connection_worker.cpp880
worker::handle_message_queue_send_packetsrc/connection/connection_worker.cpp929
worker::handle_message_queue_release_packetsrc/connection/connection_worker.cpp980
worker::handle_message_queue_new_clientsrc/connection/connection_worker.cpp1016
worker::handle_message_queue_release_packetsrc/connection/connection_worker.cpp1048
ctx->m_stats.set OPEND_NSsrc/connection/connection_worker.cpp1055
worker::handle_message_queue_new_clientsrc/connection/connection_worker.cpp1101
worker::handle_message_queue_by_indexsrc/connection/connection_worker.cpp1297
worker::handle_message_queue_shutdown_clientsrc/connection/connection_worker.cpp1312
worker::handle_message_queuesrc/connection/connection_worker.cpp1356
worker::handle_message_queuesrc/connection/connection_worker.cpp1462
worker::handle_data_packetsrc/connection/connection_worker.cpp1535
worker::handle_command_header_packetsrc/connection/connection_worker.cpp1545
worker::handle_receptionsrc/connection/connection_worker.cpp1694
m_stats.add PACKET_COUNTsrc/connection/connection_worker.cpp1731
m_stats.add BLOCKED_RMUTEXsrc/connection/connection_worker.cpp1743
worker::handle_transmissionsrc/connection/connection_worker.cpp1782
worker::handle_exhausted_add_contextsrc/connection/connection_worker.cpp1837
worker::handle_exhaustedsrc/connection/connection_worker.cpp1854
worker::initializesrc/connection/connection_worker.cpp1943
worker::finalizesrc/connection/connection_worker.cpp1975
worker::runsrc/connection/connection_worker.cpp2007
worker::attachsrc/connection/connection_worker.cpp2107
workersrc/connection/connection_worker.hpp52
worker::statussrc/connection/connection_worker.hpp55
worker::timer_typesrc/connection/connection_worker.hpp63
worker::timer_latencysrc/connection/connection_worker.hpp74
worker::timer_handlesrc/connection/connection_worker.hpp82
worker::exhausted_contextsrc/connection/connection_worker.hpp90
exhausted_contextsrc/connection/connection_worker.hpp90
worker::queue_typesrc/connection/connection_worker.hpp98
queue_typesrc/connection/connection_worker.hpp98
worker::message_typesrc/connection/connection_worker.hpp106
message_typesrc/connection/connection_worker.hpp106
worker::messagesrc/connection/connection_worker.hpp130
worker::m_parentsrc/connection/connection_worker.hpp207
m_contextsrc/connection/connection_worker.hpp221
worker::m_timer_handlersrc/connection/connection_worker.hpp230
worker::m_queuesrc/connection/connection_worker.hpp237
m_queuesrc/connection/connection_worker.hpp237
worker::m_queue_sizesrc/connection/connection_worker.hpp241
m_queue_sizesrc/connection/connection_worker.hpp241
m_removed_contextsrc/connection/connection_worker.hpp246
worker::m_exhaustedsrc/connection/connection_worker.hpp248
m_exhaustedsrc/connection/connection_worker.hpp248
m_exhaustedsrc/connection/connection_worker.hpp251
worker::m_statssrc/connection/connection_worker.hpp251
controllersrc/connection/controller.hpp43
controller::recvsrc/connection/controller.hpp170
controller::sendsrc/connection/controller.hpp193
EWMA_ALPHAsrc/connection/coordinator.cpp41
VAL_TO_SCOREsrc/connection/coordinator.cpp43
EVAL_WORKERsrc/connection/coordinator.cpp44
EVAL_CONTEXTsrc/connection/coordinator.cpp45
REGISTER_CONNECTION coordinatorsrc/connection/coordinator.cpp55
coordinator::coordinatorsrc/connection/coordinator.cpp57
coordinator::random_bitsrc/connection/coordinator.cpp229
coordinator::transfer_connectionsrc/connection/coordinator.cpp237
coordinator::scale_upsrc/connection/coordinator.cpp281
coordinator::scale_down_finishsrc/connection/coordinator.cpp317
coordinator::scale_downsrc/connection/coordinator.cpp348
coordinator::scale_trialsrc/connection/coordinator.cpp378
coordinator::scale_selectionsrc/connection/coordinator.cpp415
coordinator::statistics_EWMAsrc/connection/coordinator.cpp447
statistics_EWMAsrc/connection/coordinator.cpp447
coordinator::statistics_find_score_extremessrc/connection/coordinator.cpp460
coordinator::statistics_update_scoresrc/connection/coordinator.cpp482
statistics_update_scoresrc/connection/coordinator.cpp482
coordinator::statistics_update_connectionsrc/connection/coordinator.cpp502
statistics_update_connectionsrc/connection/coordinator.cpp502
coordinator::statistics_update_tasksrc/connection/coordinator.cpp545
statistics_update_tasksrc/connection/coordinator.cpp545
coordinator::statistics_updatesrc/connection/coordinator.cpp578
coordinator::statistics_rebalancingsrc/connection/coordinator.cpp586
coordinator::statistics_scalingsrc/connection/coordinator.cpp629
statistics_scalingsrc/connection/coordinator.cpp629
statistics_printsrc/connection/coordinator.cpp697
coordinator::eventfd_registersrc/connection/coordinator.cpp758
coordinator::eventfd_addtimersrc/connection/coordinator.cpp865
coordinator::handle_message_queue_startsrc/connection/coordinator.cpp929
coordinator::handle_message_queue_new_clientsrc/connection/coordinator.cpp934
coordinator::handle_message_queue_new_clientsrc/connection/coordinator.cpp944
coordinator::handle_message_queue_return_to_poolsrc/connection/coordinator.cpp991
coordinator::handle_message_queue_statisticssrc/connection/coordinator.cpp1032
handle_message_queue_statisticssrc/connection/coordinator.cpp1032
handle_controller_request SHOW_STATSsrc/connection/coordinator.cpp1133
coordinator::initializesrc/connection/coordinator.cpp1192
coordinator::finalizesrc/connection/coordinator.cpp1225
coordinator::runsrc/connection/coordinator.cpp1240
coordinator::run (m_timens)src/connection/coordinator.cpp1260
coordinator::run (timerfd dispatch)src/connection/coordinator.cpp1279
coordinator::attachsrc/connection/coordinator.cpp1314
coordinatorsrc/connection/coordinator.hpp43
statussrc/connection/coordinator.hpp46
worker_statisticssrc/connection/coordinator.hpp52
worker_statisticssrc/connection/coordinator.hpp54
scaling_statussrc/connection/coordinator.hpp75
scaling_statussrc/connection/coordinator.hpp77
scaling_directionsrc/connection/coordinator.hpp81
scaling_directionsrc/connection/coordinator.hpp83
scaling_statisticssrc/connection/coordinator.hpp89
timer_latencysrc/connection/coordinator.hpp95
timer_typesrc/connection/coordinator.hpp103
timer_handlesrc/connection/coordinator.hpp113
control_typesrc/connection/coordinator.hpp123
control_recvsrc/connection/coordinator.hpp140
control_sendsrc/connection/coordinator.hpp148
message_typesrc/connection/coordinator.hpp155
coordinator::messagesrc/connection/coordinator.hpp167
messagesrc/connection/coordinator.hpp171
m_timer_handlersrc/connection/coordinator.hpp245
m_controllersrc/connection/coordinator.hpp249
m_queuesrc/connection/coordinator.hpp256
m_queue_sizesrc/connection/coordinator.hpp260
m_scalingsrc/connection/coordinator.hpp261
m_current_workersrc/connection/coordinator.hpp265
m_migratingsrc/connection/coordinator.hpp268
m_scaling_statisticssrc/connection/coordinator.hpp269
m_scalingsrc/connection/coordinator.hpp271
m_scaling_statisticssrc/connection/coordinator.hpp279
m_task_statisticssrc/connection/coordinator.hpp284
m_task_statisticssrc/connection/coordinator.hpp294
m_statisticssrc/connection/coordinator.hpp294
m_statisticssrc/connection/coordinator.hpp304
statistics_print (decl)src/connection/coordinator.hpp335
statistics_EWMAsrc/connection/coordinator.hpp386
receiver::receive_in_allocatedsrc/connection/receiver.cpp311
receiver BYTES_IN_TOTAL addsrc/connection/receiver.cpp330
receiver::drainsrc/connection/receiver.cpp430
RECV_BUDGET_HITsrc/connection/receiver.cpp475
receiver RECV_BUDGET_HIT addsrc/connection/receiver.cpp475
receiversrc/connection/receiver.hpp38
receiver::m_statssrc/connection/receiver.hpp64
css_server_tasksrc/connection/server_support.c144
css_job_queues_start_scansrc/connection/server_support.c251
REGISTER_WORKERPOOLsrc/connection/server_support.c550
css_initsrc/connection/server_support.c554
thread_create_stats_worker_poolsrc/connection/server_support.c581
connections.initializesrc/connection/server_support.c595
connections.finalizesrc/connection/server_support.c611
css_push_server_tasksrc/connection/server_support.c2354
css_server_task::executesrc/connection/server_support.c2376
css_get_thread_statssrc/connection/server_support.c2636
css_get_task_statssrc/connection/server_support.c2647
css_wp_worker_get_busy_count_mappersrc/connection/server_support.c2669
css_wp_core_job_scan_mappersrc/connection/server_support.c2695
transmitter::fillsrc/connection/transmitter.cpp66
SEND_BUDGET_HITsrc/connection/transmitter.cpp78
transmitter SEND_BUDGET_HIT addsrc/connection/transmitter.cpp78
transmitter BYTES_OUT_TOTAL addsrc/connection/transmitter.cpp86
transmittersrc/connection/transmitter.hpp38
mainsrc/executables/server.c276
metadata_of_job_queuessrc/parser/show_meta.c528
entry_tasksrc/thread/thread_entry_task.hpp56
entry_manager::create_contextsrc/thread/thread_entry_task.hpp100
manager::alloc_entriessrc/thread/thread_manager.cpp82
manager::init_lockfree_systemsrc/thread/thread_manager.cpp115
manager::push_task_on_coresrc/thread/thread_manager.cpp180
manager::claim_entrysrc/thread/thread_manager.cpp234
manager::set_max_thread_count_from_configsrc/thread/thread_manager.cpp266
cubthread::initializesrc/thread/thread_manager.cpp315
initialize_thread_entriessrc/thread/thread_manager.cpp378
manager::create_worker_poolsrc/thread/thread_manager.hpp367
manager::create_and_track_resourcesrc/thread/thread_manager.hpp424
manager::destroy_and_untrack_resourcesrc/thread/thread_manager.hpp445
task::retiresrc/thread/thread_task.hpp75
condvar_waitsrc/thread/thread_waiter.hpp164
worker_poolsrc/thread/thread_worker_pool.hpp54
get_idle_timeoutsrc/thread/thread_worker_pool.hpp91
worker_pool::coresrc/thread/thread_worker_pool.hpp123
worker_pool::core::workersrc/thread/thread_worker_pool.hpp178
system_core_countsrc/thread/thread_worker_pool_impl.cpp39
worker_pool_implsrc/thread/thread_worker_pool_impl.hpp106
m_coressrc/thread/thread_worker_pool_impl.hpp202
m_max_workerssrc/thread/thread_worker_pool_impl.hpp205
m_stoppedsrc/thread/thread_worker_pool_impl.hpp208
m_round_robin_countersrc/thread/thread_worker_pool_impl.hpp211
stats_basesrc/thread/thread_worker_pool_impl.hpp220
stats_base_truesrc/thread/thread_worker_pool_impl.hpp234
stats_base::statsetsrc/thread/thread_worker_pool_impl.hpp248
stats_base::timesrc/thread/thread_worker_pool_impl.hpp250
core_implsrc/thread/thread_worker_pool_impl.hpp263
m_workerssrc/thread/thread_worker_pool_impl.hpp319
m_available_workerssrc/thread/thread_worker_pool_impl.hpp320
m_task_queuesrc/thread/thread_worker_pool_impl.hpp321
m_workers_mutexsrc/thread/thread_worker_pool_impl.hpp323
m_temp_workerssrc/thread/thread_worker_pool_impl.hpp326
m_free_temp_workerssrc/thread/thread_worker_pool_impl.hpp327
m_temp_workers_mutexsrc/thread/thread_worker_pool_impl.hpp329
worker_implsrc/thread/thread_worker_pool_impl.hpp339
worker_impl::assign_tasksrc/thread/thread_worker_pool_impl.hpp354
worker_impl::assign_task_voidsrc/thread/thread_worker_pool_impl.hpp356
m_context_psrc/thread/thread_worker_pool_impl.hpp395
worker_impl (fields)src/thread/thread_worker_pool_impl.hpp395
m_wrapped_tasksrc/thread/thread_worker_pool_impl.hpp396
m_task_cvsrc/thread/thread_worker_pool_impl.hpp399
m_task_mutexsrc/thread/thread_worker_pool_impl.hpp401
m_stopsrc/thread/thread_worker_pool_impl.hpp403
m_has_threadsrc/thread/thread_worker_pool_impl.hpp404
m_is_tempsrc/thread/thread_worker_pool_impl.hpp406
m_statssrc/thread/thread_worker_pool_impl.hpp408
wrapped_tasksrc/thread/thread_worker_pool_impl.hpp417
wrapped_task::task_onlysrc/thread/thread_worker_pool_impl.hpp420
wrapped_task::task_with_statssrc/thread/thread_worker_pool_impl.hpp425
wrapped_task::inner_typesrc/thread/thread_worker_pool_impl.hpp434
wrapped_task::m_innersrc/thread/thread_worker_pool_impl.hpp453
statssrc/thread/thread_worker_pool_impl.hpp461
stats::idsrc/thread/thread_worker_pool_impl.hpp464
stats::statdefsrc/thread/thread_worker_pool_impl.hpp479
worker_pool_impl::worker_pool_implsrc/thread/thread_worker_pool_impl.hpp525
worker_pool_impl::initializesrc/thread/thread_worker_pool_impl.hpp552
worker_pool_impl::executesrc/thread/thread_worker_pool_impl.hpp558
worker_pool_impl::execute_on_coresrc/thread/thread_worker_pool_impl.hpp565
worker_pool_impl::execute_on_coresrc/thread/thread_worker_pool_impl.hpp567
worker_pool_impl::is_runningsrc/thread/thread_worker_pool_impl.hpp577
worker_pool_impl::stop_executionsrc/thread/thread_worker_pool_impl.hpp592
worker_pool_impl::stop_executionsrc/thread/thread_worker_pool_impl.hpp594
worker_pool_impl::get_statssrc/thread/thread_worker_pool_impl.hpp670
worker_pool_impl::assign_workers_to_coressrc/thread/thread_worker_pool_impl.hpp758
worker_pool_impl::get_next_coresrc/thread/thread_worker_pool_impl.hpp780
worker_pool_impl::get_round_robin_core_hashsrc/thread/thread_worker_pool_impl.hpp787
core_impl::execute_tasksrc/thread/thread_worker_pool_impl.hpp894
worker_pool_impl::core_impl::execute_tasksrc/thread/thread_worker_pool_impl.hpp896
core_impl::warmupsrc/thread/thread_worker_pool_impl.hpp946
core_impl::stop_executionsrc/thread/thread_worker_pool_impl.hpp969
core_impl::retire_queued_taskssrc/thread/thread_worker_pool_impl.hpp998
core_impl::get_task_or_become_availablesrc/thread/thread_worker_pool_impl.hpp1010
core_impl::become_availablesrc/thread/thread_worker_pool_impl.hpp1030
core_impl::become_availablesrc/thread/thread_worker_pool_impl.hpp1032
core_impl::check_worker_not_availablesrc/thread/thread_worker_pool_impl.hpp1040
core_impl::check_worker_not_availablesrc/thread/thread_worker_pool_impl.hpp1042
core_impl::register_free_temp_listsrc/thread/thread_worker_pool_impl.hpp1063
core_impl::free_all_temp_listsrc/thread/thread_worker_pool_impl.hpp1080
core_impl::get_statssrc/thread/thread_worker_pool_impl.hpp1089
core_impl::initialize_workerssrc/thread/thread_worker_pool_impl.hpp1170
core_impl::execute_task_as_tempsrc/thread/thread_worker_pool_impl.hpp1194
worker_pool_impl::core_impl::worker_impl::assign_tasksrc/thread/thread_worker_pool_impl.hpp1267
worker_impl::assign_tasksrc/thread/thread_worker_pool_impl.hpp1267
worker_impl::stop_executionsrc/thread/thread_worker_pool_impl.hpp1326
worker_impl::runsrc/thread/thread_worker_pool_impl.hpp1387
worker_impl::init_runsrc/thread/thread_worker_pool_impl.hpp1430
worker_impl::finish_runsrc/thread/thread_worker_pool_impl.hpp1457
worker_impl::execute_current_tasksrc/thread/thread_worker_pool_impl.hpp1479
worker_impl::retire_current_tasksrc/thread/thread_worker_pool_impl.hpp1507
worker_impl::get_new_tasksrc/thread/thread_worker_pool_impl.hpp1521
stats::statdefsrc/thread/thread_worker_pool_impl.hpp1673
worker_pool_task_capper::worker_pool_task_cappersrc/thread/thread_worker_pool_taskcap.cpp34
worker_pool_task_capper::try_tasksrc/thread/thread_worker_pool_taskcap.cpp44
worker_pool_task_capper::push_tasksrc/thread/thread_worker_pool_taskcap.cpp60
worker_pool_task_capper::executesrc/thread/thread_worker_pool_taskcap.cpp74
worker_pool_task_capper::end_tasksrc/thread/thread_worker_pool_taskcap.cpp83
capped_task::~capped_tasksrc/thread/thread_worker_pool_taskcap.cpp106
capped_task::executesrc/thread/thread_worker_pool_taskcap.cpp112
worker_pool_task_cappersrc/thread/thread_worker_pool_taskcap.hpp30
worker_pool_task_capper::capped_tasksrc/thread/thread_worker_pool_taskcap.hpp55
boot_restart_server initialize_thread_entriessrc/transaction/boot_sr.c1638
  • 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.