콘텐츠로 이동

(KO) PostgreSQL 병렬 질의 — Gather, 워커, DSM 플랜

목차

아무리 잘 최적화된 단일 질의라도 고전적인 요구 풀 이터레이터 모델에서는 CPU 코어 하나 위에서 실행된다(postgres-executor.md 참조). 수백만 행을 대상으로 하는 대규모 스캔이나 집계가 병목이 될 때, 더 빨라지는 유일한 방법은 여러 코어에 작업을 나누는 것이다. Database System Concepts(Silberschatz, 7판, 22장 “Parallel and Distributed Query Processing”)는 이를 **인트라질의 병렬성(intraquery parallelism)**으로 정의한다. “단일 질의 실행의 서로 다른 부분을 여러 노드에서 병렬로 처리하는 것”이며, 여러 독립 질의를 동시에 실행하는 인터질의 병렬성(interquery parallelism)과 구별된다. 교재는 “인트라질의 병렬성은 장시간 실행 질의의 속도를 높이는 데 필수적이다”(§22.1)라고 명시한다.

교재는 인트라질의 병렬성을 두 가지 상호 보완적인 원천으로 나눈다.

  • 인트라연산 병렬성(intraoperation parallelism) (§22.1, §22.2–22.4). 스캔·조인·집계 같은 단일 관계 연산자를 입력을 노드에 분할해 병렬로 실행한다. “릴레이션의 튜플 수가 많을 수 있으므로 인트라연산 병렬성의 정도도 잠재적으로 매우 크다.” 범위 분할된 릴레이션에 대한 정렬이라면 각 파티션을 독립적으로 정렬한 뒤 결과를 이어 붙이는 방식이 그 예다.
  • 인터연산 병렬성(interoperation parallelism) (§22.5.1). 한 질의의 서로 다른 연산자를 동시에 실행한다. 연산자 B가 A의 출력을 A가 생산하는 즉시 다른 코어에서 소비하는 파이프라인 방식과, 의존성 없는 두 서브트리를 동시에 실행하는 독립 방식이 있다. 교재는 파이프라인 병렬성이 “파이프라인 체인이 짧고 지연이 전파되기 때문에 확장성이 낮다”고 솔직하게 평가하며, “병렬성 수준이 높을 때 파이프라이닝은 분할보다 덜 중요한 병렬성 원천”이라고 결론짓는다(§22.5.1.1).

교재가 최종적으로 채택하는 통합 메커니즘은 Graefe의 Volcano 시스템이 기여한 교환 연산자(exchange operator)(§22.5, §22.5.2)다.

“병렬 질의 실행 모델로, 교환 연산자를 사용한 데이터 분할과 순수 로컬 데이터에 대한 연산 실행이라는 두 종류의 단계로 병렬 질의 처리를 분해한다. 데이터 교환이 전혀 없다. 이 모델은 놀랍도록 강력하며 병렬 데이터베이스 구현에 널리 사용된다.” (DSC §22.5)

교환 연산자가 핵심 추상화인 이유는 병렬성을 하나의 연산자 유형 안에 캡슐화하기 때문이다. 플랜의 다른 모든 연산자는 “순수 로컬 데이터”를 처리하는 순차적 연산자로 작성된다. 교환 연산자만이 분할, 프로세스 간 데이터 이동, 여러 스트림을 하나로 병합하는 방법을 안다. 순차 연산자 트리에 교환 연산자를 하나 삽입하면, 다른 연산자를 수정하지 않고도 트리가 병렬화된다. Graefe의 1990년 논문 제목이 바로 “Volcano 질의 처리 시스템에서의 병렬성 캡슐화”다.

소스를 읽기 전에 짚어야 할 교환 모델의 세 가지 특성이 있다.

  1. 생산자와 소비자. 교환 연산자는 각각 아래 서브플랜의 동일한 복사본을 실행하는 N개의 생산자 프로세스와, 그 출력 스트림을 하나로 모으는 소비자 측을 갖는다. 생산자는 공유 클러스터의 네트워크 소켓이나 멀티코어 박스의 공유 메모리로 튜플을 소비자에게 전달한다.
  2. 서브플랜은 무지하다. 교환 연산자 아래의 서브플랜은 자신이 N개의 복사본 중 하나임을 모른다. 정확성은 N개의 복사본이 합산하여 전체 결과를 중복 없이 빠짐없이 생성한다는 보장에 달려 있으며, 이는 리프 스캔이 입력을 어떻게 분할하느냐의 문제지 위의 연산자들의 문제가 아니다.
  3. 순서는 별개의 관심사다. 단순한 gather는 튜플이 도착하는 순서를 보장하지 않는다. 질의에 정렬된 출력이 필요하다면, 교환 연산자는 스트림을 단순히 이어 붙이는 대신 이미 정렬된 생산자 스트림을 병합해야 한다.

PostgreSQL은 이 모델을 멀티코어 공유 메모리 환경으로 충실하게 구현한 결과다. 교환 연산자는 Gather 노드(및 순서 보존 변형인 GatherMerge)이고, N개의 생산자는 백그라운드 워커 프로세스이며, 전송 수단은 공유 메모리 큐(shm_mq)다. “서브플랜은 무지하다”는 특성은 Gather 아래 부분 플랜의 parallel-aware 플래그로 구현된다. 이 문서의 나머지 부분은 REL_18 소스에서 그 조각들을 추적한다.

교재는 모델을 제시한다. 그 외에는 순수 로컬 연산자 위에 놓이는 교환 연산자다. 이 절은 멀티코어 공유 메모리 엔진이 그 모델을 동작하게 만들기 위해 채택하는 엔지니어링 관례를 정리한다. 교재의 공유 없는(shared-nothing) 프레임이 암묵적으로 남겨 두는 문제들이다. 다음 절에서 PostgreSQL의 구체적 선택은 이 공간 안의 한 지점으로 읽으면 된다.

프로세스(또는 스레드) 더하기 공유 전송

섹션 제목: “프로세스(또는 스레드) 더하기 공유 전송”

생산자들이 동시에 실행되려면 별도의 스레드나 프로세스여야 한다. 소비자에게 튜플을 저렴하게 전달하려면 소켓을 거쳐 복사하는 대신 인메모리 링 버퍼를 공유한다. 워커-당-스레드 엔진은 전체 주소 공간을 공짜로 공유하지만 취약한 전역 상태와 락 비용을 치른다. 워커-당-프로세스 엔진은 장애 격리와 단일 스레드 익스큐터 재사용이라는 이점을 누리는 대신, 워커가 필요로 하는 모든 상태를 명시적으로 복사해야 한다. PostgreSQL은 확고하게 프로세스 진영에 속한다. 병렬 워커는 완전한 백엔드 프로세스이며, 이것이 PostgreSQL 병렬 메커니즘의 대부분이 상태 전달에 관한 이유다.

워커 프로세스는 빈 상태로 시작한다. 동일한 플랜을 실행하고 동일한 행을 보려면, 플랜 자체, 바인딩된 파라미터, MVCC 스냅샷, 진행 중인 트랜잭션 ID 집합(가시성 검사 일치를 위해), 콤보 CID 매핑, GUC 설정, 인증된 사용자, 로드된 라이브러리를 전달받아야 한다. 관례는 이 모든 것을 워커가 읽을 수 있는 공유 세그먼트에 직렬화하고, 각 소비자가 잘 알려진 키로 자신의 조각을 찾을 수 있도록 작은 목차(table-of-contents) 색인을 두는 것이다. 플랜은 엔진 범용 노드 트리 직렬화기로, 스냅샷과 트랜잭션 상태는 전용 루틴으로 직렬화된다.

공유 세그먼트의 두 단계 크기 결정

섹션 제목: “공유 세그먼트의 두 단계 크기 결정”

공유 세그먼트는 워커가 존재하기 전에 하나의 고정 크기로 할당되어야 한다. 그런데 크기는 플랜(DSM 상태가 필요한 노드 수, 직렬화된 플랜의 크기)에 의존한다. 관례는 연산자 트리를 두 번 순회하는 것이다. 먼저 추정 단계에서 모든 노드의 공간 요청을 합산하고, 할당한 다음, 초기화 단계에서 할당된 공간을 채운다. 두 단계는 동일한 노드 집합을 동일한 순서로 방문하므로 오프셋이 정확히 맞는다.

