콘텐츠로 이동

(KO) PostgreSQL 비동기 I/O — PG18 AIO 서브시스템, io_uring, 읽기 스트림

목차

PostgreSQL은 오랫동안 가장 단순한 I/O 방식을 유지했다. 백엔드가 shared_buffers에 없는 페이지를 필요로 하면 pread()를 호출하고 커널이 바이트를 돌려줄 때까지 블록했다. 이 방식이 예상보다 잘 작동한 이유는 하나다. 운영체제가 백엔드를 대신해 스토리지 레이턴시를 숨겨 줬기 때문이다. 커널의 순차 접근 감지 로직이 다음 몇 블록을 미리 페이지 캐시로 가져오므로, 동기 pread()는 데이터가 이미 메모리에 있는 경우 거의 즉시 반환했다. PostgreSQL은 이 커널 동작에 의존했고, 비트맵 힙 스캔처럼 엔진이 미래 접근 패턴을 커널보다 잘 아는 소수의 경우에는 posix_fadvise() 힌트(effective_io_concurrency)로 추가 선독을 유도했다.

Architecture of a Database System 서베이(Hellerstein, Stonebraker & Hamilton, 2007; dbms-papers/fntdb07-architecture.md에 수록)는 문제를 교과서적으로 정리한다. DBMS는 본질적으로 계산과 I/O를 *중첩(overlap)*하는 기계다. 백만 페이지를 읽어야 하는 쿼리는, 동시에 여러 읽기를 진행하고 디스크 암(또는 NVMe 큐)이 바쁜 동안 튜플 디코딩·프레디케이트 평가·해시 테이블 구성 같은 유용한 작업을 처리하지 않는 한, 거의 모든 시간을 스토리지 대기에 소비한다. 동기 블로킹 I/O는 단일 백엔드 안에서 그 중첩을 원천 봉쇄한다. 캐시 미스가 발생할 때마다 백엔드가 멈추기 때문이다. PostgreSQL의 프로세스-퍼-커넥션 모델은 여러 백엔드를 동시에 실행해 어느 정도 병렬성을 회복했지만, 단일 백엔드가 수행하는 대용량 스캔 한 건은 레이턴시에 묶인 채 남았다.

두 가지 구조적 변화가 이 구식 접근법을 수명이 다하게 만들었다. 첫째, 스토리지가 CPU 공급 속도보다 빨라졌다. 현대 NVMe 배열은 수십만 IOPS를 처리할 수 있지만, 8 KB씩 하나씩 읽고 기다리는 단일 동기 백엔드는 큐를 충분히 깊게 채우지 못해 디바이스를 포화시키지 못한다. 병목이 디바이스에서 시스템 콜 왕복 레이턴시로 이동한다. 둘째, 버퍼드 I/O 자체가 비용이 됐다. 커널 페이지 캐시에서 shared_buffers로 데이터를 복사하는 과정은 CPU를 소모하고 데이터를 이중 버퍼링한다(OS 캐시에 한 번, Postgres에 한 번). 다이렉트 I/O(O_DIRECT)는 DMA가 바이트를 디바이스에서 버퍼 풀로 직접 옮기므로 두 문제를 모두 우회하지만, AIO README가 단도직입적으로 밝히듯 “AIO 없이는 Direct IO가 대부분의 목적에서 사용 불가능할 정도로 느리다”. 커널 선독이 없어 미스가 발생할 때마다 전체 스토리지 왕복이 일어나기 때문이다. 다이렉트 I/O는 엔진 자체가 명시적이고 깊은 비동기 선독을 수행해야만 실용적이다. AIO는 다이렉트 I/O의 독립 기능이 아니라 전제 조건이다.

비동기 I/O는 읽기 발행과 결과 소비를 분리한다. 백엔드는 읽기를 제출하고 즉시 제어권을 돌려받아 추가 읽기를 발행하거나 이미 적재된 페이지를 처리하고, 실제로 필요한 시점에만 해당 읽기를 기다린다. 고전적 추상은 submit(io)가 핸들을 반환하고 wait(handle)이 IO 완료까지 블록하는 한 쌍의 연산이며, 바이트가 도착하면 공유 상태(버퍼 유효 표시, 체크섬 검증)를 갱신하는 완료 메커니즘이 뒤따른다. 공학적 난점은 해피 패스가 아니다. 인플라이트 IO 수 경계 설정, 공유 자원(버퍼)이 동시 비동기 읽기의 원본이자 목표인 상황에서 교착 회피, 완료 처리가 크리티컬 섹션 안에서 안전하게 실행되는 것(WAL 플러시가 AIO를 쓰려 하기 때문)이 어렵다.

비동기 I/O를 도입한 데이터베이스 엔진들은 메커니즘은 달라도 인식 가능한 공통 구성 요소로 수렴한다.

수명 주기가 있는 I/O 요청 디스크립터. 모든 AIO 설계에는 진행 중인 연산 하나를 나타내는 객체가 있다. 어떤 파일, 어떤 오프셋, 어떤 버퍼, 몇 바이트, 그리고 미제출 → 인플라이트 → 완료를 추적하는 상태 필드로 구성된다. 이 디스크립터는 시스템 콜보다 오래 살아야 하고 완료를 처리하는 코드에서 접근 가능해야 한다. PostgreSQL처럼 프로세스-퍼-커넥션 엔진에서 디스크립터의 위치는 핵심 설계 결정이다. 다른 프로세스가 IO를 완료해야 할 수 있으므로 단순 스택 변수는 불가능하다.

플러거블 트랜스포트. 최적 메커니즘이 플랫폼별로 다르므로 이식 가능한 엔진은 실제 IO 수행 방식을 인터페이스 뒤로 추상화한다. Linux io_uring, POSIX AIO(aio_read), Windows 오버랩드 I/O / IOCP, 동기 워커 스레드 풀, 블로킹 시스템 콜 폴백이 그 예다. 트랜스포트는 전략 객체, 즉 submit·wait·초기화용 함수 포인터 테이블로 표현되며 시작 시점에 선택된다.

