포스트

블록 I/O와 페이지 캐시 — 같은 파일을 두 번째 읽으면 왜 빠른가

같은 파일을 두 번째 읽으면 빠른 이유 — 페이지 캐시와 라이트백, readahead, 블록 I/O 스케줄러, O_DIRECT를 깊이 있게 다룹니다.

블록 I/O와 페이지 캐시 — 같은 파일을 두 번째 읽으면 왜 빠른가

“두 번째가 빠른데, 디스크가 빨라졌을 리는 없고”

큰 로그 파일을 grep 합니다. 처음엔 몇 초가 걸리는데, 곧바로 다시 돌리면 눈 깜짝할 사이에 끝납니다. 디스크가 그새 빨라졌을 리는 없습니다. 답은 하나뿐입니다 — 두 번째 읽기는 디스크에 가지 않았습니다. 데이터는 이미 RAM 안, 커널이 관리하는 페이지 캐시(page cache) 에 들어 있었고, 두 번째 read()는 디스크 컨트롤러 근처도 가지 않고 메모리에서 즉시 복사돼 돌아왔습니다.

이 한 가지 사실이 리눅스 I/O 성능의 90%를 설명합니다. 디스크는 CPU보다 수만~수십만 배 느립니다(앞 글에서 본 그 격차). 운영체제의 핵심 전략은 “느린 디스크를 빠른 메모리 뒤에 숨기는 것”이고, 그 숨김막이 페이지 캐시입니다. 이 글은 read/write 한 번이 블록 장치까지 내려가는 길 전체 — 페이지 캐시, readahead, 더티 페이지 라이트백, 블록 레이어, I/O 스케줄러 — 를 따라가며, 왜 free의 그 “사라진 메모리”가 사실은 당신 편인지까지 풉니다.

read() 한 번이 거치는 길

응용이 read(fd, buf, n)을 부르면, 그 데이터는 디스크에서 곧장 오지 않습니다. 커널은 먼저 페이지 캐시에 이미 있는지 확인합니다. 있으면(캐시 히트) 메모리→메모리 복사로 끝. 없으면(캐시 미스) 블록 레이어를 통해 디스크에서 읽어와 페이지 캐시에 채운 뒤 복사해 줍니다. 그래서 첫 읽기가 캐시를 데우고, 두 번째 읽기가 그 온기를 누립니다.

아래에서 첫 read는 페이지 캐시를 지나(미스) 느린 디스크까지 내려갔다 오지만, 두 번째 read는 페이지 캐시에서 즉시 반환됩니다. 두 경로의 길이 차이가 곧 속도 차이입니다.

응용 read() 페이지 캐시 (RAM) 디스크 (느림·블록 장치) ① 1차: 미스 → 디스크 왕복(느림) ② 2차: 히트 → 즉시 반환(빠름)

현실 체크 — “free가 보여주는 부족한 메모리는 착시다.” free -m의 buff/cache 칸은 페이지 캐시가 쓰는 메모리입니다. 이걸 보고 “메모리가 꽉 찼다”고 놀랄 필요 없습니다. 페이지 캐시는 언제든 회수 가능한(reclaimable) 메모리라, 응용이 더 필요로 하면 커널이 즉시 내어 줍니다. 그래서 진짜 봐야 할 값은 used가 아니라 available입니다. “남는 RAM은 낭비된 RAM” — 리눅스는 빈 메모리를 캐시로 꽉 채워 두는 게 정상입니다.

readahead: 순차 읽기를 미리 당겨오기

파일을 처음부터 순서대로 읽는 패턴은 너무 흔해서, 커널은 이를 감지하면 요청보다 앞서 다음 블록들을 미리 읽어 캐시에 넣어 둡니다 — readahead. read 4KB를 호출했는데 커널이 뒤이어 128KB를 당겨오는 식입니다. 덕분에 다음 read는 이미 캐시 히트가 됩니다. 무작위 접근(랜덤 I/O)이라고 판단되면 readahead 창을 줄입니다.