워커에 쓰기를 허용하면 새 XID, 커맨드 카운터 증가, 콤보 CID를 비행 중에 리더와 다른 워커들에게 역전파해야 한다. 저렴한 해법이 없는 동기화 문제다. 1세대 병렬 익스큐터의 보편적 관례는 병렬 모드를 읽기 전용으로 만드는 것이다. INSERT/UPDATE/DELETE, DDL, XID 할당은 불가능하다. 명시적인 enter/exit parallel mode 브래킷이 위반을 잡는 오류 검사를 활성화한다.

우아한 성능 저하와 최선 노력 워커

섹션 제목: “우아한 성능 저하와 최선 노력 워커”

워커 슬롯은 유한한 공유 자원이다(max_worker_processes). 병렬 플랜은 워커를 하나도 얻지 못해도 올바르게 실행되어야 한다. 소비자는 플랜을 직접 실행하는 폴백 모드로 전환한다. 워커 시작은 최선 노력 방식이다. N개를 요청하고, 포스트마스터가 승인한 수만큼 받아들이며, 리더가 N+1번째 생산자로 선택적으로 참여한다.

오류가 발생한 워커가 단순히 exit(1)하면 리더는 튜플을 기다리며 영원히 블록된다. 관례는 전용 오류 채널(튜플 채널과 분리)과 리더에게 그것을 소진하도록 알리는 시그널을 두는 것이다. 리더는 다음 인터럽트 검사 지점에서 워커의 오류를 자신의 오류로 재발생시키므로, 사용자 관점에서 병렬 오류는 “그냥 동작”한다.

PostgreSQL 심볼을 만났을 때 그것이 어떤 종류의 것인지 즉시 파악할 수 있도록 매핑표를 제시한다.

이론 / 관례PostgreSQL 이름
교환 연산자 (gather 측)Gather / GatherState (ExecGather)
순서 보존 교환GatherMerge / GatherMergeState (ExecGatherMerge)
“서브플랜은 무지하다” / 병렬 생산자plan->parallel_aware가 true인 부분 플랜
생산자 프로세스ParallelWorkerMainParallelQueryMain을 실행하는 백그라운드 워커
튜플용 공유 전송워커별 shm_mq 튜플 큐 (PARALLEL_TUPLE_QUEUE_SIZE = 64 KB)
와이어상 튜플 형식MinimalTuple(트랜잭션 헤더 없음), TupleQueueReader 경유
직렬화된 리더 상태 세그먼트InitializeParallelDSM이 생성하는 DSM 세그먼트
목차 색인PARALLEL_KEY_* 매직 넘버로 키잉되는 shm_toc
직렬화된 플랜ExecSerializePlannodeToString(PlannedStmt)
두 단계 크기 결정ExecParallelEstimateExecParallelInitializeDSM
노드별 공유 스크래치 (스캔 커서 등)DSA 영역 (PARALLEL_KEY_DSA, dsa_create_in_place)
읽기 전용 브래킷EnterParallelMode / ExitParallelMode
우아한 성능 저하need_to_scan_locally, parallel_leader_participation
대역 외 오류오류 shm_mq + PROCSIG_PARALLEL_MESSAGE + ProcessParallelMessages
실행별 병렬 핸들ParallelExecutorInfo (pei) / ParallelContext (pcxt)

프로세스 시작과 DSM/shm_mq 배관의 일반적인 부분 — ParallelContext, InitializeParallelDSM, shm_mq, 백그라운드 워커 등록 — 은 postgres-shared-memory-ipc.md에서 다룬다. 이 문서는 익스큐터가 그것을 사용하는 방식을 다룬다. Gather가 왜 플랜에 나타나는지, 부분 경로(partial path)의 비용이 어떻게 계산되는지는 postgres-planner-overview.mdpostgres-path-generation.md의 영역이다. 워커 각각이 실행하는 단일 프로세스 이터레이터 모델은 postgres-executor.md다. 이 문서는 그 이음새를 다룬다. Gather가 부분 플랜을 DSM 직렬화된 작업으로 어떻게 바꾸는지, 워커가 어떻게 시작되고 동기화되는지, 리더와 워커 튜플 스트림이 어떻게 하나로 합쳐지는지가 그 내용이다.

PostgreSQL 병렬 질의는 교환 모델을 멀티코어 공유 메모리 박스에 구현한 결과이며, 다섯 가지 설계 결정이 그 형태를 결정한다.

  1. 교환 연산자는 Gather 노드다. 플래너가 부분 플랜 위에 삽입한다. 부분 서브트리의 리프 스캔은 parallel-aware다. 예를 들어 공유 카운터에서 블록 범위를 할당받는 병렬 SeqScanN개의 동일한 복사본이 릴레이션 전체를 정확히 한 번 커버한다. GatherMerge는 워커 스트림을 힙 병합해 입력 정렬 순서를 보존하는 변형이다.
  2. 워커는 완전한 백엔드 프로세스로, 첫 실행 시 지연 포크된다. ExecGatherExecInitNode 시점에 병렬 작업을 전혀 하지 않는다. 첫 튜플을 요청받을 때만 DSM 세그먼트를 구축하고 포스트마스터에 워커 시작을 요청한다. “대형 동적 세그먼트를 할당해야 하므로, 실제로 필요할 때만 하는 것이 낫다”는 판단이다.
  3. 플랜과 모든 리더 상태는 shm_toc로 색인된 DSM 세그먼트에 담겨 전달된다. ExecInitParallelPlannodeToString으로 더미 PlannedStmt를 직렬화하고, PlanState 트리를 두 번 순회해 세그먼트 크기를 결정하며, 일반적인 InitializeParallelDSM이 GUC, 스냅샷, 트랜잭션 XID 집합, 콤보 CID, 라이브러리 — 워커가 리더와 동일한 가시성 검사를 수행하는 데 필요한 모든 것 — 를 추가한다.
  4. 튜플은 워커별 shm_mq 큐를 거쳐 MinimalTuple 형태로 리더 방향으로 흐른다. 각 워커는 자신의 64 KB 공유 메모리 링의 송신자이고, 리더는 모든 링의 수신자로서 라운드로빈으로 소진한다. 최소 튜플(minimal tuple)은 트랜잭션 헤더를 갖지 않으므로 프로세스 경계를 저렴하게 넘기 딱 좋은 형태다.
  5. 병렬 모드는 엄격히 읽기 전용이며 최선 노력이다. EnterParallelMode가 쓰기를 금지하는 검사를 활성화한다. 리더는 요청보다 적은 워커(0개까지)를 받아도 허용하며, parallel_leader_participation이 켜져 있으면 리더 자신도 생산자 역할을 한다.

플랜 형태: Gather 위에 부분 플랜

섹션 제목: “플랜 형태: Gather 위에 부분 플랜”

병렬 플랜은 Gather(또는 GatherMerge) 노드가 삽입된 일반적인 직렬 플랜이다. Gather 아래의 모든 것은 각 워커에서 실행되고, 의 모든 것은 리더에서만 실행된다. 직접 아래의 노드는 보통 리프가 parallel-aware인 부분 경로다.

flowchart TB
  subgraph LEADER["리더에서만 실행 (Gather 위)"]
    AGG["Finalize Aggregate"]
    GATH["Gather<br/>num_workers=2<br/>(교환 연산자)"]
    AGG --> GATH
  end
  subgraph WORKERS["부분 플랜 — 워커별 동일한 복사본 (+ 리더)"]
    PAGG["Partial Aggregate"]
    PSCAN["Parallel SeqScan<br/>(parallel_aware = true)<br/>공유 카운터에서 블록 범위 할당"]
    PAGG --> PSCAN
  end
  GATH -->|"DSM 직렬화된 플랜;<br/>튜플은 shm_mq로 반환"| PAGG