발행과 분리된 완료 처리. 발행자가 바이트 도착 시점에 바쁘거나 블록 상태일 수 있으므로, 성숙한 설계는 임의의 워커가 완료를 수확하거나 전담 스레드/프로세스에 완료를 위임하도록 한다. 교착 회피의 핵심이 여기 있다. 백엔드가 열 페이지를 선독하고 락에서 블록되더라도 그 열 개의 완료는 누군가에 의해 처리되어야 한다. 완료를 전역적으로 수확 가능하게 만들거나(io_uring: 어떤 백엔드든 다른 백엔드의 링을 수확 가능), 작업 전체를 위임하는 방식(워커 풀: 읽기를 수행한 워커가 완료도 처리)으로 해결한다.

I/O 결합(combining)과 선독. 다음 네 페이지도 필요한 상황에서 8 KB 페이지 하나만 읽는 것은 비효율적이다. 인접 블록들을 하나의 벡터드 preadv()/링 제출로 합치면 시스템 콜당 비용이 분산된다. “다음에 읽을 것”의 생산자는 보통 선독 스트림 또는 비동기 스캔 이터레이터로 소비자와 분리된다. SQL Server의 읽기 선독 매니저, Oracle의 db_file_multiblock_read_count, DB2의 프리페처가 모두 이 패턴의 변형이다.

// IoMethodOps — src/include/storage/aio_internal.h
// 플러거블 트랜스포트 vtable: 각 io_method가 일부 훅을 채운다.
typedef struct IoMethodOps
{
bool wait_on_fd_before_close;
size_t (*shmem_size) (void);
void (*shmem_init) (bool first_time);
void (*init_backend) (void);
bool (*needs_synchronous_execution) (PgAioHandle *ioh);
int (*submit) (uint16 num_staged_ios, PgAioHandle **staged_ios);
void (*wait_one) (PgAioHandle *ioh, uint64 ref_generation);
} IoMethodOps;

교과서가 건너뛰는 설계 제약이 가장 어렵다. PostgreSQL은 멀티프로세스 서버이므로 AIO 상태는 공유 메모리에 있어야 한다. 백엔드 A가 시작한 핸들을 백엔드 B가 완료할 수 있으므로, 디스크립터·콜백·결과는 프로세스 로컬 포인터로 저장할 수 없다. 그리고 EXEC_BACKEND 빌드에서 ASLR이 각 프로세스의 코드를 다른 주소에 매핑하므로 공유 메모리는 함수 포인터를 담을 수 없다. 백엔드 A가 설치한 완료 콜백이 백엔드 B에서는 쓰레기 주소를 가리키기 때문이다. 이 하나의 사실이 구현 전반에 걸쳐 나타나는 콜백-정수-ID 설계를 강제한다.

PostgreSQL 18은 비동기 I/O를 src/backend/storage/aio/ 아래의 독립 서브시스템으로 도입했다. 대부분의 호출자는 저수준 API를 직접 건드리지 않도록 계층화돼 있다. 서브시스템 전체는 GUC 하나 io_method로 제어되며 세 값을 가진다. sync, worker(기본값), io_uring(Linux 전용, liburing 존재 시 컴파일).

AIO 핸들이 작업의 단위다. PgAioHandle(aio_internal.h 정의)은 고정 크기 공유 메모리 레코드다. 풀 크기는 시작 시 한 번 결정된다. AioProcs()(모든 백엔드와 보조 프로세스의 합)에 io_max_concurrency를 곱한 핸들 수다. 백엔드는 pgaio_io_acquire()로 핸들을 획득하며, 이 함수는 반드시 성공한다. 프리 리스트가 비었으면 pgaio_io_wait_for_free()를 호출해 자신의 인플라이트 IO 중 하나가 완료될 때까지 기다린다. 이 보장을 견고하게 만들기 위해, API는 백엔드가 한 번에 최대 하나의 미제출 핸들만 보유할 수 있도록 강제한다(handed_out_io 가드). 이를 어기면 두 번째 획득 시도에서 elog(ERROR)가 발생한다.

정의는 스토리지 스택을 거쳐 계층화된다. 핸들을 획득하는 백엔드가 보통 IO를 정의하는 코드가 아니다. 공유 버퍼 읽기의 경우, bufmgr.c가 핸들을 획득하고 완료 콜백을 등록한 뒤 smgr.c로 내려보내고, smgr.cmd.c로 전달하고, md.c는 블록 번호를 세그먼트 파일과 오프셋으로 변환해 fd.cpgaio_io_start_readv()를 최종 호출한다. 각 계층은 자신의 완료 콜백을 등록할 수 있다. 이 구조 덕분에 AIO 서브시스템은 계층들을 서로 모르게 유지한다. bufmgr는 IO가 md를 거치는지 모르고, md는 페이지 체크섬 검증 방법을 모른다. 각 계층은 자신이 아는 관심사만 담당하는 콜백 하나를 기여한다.

여덟 개 상태, 한 방향. 핸들은 PgAioHandleState의 상태를 단조롭게 이동한다. IDLE(백엔드 프리 리스트) → HANDED_OUT(획득 반환) → DEFINED(연산 연결) → STAGED(스테이지 콜백 실행, 제출 준비 완료) → SUBMITTED(커널/워커에 전달) → COMPLETED_IO(바이트 도착, 결과 확정) → COMPLETED_SHARED(공유 콜백 실행 — 버퍼 유효 표시) → COMPLETED_LOCAL(발행자의 로컬 콜백 실행), 그 후 핸들의 세대 카운터가 증가하고 IDLE로 돌아가 재사용된다.

// PgAioHandleState — src/include/storage/aio_internal.h
typedef enum PgAioHandleState
{
PGAIO_HS_IDLE = 0,
PGAIO_HS_HANDED_OUT, /* pgaio_io_acquire()가 반환한 상태 */
PGAIO_HS_DEFINED, /* pgaio_io_start_*() 호출 완료 */
PGAIO_HS_STAGED, /* stage() 실행 완료; 제출 준비 */
PGAIO_HS_SUBMITTED, /* IO 메서드에 전달됨 */
PGAIO_HS_COMPLETED_IO, /* 완료, 결과 미처리 */
PGAIO_HS_COMPLETED_SHARED, /* 공유 완료 콜백 실행 완료 */
PGAIO_HS_COMPLETED_LOCAL, /* 로컬 완료 콜백 실행 완료 */
} PgAioHandleState;