이 동작은 응용이 의도를 알려주면 더 똑똑해집니다. posix_fadvise(fd, ..., POSIX_FADV_SEQUENTIAL)로 “순차로 읽을 거야”라고 힌트를 주면 창을 키우고, POSIX_FADV_RANDOM이면 readahead를 끕니다. 큰 파일을 한 번만 훑고 버릴 땐 POSIX_FADV_DONTNEED로 캐시를 더럽히지 않게 할 수도 있습니다(백업 작업이 전체 캐시를 밀어내는 사고를 막는 실전 기법).

write()는 더 교활하다 — 라이트백

읽기보다 쓰기가 더 흥미롭습니다. write()가 반환됐다고 데이터가 디스크에 적힌 게 아닙니다. 커널은 데이터를 페이지 캐시의 더티 페이지(dirty page) 로 표시만 하고 곧바로 반환합니다. 실제 디스크 기록은 나중에 flusher 스레드가 모아서 한꺼번에 합니다 — 이것이 라이트백(write-back) / 지연 쓰기 입니다.

왜 이렇게 할까요? (1) 응용이 디스크 속도에 묶이지 않습니다(쓰기가 즉시 반환). (2) 같은 블록을 여러 번 고치면 마지막 한 번만 디스크에 적히면 됩니다(쓰기 병합·흡수). (3) 흩어진 쓰기를 모아 정렬해 한 번에 보내면 디스크 효율이 올라갑니다.

아래에서 write는 더티 페이지(주황)를 만들고 즉시 반환됩니다. 더티 페이지가 쌓이다가, flusher가 주기적으로(혹은 임계치 도달 시) 이들을 디스크로 일괄 기록하며 깨끗한(clean) 상태로 되돌립니다.

응용 write() 페이지 캐시 더티(주황)→플러시→클린(초록) 디스크 즉시 반환 flusher 일괄 기록

라이트백의 대가는 휘발성입니다. write가 반환됐어도 디스크엔 아직 없으니, 그 사이 전원이 나가면 데이터가 사라집니다. 그래서 데이터베이스·저널처럼 내구성이 생명인 코드는 fsync(fd)로 “지금 디스크에 확실히 적어라” 라고 강제하고, 그게 반환돼야 비로소 “기록됐다”고 칩니다. 이 fsync의 순서·장벽 보장이 저널링 파일시스템의 크래시 일관성을 떠받칩니다.

더티 페이지가 무한정 쌓이게 둘 수는 없습니다(전원 사고 시 손실량·라이트백 폭주). 그래서 두 임계치가 있습니다.

  • vm.dirty_background_ratio — 더티가 이 비율을 넘으면 flusher가 백그라운드로 조용히 쓰기 시작.
  • vm.dirty_ratio — 더티가 이 비율을 넘으면 쓰는 프로세스 자신을 블로킹해 강제로 디스크에 받아 적게 함(throttling). 갑자기 쓰기가 멈칫거리면 이 한계에 부딪힌 것.

그 아래: 블록 레이어와 I/O 스케줄러

페이지 캐시 미스나 라이트백이 실제 디스크에 닿을 땐 블록 레이어를 지납니다. 커널은 요청을 bio 구조로 표현하고, 이들을 큐에 모아 병합(merge) 하고 정렬(sort) 한 뒤 장치로 보냅니다. 이 정책을 정하는 게 I/O 스케줄러입니다.

flowchart LR
  A["응용 read/write"] --> B["VFS / 페이지 캐시"]
  B -->|미스·라이트백| C["블록 레이어 (bio)"]
  C --> D["I/O 스케줄러<br/>mq-deadline · bfq · none"]
  D --> E["장치 드라이버 → 디스크/SSD"]
스케줄러특징어울리는 곳
none (noop)정렬 안 함, 큐만NVMe SSD(자체 병렬·재정렬)
mq-deadline요청에 만료시각 → 기아 방지, 가벼움일반 SSD·SATA, DB
bfq프로세스별 공정 분배, 지연 최적데스크톱·대화형