그림 1 — 병렬 집계. Gather가 교환 연산자다. 아래의 부분 서브트리는 각 워커에(선택적으로 리더에도) 복사된다. Parallel-aware SeqScan은 공유 카운터에서 워커마다 다른 블록 범위를 할당해 복사본들이 함께 모든 블록을 정확히 한 번 스캔하도록 한다. 리더의 Finalize Aggregate가 워커들의 부분 집계를 결합한다.

ExecInitGather — 깔때기 설정, 워커는 지연

섹션 제목: “ExecInitGather — 깔때기 설정, 워커는 지연”

ExecInitGatherGatherState를 구축하고, 결정적으로 외부(자식) 플랜을 초기화하지만 워커는 시작하지 않는다. 모든 워커 튜플이 저장될 깔때기 슬롯(funnel slot)MinimalTuple 기반 슬롯 — 도 설정한다. 튜플이 그 형태로 도착하기 때문이다.

// ExecInitGather — src/backend/executor/nodeGather.c (condensed)
gatherstate = makeNode(GatherState);
gatherstate->ps.plan = (Plan *) node;
gatherstate->ps.state = estate;
gatherstate->ps.ExecProcNode = ExecGather; /* the next() function */
gatherstate->initialized = false; /* workers launched lazily */
gatherstate->need_to_scan_locally =
!node->single_copy && parallel_leader_participation;
gatherstate->tuples_needed = -1;
/* now initialize outer plan (the partial subtree) */
outerNode = outerPlan(node);
outerPlanState(gatherstate) = ExecInitNode(outerNode, estate, eflags);
tupDesc = ExecGetResultType(outerPlanState(gatherstate));
/* Initialize funnel slot to same tuple descriptor as outer plan. */
gatherstate->funnel_slot = ExecInitExtraTupleSlot(estate, tupDesc,
&TTSOpsMinimalTuple);

두 가지 설계 사실이 드러난다. 첫째, initialized = false다. DSM과 워커 장치 전체는 첫 ExecGather 호출 시 구축되며 여기서는 하지 않는다. 둘째, need_to_scan_locallyparallel_leader_participation 정책을 기록한다. 리더도 생산자로 참여할지를 나타내지만, 최종 값은 실제 워커 수를 알게 되는 시작 후에 재계산된다.

ExecGather — 지연 시작, 그 다음 깔때기에서 풀기

섹션 제목: “ExecGather — 지연 시작, 그 다음 깔때기에서 풀기”

ExecGather는 교환 연산자의 next() 함수다. 첫 호출에서 병렬 장치를 가동하고, 매 호출마다 gather_getnext에서 튜플 하나를 꺼내어 선택적으로 프로젝션한다.

// ExecGather — src/backend/executor/nodeGather.c (condensed)
if (!node->initialized)
{
EState *estate = node->ps.state;
Gather *gather = (Gather *) node->ps.plan;
if (gather->num_workers > 0 && estate->es_use_parallel_mode)
{
ParallelContext *pcxt;
/* Build (or rebuild) the shared state the workers need. */
if (!node->pei)
node->pei = ExecInitParallelPlan(outerPlanState(node), estate,
gather->initParam,
gather->num_workers,
node->tuples_needed);
else
ExecParallelReinitialize(outerPlanState(node), node->pei,
gather->initParam);
pcxt = node->pei->pcxt;
LaunchParallelWorkers(pcxt); /* ask postmaster to fork */
node->nworkers_launched = pcxt->nworkers_launched;
if (pcxt->nworkers_launched > 0)
{
ExecParallelCreateReaders(node->pei); /* one reader per worker */
node->nreaders = pcxt->nworkers_launched;
node->reader = palloc(node->nreaders * sizeof(TupleQueueReader *));
memcpy(node->reader, node->pei->reader,
node->nreaders * sizeof(TupleQueueReader *));
}
else { node->nreaders = 0; node->reader = NULL; }
node->nextreader = 0;
}
/* Run plan locally if no workers, or if leader participation is on. */
node->need_to_scan_locally = (node->nreaders == 0)
|| (!gather->single_copy && parallel_leader_participation);
node->initialized = true;
}

우아한 성능 저하 관례가 바로 여기 있다. num_workers > 0 && es_use_parallel_mode이더라도 nworkers_launched == 0이 될 수 있다. 이 경우 need_to_scan_locally가 true가 되어 리더가 플랜을 직접 실행한다. single_copy 플래그는 리더가 스캔에 참여하지 않는 워커 하나짜리 퇴화 모드다. 이 경우 자식이 parallel-aware일 필요도 없다.

gather_getnext / gather_readnext — 큐 소진, 로컬 스캔 병행

섹션 제목: “gather_getnext / gather_readnext — 큐 소진, 로컬 스캔 병행”

gather_getnext는 소비자 측의 핵심이다. 반복 루프를 돈다. 워커 튜플 시도(gather_readnext), 없으면 리더가 참여 중일 때 ExecProcNode로 로컬 플랜 복사본에서 튜플 하나를 꺼낸다. 로컬 스캔 분기는 공유 DSA 영역을 먼저 es_query_dsa에 설치한다. 리더가 지금 실행 중인 parallel-aware 리프 노드들이 스캔 커서 상태를 위해 그 공유 영역을 참조하기 때문이다.

// gather_getnext — src/backend/executor/nodeGather.c (condensed)
while (gatherstate->nreaders > 0 || gatherstate->need_to_scan_locally)
{
CHECK_FOR_INTERRUPTS();
if (gatherstate->nreaders > 0)
{
tup = gather_readnext(gatherstate); /* a worker MinimalTuple */
if (HeapTupleIsValid(tup))
{
ExecStoreMinimalTuple(tup, fslot, false); /* into the funnel slot */
return fslot;
}
}
if (gatherstate->need_to_scan_locally)
{
EState *estate = gatherstate->ps.state;
/* Install our DSA area while executing the plan. */
estate->es_query_dsa = gatherstate->pei ? gatherstate->pei->area : NULL;
outerTupleSlot = ExecProcNode(outerPlan); /* leader as producer */
estate->es_query_dsa = NULL;
if (!TupIsNull(outerTupleSlot))
return outerTupleSlot;
gatherstate->need_to_scan_locally = false; /* local copy exhausted */
}
}
return ExecClearTuple(fslot); /* everyone done */

gather_readnext에 라운드로빈 다중화 로직이 있다. 현재 워커 큐에서 논블로킹 읽기를 시도하고, 현재 큐가 블록될 때만 다음 워커로 넘어간다. 큐가 완료 신호를 보내면 활성 배열에서 해당 워커를 제거한다. 살아있는 모든 워커를 순회했는데 아무것도 없고 리더도 스캔 중이 아니면, 워커가 깨울 때까지 래치에서 슬립한다.

// gather_readnext — src/backend/executor/nodeGather.c (condensed)
for (;;)
{
CHECK_FOR_INTERRUPTS(); /* drains worker errors */
reader = gatherstate->reader[gatherstate->nextreader];
tup = TupleQueueReaderNext(reader, true, &readerdone); /* nowait=true */
if (readerdone) /* this worker is finished */
{
--gatherstate->nreaders;
if (gatherstate->nreaders == 0)
{
ExecShutdownGatherWorkers(gatherstate);
return NULL;
}
memmove(/* compact the reader array, removing this slot */);
if (gatherstate->nextreader >= gatherstate->nreaders)
gatherstate->nextreader = 0;
continue;
}
if (tup)
return tup; /* got one; keep this queue */
/* Empty for now: round-robin to the next worker. */
gatherstate->nextreader++;
if (gatherstate->nextreader >= gatherstate->nreaders)
gatherstate->nextreader = 0;
if (++nvisited >= gatherstate->nreaders) /* visited all of them */
{
if (gatherstate->need_to_scan_locally)
return NULL; /* let caller scan locally */
(void) WaitLatch(MyLatch, WL_LATCH_SET | WL_EXIT_ON_PM_DEATH, 0,
WAIT_EVENT_EXECUTE_GATHER); /* nothing to do but wait */
ResetLatch(MyLatch);
nvisited = 0;
}
}