대기 참조는 재사용 이후에도 유효하다. 핸들은 IO 완료와 동시에 재활용되므로 핸들 자체를 기다릴 수 없다. 발행자는 제출 전에 PgAioWaitRef를 확보한다. 핸들의 배열 인덱스와 64비트 generation 카운터를 묶은 구조체다. pgaio_wref_wait()는 참조를 해석하고 세대가 앞으로 이동했으면 즉시 반환한다. 관심을 가졌던 IO는 이미 완료됐다는 의미다. 대기 참조는 공유 메모리에 저장할 수 있고 어떤 프로세스에서도 기다릴 수 있다.

콜백은 정수, 결과는 간결, 오류는 지연 처리. 완료 로직은 PgAioHandleCallbackID로 등록된다. 정적 테이블 aio_handle_cbs[]에 대한 1바이트 인덱스다(예: PGAIO_HCB_SHARED_BUFFER_READV, PGAIO_HCB_MD_READV). 이 간접 참조는 EXEC_BACKEND ASLR 환경에서 공유 메모리가 함수 포인터를 담을 수 없기 때문에 생긴다. 콜백은 크리티컬 섹션 안에서 실행되므로(WAL에서 AIO를 사용할 수 있도록) ereport(ERROR)를 호출할 수 없다. 각 콜백은 시스템 콜 원시 결과를 간결한 PgAioResult(상태 열거형과 소량의 오류 데이터)로 “증류”한다. 발행 백엔드는 나중에 백엔드 로컬 PgAioReturn에서 결과를 읽고, 실패를 나타내면 던지기가 안전한 컨텍스트에서 pgaio_result_report()를 호출해 오류를 올린다.

// PgAioHandleFlags — src/include/storage/aio.h
typedef enum PgAioHandleFlags
{
/* AIO가 설정돼 있어도 동기 실행을 요청 */
PGAIO_HF_SYNCHRONOUS = 1 << 0,
/* 프로세스 로컬 메모리 참조; 워커 모드에서 재오픈 불가 */
PGAIO_HF_REFERENCES_LOCAL = 1 << 1,
/* 버퍼드(비직접) IO — io_uring이 워커로 위임 가능 */
PGAIO_HF_BUFFERED = 1 << 2,
} PgAioHandleFlags;

읽기 스트림이 대부분의 호출자가 실제로 쓰는 헬퍼다. 순차 스캔, VACUUM, ANALYZE, 비트맵 힙 스캔 등은 AIO API를 직접 호출하지 않는다. read_stream_begin_relation()으로 ReadStream을 생성하고, 연속 블록 번호를 반환하는 ReadStreamBlockNumberCB 콜백을 전달한 뒤, read_stream_next_buffer()로 핀된 버퍼를 하나씩 꺼낸다. 스트림이 선독 거리를 설정하고, 인접 블록을 io_combine_limit까지 벡터드 읽기로 병합하고, 최대 max_ios개의 읽기를 동시 인플라이트 상태로 유지하고, 최근 캐시 히트/미스 이력을 바탕으로 선독 거리를 조정한다. 모든 것이 캐시에 있을 때는 거리를 1로 줄여 불필요한 선독을 없애고, 실제 I/O 후에는 거리를 두 배로 늘린다. 이것이 “이 블록들을 읽고 싶다”는 고수준 의도와 저수준 AIO 핸들 기계 사이의 다리이며, 일반적인 쿼리에서 성능 이득이 실제로 나타나는 지점이다.

서브시스템은 줄 수는 적지만 불변식이 조밀하다. 아래에서 위로 추적한다. 핸들 수명 주기(aio.c), 세 가지 메서드 구현(method_sync.c, method_worker.c, method_io_uring.c), 거의 모든 호출자가 실제로 쓰는 읽기 스트림 소비자(read_stream.c) 순서다. 버퍼 풀 통합(StartReadBuffer / WaitReadBuffers, PGAIO_HCB_SHARED_BUFFER_READV 콜백 본체)은 postgres-buffer-manager.md에, 세그먼트·fd 변환(md.c, PGAIO_HCB_MD_READV)은 postgres-smgr-md.md에 있다. 여기서는 src/backend/storage/aio/ 안에만 머문다.

pgaio_io_acquire()는 진입점이며 반드시 핸들을 반환하도록 보장된다. 프리 리스트가 비었으면 pgaio_io_wait_for_free()를 호출해 인플라이트 IO 하나가 완료될 때까지 기다린다. 실제 작업자는 논블로킹 변형 pgaio_io_acquire_nb()이며, 보장을 견고하게 유지하는 두 불변식을 집행한다. 첫째, 백엔드가 PGAIO_SUBMIT_BATCH_SIZE(32)개의 스테이징된 IO를 이미 갖고 있으면 pgaio_submit_staged()로 플러시한다. 둘째, 백엔드는 한 번에 하나의 미제출 핸들만 보유할 수 있다. handed_out_io 가드가 두 번째 동시 획득을 elog(ERROR)로 전환한다.

// pgaio_io_acquire_nb — src/backend/storage/aio/aio.c
if (pgaio_my_backend->num_staged_ios >= PGAIO_SUBMIT_BATCH_SIZE)
{
Assert(pgaio_my_backend->num_staged_ios == PGAIO_SUBMIT_BATCH_SIZE);
pgaio_submit_staged();
}
if (pgaio_my_backend->handed_out_io)
elog(ERROR, "API violation: Only one IO can be handed out");
// ... dclist idle_ios에서 꺼내고 IDLE -> HANDED_OUT으로 이동 ...
pgaio_io_update_state(ioh, PGAIO_HS_HANDED_OUT);
pgaio_my_backend->handed_out_io = ioh;

handed_out_io 규칙은 README의 자기 교착 방지 논증의 핵심이다. 백엔드는 한 번에 하나의 IO만 정의 중일 수 있으므로, 대기할 방법이 없는 정의되지 않은 N개의 핸들을 보유하는 상황이 발생하지 않는다.

bufmgr/md/fd가 연산을 정의하고 콜백을 등록하면 핸들이 스테이징된다. pgaio_io_stage()는 대상별 스테이지 콜백(완료 콜백의 상대역 — 자원을 핀하고 상태를 스냅샷)을 실행하고, 핸들을 DEFINED → STAGED로 이동하고, IO가 동기 실행이어야 하는지 결정한다(메서드가 sync이거나 IO가 프로세스 로컬 메모리를 참조하는 경우). 아니라면 핸들이 백엔드의 staged_ios[] 배열에 추가되고, 호출자가 pgaio_enter_batchmode()로 명시적으로 배치를 선택하지 않은 한 즉시 제출된다.