옛 회전 디스크(HDD) 시절엔 헤드 이동을 줄이려 정렬(엘리베이터 알고리즘) 이 결정적이었습니다. 그러나 NVMe SSD는 내부에 수많은 큐를 두고 스스로 병렬 처리·재정렬하므로, 커널이 굳이 줄 세우는 게 오히려 방해가 됩니다 — 그래서 NVMe 기본값이 none인 것이죠. 이게 blk-mq(멀티 큐 블록 레이어) 가 등장한 배경입니다: CPU 코어마다 제출 큐를 둬 잠금 경합 없이 수백만 IOPS를 흘려보냅니다.

캐시를 우회하고 싶을 때: O_DIRECT

데이터베이스는 종종 자기만의 버퍼 풀로 캐싱을 직접 관리합니다. 이럴 때 커널 페이지 캐시까지 거치면 이중 캐싱이라 메모리가 낭비됩니다. open(..., O_DIRECT)는 페이지 캐시를 건너뛰고 응용 버퍼↔디스크를 직접 잇습니다(정렬 제약이 붙음). O_SYNC는 매 write가 디스크 기록까지 끝나야 반환되게 합니다(라이트백 비활성화에 가까움). 대부분의 일반 응용은 페이지 캐시를 누리는 게 이득이고, O_DIRECT는 “내가 OS보다 내 접근 패턴을 잘 안다”고 확신할 때만 씁니다.

직접 들여다보기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 페이지 캐시가 쓰는 메모리 — used가 아니라 available을 보라
free -m            # buff/cache 칸이 페이지 캐시. available이 진짜 여유

# 캐시 효과 실측: 캐시 비우고 1차 vs 2차
sync; echo 3 | sudo tee /proc/sys/vm/drop_caches   # 페이지 캐시 비우기(테스트용)
time grep -c foo big.log     # 1차: 디스크 (느림)
time grep -c foo big.log     # 2차: 페이지 캐시 히트 (빠름)

# 더티 페이지·라이트백 임계치
cat /proc/sys/vm/dirty_ratio /proc/sys/vm/dirty_background_ratio
grep -E 'Dirty|Writeback' /proc/meminfo    # 지금 쌓인 더티 양

# 실제 디스크에 닿는 I/O량(캐시 히트는 여기 안 잡힘)
iostat -x 1        # %util, r/s w/s, await
vmstat 1           # bi/bo(블록 in/out), wa(I/O 대기)

# 현재 I/O 스케줄러 확인/변경
cat /sys/block/nvme0n1/queue/scheduler   # [none] mq-deadline bfq

iostat의 디스크 I/O가 응용이 읽는 양보다 훨씬 적다면, 그 차이가 곧 페이지 캐시가 흡수해 준 양입니다.

면접/리뷰 단골 질문

  • Q. 같은 파일 두 번째 읽기가 빠른 이유는? → 첫 읽기가 데이터를 페이지 캐시(RAM)에 채워서. 두 번째는 디스크에 안 가고 캐시 히트로 메모리 복사만 한다.
  • Q. write() 반환 = 디스크 기록 완료인가? → 아니다. 더티 페이지로 표시만 하고 즉시 반환(라이트백). 실제 기록은 flusher가 나중에. 내구성이 필요하면 fsync()로 강제해야 한다.
  • Q. free에서 메모리가 거의 안 남는데 문제인가? → 대개 아니다. buff/cache는 회수 가능한 페이지 캐시다. available을 봐야 한다. “빈 RAM은 낭비된 RAM.”
  • Q. NVMe SSD의 기본 I/O 스케줄러가 none인 이유는? → 장치가 내부에서 병렬 처리·재정렬을 하므로 커널의 정렬이 불필요·방해. blk-mq로 코어별 큐를 둬 경합 없이 흘려보낸다.
  • Q. O_DIRECT는 언제 쓰나? → DB처럼 자체 캐시를 가진 응용이 이중 캐싱을 피하려 할 때. 일반 응용은 페이지 캐시를 누리는 게 이득.
  • Q. dirty_ratio에 도달하면? → 쓰는 프로세스가 블로킹되어 강제로 디스크에 받아 적게 된다(throttling). 쓰기가 갑자기 멈칫하는 원인.