소스 주석은 성능 조정의 묘미를 설명한다. PostgreSQL은 “매 튜플마다 nextreader 포인터를 전진시키던 방식에서 벗어났다. 블록 없이 읽을 수 있는 한 같은 큐에서 계속 읽는 것이 훨씬 효율적이다.” 한 큐에 머무는 것이 배치 지역성을 극대화하고 래치 오버헤드를 최소화한다.

flowchart TB
  subgraph LDR["리더 프로세스"]
    EG["ExecGather / gather_getnext"]
    RR["gather_readnext<br/>라운드로빈, 논블로킹"]
    LOC["로컬 스캔<br/>(parallel_leader_participation)"]
    EG --> RR
    EG --> LOC
  end
  subgraph W0["워커 0 (ParallelQueryMain)"]
    P0["부분 플랜 익스큐터 복사본"]
  end
  subgraph W1["워커 1"]
    P1["부분 플랜 익스큐터 복사본"]
  end
  P0 -->|"MinimalTuple"| Q0[["shm_mq 큐 0<br/>64 KB 링"]]
  P1 -->|"MinimalTuple"| Q1[["shm_mq 큐 1"]]
  Q0 --> RR
  Q1 --> RR
  LOC -->|"es_query_dsa 경유<br/>공유 스캔 커서"| DSA[("DSA 영역")]
  P0 -.-> DSA
  P1 -.-> DSA

그림 2 — 깔때기. 각 워커는 자신의 64 KB shm_mq의 유일한 송신자다. 리더는 모든 큐의 수신자로서 라운드로빈으로 소진하며, 모든 큐가 비어 있고 자신도 스캔 중이 아닐 때만 래치에서 파킹한다. 워커에서(그리고 참여할 때는 리더에서도) 실행되는 parallel-aware 리프 노드들은 DSA 영역의 공유 커서로 각자 어떤 힙 블록을 읽을지 조율한다.

GatherMerge는 플랜이 정렬된 출력을 필요로 할 때 사용된다. 예를 들어 각 워커 아래에 Sort가 있고 그 위에 순서 의존 노드가 있는 경우다. gather_readnext의 라운드로빈 이어 붙이기 대신, 정렬 컬럼을 키로 하는 워커별 스트림 헤드의 이진 힙을 유지한다. 매 호출마다 전역적으로 가장 작은 튜플을 꺼내고 해당 워커 슬롯을 다시 채운다. 시작 경로는 Gather와 동일하다 — ExecInitParallelPlan, LaunchParallelWorkers, ExecParallelCreateReaders.

// ExecGatherMerge — src/backend/executor/nodeGatherMerge.c (condensed)
if (!node->initialized)
{
if (gm->num_workers > 0 && estate->es_use_parallel_mode)
{
if (!node->pei)
node->pei = ExecInitParallelPlan(outerPlanState(node), estate,
gm->initParam, gm->num_workers,
node->tuples_needed);
else
ExecParallelReinitialize(outerPlanState(node), node->pei,
gm->initParam);
pcxt = node->pei->pcxt;
LaunchParallelWorkers(pcxt);
node->nworkers_launched = pcxt->nworkers_launched;
if (pcxt->nworkers_launched > 0)
ExecParallelCreateReaders(node->pei);
/* ... build the binary heap, one slot per reader ... */
}
}

대가는 있다. GatherMerge는 어떤 워커가 적어도 튜플 하나를 생성할 때까지 그 워커의 튜플을 반환할 수 없다. 느리거나 멈춘 워커가 병합 전체를 지연시킨다. 단순 Gather는 그런 순서 결합이 없다. 이론에서 “순서는 별개의 관심사”라는 특성을 구현하는 엔지니어링 비용이다.

심볼 이름에 기준을 둔다. 함수나 구조체 이름은 대부분의 리팩터링에도 안정적인 핸들이다. 줄 번호는 누군가가 서식을 수정하는 즉시 틀려진다. 현재 위치는 git grep -n '<symbol>' src/backend/로 찾는다. 위치 힌트 표의 줄 번호는 커밋 273fe94(REL_18_STABLE) 기준 관측값이며 빠른 참고용이다.

워커에 전달되는 플랜은 복사본이다. 워커가 리더의 플랜을 변경해서는 안 되기 때문이다. 더미 PlannedStmt로 감싸며 두 가지 수정이 중요하다. resjunk 컬럼 표시를 제거하고(튜플이 사용자가 아닌 다른 백엔드로 돌아오므로 필요할 수 있다), 병렬-안전하지 않은 서브플랜을 NULL “구멍”으로 대체해 워커가 그것을 ExecInitNode할 수 없게 막는다.

// ExecSerializePlan — src/backend/executor/execParallel.c (condensed)
plan = copyObject(plan); /* don't touch the original */
foreach(lc, plan->targetlist)
lfirst_node(TargetEntry, lc)->resjunk = false; /* keep junk cols on the wire */
pstmt = makeNode(PlannedStmt);
pstmt->commandType = CMD_SELECT; /* always read-only */
pstmt->planTree = plan;
pstmt->rtable = estate->es_range_table;
/* Transfer only parallel-safe subplans, NULL-padding the unsafe ones. */
pstmt->subplans = NIL;
foreach(lc, estate->es_plannedstmt->subplans)
{
Plan *subplan = (Plan *) lfirst(lc);
if (subplan && !subplan->parallel_safe)
subplan = NULL;
pstmt->subplans = lappend(pstmt->subplans, subplan);
}
return nodeToString(pstmt); /* the generic serializer */

nodeToString은 엔진 전역 노드 트리 직렬화기다(postgres-node-trees.md 소유). 이것을 재사용하기 때문에 “플랜을 전송한다”는 작업이 한 줄로 끝난다.

두 단계 DSM 크기 결정 — ExecParallelEstimate 후 init

섹션 제목: “두 단계 DSM 크기 결정 — ExecParallelEstimate 후 init”

ExecInitParallelPlan은 세그먼트를 생성하기 전에 크기를 결정한다. 추정 단계는 planstate_tree_walkerPlanState 트리를 순회하며 노드를 세고 각 parallel-aware 노드가 공간을 예약하게 한다. EXPLAIN ANALYZE 계측을 위해서만 DSM이 필요한 노드는 조건 없이 예약한다.

// ExecParallelEstimate — src/backend/executor/execParallel.c (condensed)
e->nnodes++; /* count every node */
switch (nodeTag(planstate))
{
case T_SeqScanState:
if (planstate->plan->parallel_aware)
ExecSeqScanEstimate((SeqScanState *) planstate, e->pcxt);
break;
case T_SortState:
/* even when not parallel-aware, for EXPLAIN ANALYZE */
ExecSortEstimate((SortState *) planstate, e->pcxt);
break;
/* ... Index/BitmapHeap/Append/Hash/Agg/Memoize/... ... */
default:
break;
}
return planstate_tree_walker(planstate, ExecParallelEstimate, e);

추정기는 익스큐터의 고정 조각들 각각에 대한 청크 크기와 키 수를 누적한다. 고정 상태, 질의 텍스트, 직렬화된 PlannedStmt, ParamListInfo, 워커별 BufferUsage/WalUsage, 튜플 큐, 선택적 계측, DSA 영역이 각각 2^32 이상의 PARALLEL_KEY_* 매직 넘버로 태그된다. 개별 노드는 자신의 상태에 더 작은 키를 사용할 수 있다.

// ExecInitParallelPlan — src/backend/executor/execParallel.c (condensed)
pstmt_data = ExecSerializePlan(planstate->plan, estate);
pcxt = CreateParallelContext("postgres", "ParallelQueryMain", nworkers);
pei->pcxt = pcxt;
shm_toc_estimate_chunk(&pcxt->estimator, sizeof(FixedParallelExecutorState));
shm_toc_estimate_keys(&pcxt->estimator, 1);
/* ... query text, PlannedStmt, ParamListInfo, Buffer/Wal usage ... */
shm_toc_estimate_chunk(&pcxt->estimator,
mul_size(PARALLEL_TUPLE_QUEUE_SIZE, pcxt->nworkers));
shm_toc_estimate_keys(&pcxt->estimator, 1);
/* Let parallel-aware nodes add to the estimate; also counts nnodes. */
e.pcxt = pcxt; e.nnodes = 0;
ExecParallelEstimate(planstate, &e);
shm_toc_estimate_chunk(&pcxt->estimator, dsa_minsize); /* DSA area */
shm_toc_estimate_keys(&pcxt->estimator, 1);
/* Everyone's asked for space; now create the DSM. */
InitializeParallelDSM(pcxt);