// pgaio_io_stage — src/backend/storage/aio/aio.c
pgaio_io_update_state(ioh, PGAIO_HS_DEFINED);
pgaio_my_backend->handed_out_io = NULL; /* 새 IO 스테이징 허용 */
pgaio_io_call_stage(ioh);
pgaio_io_update_state(ioh, PGAIO_HS_STAGED);
needs_synchronous = pgaio_io_needs_synchronous_execution(ioh);
if (!needs_synchronous)
{
pgaio_my_backend->staged_ios[pgaio_my_backend->num_staged_ios++] = ioh;
if (!pgaio_my_backend->in_batchmode)
pgaio_submit_staged();
}

pgaio_submit_staged()에서 제어가 메서드 vtable로 넘어간다. pgaio_method_ops->submit() 호출을 크리티컬 섹션으로 감싼다. 제출 자체가 이전 IO를 완료해야 할 수 있고(README의 크리티컬 섹션 내 WAL 시나리오), 완료는 크래시 안전해야 하기 때문이다.

// pgaio_submit_staged — src/backend/storage/aio/aio.c
START_CRIT_SECTION();
did_submit = pgaio_method_ops->submit(pgaio_my_backend->num_staged_ios,
pgaio_my_backend->staged_ios);
END_CRIT_SECTION();
pgaio_my_backend->num_staged_ios = 0;

어떤 메서드가 IO를 실행했든, 완료는 하나의 함수 pgaio_io_process_completion()으로 수렴한다. 항상 크리티컬 섹션 안에서 호출되고(Assert(CritSectionCount > 0)), 핸들을 SUBMITTED → COMPLETED_IO로 진행시키고, 공유 완료 콜백(모든 백엔드에 보이는 공유 상태 갱신 — 버퍼 유효 표시, PgAioResult로 오류 증류)을 실행하고, COMPLETED_SHARED로 이동한 뒤 핸들의 조건 변수를 브로드캐스트해 대기자를 깨운다. 발행 백엔드가 완료를 처리하는 경우에만 로컬 콜백을 실행하고 핸들을 회수한다.

// pgaio_io_process_completion — src/backend/storage/aio/aio.c
Assert(ioh->state == PGAIO_HS_SUBMITTED);
Assert(CritSectionCount > 0);
ioh->result = result;
pgaio_io_update_state(ioh, PGAIO_HS_COMPLETED_IO);
pgaio_io_call_complete_shared(ioh);
pgaio_io_update_state(ioh, PGAIO_HS_COMPLETED_SHARED);
ConditionVariableBroadcast(&ioh->cv);
if (ioh->owner_procno == MyProcNumber)
pgaio_io_reclaim(ioh); /* 로컬 콜백도 실행 */

공유/로컬 분리는 EXEC_BACKEND 제약을 구체화한 것이다. 공유 콜백(PGAIO_HCB_SHARED_BUFFER_READV)은 완료를 수확하는 임의 프로세스에서 실행되므로 공유 메모리만 건드린다. 로컬 콜백은 발행자에서만 실행되므로 백엔드 로컬 상태를 건드릴 수 있다. 둘 다 포인터가 아닌 1바이트 PgAioHandleCallbackID로 명명된다.

flowchart TD
  A["pgaio_io_acquire_nb()<br/>IDLE to HANDED_OUT"] --> B["bufmgr/smgr/md가 IO 정의<br/>콜백 등록"]
  B --> C["pgaio_io_stage()<br/>스테이지 콜백, DEFINED to STAGED"]
  C -->|동기 필요| D["pgaio_io_perform_synchronously()"]
  C -->|비동기| E["staged_ios[]<br/>추가, 배치 여부 결정"]
  E --> F["pgaio_submit_staged()<br/>CRIT: method->submit()"]
  F --> G["SUBMITTED<br/>커널/워커 소유"]
  G --> H["완료 수확<br/>워커, io_uring drain, 또는 sync"]
  H --> I["pgaio_io_process_completion()<br/>CRIT: 공유 콜백, COMPLETED_SHARED"]
  I --> J["ConditionVariableBroadcast(cv)<br/>대기자 깨움"]
  I -->|"발행자 == self"| K["pgaio_io_reclaim()<br/>로컬 콜백, 세대 증가, IDLE"]
  J -.->|"pgaio_wref_wait()"| K

sync 메서드는 퇴화 케이스이자 안전망이다. submit 훅이 없다. needs_synchronous_execution이 true를 반환하므로 IO가 스테이징 중 인라인으로 실행된다. 각 IO가 발행된 위치에서 블로킹 preadv()/pwritev()로 수행된다. 두 가지 이유로 존재한다. 상위 계층을 AIO 기계 없이 디버깅하기 위함이고, 워커도 io_uring도 사용 불가능한 플랫폼이나 빌드에서 폴백을 제공하기 위함이다. 프로세스 외부 완료가 없으므로 구조상 모든 교착 문제를 우회한다.

메서드 2 — worker (method_worker.c, 기본값)

섹션 제목: “메서드 2 — worker (method_worker.c, 기본값)”

워커 모드는 공유 메모리 링 PgAioWorkerSubmissionQueue로 IO를 전용 B_IO_WORKER 프로세스 풀(기본 io_workers = 3, 상한 MAX_IO_WORKERS = 32)에 위임한다. 제출은 AioWorkerSubmissionQueueLock 아래에서 각 스테이징된 핸들을 큐에 삽입하고 유휴 워커의 래치를 설정해 깨운다. 큐가 꽉 찬 IO는 나머지를 위임한 후에 제출자가 동기적으로 실행해 동시성을 높게 유지한다.

// pgaio_worker_submit_internal — src/backend/storage/aio/method_worker.c
LWLockAcquire(AioWorkerSubmissionQueueLock, LW_EXCLUSIVE);
for (int i = 0; i < num_staged_ios; ++i)
{
if (!pgaio_worker_submission_queue_insert(staged_ios[i]))
{
/* 큐 꽉 참: 위임 후 동기 폴백 */
synchronous_ios[nsync++] = staged_ios[i];
continue;
}
if (wakeup == NULL)
{
worker = pgaio_worker_choose_idle();
if (worker >= 0)
wakeup = io_worker_control->workers[worker].latch;
}
}
LWLockRelease(AioWorkerSubmissionQueueLock);
if (wakeup)
SetLatch(wakeup);

