PREEMPT_NONE: 스레드가 자발적으로 CPU를 양보할 때까지(syscall, I/O 블록, sleep) 거의 중단하지 않음. 전통적인 서버 기본값으로 컨텍스트 스위치가 적고 처리량이 높음
PREEMPT_FULL: 안전한 거의 모든 지점에서 실행 중인 스레드를 중단 가능. 응답 시간은 줄지만 컨텍스트 스위치 오버헤드 증가. 전통적인 데스크톱 기본값
PREEMPT_LAZY: Linux 6.12에서 도입된 절충안으로, 자연스러운 경계를 기다리면서도 필요시 선점 허용. PREEMPT_NONE의 처리량 특성을 근사하도록 설계
Linux 7.0에서 PREEMPT_NONE이 최신 CPU 아키텍처에서 제거되어 PREEMPT_FULL과 PREEMPT_LAZY만 남음
PREEMPT_LAZY가 대부분의 서버 소프트웨어에서는 대체재로 작동하지만, PostgreSQL에서는 치명적 차이 발생
PostgreSQL 메모리 관리
PostgreSQL은 고정 크기의 데이터 페이지(기본 8KB)를 기본 저장 단위로 사용하며, 테이블 행, B-tree 인덱스 노드, 메타데이터 등 모두 이 페이지에 저장
디스크 읽기를 줄이기 위해 공유 버퍼 풀(shared buffer pool)이라는 대규모 공유 메모리 영역에 최근 읽은 데이터 페이지를 캐싱
클라이언트가 접속하면 전용 백엔드 프로세스가 생성되며, 버퍼 풀에 없는 페이지는 디스크에서 읽은 뒤 빈 버퍼 또는 축출 가능한 버퍼를 찾아야 함
이 버퍼 선택 작업을 담당하는 함수가 StrategyGetBuffer
PostgreSQL의 스핀락
스핀락은 잠금을 기다리며 잠들지 않고 루프를 돌며 계속 확인하는 잠금 메커니즘
매우 짧은 임계 구간에서는 스레드를 재우고 깨우는 비용보다 회전이 더 효율적
핵심 가정: 락을 보유한 스레드가 아주 빠르게 해제할 것
StrategyGetBuffer는 버퍼 선택을 보호하기 위해 단일 전역 스핀락 사용
96-vCPU, 1,024 클라이언트 환경에서 모든 백엔드가 동일 락을 놓고 경쟁
가상 메모리와 TLB
모든 프로세스는 가상 메모리 주소를 사용하며, 하드웨어가 페이지 테이블(다단계 트리 구조)을 통해 물리 주소로 변환
매번 페이지 테이블을 순회하면 느리므로, CPU는 최근 변환 결과를 캐싱하는 TLB(Translation Lookaside Buffer) 보유
TLB 히트 시 빠른 접근, TLB 미스 시 페이지 테이블 워크가 필요하여 시간 소요
Linux는 지연 할당(lazy allocation) 원칙을 사용하여, 가상 메모리 할당 시 실제 물리 페이지는 첫 접근 시점에 매핑
첫 접근 시 마이너 페이지 폴트 발생: 커널이 물리 페이지를 할당하고 매핑을 저장하며, 일반 읽기/쓰기보다 수 마이크로초 단위로 느림
4KB 페이지의 문제
벤치마크에서 shared_buffers를 120GB로 설정, 4KB 메모리 페이지 기준 약 3,100만 개의 메모리 페이지, 즉 3,100만 건의 잠재적 첫 접근 페이지 폴트
120GB 공유 버퍼 풀을 사용하는 장시간 벤치마크에서는 새로운 메모리 영역이 계속 워킹셋에 진입하므로 페이지 폴트가 시작 시에만이 아니라 지속적으로 발생
StrategyGetBuffer 내에서 스핀락을 보유한 상태로 공유 메모리에 접근할 때 해당 영역이 아직 매핑되지 않았으면 마이너 페이지 폴트 발생
PREEMPT_NONE(Linux 7.0 이전): 백엔드 A가 페이지 폴트 핸들러에 진입해도 자발적 재스케줄링 포인트를 회피하므로, 폴트 해결 전에 스케줄 아웃될 가능성이 낮음. 대기 시간이 예상보다 길어지지만 피해 제한적
PREEMPT_LAZY(Linux 7.0 이후): 스케줄러가 페이지 폴트 핸들러 내부에서 백엔드 A를 선점하여 다른 프로세스를 스케줄링할 수 있음. 폴트 처리가 완료되어도 스케줄러가 제어권을 돌려줄 때까지 추가 대기 시간 t 발생
이 추가 대기 시간은 단순 t가 아니라 현재 회전 중인 모든 백엔드 수 × t의 CPU 낭비로 증폭
96-vCPU에 수백 개 백엔드 환경에서 이 승수 효과가 치명적이며, 결과적으로 CPU의 56%가 s_lock에서 소모
Huge Pages를 통한 해결
shared_buffers 120GB 기준, 메모리 페이지 크기를 변경하면 잠재적 페이지 폴트 수가 극적으로 감소
4KB 페이지: ~31,000,000건의 잠재적 페이지 폴트
2MB Huge Pages: ~61,440건
1GB Huge Pages: ~120건
페이지 크기 증가는 페이지 폴트 수 감소뿐 아니라 TLB 압력도 완화: 훨씬 적은 TLB 엔트리로 동일 메모리를 커버하므로 TLB 미스와 페이지 테이블 워크 감소
StrategyGetBuffer가 락 보유 중 폴트를 발생시키지 않게 되어 락 보유자가 빠르게 완료, 다른 백엔드는 밀리초 대신 마이크로초만 대기. 회귀 현상 해소
PostgreSQL에서 huge pages 설정은 huge_pages 파라미터로 제어
off, on, try(기본값) 세 가지 값 지원
try는 huge pages가 가능하면 사용하고 불가능하면 4KB로 조용히 폴백하므로, 잘못된 구성을 인지하지 못할 위험
on으로 설정하면 huge pages를 사용할 수 없을 때 PostgreSQL이 시작에 실패하여 문제를 즉시 인지 가능
트레이드오프: huge pages는 사전 할당·예약 방식이므로 PostgreSQL이 전부 사용하지 않더라도 해당 메모리를 시스템 나머지 부분에서 사용 불가. 페이지 일부만 사용될 경우 나머지가 낭비. 대규모 shared_buffers를 사용하는 프로덕션 환경에서는 대체로 트레이드오프 감수 가치 있음
향후 전개
선점 변경을 설계한 Intel 커널 엔지니어 Peter Zijlstra는 PostgreSQL이 Restartable Sequences(rseq) 를 채택할 것을 제안
rseq는 유저스페이스 코드가 임계 구간 중 선점 또는 마이그레이션 여부를 감지하고 해당 구간을 재시작할 수 있게 하는 Linux 커널 기능
PostgreSQL의 스핀락 경로에 rseq를 적용하면 선점된 락 보유자가 모든 대기 백엔드를 지연시키는 시나리오 회피 가능
PostgreSQL 커뮤니티의 반응은 부정적
Linux 7.0 이전에는 무료로 확보되던 성능을 되찾기 위해 별도 커널 기능을 채택해야 한다는 점이 수용하기 어려움
커널의 오랜 원칙인 "유저스페이스를 깨뜨리지 않는다"(커널 업그레이드 전 정상 작동하던 소프트웨어는 업그레이드 후에도 정상 작동해야 함)에 위배된다는 입장