InitializeParallelDSM이 반환된 후 공간이 존재하지만 아직 초기화되지 않은 상태다. 이후 각 청크를 할당하고 채운다. shm_toc_allocate로 조각을 잘라내고, shm_toc_insert로 키 아래 등록하며, 고정 상태·질의 문자열·직렬화된 플랜·직렬화된 파라미터를 저장하고 튜플 큐를 설정한다.

// ExecInitParallelPlan — store phase (condensed)
fpes = shm_toc_allocate(pcxt->toc, sizeof(FixedParallelExecutorState));
fpes->tuples_needed = tuples_needed;
fpes->eflags = estate->es_top_eflags;
fpes->jit_flags = estate->es_jit_flags;
shm_toc_insert(pcxt->toc, PARALLEL_KEY_EXECUTOR_FIXED, fpes);
pstmt_space = shm_toc_allocate(pcxt->toc, pstmt_len);
memcpy(pstmt_space, pstmt_data, pstmt_len);
shm_toc_insert(pcxt->toc, PARALLEL_KEY_PLANNEDSTMT, pstmt_space);
pei->tqueue = ExecParallelSetupTupleQueues(pcxt, false); /* the funnel queues */
/* A DSA area shared by leader and workers (parallel-aware node scratch). */
area_space = shm_toc_allocate(pcxt->toc, dsa_minsize);
shm_toc_insert(pcxt->toc, PARALLEL_KEY_DSA, area_space);
pei->area = dsa_create_in_place(area_space, dsa_minsize,
LWTRANCHE_PARALLEL_QUERY_DSA, pcxt->seg);
/* Initialize pass: visits the identical nnodes in identical order. */
estate->es_query_dsa = pei->area;
ExecParallelInitializeDSM(planstate, &d);
estate->es_query_dsa = NULL;
if (e.nnodes != d.nnodes)
elog(ERROR, "inconsistent count of PlanState nodes");

e.nnodes != d.nnodes 단언은 두 단계 계약의 가드레일이다. 추정과 초기화는 반드시 동일한 노드 집합을 방문해야 한다. 그렇지 않으면 한 단계에서 계산된 오프셋이 다른 단계에서 어긋난다.

튜플 큐 — ExecParallelSetupTupleQueues

섹션 제목: “튜플 큐 — ExecParallelSetupTupleQueues”

각 워커는 연속 블록에서 잘라낸 PARALLEL_TUPLE_QUEUE_SIZE(64 KB) 크기의 shm_mq 하나를 받는다. 리더가 각 큐의 수신자로 자신을 설정한다. 워커는 나중에 자신을 송신자로 설정한다.

// ExecParallelSetupTupleQueues — src/backend/executor/execParallel.c (condensed)
tqueuespace = shm_toc_allocate(pcxt->toc,
mul_size(PARALLEL_TUPLE_QUEUE_SIZE, pcxt->nworkers));
for (i = 0; i < pcxt->nworkers; ++i)
{
shm_mq *mq = shm_mq_create(tqueuespace + ((Size) i) * PARALLEL_TUPLE_QUEUE_SIZE,
(Size) PARALLEL_TUPLE_QUEUE_SIZE);
shm_mq_set_receiver(mq, MyProc); /* leader receives */
responseq[i] = shm_mq_attach(mq, pcxt->seg, NULL);
}
shm_toc_insert(pcxt->toc, PARALLEL_KEY_TUPLE_QUEUE, tqueuespace);
return responseq;

리더가 워커 포크 중에 유용한 준비 작업을 할 수 있도록, 리더는 시작 이후 ExecParallelCreateReaders에서 별도로 리더를 생성한다.

// ExecParallelCreateReaders — src/backend/executor/execParallel.c (condensed)
for (i = 0; i < nworkers; i++)
{
shm_mq_set_handle(pei->tqueue[i], pei->pcxt->worker[i].bgwhandle);
pei->reader[i] = CreateTupleQueueReader(pei->tqueue[i]);
}

일반적인 LaunchParallelWorkers(parallel.c)는 요청된 슬롯마다 백그라운드 워커 하나를 등록한다. 진입점 이름은 ParallelWorkerMain으로 고정되고, DSM 핸들이 워커의 메인 인자로 전달되어 연결할 수 있게 한다. 리더는 먼저 락 그룹 리더가 되어 자신과 워커 사이의 감지되지 않는 데드락을 방지한다.

// LaunchParallelWorkers — src/backend/access/transam/parallel.c (condensed)
BecomeLockGroupLeader(); /* group locking */
memset(&worker, 0, sizeof(worker));
snprintf(worker.bgw_name, BGW_MAXLEN, "parallel worker for PID %d", MyProcPid);
worker.bgw_flags = BGWORKER_SHMEM_ACCESS | BGWORKER_BACKEND_DATABASE_CONNECTION
| BGWORKER_CLASS_PARALLEL;
worker.bgw_start_time = BgWorkerStart_ConsistentState;
sprintf(worker.bgw_library_name, "postgres");
sprintf(worker.bgw_function_name, "ParallelWorkerMain");
worker.bgw_main_arg = UInt32GetDatum(dsm_segment_handle(pcxt->seg));
worker.bgw_notify_pid = MyProcPid;
for (i = 0; i < pcxt->nworkers_to_launch; ++i)
{
memcpy(worker.bgw_extra, &i, sizeof(int)); /* this worker's number */
if (!any_registrations_failed &&
RegisterDynamicBackgroundWorker(&worker, &pcxt->worker[i].bgwhandle))
{
shm_mq_set_handle(pcxt->worker[i].error_mqh, pcxt->worker[i].bgwhandle);
pcxt->nworkers_launched++; /* best-effort count */
}
else
any_registrations_failed = true; /* hit max_worker_processes */
}

등록 실패는 오류가 아니다. 단지 nworkers_launched가 요청보다 낮아지고, 호출자인 Gather가 로컬 스캔으로 대응한다. 코드로 표현된 우아한 성능 저하 관례다.

워커 — ParallelWorkerMain이 리더 상태를 복원한다

섹션 제목: “워커 — ParallelWorkerMain이 리더 상태를 복원한다”

ParallelWorkerMain(parallel.c)은 모든 병렬 워커가 실행하는 함수다. 튜플 하나를 실행하기 전에, 자신의 프라이빗 프로세스를 리더처럼 보이게 만들어야 한다. 순서가 섬세하며 소스에 주석이 상세하다. 핵심 복원 작업은 다음과 같다.

// ParallelWorkerMain — src/backend/access/transam/parallel.c (condensed, reordered)
seg = dsm_attach(DatumGetUInt32(main_arg)); /* attach the segment */
toc = shm_toc_attach(PARALLEL_MAGIC, dsm_segment_address(seg));
fps = shm_toc_lookup(toc, PARALLEL_KEY_FIXED, false);
/* Attach the error queue FIRST, so later failures reach the leader. */
mq = (shm_mq *) (error_queue_space + ParallelWorkerNumber * PARALLEL_ERROR_QUEUE_SIZE);
shm_mq_set_sender(mq, MyProc);
mqh = shm_mq_attach(mq, seg, NULL);
pq_redirect_to_shm_mq(seg, mqh); /* elog/ereport -> leader */
BecomeLockGroupMember(fps->parallel_leader_pgproc, fps->parallel_leader_pid);
SetAuthenticatedUserId(fps->authenticated_user_id);
BackgroundWorkerInitializeConnectionByOid(fps->database_id, ...); /* same DB */
StartParallelWorkerTransaction(tstatespace); /* XID set, txn block state */
RestoreComboCIDState(combocidspace); /* visibility consistency */
asnapshot = RestoreSnapshot(asnapspace); /* same MVCC view */
RestoreTransactionSnapshot(tsnapshot, fps->parallel_leader_pgproc);
PushActiveSnapshot(asnapshot);
InvalidateSystemCaches(); /* tuples we can see changed */
RestoreGUCState(gucspace); /* every GUC value */
SetUserIdAndSecContext(fps->current_user_id, fps->sec_context);
EnterParallelMode(); /* arm read-only checks */
entrypt(seg, toc); /* == ParallelQueryMain */