IoWorkerMain()은 워커별 루프다. 락 아래에서 큐 항목 하나를 소비하고, 큐가 깊으면 최대 IO_WORKER_WAKEUP_FANOUT(2)개의 동료를 깨우고, 파일 디스크립터를 다시 연다(pgaio_io_reopen() — 워커는 다른 프로세스여서 발행자의 열린 fd를 공유하지 않는다). 그 후 IO를 동기적으로 수행한다. 완료는 워커 안에서 실행된다. 발행 백엔드가 블록 상태여도 워커가 IO를 완료하므로 README의 교착 방지 규칙을 충족한다.

// IoWorkerMain — src/backend/storage/aio/method_worker.c
LWLockAcquire(AioWorkerSubmissionQueueLock, LW_EXCLUSIVE);
if ((io_index = pgaio_worker_submission_queue_consume()) == -1)
io_worker_control->idle_worker_mask |= (UINT64_C(1) << MyIoWorkerId);
else
{
io_worker_control->idle_worker_mask &= ~(UINT64_C(1) << MyIoWorkerId);
nwakeups = Min(pgaio_worker_submission_queue_depth(), IO_WORKER_WAKEUP_FANOUT);
/* ... 동료 래치 수집 ... */
}
LWLockRelease(AioWorkerSubmissionQueueLock);
// ... 실제 작업:
HOLD_INTERRUPTS();
pgaio_io_reopen(ioh);
pgaio_io_perform_synchronously(ioh); /* 자체 크리티컬 섹션 포함 */
RESUME_INTERRUPTS();
flowchart LR
  subgraph Issuer["발행 백엔드"]
    S["pgaio_worker_submit_internal()<br/>링에 삽입"]
  end
  subgraph SHM["공유 메모리"]
    Q["PgAioWorkerSubmissionQueue<br/>(io 핸들 인덱스 링)"]
    M["idle_worker_mask"]
  end
  subgraph Workers["B_IO_WORKER 풀 (io_workers, 최대 32)"]
    W1["IoWorkerMain #0<br/>재오픈 + preadv + 완료"]
    W2["IoWorkerMain #1"]
    W3["IoWorkerMain #2"]
  end
  S -->|"queue_insert + SetLatch"| Q
  Q --> W1
  Q --> W2
  Q --> W3
  W1 -->|"pgaio_io_process_completion()"| SHM
  M -.->|"choose_idle"| S

메서드 3 — io_uring (method_io_uring.c, Linux 전용)

섹션 제목: “메서드 3 — io_uring (method_io_uring.c, Linux 전용)”

io_uring 모드는 각 백엔드에 자체 링(PgAioUringContext, 공유 메모리의 pgaio_uring_procs() 슬롯당 하나)을 부여한다. io_uring_queue_init_mem()으로 생성하므로 링 버퍼가 공유 세그먼트에 위치한다. 제출은 pgaio_uring_sq_from_io()로 스테이징된 IO당 SQE(submission-queue entry)를 채우고 io_uring_submit()을 호출한다. 미묘한 점이 있다. 버퍼드 IO의 경우 여러 IO가 이미 인플라이트 상태이면 IOSQE_ASYNC를 설정해 커널이 페이지 캐시 복사를 자체 워커 스레드로 위임하게 한다. 처음 몇 개는 인라인이 레이턴시가 낮지만 부하 상태에서는 복사가 직렬화되기 때문이다.

// pgaio_uring_submit — src/backend/storage/aio/method_io_uring.c
sqe = io_uring_get_sqe(uring_instance);
if (!sqe)
elog(ERROR, "io_uring submission queue is unexpectedly full");
pgaio_io_prepare_submit(ioh);
pgaio_uring_sq_from_io(ioh, sqe);
if (in_flight_before > 4 && (ioh->flags & PGAIO_HF_BUFFERED))
io_uring_sqe_set_flags(sqe, IOSQE_ASYNC);
// ... io_uring_submit() 루프, EINTR/EAGAIN 처리 ...

핵심 특성은 어떤 백엔드든 다른 백엔드의 링을 드레인할 수 있다는 점이다. 링이 공유 메모리에 있지만 커널의 CQ 수확 경로는 멀티프로세스 안전을 보장하지 않으므로, 각 컨텍스트는 completion_lock LWLock을 갖는다. pgaio_uring_drain_locked()는 배치로 CQE를 피크하고, 각 CQE의 사용자 데이터에 저장된 PgAioHandle *를 복원해 모든 메서드가 공통으로 쓰는 pgaio_io_process_completion()으로 라우팅한다.

// pgaio_uring_drain_locked — src/backend/storage/aio/method_io_uring.c
Assert(LWLockHeldByMeInMode(&context->completion_lock, LW_EXCLUSIVE));
orig_ready = ready = io_uring_cq_ready(&context->io_uring_ring);
while (ready > 0)
{
ncqes = io_uring_peek_batch_cqe(&context->io_uring_ring, cqes,
Min(PGAIO_MAX_LOCAL_COMPLETED_IO, ready));
ready -= ncqes;
for (int i = 0; i < ncqes; i++)
{
struct io_uring_cqe *cqe = cqes[i];
PgAioHandle *ioh = io_uring_cqe_get_data(cqe);
io_uring_cqe_seen(&context->io_uring_ring, cqe);
pgaio_io_process_completion(ioh, cqe->res);
}
}

워커 모드가 완료를 위임하는 반면, io_uring 모드는 완료를 전역 드레이너블하게 만든다. 백엔드 A가 발행한 IO를 기다리는 백엔드 B는 단순히 A의 completion_lock을 잡고 직접 수확한다. PgAioUringContext 주석이 이를 직접 명시한다. “여러 백엔드가 이 백엔드의 io_uring 인스턴스 완료를 처리할 수 있다 … 한 번에 단 하나의 백엔드만 io 완료를 처리한다.”