정리

  • 페이지 캐시는 느린 디스크를 빠른 RAM 뒤에 숨기는 커널의 핵심 장치다 — 두 번째 읽기가 빠른 이유.
  • 읽기는 히트/미스로 갈리고, readahead가 순차 패턴을 미리 당겨 히트율을 높인다.
  • 쓰기는 라이트백: 더티 페이지로 즉시 반환하고 flusher가 일괄 기록 → 빠르지만 휘발성, fsync로 내구성 확보. dirty_ratio가 폭주를 막는다.
  • 그 아래 블록 레이어 + I/O 스케줄러가 요청을 병합·정렬한다. NVMe는 blk-mq + none으로 장치 병렬성을 살린다.
  • free의 buff/cache는 착시다 — available을 보라. 캐시는 언제든 회수된다.

다음 글: 한 대의 물리 머신을 통째로 나누는 가상화와 컨테이너로 넘어갑니다. 페이지 캐시·블록 I/O가 여러 게스트/컨테이너 사이에서 어떻게 공유·격리되는지가 다음 무대입니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

시리즈 · 운영체제 A-Z (20편)

  1. 운영체제란 무엇인가 — 커널·유저모드·시스템콜, 왜 프로그램은 하드웨어를 직접 못 만지나
  2. 부팅 — 전원 버튼에서 커널까지, 그 1초 사이에 일어나는 모든 일
  3. 프로세스란 무엇인가 — 주소 공간·PCB·fork와 exec, 그리고 COW의 마법
  4. 스레드와 동시성 — 한 주소 공간 속 여러 실행 흐름, 그리고 비결정성의 시작
  5. CPU 스케줄링 — 누구에게 CPU를 줄까, FCFS부터 리눅스 CFS까지
  6. 컨텍스트 스위치 — 전환은 공짜가 아니다, 직접비용과 캐시·TLB 오염
  7. 프로세스 간 통신(IPC) — 격리된 프로세스가 협력하는 법, 파이프부터 공유 메모리까지
  8. 동기화 (1) — 경쟁 상태와 임계 구역, 뮤텍스 vs 스핀락
  9. 동기화 (2) — 세마포어·조건 변수, 그리고 데드락의 네 가지 조건
  10. 가상 메모리 — 모든 프로세스가 같은 주소에서 시작하는데 왜 안 겹치나
  11. 페이징·페이지 테이블·TLB — 4단계 주소 변환과 그 캐시
  12. 페이지 폴트·디맨드 페이징·스왑 — malloc 1GB가 즉시 성공하는 이유
  13. 페이지 교체 알고리즘 — 메모리가 꽉 찼을 때 누구를 쫓아낼까
  14. 메모리 할당자 — malloc은 어떻게 힙을 쪼개나, buddy와 slab까지
  15. 파일 시스템 (1) — 파일명은 파일이 아니다, inode와 VFS의 세계
  16. 파일 시스템 (2) — 쓰는 중 전원이 꺼지면? 저널링과 크래시 일관성
  17. I/O·인터럽트·DMA — CPU보다 백만 배 느린 장치와 효율적으로 대화하기
  18. 블록 I/O와 페이지 캐시 — 같은 파일을 두 번째 읽으면 왜 빠른가 지금 보는 글
  19. 가상화와 컨테이너 — VM과 컨테이너, 격리 스펙트럼의 양 끝
  20. 성능 관찰과 병목 분석 — "느려요"를 추측이 아니라 측정으로 (시리즈 마무리)