README는 각 복원 작업이 왜 존재하는지 명확하게 설명한다. XID 집합과 콤보 CID는 “워커에서의 튜플 가시성 검사 결과가 시작 백엔드에서와 동일하도록” 복원한다. 스냅샷도 같은 이유다. GUC는 플래너/익스큐터 동작이 일치하도록 복원한다. 오류 큐 연결이 의도적으로 첫 번째인 이유는 “그렇게 하기 전에 발생하는 오류는 보고되지 않기 때문”이다.

환경이 리더와 일치하면, 익스큐터 전용 진입점 ParallelQueryMain(execParallel.c)이 DSM에서 QueryDesc를 재구축하고 일반 익스큐터를 실행한다. 단, DestReceiver가 워커의 튜플 큐에 쓴다는 점이 다르다.

// ParallelQueryMain — src/backend/executor/execParallel.c (condensed)
fpes = shm_toc_lookup(toc, PARALLEL_KEY_EXECUTOR_FIXED, false);
receiver = ExecParallelGetReceiver(seg, toc); /* writes to the shm_mq */
queryDesc = ExecParallelGetQueryDesc(toc, receiver, instrument_options);
area = dsa_attach_in_place(shm_toc_lookup(toc, PARALLEL_KEY_DSA, false), seg);
ExecutorStart(queryDesc, fpes->eflags);
queryDesc->planstate->state->es_query_dsa = area; /* shared scan cursors */
ExecParallelInitializeWorker(queryDesc->planstate, &pwcxt);
ExecSetTupleBound(fpes->tuples_needed, queryDesc->planstate);
InstrStartParallelQuery();
ExecutorRun(queryDesc, ForwardScanDirection,
fpes->tuples_needed < 0 ? (int64) 0 : fpes->tuples_needed);
ExecutorFinish(queryDesc);
/* report Buffer/Wal usage + instrumentation back through DSM, then: */
ExecutorEnd(queryDesc);

ExecParallelGetReceiver는 이 워커의 슬롯을 튜플 큐 블록에서 찾아 워커를 송신자로 표시하고 CreateTupleQueueDestReceiver로 감싼다. 워커 익스큐터가 생성하는 모든 튜플이 MinimalTuple로 직렬화되어 리더가 소진하는 링에 푸시된다.

// ExecParallelGetReceiver — src/backend/executor/execParallel.c
mqspace = shm_toc_lookup(toc, PARALLEL_KEY_TUPLE_QUEUE, false);
mqspace += ParallelWorkerNumber * PARALLEL_TUPLE_QUEUE_SIZE;
mq = (shm_mq *) mqspace;
shm_mq_set_sender(mq, MyProc); /* worker sends */
return CreateTupleQueueDestReceiver(shm_mq_attach(mq, seg, NULL));

플랜은 ExecParallelGetQueryDescstringToNode(리더의 nodeToString의 역함수)로 재구축하고, GetActiveSnapshot()을 부여한다. 이 스냅샷은 리더가 푸시하고 ParallelWorkerMain이 이미 복원한 것이다.

flowchart TB
  L0["리더: ExecGather (첫 호출)"]
  L1["ExecInitParallelPlan<br/>플랜 직렬화, DSM 크기 결정 및 채우기"]
  L2["LaunchParallelWorkers<br/>RegisterDynamicBackgroundWorker x N"]
  L0 --> L1 --> L2
  L2 -->|"포스트마스터 포크"| W0["ParallelWorkerMain"]
  W0 --> W1["DSM + 오류 큐 연결<br/>GUC/스냅샷/XID/콤보CID 복원"]
  W1 --> W2["EnterParallelMode<br/>ParallelQueryMain"]
  W2 --> W3["ExecutorStart/Run<br/>DestReceiver -> shm_mq"]
  W3 -->|"MinimalTuples"| L3["리더 gather_readnext<br/>큐 소진"]
  W3 -->|"오류 발생 시"| ERR["오류 shm_mq +<br/>PROCSIG_PARALLEL_MESSAGE"]
  ERR --> L4["리더 CHECK_FOR_INTERRUPTS<br/>ProcessParallelMessages 재발생"]

그림 3 — 엔드투엔드 시작과 데이터 흐름. 리더가 플랜과 상태를 DSM에 직렬화하고, 포스트마스터에 워커 포크를 요청한다. 각 워커는 리더의 환경을 재구축한 후 자신의 익스큐터 복사본을 실행한다. 출력 DestReceiverMinimalTuple을 리더가 소진하는 공유 큐에 쓴다. 오류는 별도 오류 큐를 거쳐 리더의 다음 인터럽트 검사 지점에 나타난다.

워커의 elog/ereportpq_redirect_to_shm_mq로 전용 오류 큐(튜플 큐와 분리)로 리다이렉트된다. 메시지를 보내면 PROCSIG_PARALLEL_MESSAGE로 리더에게 시그널이 간다. 리더의 다음 CHECK_FOR_INTERRUPTSProcessParallelMessages를 실행하며, 모든 워커의 오류 큐를 소진하고 재발생시킨다.

// ProcessParallelMessages — src/backend/access/transam/parallel.c (condensed)
dlist_foreach(iter, &pcxt_list)
{
pcxt = dlist_container(ParallelContext, node, iter.cur);
for (i = 0; i < pcxt->nworkers_launched; ++i)
while (pcxt->worker[i].error_mqh != NULL)
{
res = shm_mq_receive(pcxt->worker[i].error_mqh, &nbytes, &data, true);
if (res == SHM_MQ_WOULD_BLOCK)
break;
else if (res == SHM_MQ_SUCCESS)
ProcessParallelMessage(pcxt, i, &msg); /* re-throws ErrorResponse */
else
ereport(ERROR, ... "lost connection to parallel worker");
}
}

README가 리더에게 “정기적으로 CHECK_FOR_INTERRUPTS()를 실행하라”고 강조하는 이유가 여기 있다. 워커의 오류가 리더의 오류로 전환되는 유일한 지점이기 때문이다. gather_readnext가 루프 이터레이션마다 CHECK_FOR_INTERRUPTS를 호출하는 것은 리더가 튜플을 소진하는 동안에도 워커 오류가 즉시 나타나도록 하기 위해서다.

정리와 재사용 — ExecParallelFinish / Cleanup / Reinitialize

섹션 제목: “정리와 재사용 — ExecParallelFinish / Cleanup / Reinitialize”

ExecParallelFinish는 튜플 큐를 분리하여(아직 실행 중인 워커가 더 이상 출력이 필요 없음을 알게 된다) 리더를 파괴하고, WaitForParallelWorkersToFinish를 호출한다. 모든 워커가 종료하기 전에 트랜잭션을 정리하면 “워커가 아직 스캔 중인 동안 릴레이션이 사라질” 수 있기 때문이다. ExecParallelCleanup은 계측을 누적하고, 직렬화된 파라미터를 해제하며, DSA를 분리하고, DestroyParallelContext로 DSM을 해제한다. 중첩 루프의 내부 측에 있는 Gather처럼 재스캔이 필요한 노드라면, ExecParallelReinitializeReinitializeParallelDSM으로 동일한 DSM을 재활용하고 (변경되었을 수 있는) 파라미터만 재직렬화한다. 세그먼트 전체를 다시 만들 필요가 없다.

위치 힌트 (2026-06-05 기준, REL_18 273fe94)