핸들은 IO 완료 즉시 회수·재활용되므로 핸들 포인터 자체를 기다리는 것은 안전하지 않다. 발행자는 제출 전에 PgAioWaitRef(인덱스 + 64비트 세대)를 확보하고, pgaio_wref_wait()는 세대가 앞으로 이동했으면 즉시 반환한다. 저수준 pgaio_io_wait()는 읽기 배리어 뒤에서 세대 비교를 수행하는 pgaio_io_was_recycled()를 확인한다. 오래된 대기는 use-after-free가 아닌 저렴한 no-op이 된다.

읽기 스트림 소비자 (read_stream.c)

섹션 제목: “읽기 스트림 소비자 (read_stream.c)”

거의 모든 호출자는 핸들 API를 직접 건드리지 않는다. 순차 스캔, VACUUM, ANALYZE, 비트맵 힙 스캔 등은 read_stream_begin_relation()(또는 read_stream_begin_smgr_relation())으로 ReadStream을 생성하고, 연속 블록 번호를 반환하는 ReadStreamBlockNumberCB를 전달한 뒤, read_stream_next_buffer()로 핀된 버퍼를 하나씩 꺼낸다. 스트림이 “이 블록들을 읽고 싶다”는 의도를 깊고 결합되고 동시적인 AIO로 변환한다.

read_stream_look_ahead()가 엔진이다. 두 가지 예산이 허용하는 동안 실행된다. 인플라이트 읽기가 max_ios보다 적고, 핀된 또는 대기 중인 버퍼 수가 적응형 선독 distance보다 적을 때다. 새 블록이 인접하면 대기 중인 읽기에 병합하고, io_combine_limit에 도달하거나 더 이상 확장이 불가능하면 벡터드 AIO를 발행한다.

// read_stream_look_ahead — src/backend/storage/aio/read_stream.c
while (stream->ios_in_progress < stream->max_ios &&
stream->pinned_buffers + stream->pending_read_nblocks < stream->distance)
{
// ...
blocknum = read_stream_get_block(stream, per_buffer_data);
if (blocknum == InvalidBlockNumber)
{
stream->distance = 0; /* 스트림 끝 */
break;
}
/* 인접한가? 대기 중인 벡터드 읽기에 병합 */
if (stream->pending_read_nblocks > 0 &&
stream->pending_read_blocknum + stream->pending_read_nblocks == blocknum)
{
stream->pending_read_nblocks++;
continue;
}
/* 비연속: 대기 중인 읽기를 플러시하고 새 읽기 시작 */
while (stream->pending_read_nblocks > 0)
{
if (!read_stream_start_pending_read(stream) ||
stream->ios_in_progress == stream->max_ios)
{
read_stream_unget_block(stream, blocknum); /* 되감기, 중단 */
return;
}
}
stream->pending_read_blocknum = blocknum;
stream->pending_read_nblocks = 1;
}

read_stream_next_buffer()는 소비자 측이며 distance가 조정되는 위치다. 반환할 버퍼 뒤의 IO를 실제로 기다려야 하면(WaitReadBuffers), 선독 거리를 두 배로 늘린다(max_pinned_buffers로 상한). 실제 I/O가 계속 발생하는 스트림은 선독 깊이를 빠르게 늘리고, 모두 캐시에서 찾는 스트림은 거리를 1로 줄여 불필요한 선독을 없앤다.

// read_stream_next_buffer — src/backend/storage/aio/read_stream.c
WaitReadBuffers(&stream->ios[io_index].op);
stream->ios_in_progress--;
if (++stream->oldest_io_index == stream->max_ios)
stream->oldest_io_index = 0;
/* I/O 후 선독 거리를 빠르게 늘린다. */
distance = stream->distance * 2;
distance = Min(distance, stream->max_pinned_buffers);
stream->distance = distance;

batch_mode에 대한 주의 사항이 있다. 스트림의 콜백이 교착 안전하다고 알려진 경우, 스트림은 선독을 pgaio_enter_batchmode() / pgaio_exit_batchmode()로 감싸서 여러 읽기를 스테이징한 뒤 하나의 pgaio_submit_staged() 호출로 제출한다. 제출 비용이 분산된다. 기본값은 각 IO를 즉시 제출하는 것이며(pgaio_io_stage() 참고), 미제출 IO를 배치로 들고 있는 것 자체가 콜백이 블록할 경우 교착을 유발할 수 있기 때문이다.

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

섹션 제목: “위치 힌트 (2026-06-05 기준, REL_18 273fe94)”
심볼파일
pgaio_io_acquiresrc/backend/storage/aio/aio.c162
pgaio_io_acquire_nbsrc/backend/storage/aio/aio.c188
pgaio_io_get_wrefsrc/backend/storage/aio/aio.c366
pgaio_io_update_statesrc/backend/storage/aio/aio.c386
pgaio_io_stagesrc/backend/storage/aio/aio.c424
pgaio_io_prepare_submitsrc/backend/storage/aio/aio.c510
pgaio_io_process_completionsrc/backend/storage/aio/aio.c528
pgaio_io_was_recycledsrc/backend/storage/aio/aio.c558
pgaio_io_waitsrc/backend/storage/aio/aio.c579
pgaio_io_wait_for_freesrc/backend/storage/aio/aio.c761
pgaio_wref_waitsrc/backend/storage/aio/aio.c991
pgaio_submit_stagedsrc/backend/storage/aio/aio.c1123
PgAioWorkerSubmissionQueue (struct)src/backend/storage/aio/method_worker.c55
pgaio_worker_submission_queue_insertsrc/backend/storage/aio/method_worker.c181
pgaio_worker_submission_queue_consumesrc/backend/storage/aio/method_worker.c202
pgaio_worker_submit_internalsrc/backend/storage/aio/method_worker.c244
pgaio_worker_submitsrc/backend/storage/aio/method_worker.c295
IoWorkerMainsrc/backend/storage/aio/method_worker.c386
io_workers (GUC 변수)src/backend/storage/aio/method_worker.c94
PgAioUringContext (struct)src/backend/storage/aio/method_io_uring.c87
pgaio_uring_submitsrc/backend/storage/aio/method_io_uring.c405
pgaio_uring_sq_from_iosrc/backend/storage/aio/method_io_uring.c(선언 59)
pgaio_uring_drain_lockedsrc/backend/storage/aio/method_io_uring.c526
pgaio_uring_wait_onesrc/backend/storage/aio/method_io_uring.c584
read_stream_get_blocksrc/backend/storage/aio/read_stream.c179
read_stream_start_pending_readsrc/backend/storage/aio/read_stream.c230
read_stream_look_aheadsrc/backend/storage/aio/read_stream.c429
read_stream_begin_relationsrc/backend/storage/aio/read_stream.c746
read_stream_next_buffersrc/backend/storage/aio/read_stream.c800
PGAIO_SUBMIT_BATCH_SIZE (=32)src/include/storage/aio_internal.h28
PgAioHandleState (enum)src/include/storage/aio_internal.h43
IoMethodOps (vtable)src/include/storage/aio_internal.h260
PgAioHandleCallbackID (enum)src/include/storage/aio.h192
PgAioResult (struct)src/include/storage/aio_types.h99
MAX_IO_WORKERS (=32)src/include/storage/proc.h460

