PostgreSQL 19에서 io_uring을 사용한 커널 관리 비동기 버퍼드 읽기와 worker 방식의 차이, 실행 계획, strace, 시스템 메트릭을 살펴봅니다.
이전 게시물에서는 Asynchronous Sequential Scan의 이점을 얻는 쿼리를 실행했습니다. OS 수준의 read 호출은 여전히 동기식(pread64(),preadv())으로 남아 있지만, PostgreSQL의 IO worker가 이를 발행하고 비동기 IO 큐를 관리합니다. Linux는 PostgreSQL이 io_uring 시스템 호출을 통해 직접 사용할 수 있는 비동기 버퍼드 I/O를 제공합니다.
이번 글에서는 worker 대신 io_uring IO method를 사용하여 같은 쿼리를 실행합니다. 저는 Docker 컨테이너 안에서 실행 중인데 Secure Computing Mode(seccomp)가 io_uring 시스템 호출을 비활성화하므로, seccomp를 비활성화한 상태로 컨테이너를 시작했습니다:
docker run -d --name pg19 \
-p 5432:5432 \
-e POSTGRES_PASSWORD=xxx \
--security-opt seccomp=unconfined \
postgres:19beta1 \
-c io_method=io_uring
연결한 뒤(PGUSER=postgres PGPASSWORD=xxx PGHOST=localhost psql) 설정을 확인했습니다:
postgres=# \dconfig io_*
List of configuration parameters
Parameter | Value
---------------------------+----------
io_combine_limit | 128kB
io_max_combine_limit | 128kB
io_max_concurrency | 64
io_max_workers | 8
io_method | io_uring
io_min_workers | 2
io_worker_idle_timeout | 1min
io_worker_launch_interval | 100ms
(8 rows)
이전 글과 비슷하지만 io_method가 다릅니다. 큰 TOASTed 문서가 관련된 쿼리가 아니라, io_combine의 이점을 얻는 같은 쿼리를 실행하겠습니다:
postgres=# explain (analyze, buffers, io, costs off)
select count(*),avg(length(data)) from smalldocs;
QUERY PLAN
------------------------------------------------------------------------------------------------------
Finalize Aggregate (actual time=941.539..943.440 rows=1.00 loops=1)
Buffers: shared hit=15019 read=131281 dirtied=801 written=432
-> Gather (actual time=941.398..943.428 rows=3.00 loops=1)
Workers Planned: 2
Workers Launched: 2
Buffers: shared hit=15019 read=131281 dirtied=801 written=432
-> Partial Aggregate (actual time=939.501..939.502 rows=1.00 loops=3)
Buffers: shared hit=15019 read=131281 dirtied=801 written=432
-> Parallel Seq Scan on smalldocs (actual time=0.033..155.375 rows=341333.33 loops=3)
Prefetch: avg=74.32 max=91 capacity=94
I/O: count=8247 waits=54 size=15.92 in-progress=4.97
Buffers: shared hit=15019 read=131281 dirtied=801 written=432
Worker 0: Prefetch: avg=74.41 max=91 capacity=94
I/O: count=2695 waits=30 size=15.93 in-progress=4.98
Worker 1: Prefetch: avg=73.99 max=91 capacity=94
I/O: count=2760 waits=13 size=15.88 in-progress=4.95
Planning:
Buffers: shared hit=5
Planning Time: 0.094 ms
Execution Time: 943.470 ms
(20 rows)
이 실행 계획은 이전 것과 비슷한데, prefetch로 보이는 io combine이 worker와 io_uring 모두에서 같은 방식으로 동작하기 때문입니다. 이제 차이는 postgres: io worker 프로세스가 더 이상 보이지 않는다는 점이며, 이는 커널이 관리합니다.
저는 PostgreSQL backend와 parallel worker에 strace를 사용했습니다:
# echo 3 | sudo tee /proc/sys/vm/drop_caches &&
strace -fyye trace=io_uring_enter,io_uring_setup,io_uring_enter,io_uring_register -s 0 -qq \
-p $(pgrep -fd, "postgres: ") -T -o /dev/stdout
2143284 io_uring_enter(4, 1, 0, 0, NULL, 8) = 1 <0.000016>
2143284 io_uring_enter(4, 0, 1, IORING_ENTER_GETEVENTS, NULL, 8) = -1 EINTR (Interrupted system call) <0.000307>
2143284 io_uring_enter(4, 1, 0, 0, NULL, 8) = 1 <0.000019>
2143284 io_uring_enter(4, 1, 0, 0, NULL, 8) = 1 <0.000025>
2143284 io_uring_enter(4, 1, 0, 0, NULL, 8) = 1 <0.000025>
2143284 io_uring_enter(4, 0, 1, IORING_ENTER_GETEVENTS, NULL, 8) = 0 <0.001068>
2143284 io_uring_enter(4, 1, 0, 0, NULL, 8) = 1 <0.000034>
2143284 io_uring_enter(4, 1, 0, 0, NULL, 8) = 1 <0.000041>
2143284 io_uring_enter(4, 0, 1, IORING_ENTER_GETEVENTS, NULL, 8) = 0 <0.000714>
2143284 io_uring_enter(4, 1, 0, 0, NULL, 8) = 1 <0.000024>
2143284 io_uring_enter(4, 0, 1, IORING_ENTER_GETEVENTS, NULL, 8) = 0 <0.000720>
2143284 io_uring_enter(4, 1, 0, 0, NULL, 8) = 1 <0.000028>
2143284 io_uring_enter(4, 0, 1, IORING_ENTER_GETEVENTS, NULL, 8) = 0 <0.000505>
2143284 io_uring_enter(4, 1, 0, 0, NULL, 8) = 1 <0.000015>
2143284 io_uring_enter(4, 1, 0, 0, NULL, 8) = 1 <0.000046>
2143284 io_uring_enter(4, 0, 1, IORING_ENTER_GETEVENTS, NULL, 8) = 0 <0.000406>
2143284 io_uring_enter(4, 2, 0, 0, NULL, 8) = 2 <0.000043>
2143284 io_uring_enter(4, 1, 0, 0, NULL, 8) = 1 <0.000025>
2143284 io_uring_enter(4, 1, 0, 0, NULL, 8) = 1 <0.000088>
2143284 io_uring_enter(4, 1, 0, 0, NULL, 8) = 1 <0.000209>
2143284 io_uring_enter(4, 1, 0, 0, NULL, 8) = 1 <0.000096>
2143284 io_uring_enter(4, 1, 0, 0, NULL, 8) = 1 <0.000020>
2143284 io_uring_enter(4, 1, 0, 0, NULL, 8) = 1 <0.000018>
2143284 io_uring_enter(4, 1, 0, 0, NULL, 8) = 1 <0.000026>
2143284 io_uring_enter(4, 1, 0, 0, NULL, 8) = 1 <0.000024>
2143284 io_uring_enter(4, 1, 0, 0, NULL, 8) = 1 <0.000017>
2143284 io_uring_enter(4, 1, 0, 0, NULL, 8) = 1 <0.000016>
2143284 io_uring_enter(4, 1, 0, 0, NULL, 8) = 1 <0.000031>
2143284 io_uring_enter(4, 1, 0, 0, NULL, 8) = 1 <0.000053>
2143284 io_uring_enter(4, 1, 0, 0, NULL, 8) = 1 <0.000022>
2143284 io_uring_enter(4, 1, 0, 0, NULL, 8) = 1 <0.000013>
2143284 io_uring_enter(4, 1, 0, 0, NULL, 8) = 1 <0.000020>
2143284 io_uring_enter(4, 1, 0, 0, NULL, 8) = 1 <0.000016>
2143284 io_uring_enter(4, 0, 1, IORING_ENTER_GETEVENTS, NULL, 8) = 0 <0.000560>
...
시스템 호출은 io_uring_enter(fd, to_submit, min_complete, flags, sig, sigsz)이므로:
io_uring_enter(4, 1, 0, 0, NULL, 8) = 1은 다음을 의미합니다: 커널, 여기 내 submission queue에서 새로운 I/O 요청 하나를 보냅니다. 기다리지는 않겠습니다. 커널은 submission 하나가 소비되었다고 확인합니다.io_uring_enter(4, 0, 1, IORING_ENTER_GETEVENTS, NULL, 8) = 0은 다음을 의미합니다: 아무것도 submit하지 않습니다. 최소 하나의 completion이 가능해질 때까지 기다립니다. 반환값이 0인 이유는 이 호출에서 새로운 submission이 이루어지지 않았기 때문입니다. 짧은 경과 시간은 completion이 빠르게 가능해졌음을 보여줍니다.io_uring 추적은 PostgreSQL이 개별 read 작업을 기다리지 않는다는 점을 보여줍니다. 대신 backend는 io_uring_enter(..., 1, 0, ...)을 통해 요청을 계속 submit하고, io_uring_enter(..., 0, 1, IORING_ENTER_GETEVENTS, ...)으로 completion queue에서 완료된 요청을 가져옵니다. 대부분의 completion은 즉시 발생하는데, 이는 read 스트림이 충분한 I/O 활동을 유지하여 필요할 때 결과가 준비되어 있음을 시사합니다. 이런 동작은 EXPLAIN 통계와도 일치하는데, 깊은 prefetch queue, 큰 combined read, 그리고 수천 번의 I/O 작업에도 불구하고 최소한의 대기를 보여줍니다.
worker 구현과의 차이는 PostgreSQL이 무엇을 읽느냐가 아니라, 그 읽기를 어떻게 submit하고 완료하느냐입니다:
| io_mode=sync | io_mode=worker | io_mode=io_uring |
|---|---|---|
| postgres backend | postgres backend | postgres backend |
↳ pread64() or pread64() | (async)↳ postgres: io worker | (async)↳ io_uring_enter() |
| ↳ kernel | ↳ pread64() or pread64() | ↳ kernel |
| ↳ kernel |
다음으로 PostgreSQL query worker 다섯 개를 병렬로 두고 같은 테스트를 실행했습니다:
postgres=# \! echo 3 | sudo tee /proc/sys/vm/drop_caches
3
postgres=# set max_parallel_workers_per_gather = 5;
SET
postgres=# explain (analyze, buffers, io, settings, costs off)
select count(*),avg(length(data)) from smalldocs;
QUERY PLAN
--------------------------------------------------------------------------------------------------------
Finalize Aggregate (actual time=48293.834..48295.976 rows=1.00 loops=1)
Buffers: shared hit=9776 read=136512
-> Gather (actual time=48271.604..48295.958 rows=6.00 loops=1)
Workers Planned: 5
Workers Launched: 5
Buffers: shared hit=9776 read=136512
-> Partial Aggregate (actual time=48237.365..48237.365 rows=1.00 loops=6)
Buffers: shared hit=9776 read=136512
-> Parallel Seq Scan on smalldocs (actual time=1.863..47814.487 rows=170666.67 loops=6)
Prefetch: avg=80.29 max=92 capacity=94
I/O: count=8661 waits=8429 size=15.76 in-progress=4.97
Buffers: shared hit=9776 read=136512
Worker 0: Prefetch: avg=82.96 max=91 capacity=94
I/O: count=1434 waits=1418 size=15.95 in-progress=4.93
Worker 1: Prefetch: avg=80.43 max=91 capacity=94
I/O: count=1406 waits=1394 size=15.87 in-progress=4.89
Worker 2: Prefetch: avg=77.46 max=92 capacity=94
I/O: count=1428 waits=1384 size=15.66 in-progress=4.91
Worker 3: Prefetch: avg=81.88 max=91 capacity=94
I/O: count=1451 waits=1417 size=15.69 in-progress=5.02
Worker 4: Prefetch: avg=76.47 max=91 capacity=94
I/O: count=1434 waits=1395 size=15.69 in-progress=5.03
Settings: max_parallel_workers_per_gather = '5'
Planning Time: 0.060 ms
Execution Time: 48296.009 ms
(25 rows)
평균적으로 각 프로세스는 5개의 I/O 작업을 진행 중이며, 총합은 30개입니다. 실행이 계속되면서 부하 평균이 상승하는데, 이는 interrupt 불가능한 대기도 runnable task와 함께 계산되기 때문입니다:
top - 15:32:43 up 23 days, 40 min, 1 user, load average: 25.11, 10.84, 4.17
Threads: 1031 total, 1 running, 1030 sleeping, 0 stopped, 0 zombie
%Cpu(s): 2.4 us, 1.5 sy, 0.0 ni, 0.0 id, 95.3 wa, 0.1 hi, 0.7 si, 0.0 st
GiB Mem : 23.6 total, 14.9 free, 5.9 used, 2.8 buff/cache
GiB Swap: 4.0 total, 3.8 free, 0.2 used. 14.0 avail Mem
PID USER VIRT S %CPU %MEM TIME+ COMMAND WCHAN
2187030 root 0.0m I 1.3 0.0 0:04.41 [kworker/u8:4-iscsi_q_1] -
2211481 opc 221.9m R 1.3 0.0 0:02.65 top -
2143284 100998 251.3m S 1.0 0.7 0:47.66 postgres: postgres postgres 10.0.2.100(35862) EXPLAIN arm64_sys+
2212667 100998 247.1m S 1.0 0.2 0:00.38 postgres: parallel worker for PID 73 arm64_sys+
2212669 100998 247.1m S 1.0 0.1 0:00.39 postgres: parallel worker for PID 73 arm64_sys+
2212670 100998 247.1m S 1.0 0.1 0:00.39 postgres: parallel worker for PID 73 arm64_sys+
2212671 100998 247.1m S 1.0 0.2 0:00.39 postgres: parallel worker for PID 73 arm64_sys+
2212668 100998 247.1m S 0.7 0.2 0:00.40 postgres: parallel worker for PID 73 arm64_sys+
2212257 100998 251.3m D 0.3 0.7 0:00.07 postgres: postgres postgres 10.0.2.100(35862) EXPLAIN generic_f+
2210102 root 0.0m I 0.3 0.0 0:01.36 [kworker/u8:1-xfs-cil/sdb] -
2212682 100998 247.1m D 0.3 0.2 0:00.02 postgres: parallel worker for PID 73 generic_f+
2212694 100998 247.1m D 0.3 0.2 0:00.02 postgres: parallel worker for PID 73 generic_f+
2212794 100998 247.1m D 0.3 0.2 0:00.01 postgres: parallel worker for PID 73 generic_f+
4157944 opc 23.1m D 0.3 0.1 18:26.08 /usr/bin/fuse-overlayfs -o lowerdir=/data/opc/share/containers/storage/overlay/l/JU5OB2S2NJVEHXCBJJ2RUU7R4M:/data/opc/share/containers/storage/overlay/l/SCFJOZKVJCNPWIHHO5L+ wait_on_p+
1 root 380.1m S 0.0 0.1 7:11.80 /usr/lib/systemd/systemd --switched-root --system --deserialize 18 -
io_uring에서의 동작은 worker method보다 더 미묘합니다. 동기식 pread64() 또는 preadv()에서는 호출 프로세스가 read가 완료될 때까지 block되며, I/O 중에는 interrupt 불가능한 sleep(D 상태)에 들어갈 수 있습니다. 다음은 io_method=worker와 max_parallel_workers_per_gather = 5로 실행했을 때의 top입니다:
top - 18:06:57 up 23 days, 3:14, 1 user, load average: 8.15, 8.05, 4.48
Threads: 1014 total, 1 running, 1013 sleeping, 0 stopped, 0 zombie
%Cpu(s): 2.3 us, 3.3 sy, 0.0 ni, 3.5 id, 89.8 wa, 0.2 hi, 0.9 si, 0.0 st
MiB Mem : 24132.3 total, 16070.1 free, 6064.6 used, 1997.6 buff/cache
MiB Swap: 4095.9 total, 3878.6 free, 217.3 used. 14712.0 avail Mem
PID USER VIRT S %CPU %MEM TIME+ COMMAND
2221405 opc 227264 R 1.3 0.0 1:57.53 top
2291054 100998 234816 S 1.3 0.1 0:00.23 postgres: parallel worker for PID 75
2291057 100998 234816 S 1.3 0.1 0:00.23 postgres: parallel worker for PID 75
2280024 root 0 I 1.0 0.0 0:05.86 [kworker/u8:1-iscsi_q_1]
2287675 100998 235968 S 1.0 0.7 0:06.82 postgres: postgres postgres 10.0.2.100(39936) EXPLAIN
1680775 opc 24896 D 0.7 0.1 59:06.99 /usr/bin/fuse-overlayfs -o lowerdir=/data/opc/share/containers/storage/overlay/l/JU5OB2S2NJVEHXCBJJ2RUU7R4M:/data/opc/share/containers/storage/overlay/l/SCFJOZKV+
2265340 root 0 I 0.7 0.0 0:09.13 [kworker/u8:0-iscsi_q_1]
2291053 100998 234816 D 0.7 0.1 0:00.23 postgres: parallel worker for PID 75
2291055 100998 234816 D 0.7 0.1 0:00.23 postgres: parallel worker for PID 75
2291056 100998 234816 D 0.7 0.1 0:00.23 postgres: parallel worker for PID 75
2287499 100998 231808 D 0.3 0.6 0:00.74 postgres: io worker 0
2287500 100998 231808 D 0.3 0.5 0:00.53 postgres: io worker 1
2288950 100998 231808 D 0.3 0.5 0:00.54 postgres: io worker 2
2288953 100998 231808 D 0.3 0.5 0:00.41 postgres: io worker 3
2288954 100998 231808 D 0.3 0.5 0:00.44 postgres: io worker 4
2288955 100998 231808 D 0.3 0.5 0:00.39 postgres: io worker 5
io_uring에서는 PostgreSQL이 io_uring_enter()를 통해 요청을 submit하고, 그 read가 진행 중인 동안 다른 처리를 계속할 수 있습니다. 아직 가능한 completion이 필요할 때만 기다립니다.
쿼리를 다섯 개의 병렬 worker로 늘렸을 때, CPU는 거의 놀고 있었음에도 시스템의 부하 평균은 25를 넘었습니다:
load average: 25.11, 10.84, 4.17
%Cpu(s): 2.4 us, 1.5 sy, 95.3 wa
이 부하 평균이 대부분 CPU를 두고 경쟁하는 runnable 프로세스 때문이라면 CPU는 바빠야 합니다. 하지만 그렇지 않습니다. CPU 시간의 약 4%만 사용자 또는 커널 코드 실행에 쓰였고, 대부분의 시간은 I/O 대기에 쓰였습니다. Linux에서 부하 평균은 runnable task(R)뿐 아니라 interrupt 불가능한 sleep(D) 상태의 task도 포함합니다. 이런 높은 부하 평균, 대부분 유휴인 CPU, 그리고 지배적인 I/O wait의 조합은 병목이 CPU 용량이 아니라 스토리지 성능임을 시사합니다. 일부 대기는 top에 D 상태 task로 보이지만, 다른 것들은 스냅샷으로 포착하기에는 너무 짧으면서도 여전히 스케줄러의 부하 계산에 기여할 수 있습니다.
시스템이 io_uring을 사용하기 시작하면 시스템 관리자는 이런 점을 주의 깊게 살펴봐야 합니다. 눈에 띄는 R 또는 D 상태가 없는데도 부하 평균이 높은 상황은 분석하기 까다로울 수 있습니다.
PostgreSQL 수준에서 IO wait class는 비동기 IO에 대해 서로 다른 wait event를 반영합니다. io_submit=worker를 사용할 때 backend는 AioIoCompletion으로 io worker의 완료를 기다립니다:
io_submit=io_uring에서는 backend가 먼저 AioIoSubmission으로 IO submit을 기다리는데, 이는 매우 짧고, 그 다음 AioIoCompletion으로 IO 실행을 기다립니다:
전통적인 동기 IO 대기인 DataFileRead를 보여주기 위해, 이전 블로그 글의 TOASTed 테이블에서 select를 실행했습니다:
이는 비동기 IO가 항상 가능한 것은 아니라는 점을 다시 상기시켜 줍니다.
PostgreSQL 19의 비동기 I/O는 다른 테이블 블록을 읽는 것에 관한 것이 아닙니다. 동일한 sequential scan이 수행됩니다. 큰 테이블의 경우 블록은 sequential-scan ring buffer를 통해 buffer manager에서 읽히므로, 스캔은 전체 shared buffer pool을 가득 채우는 일을 피합니다.
차이는 대기를 조직하는 방식에 있습니다.
io_method=worker에서는 PostgreSQL backend가 read 요청을 전용 I/O worker 프로세스에 위임합니다. 이 worker는 동기식 pread64() 호출을 발행하고, 읽을 연속 크기가 둘 이상일 때는 preadv()를 사용하며, worker 프로세스는 커널이 각 read를 완료하는 동안 block될 수 있습니다.
io_method=io_uring에서는 PostgreSQL이 io_uring submission queue를 통해 커널에 직접 요청을 submit합니다. 커널은 completion queue를 통해 완료된 작업을 보고합니다. 따라서 PostgreSQL은 여러 read를 동시에 진행 상태로 유지할 수 있고, 대개 completion이 가능해지는 즉시 이를 소비할 수 있습니다. 요청했을 때 completion이 아직 가능하지 않다면 backend는 여전히 기다릴 수 있습니다.
io_combine은 그 선택과 독립적입니다. 여전히 가까운 블록 read를 더 큰 I/O 작업으로 결합합니다. io_method는 그 작업이 어떻게 submit되고 완료되는지를 결정합니다. 즉, 동기식 pread64() 또는 preadv()를 사용하는 PostgreSQL I/O worker를 통해서인지, 아니면 io_uring을 사용하는 커널 관리 비동기 I/O를 통해서인지입니다.
실행 계획, 추적, 시스템 메트릭은 모두 같은 이야기를 들려줍니다. PostgreSQL은 I/O 대기를 없애고 있는 것이 아닙니다. 대신 read를 결합하고, 깊은 prefetch 파이프라인을 유지하며, executor가 필요로 할 때 completion이 이미 준비되어 있는 경우가 많도록 충분한 미해결 요청을 유지함으로써 그 지연의 상당 부분을 숨깁니다. 이런 접근은 PostgreSQL이 앞으로의 페이지 접근을 예측할 수 있는 작업, 예를 들어 Sequential Scan, Bitmap Heap Scan, Vacuum에서 효과적입니다.