섹션 제목: “위치 힌트 (2026-06-05 기준, REL_18 273fe94)”
심볼파일
ExecInitGathersrc/backend/executor/nodeGather.c53
ExecGathersrc/backend/executor/nodeGather.c137
gather_getnextsrc/backend/executor/nodeGather.c263
gather_readnextsrc/backend/executor/nodeGather.c311
ExecShutdownGatherWorkerssrc/backend/executor/nodeGather.c400
ExecShutdownGathersrc/backend/executor/nodeGather.c418
ExecReScanGathersrc/backend/executor/nodeGather.c442
ExecInitGatherMergesrc/backend/executor/nodeGatherMerge.c67
ExecGatherMergesrc/backend/executor/nodeGatherMerge.c183
gather_merge_readnextsrc/backend/executor/nodeGatherMerge.c636
ExecSerializePlansrc/backend/executor/execParallel.c146
ExecParallelEstimatesrc/backend/executor/execParallel.c233
ExecParallelSetupTupleQueuessrc/backend/executor/execParallel.c547
ExecInitParallelPlansrc/backend/executor/execParallel.c599
ExecParallelCreateReaderssrc/backend/executor/execParallel.c890
ExecParallelReinitializesrc/backend/executor/execParallel.c916
ExecParallelFinishsrc/backend/executor/execParallel.c1156
ExecParallelCleanupsrc/backend/executor/execParallel.c1209
ExecParallelGetReceiversrc/backend/executor/execParallel.c1245
ExecParallelGetQueryDescsrc/backend/executor/execParallel.c1261
ExecParallelInitializeWorkersrc/backend/executor/execParallel.c1334
ParallelQueryMainsrc/backend/executor/execParallel.c1429
CreateParallelContextsrc/backend/access/transam/parallel.c173
InitializeParallelDSMsrc/backend/access/transam/parallel.c211
ReinitializeParallelDSMsrc/backend/access/transam/parallel.c508
LaunchParallelWorkerssrc/backend/access/transam/parallel.c580
WaitForParallelWorkersToFinishsrc/backend/access/transam/parallel.c803
ProcessParallelMessagessrc/backend/access/transam/parallel.c1055
ProcessParallelMessagesrc/backend/access/transam/parallel.c1144
ParallelWorkerMainsrc/backend/access/transam/parallel.c1299
PARALLEL_TUPLE_QUEUE_SIZE (= 65536)src/backend/executor/execParallel.c69

/data/hgryoo/references/postgres 커밋 273fe94(REL_18_STABLE)에서 검증했다. 수행한 검사:

  • GatherExecInitGather가 아닌 첫 ExecGather 호출 시 지연 워커를 시작한다. 확인: ExecInitGathergatherstate->initialized = false로 설정하고 외부 플랜에만 ExecInitNode를 호출한다. ExecInitParallelPlan / LaunchParallelWorkers 블록은 ExecGatherif (!node->initialized) 안에 있다. 소스 주석에 근거가 명시된다(“대형 동적 세그먼트를 할당해야 한다”).
  • 튜플 큐 크기는 워커당 64 KB다. execParallel.c#define PARALLEL_TUPLE_QUEUE_SIZE 65536. ExecParallelSetupTupleQueues에서 각 큐가 그 크기로 shm_mq_create된다.
  • 튜플은 MinimalTuple로 교환된다. ExecInitGather에서 깔때기 슬롯이 &TTSOpsMinimalTuple로 생성된다. gather_getnext는 워커 튜플을 ExecStoreMinimalTuple로 저장한다. 워커 수신기는 CreateTupleQueueDestReceiver다.
  • 플랜은 nodeToString으로 직렬화되고 stringToNode로 재구축된다. ExecSerializePlannodeToString(pstmt)를 반환한다. ExecParallelGetQueryDescstringToNode(pstmtspace)를 호출한다.
  • 두 단계 노드 수 불변식이 존재한다. ExecParallelEstimatee.nnodes를 증가시키고, ExecParallelInitializeDSMd.nnodes를 증가시킨다. 다르면 ExecInitParallelPlan이 “inconsistent count of PlanState nodes” 오류를 발생시킨다.
  • 읽기 전용 강제. ExecSerializePlanpstmt->commandType = CMD_SELECT를 하드코딩한다. ParallelWorkerMain이 진입점 호출 전에 EnterParallelMode()를 호출한다. (EnterParallelMode/ExitParallelMode 검사 메커니즘은 xact.c에 있다. postgres-xact.md 참조.)
  • 오류 경로. ParallelWorkerMainpq_redirect_to_shm_mq를 호출한다. ProcessParallelMessagespcxt->worker[i].error_mqh를 소진하고 ProcessParallelMessage가 재발생시킨다. PROCSIG_PARALLEL_MESSAGE 시그널 상수는 parallel.c에서 참조된다.
  • REL_18 온전성. XLOG2 rmgr과 B_DATACHECKSUMSWORKER_* 심볼은 단언하지 않았다. 워커는 질의별 BufferUsageWalUsage(PARALLEL_KEY_WAL_USAGE)를 보고한다. 최신 릴리스와 일치한다.
  • 심볼 존재 확인. 위치 힌트 표의 모든 심볼을 인용된 파일에서 grep -nE '^<symbol>'으로 확인했다(이 커밋 기준 ±0줄).

독립적으로 재도출하지 않은 부분(소스 주석/README에서 인용): 일반적인 InitializeParallelDSM이 복사하는 상태의 정확한 목록(라이브러리, 콤보 CID, relmapper, REINDEX 상태 등)은 README.parallelInitializeParallelDSM 본문에 열거되어 있다. 인용했으며 각 항목을 재검증하지는 않았다.

PostgreSQL 너머 — 비교 설계와 연구 전선

섹션 제목: “PostgreSQL 너머 — 비교 설계와 연구 전선”

PostgreSQL 병렬 익스큐터는 의도적으로 보수적인, Volcano 교환 모델의 1세대 구현이다. 설계 공간과 문헌에 비추어 보면 어떤 선택이 근본적이고 어떤 선택이 출발점의 산물인지 명확해진다.

워커-당-프로세스 vs. 워커-당-스레드

섹션 제목: “워커-당-프로세스 vs. 워커-당-스레드”

PostgreSQL의 가장 중요한 단일 결정은 워커가 스레드가 아닌 완전한 백엔드 프로세스라는 것이다. 이점은 코드 재사용 측면에서 막대하다. 워커는 일반 질의와 동일한 ExecutorStart/ExecutorRun을 실행하고(postgres-executor.md), 장애 격리는 자동으로 따라온다. 충돌한 워커가 리더의 힙을 오염시킬 수 없다. 대가는 “상태 공유” 절에 있는 모든 것이다. GUC, 스냅샷, XID, 콤보 CID, 라이브러리 각각을 명시적으로 직렬화하고 재설치해야 한다. 프로세스는 주소 공간을 공유하지 않기 때문이다. 워커-당-스레드 엔진(SQL Server, MySQL, DuckDB, 대부분의 OLAP 엔진 등)은 그 모두를 공짜로 공유하고, 취약한 전역 상태, 만연한 락, 충돌 격리 상실을 치른다. PostgreSQL이 프로세스를 선택한 것은 전체 아키텍처(postgres-postmaster.md, postgres-backend-lifecycle.md)가 커넥션-당-프로세스이기 때문이다. 병렬 워커는 그 기계를 통째로 재사용한다. 직렬화 비용은 그 재사용의 대가다.

풀 기반 교환 vs. 푸시 기반 / 모젤 구동

섹션 제목: “풀 기반 교환 vs. 푸시 기반 / 모젤 구동”