아래 모든 주장은 /data/hgryoo/references/postgres, 브랜치 REL_18_STABLE, 커밋 273fe94852b3a7e34fd171e8abdf1481beb302fa(PostgreSQL 18.x) 소스를 직접 확인해 검증했다.

  • 세 가지 io_method, worker가 기본값. src/include/storage/aio.hIoMethodIOMETHOD_SYNC, IOMETHOD_WORKER, IOMETHOD_IO_URING(IOMETHOD_IO_URING_ENABLED 가드)을 열거한다. 기본값은 워커 모드이고 io_workers = 3이 기본 워커 수다(method_worker.c).
  • 최대 하나의 핸드아웃 IO. pgaio_io_acquire_nb()(aio.c)가 handed_out_io가 이미 설정된 경우 elog(ERROR, "API violation: Only one IO can be handed out")를 발생시킨다. 원문 그대로 검증됨.
  • 제출 배치 크기 32. PGAIO_SUBMIT_BATCH_SIZEaio_internal.h32로 정의돼 있다. staged_ios[PGAIO_SUBMIT_BATCH_SIZE]가 백엔드별 스테이징 배열이다.
  • 8단계 핸들 상태 머신. PgAioHandleStateaio_internal.h에 정확히 PGAIO_HS_IDLE, _HANDED_OUT, _DEFINED, _STAGED, _SUBMITTED, _COMPLETED_IO, _COMPLETED_SHARED, _COMPLETED_LOCAL(8개)을 나열한다.
  • 완료는 크리티컬 섹션에서 실행. pgaio_io_process_completion()CritSectionCount > 0을 단언하고, pgaio_submit_staged()pgaio_method_ops->submit()START_CRIT_SECTION() / END_CRIT_SECTION()으로 감싼다. 확인됨.
  • 콜백은 포인터가 아닌 정수 ID. PgAioHandleCallbackID(aio.h)가 열거형(PGAIO_HCB_INVALID, PGAIO_HCB_MD_READV, PGAIO_HCB_SHARED_BUFFER_READV, PGAIO_HCB_LOCAL_BUFFER_READV)이다. README가 EXEC_BACKEND ASLR 아래에서 공유 메모리에 함수 포인터를 담을 수 없음을 명시하며 간접 참조의 동기를 설명한다.
  • PgAioResult는 8바이트, 비트 패킹. aio_types.h가 비트필드 id, status, error_dataint32 result로 정의하고, StaticAssertDecl(sizeof(PgAioResult) == 8, ...)로 크기를 강제한다.
  • 워커 풀 상한 32. MAX_IO_WORKERSsrc/include/storage/proc.h32로 정의돼 있다. IO_WORKER_WAKEUP_FANOUTmethod_worker.c2다. 워커는 B_IO_WORKER로 실행된다(IoWorkerMain에서 MyBackendType = B_IO_WORKER).
  • io_uring: 백엔드당 하나의 링, 어떤 백엔드도 드레인 가능. PgAioUringContext가 링당 completion_lock LWLock을 갖는다. 헤더 주석이 여러 백엔드가 그 락 아래에서 하나의 링을 드레인할 수 있음을 확인한다. pgaio_uring_drain_locked()io_uring_cqe_get_data()로 핸들을 복원하고 pgaio_io_process_completion()으로 라우팅한다.
  • io_uring 버퍼드 IO 위임 휴리스틱. pgaio_uring_submit()in_flight_before > 4 && (ioh->flags & PGAIO_HF_BUFFERED)인 경우에만 IOSQE_ASYNC를 설정한다. 원문 그대로 검증됨.
  • 읽기 스트림 거리는 실제 I/O 후 두 배. read_stream_next_buffer()WaitReadBuffers() 직후 distance = stream->distance * 2; distance = Min(distance, stream->max_pinned_buffers)를 계산한다. 선독은 max_ios와 적응형 distance로 경계 설정되며, 병합은 io_combine_limit(bufmgr.hDEFAULT_IO_COMBINE_LIMIT = Min(MAX_IO_COMBINE_LIMIT, (128*1024)/BLCKSZ))으로 상한된다.
  • “AIO 없이는 Direct IO가 사용 불가능할 정도로 느리다.” src/backend/storage/aio/README.md(Motivation)에서 인용. AIO가 다이렉트 I/O의 독립 기능이 아닌 전제 조건임을 확인한다.
  • PGAIO_HCB_SHARED_BUFFER_READV 본체(페이지 유효성 표시, 체크섬 검증)와 StartReadBuffer/WaitReadBuffersbufmgr.c에 있으며 postgres-buffer-manager.md에서 다룬다.
  • PGAIO_HCB_MD_READV와 블록-세그먼트 변환은 md.c / smgr.c에 있으며 postgres-smgr-md.md를 참고한다.
  • 백엔드별 핸들 풀 정확한 크기 산정(AioProcs() * io_max_concurrency)은 aio_init.c / aio_funcs.c에 있다. 문서는 형태를 명시하지만 보조 프로세스 회계 세부 사항은 줄 단위로 검증되지 않았다.
  • io_uring 기능 탐지(pgaio_uring_check_capabilities(), io_uring_queue_init_mem() vs io_uring_queue_init() 폴백)는 설계 수준에서만 기술됐다.

PostgreSQL 너머 — 비교 설계와 연구 프런티어

섹션 제목: “PostgreSQL 너머 — 비교 설계와 연구 프런티어”

PostgreSQL의 AIO 서브시스템은 다른 엔진들보다 늦게 도착했고, 설계에는 그들을 지켜보며 얻은 교훈이 반영돼 있다.

SQL Server는 1990년대부터 비동기 스캐터/개더 읽기 선독 매니저를 갖고 있다. 버퍼 매니저가 쿼리 프로세서가 선언한 접근 패턴을 기반으로 읽기 선독을 발행하고(범위 스캔이 다음 N 페이지를 스토리지 엔진에 알린다), 완료는 IOCP(I/O Completion Ports)가 처리한다. io_uring의 전역 드레이너블 완료 모델과 구조적으로 유사하다. 핵심 구조 차이는 스레딩이다. SQL Server는 태스크당 스레드(또는 파이버) 엔진이므로 미완료 읽기가 PostgreSQL 백엔드에서 동기 pread()가 블록하는 방식으로 워커를 블록하지 않는다. PostgreSQL의 프로세스 모델이 공유 메모리 핸들과 정수 콜백 ID를 필요로 하는 이유다. 스레드 엔진이 개인 스택에 보관하는 상태를 여기서는 어떤 프로세스에서도 접근 가능하게 만들어야 한다.

OracleDBWR/LGWR 백그라운드 프로세스와 멀티블록 읽기용 db_file_multiblock_read_count로 비동기 I/O를 노출한다. PostgreSQL io_combine_limit의 직계 조상이다. Oracle의 ASM과 다이렉트 패스 읽기는 PostgreSQL의 다이렉트 I/O + AIO가 하는 것처럼 OS 캐시를 우회하며, Oracle은 PG18 README가 이제 명시하는 것을 오래전에 결론 내렸다. 엔진 구동 선독 없는 다이렉트 I/O는 성능 함정이라는 것이다.

io_uring 인터페이스 자체(Axboe, 2019)가 핵심 기술이며, PostgreSQL의 채택은 신중한 것이다. io_uring의 제출/완료 링 쌍이 submit()/drain 모델에 거의 직접 대응하지만, PostgreSQL은 io_uring이 다루지 않는 문제를 해결해야 했다. 멀티프로세스 서버에서 완료가 어느 링에 착지하고 누가 수확할 수 있는가? 답은 공유 메모리 안의 백엔드당 하나의 링, 어떤 백엔드든 어떤 링이든 드레인할 수 있도록 링당 completion_lock으로 보호하는 것이다. Linux 프리미티브 위의 PostgreSQL 특화 접착제다. 버퍼드 IO에 대한 IOSQE_ASYNC 휴리스틱은 알려진 io_uring 날카로운 모서리를 반영한다. 인라인 실행이 제출 CPU에서 페이지 캐시 데이터를 복사해 부하 상태에서 직렬화되므로, 몇 개의 IO가 이미 인플라이트 상태이면 커널 워커로 위임한다.

연구 프런티어. 교과서적 프레이밍 — DBMS를 계산과 I/O의 중첩 기계로 보는 관점(Hellerstein, Stonebraker & Hamilton, dbms-papers/fntdb07-architecture.md 수록) — 은 이제 두 방향으로 밀리고 있다. 첫째, 학습 및 적응형 선독이다. PostgreSQL 읽기 스트림 거리 휴리스틱(미스 후 두 배, 히트 후 축소)은 수작업 조정 컨트롤러이며, 이런 컨트롤러를 접근 패턴을 예측하는 모델로 교체하는 활발한 연구가 있다. 둘째, 커널 우회와 컴퓨테이셔널 스토리지다. SPDK 스타일 사용자 공간 NVMe 드라이버와 디바이스에서 프레디케이트 평가를 실행하는 스마트 SSD는 “I/O와 계산의 중첩” 아이디어를 호스트 측 AIO 계층이 할 수 있는 것 이상으로 밀어붙인다. PostgreSQL의 플러거블 IoMethodOps vtable은 의도적으로 그런 메서드가 sync, worker, io_uring 옆에 언젠가 끼어들 수 있는 솔기다. 교착 회피 계약(메서드는 어떤 백엔드든 IO를 완료할 수 있도록 하거나 대역 외 완료를 보장해야 한다)이 미래 메서드가 반드시 지켜야 할 불변식이다.

  • 코드 (REL_18_STABLE, 커밋 273fe94):
    • src/backend/storage/aio/aio.c — 핸들 수명 주기, 스테이징, 제출, 완료, 대기 참조.
    • src/backend/storage/aio/aio_callback.c, aio_target.c, aio_io.c — 콜백 디스패치, 타깃 추상화, IO 연산 정의.
    • src/backend/storage/aio/aio_init.c, aio_funcs.c — 공유 메모리 크기 산정, SQL 가시 인트로스펙션.
    • src/backend/storage/aio/method_sync.c — 동기 폴백 메서드.
    • src/backend/storage/aio/method_worker.c — 워커 풀, 제출 큐, IoWorkerMain, B_IO_WORKER.
    • src/backend/storage/aio/method_io_uring.c — 백엔드당 링, 제출, 드레인, wait_one.
    • src/backend/storage/aio/read_stream.c — 선독, IO 결합, 적응형 거리, 소비자 API.
    • src/backend/storage/aio/README.md — 동기, 교착/기아 설계 기준, EXEC_BACKEND 콜백 ID 논거.
    • src/include/storage/aio.h, aio_internal.h, aio_types.h — 공개 API, IoMethodOps vtable, PgAioHandleState, PgAioResult, 콜백 ID.
    • src/include/storage/proc.hMAX_IO_WORKERS.
    • src/include/storage/bufmgr.hio_combine_limit, MAX_IO_COMBINE_LIMIT.
  • 이론: Hellerstein, Stonebraker & Hamilton, Architecture of a Database System (FnTDB 2007) — 계산과 I/O 중첩. 로컬 사본 knowledge/research/dbms-papers/fntdb07-architecture.md.
  • 관련 KB 문서: postgres-buffer-manager.md (공유 버퍼 완료 콜백, StartReadBuffer/WaitReadBuffers), postgres-smgr-md.md (세그먼트/fd 변환, PGAIO_HCB_MD_READV), postgres-shared-memory-ipc.md (공유 메모리 레이아웃, LWLock), postgres-aux-processes.md (B_IO_WORKER 보조 프로세스 분류), postgres-checkpoint.mdpostgres-xlog-wal.md (WAL/데이터 쓰기의 AIO).