PostgreSQL의 Gather풀 기반이다. 리더의 gather_readnext가 워커별 큐를 내려당기고, 각 워커는 이터레이터 모델로 독립적으로 자신의 파티션을 끌어올린다. 고전적인 Volcano 교환이다. 영향력 있는 대안은 모젤 구동 병렬성(Leis 외, “Morsel-Driven Parallelism: A NUMA-Aware Query Evaluation Framework for the Many-Core Age,” SIGMOD 2014)이다. HyPer와 이후의 Umbra가 구현했다. 고정된 워커 스레드 풀이 공유 디스패처에서 작은 “모젤” 입력 단위를 꺼내어 컴파일된 파이프라인으로 푸시한다. 병렬성이 탄력적이고(스레드는 자유로울 때 다음 모젤을 가져간다), NUMA를 인식하며, 플랜 시점에 PostgreSQL이 고정하는 정적 병렬성 정도를 피한다. PostgreSQL의 parallel-aware SeqScan 블록 범위 할당은 모젤 디스패치의 거친 단일 연산자 버전이다. DSA 영역의 공유 블록 카운터가 미니어처 모젤 디스패처다. 그러나 PostgreSQL은 플랜 시점에 num_workers를 고정하며, 모젤 구동 엔진처럼 연산자 전체에 걸쳐 재균형을 맞출 수 없다. 벡터화 푸시 엔진(MonetDB/X100 계열; Boncz, Zukowski & Nes, “MonetDB/X100: Hyper-Pipelining Query Execution,” CIDR 2005)은 푸시 데이터플로우와 배치 단위 처리를 결합해, PostgreSQL 튜플-단위 깔때기가 여전히 내는 이터레이터 오버헤드를 공략한다. 단, PG의 표현식 JIT(postgres-expression-eval.md)가 워커 내부에서 그 간격을 좁힌다.

단일 Gather깔때기다. 모든 워커 튜플이 MinimalTuple로 직렬화되어 64 KB 링을 통과하고, 리더 하나가 역직렬화한다. 고카디널리티 결과에서는 병렬 스캔이 분산한 작업이 재중앙화되며, 리더가 병목이 될 수 있다. 병렬 DB 전통에서 “교환은 확장성의 한계”라는 관측이 오래전부터 있었고, 많은 공유 없는 연구에서 정량화되었다. 상용 병렬 엔진들은 다단계 교환(교환 아래에서 부분 집계를 해 그것을 넘어가는 튜플을 줄인다 — PostgreSQL도 Partial / Finalize Aggregate로 이를 한다)과 재분할 교환(단일 연산자 후에 하나의 노드로 깔때기하는 대신 파이프라인 전체에서 데이터를 병렬 상태로 유지하는 교환)으로 이를 완화한다. PostgreSQL에는 일반적인 재분할 교환이 없다. 병렬성은 모든 Gather에서 붕괴하고, 다음 Gather 아래에서 다시 확장되어야 한다. 깊은 병렬 플랜이 가능한 한 많은 작업을 단일 최상위 Gather 아래로 밀어넣는 이유다. 병렬 해시 조인(인접한 postgres-table-am.md; ExecParallelEstimateT_HashJoinState parallel-aware 경로)은 DSA 영역에 상태를 두어 워커 전체가 공유하므로 깔때기화 없이 유지되는 주요 예외다.

워커 수는 테이블 크기와 max_parallel_workers_per_gather를 기반으로 플래너가 결정하고, 런타임에 최선 노력으로 실행된다(nworkers_launched가 더 낮을 수 있다). 질의 중간에 증가하거나 재균형을 맞추지 않는다. 적응형 시스템은 런타임 피드백에 따라 병렬성 정도를 재검토한다. 적응형 질의 처리(Deshpande, Ives & Raman, “Adaptive Query Processing,” Foundations and Trends in Databases, 2007)와 런타임 재최적화의 연구 전선이다. PostgreSQL의 parallel_leader_participation은 아주 작은 적응형 터치다(워커가 부족할 때 리더가 채운다). 그러나 아키텍처는 근본적으로 플랜 시점 정적이다.

DSC 22장은 교환 연산자를 생산자가 네트워크로 튜플을 보내는 공유 없는 클러스터로 프레이밍한다. PostgreSQL의 교환은 엄격히 단일 노드 공유 메모리다. 워커는 로컬 프로세스이고 전송은 공유 메모리이며 소켓이 아니다. PostgreSQL 생태계에서의 교차 노드 병렬성은 코어 익스큐터 위에 레이어된 확장 기능들(Citus, Greenplum, postgres_fdw 비동기 append)에 있다. nodeGather.c에 있지 않다. 이것이 범위를 가장 명확하게 보여준다. 코어는 교재 모델의 멀티코어 조각만 구현하고, 분산 조각은 완전히 위임한다.

현대 독자가 주목할 방향: (1) 정적 num_workers를 대체하는 모젤 구동 / 작업 가로채기(work-stealing) 스케줄러; (2) 튜플-단위 깔때기를 퇴역시키는 벡터화 또는 완전 컴파일 푸시 파이프라인; (3) 연산자 전체에서 병렬성이 유지되는 재분할 교환; (4) 읽기 전용 제약 완화(병렬 DML, B-트리용 병렬 인덱스 빌드는 이미 있다 — postgres-nbtree.md 참조); (5) NUMA 인식 워커 배치. PostgreSQL의 설계는 의도적으로 이 공간에서 가장 단순한 올바른 지점이다. 보수성(읽기 전용, 프로세스 기반, 정적, 단일 노드, Gather-당-깔때기)이 바로 30년 된 단일 스레드 익스큐터를 재작성하지 않고도 병렬 질의를 안착시킬 수 있게 한 것이다.

  • 소스 트리 (/data/hgryoo/references/postgres, REL_18_STABLE, 커밋 273fe94, 2026-06-05 관측):
    • src/backend/executor/nodeGather.cGather 노드: 지연 시작, gather_getnext/gather_readnext 깔때기, 로컬 스캔 폴백.
    • src/backend/executor/nodeGatherMerge.c — 순서 보존 변형; 워커 스트림의 이진 힙 병합.
    • src/backend/executor/execParallel.cExecInitParallelPlan, 플랜 직렬화, 두 단계 DSM 크기 결정, 튜플 큐, ParallelQueryMain, 정리/재초기화.
    • src/backend/access/transam/parallel.c — 일반 ParallelContext, InitializeParallelDSM, LaunchParallelWorkers, ParallelWorkerMain 상태 복원, 오류 메시지 처리.
    • src/backend/access/transam/README.parallel — 설계 문서: 개요, 오류 보고, 상태 공유, 트랜잭션 통합, 코딩 관례.
  • 교재 (knowledge/research/dbms-general/database-system-concepts.md): Silberschatz, Korth & Sudarshan, Database System Concepts, 7판, 22장 “Parallel and Distributed Query Processing” — §22.1(인트라/인터질의, 인트라/인터연산 병렬성), §22.5(질의 플랜의 병렬 평가), §22.5.1(인터연산: 파이프라인 vs. 독립 병렬성), §22.5.2(교환 연산자 모델); 15장 §15.7.2(파이프라이닝, 병렬 챕터에서 참조).
  • 기초 논문 (.omc/plans/postgres-paper-bibliography.md 참조): G. Graefe, “Encapsulation of Parallelism in the Volcano Query Processing System,” SIGMOD 1990 — 교환 연산자. G. Graefe & W. McKenna, “The Volcano Optimizer Generator,” ICDE 1993.
  • 비교 / 전선 문헌 (§6에 언급, KB에 없음): Leis 외, “Morsel-Driven Parallelism,” SIGMOD 2014; Boncz, Zukowski & Nes, “MonetDB/X100: Hyper-Pipelining Query Execution,” CIDR 2005; Deshpande, Ives & Raman, “Adaptive Query Processing,” FnT Databases 2007.
  • 교차 참조 (이 KB): postgres-executor.md(워커별 이터레이터 모델), postgres-shared-memory-ipc.md(DSM, shm_toc, shm_mq, 백그라운드 워커), postgres-planner-overview.mdpostgres-path-generation.md(Gather와 부분 경로가 왜 생성되는지), postgres-mvcc-snapshots.md(워커에서 복원되는 스냅샷 의미론), postgres-xact.md(병렬 모드 읽기 전용 강제), postgres-expression-eval.md(워커별 표현식 평가/JIT), postgres-postmaster.md / postgres-backend-lifecycle.md(워커-당-프로세스 기반).