Direct I/O & Buffered I/O
Linux 파일 I/O의 두 가지 핵심 모드인 Buffered I/O와 Direct I/O를 커널 경로, 캐시 일관성, 장애 복구, 성능 운영 관점에서 심층 분석합니다. Buffered I/O의 Page Cache 히트율·쓰기 지연·readahead 상호작용, Direct I/O의 DMA 직접 전송과 페이지 캐시 우회 특성, O_DIRECT/O_SYNC/O_DSYNC/fsync 조합별 영속성 보장 수준, iomap 기반 최신 DIO 프레임워크, 파일시스템별 정렬 제약과 실패 패턴, AIO+DIO와 io_uring+DIO의 비동기 Direct I/O, 데이터베이스·로그·백업·대용량 스캔 워크로드별 선택 기준, NVMe에서의 Direct I/O 최적화, fio/tracepoint/perf를 이용한 정량 검증 방법까지 실전 의사결정에 필요한 내용을 종합적으로 다룹니다.
핵심 요약
- Page Cache — 디스크 데이터를 메모리에 캐싱하는 커널 계층. Buffered I/O의 핵심 메커니즘입니다.
- O_DIRECT — Page Cache를 우회하여 DMA로 디스크와 직접 데이터를 주고받는 플래그입니다.
- dirty writeback — Page Cache의 변경된 페이지를 비동기적으로 디스크에 기록하는 메커니즘입니다.
- iomap — 최신 커널의 Direct I/O를 처리하는 통합 프레임워크. ext4, XFS 등에서 사용합니다.
- O_SYNC/fsync — 데이터 영속성을 보장하는 동기화 메커니즘. DIO와 조합하여 사용합니다.
단계별 이해
- 구성요소 확인
Page Cache, address_space, BIO, kiocb 등 핵심 자료구조와 주요 API를 먼저 식별합니다. - 요청 흐름 추적
Buffered read/write와 Direct read/write의 커널 내부 호출 경로를 순서대로 따라갑니다. - 예외 경로 점검
정렬 실패, 캐시 무효화, 부분 쓰기, fallback 등 경계 조건을 확인합니다. - 성능/안정성 점검
dirty ratio, writeback 주기, I/O 크기별 대역폭, 지연시간을 측정하고 조정합니다.
개요
Linux는 파일 I/O에 두 가지 모드를 제공합니다. 기본 모드인 Buffered I/O는 커널의 Page Cache를 중간 버퍼로 활용하여
높은 처리량과 낮은 지연시간을 제공하고, Direct I/O(O_DIRECT)는 Page Cache를 우회하여 애플리케이션 메모리와 디스크 사이에 직접 DMA 전송을 수행합니다.
Buffered I/O vs Direct I/O
| 항목 | Buffered I/O (기본) | Direct I/O (O_DIRECT) |
|---|---|---|
| Page Cache | 사용 (캐싱) | 우회 (no cache) |
| 데이터 경로 | 앱 → Page Cache → 디스크 | 앱 → 디스크 (DMA) |
| 메모리 복사 | 2회 (유저↔커널, 커널↔디스크) | 0-1회 (DMA 직접) |
| read() 응답 | 빠름 (캐시 히트 시) | 항상 디스크 대기 |
| write() 응답 | 빠름 (비동기 writeback) | 느림 (동기 전송) |
| 정렬 요구 | 없음 | 블록 크기 정렬 필수 |
| 예측 가능성 | 낮음 (캐시 상태 의존) | 높음 (항상 디스크) |
| CPU 사용 | 높음 (복사 오버헤드) | 낮음 (DMA offload) |
| 적합한 경우 | 일반 애플리케이션 | DB, 스트리밍, 자체 캐시 |
전체 I/O 경로 비교
copy_from_user()/copy_to_user()로 유저 버퍼와 Page Cache 사이에 데이터를 복사합니다. Direct I/O는 유저 버퍼의 물리 페이지를 get_user_pages()로 핀(pin)하여 DMA 컨트롤러가 직접 디스크와 전송합니다. 이 때문에 버퍼가 반드시 물리 페이지 경계에 정렬되어야 합니다.
Buffered I/O (Page Cache)
Buffered I/O는 Page Cache를 통해 디스크 I/O를 캐싱하는 기본 모드입니다. 커널은 디스크에서 읽은 데이터를 Page Cache에 보관하고, 쓰기 요청은 먼저 Page Cache에 기록한 뒤 비동기적으로 디스크에 반영합니다. 이 메커니즘 덕분에 반복 읽기는 디스크 접근 없이 메모리에서 즉시 반환되고, 쓰기는 즉시 반환되어 애플리케이션 응답 시간이 크게 단축됩니다.
읽기 경로
Buffered read는 generic_file_read_iter()를 거쳐 Page Cache를 먼저 검색합니다.
캐시 히트 시 copy_to_user()로 유저 버퍼에 즉시 복사하고, 미스 시 파일시스템의
readpage() (또는 최신 커널에서는 readahead())를 호출하여 디스크에서 읽습니다.
/* mm/filemap.c — Buffered read 핵심 경로 (커널 6.x) */
ssize_t filemap_read(struct kiocb *iocb, struct iov_iter *iter,
ssize_t already_read)
{
struct file *filp = iocb->ki_filp;
struct address_space *mapping = filp->f_mapping;
struct file_ra_state *ra = &filp->f_ra;
loff_t isize, end_offset;
struct folio *folio;
pgoff_t index;
for (;;) {
/* 1. Page Cache에서 folio 검색 */
folio = filemap_get_read_batch(mapping, index, ...);
if (!folio) {
/* 2. 캐시 미스 → readahead 발동 */
page_cache_sync_readahead(mapping, ra, filp, index, ...);
folio = filemap_get_read_batch(mapping, index, ...);
if (!folio)
goto put_folios;
}
/* 3. folio가 최신 상태(uptodate)인지 확인 */
if (!folio_test_uptodate(folio)) {
/* 디스크 읽기가 아직 완료되지 않음 → 대기 */
folio_wait_locked(folio);
}
/* 4. 유저 버퍼로 데이터 복사 */
copied = copy_folio_to_iter(folio, offset, bytes, iter);
already_read += copied;
}
return already_read;
}
쓰기 경로
Buffered write는 generic_file_write_iter()를 거쳐 유저 데이터를 Page Cache에 복사하고,
해당 페이지를 dirty로 마킹한 뒤 즉시 반환합니다. 실제 디스크 기록은 writeback 스레드가 비동기적으로 처리합니다.
/* mm/filemap.c — Buffered write 핵심 경로 */
ssize_t generic_perform_write(struct kiocb *iocb, struct iov_iter *i)
{
struct file *file = iocb->ki_filp;
struct address_space *mapping = file->f_mapping;
const struct address_space_operations *a_ops = mapping->a_ops;
long status = 0;
ssize_t written = 0;
do {
struct page *page;
unsigned long offset, bytes;
/* 1. 페이지 할당/검색 + 파일시스템 예약 */
status = a_ops->write_begin(file, mapping, pos, bytes,
&page, &fsdata);
/* 2. 유저 버퍼 → Page Cache 복사 */
copied = copy_page_from_iter_atomic(page, offset, bytes, i);
flush_dcache_page(page);
/* 3. 완료 + dirty 마킹 */
status = a_ops->write_end(file, mapping, pos, bytes, copied,
page, fsdata);
/* write_end() 내부에서 set_page_dirty() 호출 */
written += copied;
pos += copied;
/* 4. dirty 비율 조절 */
balance_dirty_pages_ratelimited(mapping);
} while (iov_iter_count(i));
return written;
}
- 읽기 성능: 캐시 히트 시 디스크 접근 없이 메모리에서 즉시 반환
- 쓰기 성능: write() 즉시 반환 (비동기 writeback)
- Readahead: 순차 읽기 패턴 감지 시 미리 읽기
- Write coalescing: 여러 작은 쓰기를 모아서 한 번에 디스크 기록
- 통합 캐시: 프로세스 간 캐시 공유
fsync()/fdatasync() 또는 O_SYNC/O_DSYNC를 사용해야 합니다.
Page Cache 구조
Page Cache는 address_space 구조체로 관리됩니다. 각 inode마다 하나의 address_space가 연결되어 있으며,
xarray(이전에는 radix tree)를 사용하여 파일 오프셋에서 물리 페이지로의 매핑을 관리합니다.
address_space 구조체
/* include/linux/fs.h */
struct address_space {
struct inode *host; /* 소유 inode */
struct xarray i_pages; /* 페이지 radix tree */
unsigned long nrpages; /* 총 페이지 수 */
unsigned long nrexceptional;
const struct address_space_operations *a_ops;
unsigned long flags;
struct rw_semaphore i_mmap_rwsem;
struct rb_root_cached i_mmap; /* mmap 영역 */
unsigned long writeback_index;
};
address_space_operations
struct address_space_operations {
int (*readpage)(struct file *, struct page *);
int (*writepage)(struct page *, struct writeback_control *);
int (*readpages)(struct file *, struct address_space *,
struct list_head *, unsigned);
int (*write_begin)(struct file *, struct address_space *,
loff_t pos, unsigned len, unsigned flags,
struct page **, void **);
int (*write_end)(struct file *, struct address_space *,
loff_t pos, unsigned len, unsigned copied,
struct page *, void *);
int (*direct_IO)(struct kiocb *, struct iov_iter *);
/* 최신 커널 (6.x) 추가 콜백 */
void (*readahead)(struct readahead_control *);
bool (*dirty_folio)(struct address_space *, struct folio *);
int (*migrate_folio)(struct address_space *, struct folio *dst,
struct folio *src, enum migrate_mode);
};
folio 페이지 플래그 상태 전이
| 플래그 | 설명 | 설정 시점 | 해제 시점 |
|---|---|---|---|
PG_locked | 페이지 잠금 (I/O 진행 중) | readpage/writepage 시작 | I/O 완료 시 |
PG_uptodate | 디스크와 동기화됨 | readpage 완료 | 무효화(invalidate) 시 |
PG_dirty | 디스크보다 새로운 데이터 | write_end() 후 | writeback 시작 시 |
PG_writeback | 디스크로 쓰기 진행 중 | writeback 시작 | BIO 완료 콜백 |
PG_referenced | 최근 접근됨 (LRU 승격용) | 페이지 접근 시 | LRU 스캔 시 |
Dirty Writeback 메커니즘
Buffered write에서 dirty로 마킹된 페이지는 커널의 writeback 메커니즘에 의해 비동기적으로 디스크에 기록됩니다. 이 과정을 제어하는 핵심 파라미터와 내부 동작을 살펴봅니다.
Dirty Writeback 파라미터
| 파라미터 | 기본값 | 단위 | 설명 |
|---|---|---|---|
vm.dirty_background_ratio | 10 | % | 이 비율 초과 시 백그라운드 writeback 시작 |
vm.dirty_ratio | 20 | % | 이 비율 초과 시 write() 프로세스가 직접 writeback 수행 (throttle) |
vm.dirty_background_bytes | 0 | bytes | ratio 대신 절대값 지정 (0이면 ratio 사용) |
vm.dirty_bytes | 0 | bytes | ratio 대신 절대값 지정 |
vm.dirty_writeback_centisecs | 500 | 1/100초 | writeback 스레드 깨우는 주기 (5초) |
vm.dirty_expire_centisecs | 3000 | 1/100초 | 이 시간 지난 dirty 페이지 강제 writeback (30초) |
/* mm/page-writeback.c — balance_dirty_pages 핵심 로직 */
static void balance_dirty_pages(struct bdi_writeback *wb,
unsigned long pages_dirtied)
{
unsigned long thresh;
unsigned long bg_thresh;
unsigned long nr_dirty;
for (;;) {
/* 현재 dirty 페이지 수와 임계값 계산 */
global_dirty_limits(&bg_thresh, &thresh);
nr_dirty = global_node_page_state(NR_FILE_DIRTY) +
global_node_page_state(NR_WRITEBACK);
if (nr_dirty <= thresh)
break; /* 임계값 이하 → 통과 */
/* dirty_ratio 초과 → 프로세스 throttle */
/* I/O 대역폭에 비례한 대기 시간 계산 */
long pause = wb_dirty_limits(wb, &dirty, ...);
/* write 프로세스를 강제로 sleep */
io_schedule_timeout(pause);
/* 이 동안 writeback이 dirty 페이지를 디스크로 내보냄 */
}
}
dirty_ratio가 높으면 더 많은 데이터를 Page Cache에 버퍼링할 수 있어 순간 쓰기 대역폭이 높아지지만,
fsync() 시 flush해야 할 양이 많아져 지연 시간이 길어집니다. 데이터베이스처럼 주기적으로 fsync()를 호출하는 워크로드에서는
dirty_background_ratio=5, dirty_ratio=10 정도로 낮추는 것이 좋습니다.
Direct I/O (O_DIRECT)
Direct I/O는 Page Cache를 우회하고 디스크와 직접 DMA 전송합니다. 유저 버퍼의 물리 페이지를
get_user_pages()로 핀(pin)한 후 BIO를 구성하여 블록 레이어에 직접 제출합니다.
O_DIRECT 사용
/* 유저 공간 코드 */
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>
int main()
{
int fd;
void *buf;
size_t size = 4096; /* 블록 크기 정렬 */
/* O_DIRECT 플래그로 열기 */
fd = open("/dev/sda1", O_RDWR | O_DIRECT);
/* 정렬된 버퍼 할당 (필수!) */
posix_memalign(&buf, 512, size);
/* Direct I/O 읽기 */
pread(fd, buf, size, 0); /* offset도 512 배수여야 함 */
/* Direct I/O 쓰기 */
pwrite(fd, buf, size, 4096);
free(buf);
close(fd);
return 0;
}
- 버퍼 주소: 512바이트 정렬 (일부 디바이스는 4KB)
- I/O 크기: 512바이트 배수
- 파일 오프셋: 512바이트 배수
/* 정렬 확인 */
if ((unsigned long)buf % 512 != 0) {
fprintf(stderr, "Buffer not aligned\n");
return -1;
}
커널 Direct I/O 경로
/* mm/filemap.c */
ssize_t generic_file_read_iter(struct kiocb *iocb, struct iov_iter *iter)
{
if (iocb->ki_flags & IOCB_DIRECT) {
/* Direct I/O 경로 */
struct address_space *mapping = iocb->ki_filp->f_mapping;
return mapping->a_ops->direct_IO(iocb, iter);
}
/* Buffered I/O 경로 (기본) */
return generic_file_buffered_read(iocb, iter, 0);
}
/* fs/direct-io.c */
ssize_t __blockdev_direct_IO(struct kiocb *iocb, struct inode *inode,
struct block_device *bdev,
struct iov_iter *iter,
get_block_t get_block)
{
/* 1. 정렬 검증 */
if (!is_aligned(iter))
return -EINVAL;
/* 2. Page Cache invalidate (일관성) */
invalidate_inode_pages2_range(mapping, start, end);
/* 3. DMA 전송 준비 */
struct dio *dio = kmem_cache_alloc(dio_cache, GFP_KERNEL);
/* 4. BIO 제출 (직접 디스크) */
do_direct_IO(dio, iocb, inode, iter, get_block);
return transferred;
}
invalidate_inode_pages2_range()를 호출하여 해당 범위의 캐시를 무효화하지 않으면,
이후 Buffered read가 오래된 데이터를 반환합니다. 반대로, Buffered write로 Page Cache에 dirty 데이터가 있는 상태에서
Direct read를 하면 아직 디스크에 반영되지 않은 데이터를 놓칠 수 있습니다.
iomap 기반 Direct I/O
최신 리눅스 커널(5.x+)에서는 레거시 __blockdev_direct_IO() 대신
iomap 프레임워크를 사용하여 Direct I/O를 처리합니다. iomap은 파일 오프셋에서 블록 디바이스 매핑을 추상화하여,
파일시스템이 블록 매핑만 제공하면 나머지 I/O 제출은 iomap이 자동으로 처리합니다.
/* fs/iomap/direct-io.c — iomap DIO 핵심 */
ssize_t iomap_dio_rw(struct kiocb *iocb, struct iov_iter *iter,
const struct iomap_ops *ops,
const struct iomap_dio_ops *dops,
unsigned int dio_flags, void *private,
unsigned int nr_pages)
{
struct iomap_dio *dio;
/* 1. DIO 컨텍스트 할당 */
dio = kmalloc(sizeof(*dio), GFP_KERNEL);
dio->iocb = iocb;
dio->flags = dio_flags;
atomic_set(&dio->ref, 1);
/* 2. iomap_iter 루프: 파일 범위를 extent 단위로 분할 */
while ((ret = iomap_iter(&iomi, ops)) > 0) {
/* 파일시스템이 iomap_begin()으로 블록 매핑 제공 */
iomap_dio_iter(&iomi, dio);
}
/* 3. 동기 DIO: 모든 BIO 완료 대기 */
if (!is_sync_kiocb(iocb))
return -EIOCBQUEUED; /* 비동기: 나중에 완료 */
/* 동기: 블로킹 대기 */
return iomap_dio_complete(dio);
}
/* 파일시스템별 iomap_ops 예시 (XFS) */
const struct iomap_ops xfs_direct_write_iomap_ops = {
.iomap_begin = xfs_direct_write_iomap_begin,
.iomap_end = xfs_direct_write_iomap_end,
};
레거시 DIO vs iomap DIO 비교
| 항목 | 레거시 __blockdev_direct_IO | iomap DIO |
|---|---|---|
| 도입 시기 | 커널 2.6 | 커널 4.10+ |
| 블록 매핑 | get_block() 콜백 | iomap_begin() 콜백 |
| BIO 생성 | fs/direct-io.c 자체 구현 | bio_iov_iter_get_pages() |
| 대형 I/O | 비효율 (get_block 반복) | 효율적 (extent 단위) |
| 파일시스템 지원 | ext4 (fallback), btrfs (자체) | ext4, XFS, zonefs, gfs2 |
| 비동기 DIO | 제한적 | 완전 지원 (EIOCBQUEUED) |
| 코드 위치 | fs/direct-io.c | fs/iomap/direct-io.c |
O_SYNC, O_DSYNC, fsync()
데이터 동기화 방법에도 여러 변형이 있습니다. 각 방법이 보장하는 범위와 성능 특성이 다르므로, 워크로드에 맞는 적절한 수준을 선택해야 합니다.
동기화 옵션 비교
| 방법 | 메타데이터 | 데이터 | 시점 | 성능 |
|---|---|---|---|---|
| 기본 (비동기) | X | X | 나중에 (pdflush) | 빠름 |
| O_DSYNC | X | O | write() 반환 전 | 느림 |
| O_SYNC | O | O | write() 반환 전 | 매우 느림 |
| O_DIRECT | X | O (즉시) | read/write 중 | 변동 |
| fsync() | O | O | fsync() 호출 시 | 배치 가능 |
| fdatasync() | X (필수만) | O | fdatasync() 호출 시 | fsync()보다 빠름 |
| sync_file_range() | X | O (범위) | 호출 시 | 세밀한 제어 |
O_DIRECT와 동기화 플래그 조합
| 조합 | 데이터 영속성 | 메타데이터 영속성 | 사용 사례 |
|---|---|---|---|
O_DIRECT 단독 | 디스크 전송 완료 (캐시 무보장) | X | 일반 DIO 읽기/쓰기 |
O_DIRECT | O_DSYNC | 디스크 영속 보장 | X | DB 데이터 파일 |
O_DIRECT | O_SYNC | 디스크 영속 보장 | O | DB WAL, 트랜잭션 로그 |
O_DIRECT + fsync() | fsync 시점에 영속 보장 | O | 배치 동기화 |
O_DIRECT | O_DSYNC를 사용하거나
fsync()를 호출하여 디스크 캐시까지 flush(FUA/FLUSH 명령)해야 합니다.
fsync() 구현
/* fs/sync.c */
int vfs_fsync_range(struct file *file, loff_t start, loff_t end,
int datasync)
{
struct address_space *mapping = file->f_mapping;
/* 1. dirty 페이지를 디스크로 flush */
if (mapping->nrpages) {
int err = filemap_fdatawrite_range(mapping, start, end);
if (err)
return err;
}
/* 2. 파일시스템별 sync 작업 */
if (file->f_op->fsync)
return file->f_op->fsync(file, start, end, datasync);
return 0;
}
/* 유저 공간 */
write(fd, buf, size);
fsync(fd); /* 디스크까지 확실히 기록 */
fdatasync(fd); /* 메타데이터는 필수만 (더 빠름) */
sync_file_range() 세밀한 제어
/* 유저 공간: sync_file_range로 범위 동기화 */
#include <fcntl.h>
/* 1단계: 비동기 writeback 시작 (논블로킹) */
sync_file_range(fd, offset, length,
SYNC_FILE_RANGE_WRITE);
/* ... 다른 작업 수행 ... */
/* 2단계: 이전 writeback 완료 대기 */
sync_file_range(fd, offset, length,
SYNC_FILE_RANGE_WAIT_BEFORE | SYNC_FILE_RANGE_WRITE |
SYNC_FILE_RANGE_WAIT_AFTER);
/* 주의: sync_file_range는 메타데이터를 동기화하지 않음
→ 파일 크기 변경, 새 할당 등은 fsync() 필요 */
sync_file_range()는 데이터만 writeback하고 메타데이터는 동기화하지 않습니다.
파일이 확장되거나 새로운 블록이 할당된 경우, sync_file_range만으로는 정전 시 데이터 손실이 발생할 수 있습니다.
PostgreSQL은 이 함수를 사용하여 checkpoint를 분산시키지만, 최종 fsync()를 반드시 호출합니다.
정렬 요구사항 상세
Direct I/O의 정렬 조건은 블록 디바이스에 따라 다릅니다. 최신 커널에서는 파일시스템의
dio_align 정보를 사용하여 더 유연한 정렬 검증이 가능합니다.
논리적 블록 크기 확인
# 블록 크기 확인
cat /sys/block/sda/queue/logical_block_size
# 512
cat /sys/block/sda/queue/physical_block_size
# 4096 ← Advanced Format (4K Native)
# NVMe 디바이스 정보
cat /sys/block/nvme0n1/queue/logical_block_size
# 512
cat /sys/block/nvme0n1/queue/physical_block_size
# 4096
cat /sys/block/nvme0n1/queue/minimum_io_size
# 4096
cat /sys/block/nvme0n1/queue/optimal_io_size
# 0 ← 특별한 선호 없음
/* 코드에서 블록 크기 확인 */
#include <sys/ioctl.h>
#include <linux/fs.h>
int blksize;
ioctl(fd, BLKSSZGET, &blksize); /* logical block size */
printf("Block size: %d\n", blksize);
int phys_blksize;
ioctl(fd, BLKPBSZGET, &phys_blksize); /* physical block size */
/* 파일시스템의 DIO 정렬 요구사항 확인 (커널 6.1+) */
struct statx stx;
statx(AT_FDCWD, path, 0, STATX_DIOALIGN, &stx);
printf("DIO mem align: %u\n", stx.stx_dio_mem_align);
printf("DIO offset align: %u\n", stx.stx_dio_offset_align);
정렬 계산
| 디바이스 | Logical BS | Physical BS | 권장 정렬 |
|---|---|---|---|
| HDD (Legacy) | 512 | 512 | 512 |
| HDD (AF 4Kn) | 4096 | 4096 | 4096 |
| SSD | 512 | 4096 | 4096 |
| NVMe | 512 | 4096 | 4096 (페이지 크기) |
파일시스템별 DIO 정렬 요구사항
| 파일시스템 | 최소 정렬 | DIO 구현 | 비고 |
|---|---|---|---|
| ext4 | 블록 크기 (보통 4KB) | iomap (기본) / 레거시 fallback | sub-block DIO 시 buffered fallback 가능 |
| XFS | 블록 크기 (512B~64KB) | iomap | reflink 파일은 CoW 발생 |
| Btrfs | 섹터 크기 | 자체 구현 | CoW 파일시스템, DIO 시 제약 있음 |
| F2FS | 블록 크기 (4KB) | 자체 구현 | GC 중 DIO 대기 가능 |
| tmpfs | 지원 안 함 | N/A | O_DIRECT 무시 (항상 buffered) |
statx() 시스템 콜에 STATX_DIOALIGN 마스크를 지정하면
파일시스템이 요구하는 정확한 DIO 정렬 조건을 반환합니다. 이를 통해 하드코딩된 512/4096 대신 런타임에 정렬 요구사항을 확인할 수 있습니다.
stx_dio_mem_align은 메모리 버퍼 정렬, stx_dio_offset_align은 파일 오프셋/크기 정렬입니다.
AIO + Direct I/O
POSIX AIO(libaio)와 Direct I/O를 조합하면 비동기 Direct I/O가 가능합니다.
이를 통해 I/O 제출 후 즉시 반환받아 다른 작업을 수행하고, 나중에 완료를 확인할 수 있습니다.
이 조합은 데이터베이스(MySQL InnoDB, Oracle)에서 광범위하게 사용됩니다.
/* libaio + O_DIRECT 예제 */
#include <libaio.h>
#include <fcntl.h>
#include <stdlib.h>
#include <stdio.h>
#define BLOCK_SIZE 4096
#define MAX_EVENTS 64
int main()
{
io_context_t ctx = 0;
struct iocb cb;
struct iocb *cbs[1];
struct io_event events[MAX_EVENTS];
void *buf;
int fd, ret;
/* 1. AIO 컨텍스트 초기화 */
ret = io_setup(MAX_EVENTS, &ctx);
/* 2. O_DIRECT로 파일 열기 */
fd = open("/data/test.dat", O_RDWR | O_DIRECT);
/* 3. 정렬된 버퍼 할당 */
posix_memalign(&buf, BLOCK_SIZE, BLOCK_SIZE);
/* 4. 비동기 읽기 요청 구성 */
io_prep_pread(&cb, fd, buf, BLOCK_SIZE, 0);
/* 5. I/O 제출 (논블로킹) */
cbs[0] = &cb;
ret = io_submit(ctx, 1, cbs);
/* 6. 완료 대기 */
ret = io_getevents(ctx, 1, MAX_EVENTS, events, NULL);
printf("Completed: %ld bytes\n", events[0].res);
io_destroy(ctx);
free(buf);
close(fd);
return 0;
}
O_DIRECT가 아닌 경우 AIO가 커널 내부에서 동기적으로 실행되어 비동기 이점이 사라집니다- I/O 제출(
io_submit)이 블로킹될 수 있습니다 (메타데이터 조회, 페이지 할당 등) - 최대 동시 이벤트 수가
/proc/sys/fs/aio-max-nr로 제한됩니다 (기본 65536) - 시스템 콜 오버헤드가 높습니다 (submit/getevents 각각 syscall)
io_uring이 개발되었습니다.
io_uring + Direct I/O
io_uring(커널 5.1+)은 리눅스의 최신 비동기 I/O 프레임워크로, 공유 링 버퍼를 통해
시스템 콜 없이 I/O를 제출하고 완료를 확인할 수 있습니다. Direct I/O와 결합하면
최고 성능의 비동기 Direct I/O를 달성합니다.
/* io_uring + O_DIRECT 예제 (liburing) */
#include <liburing.h>
#include <fcntl.h>
#include <stdlib.h>
#include <stdio.h>
#define BLOCK_SIZE 4096
#define QD 64
int main()
{
struct io_uring ring;
struct io_uring_sqe *sqe;
struct io_uring_cqe *cqe;
void *buf;
int fd;
/* 1. io_uring 초기화 */
io_uring_queue_init(QD, &ring, 0);
/* 2. O_DIRECT로 파일 열기 */
fd = open("/data/test.dat", O_RDONLY | O_DIRECT);
/* 3. 정렬된 버퍼 */
posix_memalign(&buf, BLOCK_SIZE, BLOCK_SIZE);
/* 4. SQE 작성 */
sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, BLOCK_SIZE, 0);
io_uring_sqe_set_data(sqe, buf); /* 사용자 데이터 */
/* 5. 제출 (커널에 SQ tail 통지) */
io_uring_submit(&ring);
/* 6. CQE 대기 */
io_uring_wait_cqe(&ring, &cqe);
printf("Read %d bytes\n", cqe->res);
io_uring_cqe_seen(&ring, cqe);
io_uring_queue_exit(&ring);
free(buf);
close(fd);
return 0;
}
비동기 DIO 프레임워크 비교
| 항목 | POSIX AIO (libaio) | io_uring |
|---|---|---|
| 커널 버전 | 2.6+ | 5.1+ |
| 시스템 콜 | io_submit/io_getevents (매번) | io_uring_enter (선택적, 폴링 가능) |
| Buffered I/O 지원 | 동기로 fallback | 진정한 비동기 (워커 스레드) |
| 배치 제출 | 가능 (io_submit 배열) | 가능 (SQ 배치 + 단일 submit) |
| 폴링 모드 | 없음 | SQPOLL (커널 폴링 스레드) |
| 고정 버퍼 | 없음 | IORING_REGISTER_BUFFERS (핀 재사용) |
| 성능 (NVMe QD64) | 약 700K IOPS | 약 1.2M+ IOPS |
| CPU 오버헤드 | 높음 (syscall 빈도) | 낮음 (공유 메모리, 폴링) |
io_uring_register_buffers()로 버퍼를 미리 등록하면 매 I/O마다 get_user_pages()를 호출하지 않아도 됩니다.
이는 DIO의 가장 큰 오버헤드 중 하나를 제거하여, NVMe 같은 고속 장치에서 IOPS를 20-30% 추가로 향상시킵니다.
데이터베이스 I/O 패턴
데이터베이스는 Direct I/O의 가장 중요한 사용자입니다. 각 데이터베이스가 I/O를 어떻게 활용하는지 살펴봅니다.
MySQL InnoDB의 I/O 패턴
# MySQL InnoDB DIO 설정
[mysqld]
innodb_flush_method = O_DIRECT # 데이터 파일: O_DIRECT
innodb_flush_log_at_trx_commit = 1 # 매 트랜잭션 커밋 시 fsync
innodb_doublewrite = 1 # 부분 쓰기 보호
innodb_io_capacity = 2000 # flush 속도 제한 (IOPS)
innodb_io_capacity_max = 4000 # 최대 flush 속도
| InnoDB 파일 | I/O 모드 | 동기화 방법 | 설명 |
|---|---|---|---|
| 데이터 파일 (.ibd) | O_DIRECT | fsync() | Buffer Pool이 자체 캐시 역할 |
| redo 로그 (ib_logfile) | O_DIRECT (O_DIRECT_NO_FSYNC 시) | fsync() 또는 O_DSYNC | 트랜잭션 영속성 |
| doublewrite buffer | O_DIRECT | fsync() | 부분 쓰기(torn page) 방지 |
| undo 로그 | O_DIRECT | fsync() | 롤백 데이터 |
PostgreSQL의 I/O 패턴
# PostgreSQL 설정
fsync = on # 반드시 on (off는 위험)
wal_sync_method = fdatasync # or open_datasync
full_page_writes = on # 부분 쓰기 보호 (torn page)
checkpoint_completion_target = 0.9 # checkpoint 분산
effective_io_concurrency = 200 # 비동기 I/O 동시성 (SSD)
io_method=io_uring과 Direct I/O를 지원하기 시작했으며,
이를 통해 이중 캐싱 문제를 해결하고 대용량 데이터베이스에서 더 예측 가능한 성능을 제공합니다.
워크로드별 I/O 전략
| 워크로드 | I/O 패턴 | 권장 모드 | 이유 |
|---|---|---|---|
| OLTP (짧은 트랜잭션) | 랜덤 4-16KB R/W | O_DIRECT + fsync | 자체 캐시, 트랜잭션 보장 |
| OLAP (분석 쿼리) | 순차 대용량 읽기 | O_DIRECT (대형 I/O) | 캐시 오염 방지, 예측 가능 |
| WAL / redo 로그 | 순차 append, 소량 | O_DIRECT | O_DSYNC | 즉시 영속성 보장 |
| 테이블스페이스 백업 | 순차 대용량 읽기 | O_DIRECT | 운영 캐시 오염 방지 |
| 정적 웹 콘텐츠 | 순차/랜덤 읽기 | Buffered | 반복 접근, 캐시 히트율 높음 |
| 로그 수집 | 순차 append | Buffered + fsync 주기적 | 작은 쓰기 병합, 적절한 영속성 |
| 비디오 스트리밍 | 순차 대용량 읽기 | O_DIRECT | 재사용 없음, 캐시 낭비 방지 |
NVMe Direct I/O 최적화
NVMe SSD는 극히 낮은 지연시간(수십 마이크로초)과 높은 병렬성(64K+ 큐 깊이)을 제공합니다. 이러한 장치에서 Direct I/O의 성능을 최대한 활용하려면 소프트웨어 스택의 오버헤드를 최소화해야 합니다.
# NVMe DIO 최적화 시스템 설정
# 1. I/O 스케줄러: none (NVMe는 자체 큐 관리)
echo none > /sys/block/nvme0n1/queue/scheduler
# 2. 인터럽트 병합 (interrupt coalescing)
cat /sys/class/nvme/nvme0/iocoalesce_threshold
# NVMe 컨트롤러별 설정 (nvme-cli)
nvme set-feature /dev/nvme0 -f 0x08 -v 0x000a0064
# → threshold=100us, aggregation=10
# 3. 큐 깊이 확인
cat /sys/block/nvme0n1/queue/nr_requests
# 1023 (기본)
# 4. 읽기 요청 최대 크기
cat /sys/block/nvme0n1/queue/max_sectors_kb
# 128 (기본) → 대형 DIO에 영향
# 5. IRQ 폴링 (io_uring IOPOLL)
# io_uring_setup에 IORING_SETUP_IOPOLL 플래그 설정
# → 인터럽트 대신 폴링으로 BIO 완료 확인
- io_uring: SQPOLL + IOPOLL + REGISTER_BUFFERS 조합
- 스케줄러:
none(mq-deadline이나 bfq는 불필요한 오버헤드) - 큐 깊이: NVMe는 높은 QD에서 최대 성능. QD 64-128 이상 유지
- I/O 크기: 4KB 랜덤 IOPS가 목적이면 4KB, 대역폭이 목적이면 128KB+
- NUMA 정합: NVMe가 연결된 NUMA 노드의 CPU에서 I/O 제출
사용 사례
Buffered I/O와 Direct I/O는 각각 적합한 사용 사례가 있습니다. 잘못된 선택은 심각한 성능 저하나 데이터 손실로 이어질 수 있으므로, 워크로드의 특성을 정확히 파악해야 합니다.
Buffered I/O 권장
- 일반 애플리케이션: 텍스트 편집기, 컴파일러, 웹 브라우저
- 읽기 중심: 로그 분석, grep, find
- 작은 I/O: 수백 바이트 단위 읽기/쓰기
- 반복 접근: 같은 파일을 여러 번 읽는 경우
- mmap 사용: 메모리 매핑 I/O는 Page Cache 필수
Direct I/O 권장
- 자체 캐시: DB는 InnoDB Buffer Pool 같은 자체 캐시가 있음 → Page Cache는 중복
- 예측 가능성: Buffered I/O는 메모리 압박 시 성능 변동 → Direct I/O는 항상 일정
- 대용량 I/O: MB 단위 테이블스캔 → Page Cache 오염 방지
- WAL (Write-Ahead Log): fsync() 호출 오버헤드 없이 확실한 영속성
예시: MySQL InnoDB innodb_flush_method=O_DIRECT, PostgreSQL wal_sync_method=open_datasync
워크로드별 권장
| 워크로드 | 권장 모드 | 이유 |
|---|---|---|
| 웹 서버 (정적 파일) | Buffered | 반복 접근, 캐시 히트율 높음 |
| DB (OLTP) | Direct + fsync | 자체 캐시, 트랜잭션 보장 |
| DB (OLAP) | Direct | 대용량 순차 스캔, 캐시 오염 방지 |
| 비디오 스트리밍 | Direct | 대용량 순차, 재사용 없음 |
| 로그 수집 | Buffered + O_SYNC | 작은 쓰기, 순차, 손실 방지 |
| 백업 | Direct | 대용량 읽기, 캐시 낭비 방지 |
Page Cache와 DIO 일관성
같은 파일에 대해 Buffered I/O와 Direct I/O를 동시에 사용하면 데이터 불일치가 발생할 수 있습니다. 커널은 이 문제를 완화하기 위한 메커니즘을 제공하지만, 완벽하지 않습니다.
/* 커널의 DIO 전 캐시 무효화 처리 */
static ssize_t ext4_dio_write_iter(struct kiocb *iocb,
struct iov_iter *from)
{
struct inode *inode = file_inode(iocb->ki_filp);
/* 1. inode 락 획득 (exclusive) */
inode_lock(inode);
/* 2. DIO 전에 dirty 페이지 flush + 캐시 무효화 */
ret = filemap_write_and_wait_range(mapping, pos, end);
if (ret)
goto out;
/* 3. Page Cache 무효화 */
ret = invalidate_inode_pages2_range(mapping,
pos >> PAGE_SHIFT, end >> PAGE_SHIFT);
/* 4. iomap DIO 수행 */
ret = iomap_dio_rw(iocb, from, &ext4_iomap_ops,
&ext4_dio_write_ops, 0, NULL, 0);
inode_unlock(inode);
return ret;
}
- 프로세스 A가 buffered write → 프로세스 B가 DIO read: dirty 데이터 누락 가능
- 프로세스 A가 DIO write → 프로세스 B가 buffered read: stale 캐시 반환 가능
- 해결책: 파일 단위로 I/O 모드를 통일하거나,
flock()/fcntl()으로 동기화
성능 비교
실제 벤치마크 결과입니다 (NVMe SSD 기준, fio 사용).
순차 읽기 (1GB 파일)
| 모드 | I/O 크기 | 대역폭 | IOPS | CPU |
|---|---|---|---|---|
| Buffered (cold) | 4KB | 500 MB/s | 128K | 25% |
| Buffered (hot) | 4KB | 3000 MB/s | 768K | 80% |
| Direct | 4KB | 450 MB/s | 115K | 15% |
| Direct | 128KB | 2000 MB/s | 16K | 10% |
랜덤 쓰기 (4KB)
| 모드 | 지연시간 | IOPS | Durability |
|---|---|---|---|
| Buffered | 0.05 ms | 200K | 손실 가능 (crash) |
| Buffered + fsync | 5 ms | 200 | 보장 |
| O_DSYNC | 5 ms | 200 | 보장 |
| O_DIRECT | 1 ms | 10K | 보장 |
I/O 엔진별 NVMe 성능 비교 (fio, QD=64, 4KB 랜덤 읽기)
| I/O 엔진 | 모드 | IOPS | 평균 지연(us) | p99 지연(us) |
|---|---|---|---|---|
| psync (동기) | O_DIRECT | 약 80K | 12 | 25 |
| libaio | O_DIRECT | 약 700K | 85 | 120 |
| io_uring | O_DIRECT | 약 900K | 68 | 95 |
| io_uring (SQPOLL) | O_DIRECT | 약 1.1M | 55 | 80 |
| io_uring (SQPOLL+IOPOLL) | O_DIRECT | 약 1.3M | 45 | 65 |
# fio 벤치마크 예제: O_DIRECT 랜덤 읽기
fio --name=randread \
--ioengine=io_uring \
--direct=1 \
--bs=4k \
--iodepth=64 \
--rw=randread \
--numjobs=4 \
--filename=/dev/nvme0n1 \
--runtime=60 \
--time_based \
--group_reporting
# fio 벤치마크: Buffered vs Direct 순차 쓰기 비교
fio --name=seqwrite_buf \
--ioengine=psync \
--direct=0 \
--bs=128k \
--rw=write \
--size=1G \
--filename=/tmp/test_buf
fio --name=seqwrite_dio \
--ioengine=psync \
--direct=1 \
--bs=128k \
--rw=write \
--size=1G \
--filename=/tmp/test_dio
모니터링
Page Cache와 Direct I/O 활동을 관찰하는 방법입니다.
/proc/meminfo - Page Cache
cat /proc/meminfo | grep -E 'Cached|Dirty|Writeback'
# Cached: 8245632 kB ← Page Cache 크기
# Dirty: 125440 kB ← 아직 디스크에 안 쓴 dirty 페이지
# Writeback: 2560 kB ← 현재 디스크로 쓰는 중
iostat - 디스크 I/O
iostat -x 1
# Device r/s w/s rkB/s wkB/s %util
# sda 150 300 6000 12000 45%
# NVMe 상세 정보
iostat -x nvme0n1 1
# Device r/s w/s rkB/s wkB/s rrqm/s wrqm/s %util
# nvme0n1 50K 10K 200000 40000 0 0 80%
# rrqm/wrqm = 0: Direct I/O는 merge 없음
iotop - 프로세스별 I/O
iotop -o
# TID PRIO USER DISK READ DISK WRITE COMMAND
# 1234 be/4 postgres 15.2 M/s 0.0 B/s postgres: writer
/proc/[pid]/io
cat /proc/1234/io
# rchar: 1234567890 ← 읽은 바이트 (Page Cache 포함)
# wchar: 9876543210 ← 쓴 바이트
# read_bytes: 123456789 ← 실제 디스크 읽기 (Direct I/O 등)
# write_bytes: 987654321 ← 실제 디스크 쓰기
# cancelled_write_bytes: 0 ← 취소된 쓰기 (truncate 등)
# DIO 비율 계산
# Direct I/O 비율 = read_bytes / rchar (1에 가까울수록 DIO 비중 높음)
# Buffered 비율 = (rchar - read_bytes) / rchar (Page Cache에서 서비스된 비율)
tracepoint로 DIO 추적
# Direct I/O 이벤트 추적
echo 1 > /sys/kernel/debug/tracing/events/filemap/mm_filemap_delete_from_page_cache/enable
echo 1 > /sys/kernel/debug/tracing/events/writeback/writeback_dirty_page/enable
# BPF를 이용한 DIO 추적 (bcc 도구)
biosnoop
# TIME(s) COMM PID DISK T SECTOR BYTES LAT(ms)
# 0.000000 postgres 1234 nvme0n1 R 1234567 4096 0.050
# BPF로 DIO vs Buffered 비율 확인
funccount -i 1 'generic_file_read_iter' 'iomap_dio_rw'
# FUNC COUNT
# generic_file_read_iter 15230 ← Buffered read
# iomap_dio_rw 8540 ← Direct I/O
# perf로 DIO 지연 시간 분석
perf trace -e read,write,fsync -p 1234
# 0.000 read(fd=3, buf=0x7f.., count=4096) = 4096 (0.045 ms)
# 0.045 write(fd=4, buf=0x7f.., count=4096) = 4096 (0.890 ms)
/proc/[pid]/io에서 rchar(총 읽기)와 read_bytes(실제 디스크 읽기)를 비교합니다.
Buffered I/O는 rchar >> read_bytes (캐시 히트), Direct I/O는 rchar ≈ read_bytes입니다.
비슷하게 wchar와 write_bytes를 비교하면 쓰기 모드를 추측할 수 있습니다.
튜닝 파라미터
Buffered I/O의 writeback 동작을 제어하는 파라미터와 Direct I/O 관련 시스템 설정입니다.
Dirty Page Writeback
# dirty 페이지가 이 비율 넘으면 writeback 시작
sysctl -w vm.dirty_background_ratio=5 # 기본값: 10 (RAM의 10%)
# dirty 페이지가 이 비율 넘으면 write() 블로킹
sysctl -w vm.dirty_ratio=10 # 기본값: 20
# dirty 페이지가 이 시간 지나면 flush (centiseconds)
sysctl -w vm.dirty_expire_centisecs=1500 # 기본값: 3000 (30초)
# writeback 커널 스레드 깨우는 주기
sysctl -w vm.dirty_writeback_centisecs=500 # 기본값: 500 (5초)
워크로드별 튜닝 프로파일
| 워크로드 | dirty_bg_ratio | dirty_ratio | expire_cs | writeback_cs | 이유 |
|---|---|---|---|---|---|
| DB (OLTP) | 5 | 10 | 1500 | 500 | fsync 지연 최소화 |
| DB (OLAP) | 10 | 20 | 3000 | 500 | 기본값 적절 |
| 파일 서버 | 5 | 15 | 1500 | 300 | 빈번한 flush, 데이터 안전 |
| 대용량 복사 | 3 | 10 | 500 | 100 | burst 억제, 안정적 throughput |
| 로그 서버 | 10 | 40 | 6000 | 500 | 대량 버퍼링, 배치 flush |
| VM 호스트 | 5 | 10 | 1500 | 500 | 게스트 I/O 지연 최소화 |
데이터베이스 최적화 예제
# PostgreSQL 설정
fsync = on
wal_sync_method = fdatasync # or open_datasync
checkpoint_completion_target = 0.9
effective_io_concurrency = 200 # SSD에서 DIO 동시 프리페치
# MySQL/InnoDB 설정
innodb_flush_method = O_DIRECT
innodb_flush_log_at_trx_commit = 1
innodb_io_capacity = 2000 # SSD에 맞게 조정
innodb_io_capacity_max = 4000
innodb_read_io_threads = 4
innodb_write_io_threads = 4
# 시스템 레벨 (DB 서버용)
sysctl -w vm.dirty_background_ratio=5
sysctl -w vm.dirty_ratio=10
sysctl -w vm.swappiness=1 # 스왑 최소화
sysctl -w vm.overcommit_memory=2 # OOM 방지
dd나 cp로 수십 GB 파일을 복사할 때, 기본 dirty_ratio=20에서는
수 GB의 dirty 데이터가 Page Cache에 쌓인 후 한꺼번에 writeback이 시작되어
시스템 전체가 I/O 대기 상태에 빠질 수 있습니다 ("I/O stall").
이런 워크로드에서는 dirty_background_bytes와 dirty_bytes를 절대값으로 설정하여
dirty 데이터 상한을 직접 지정하는 것이 좋습니다.
# 예: dirty 데이터를 최대 256MB로 제한
sysctl -w vm.dirty_background_bytes=67108864 # 64MB
sysctl -w vm.dirty_bytes=268435456 # 256MB
DIO 실패 패턴과 디버깅
Direct I/O는 Buffered I/O보다 엄격한 요구사항을 갖고 있어 다양한 실패 상황이 발생할 수 있습니다. 주요 실패 패턴과 디버깅 방법을 정리합니다.
주요 실패 패턴
| 에러 | 원인 | 해결방법 |
|---|---|---|
-EINVAL | 버퍼/오프셋/크기 정렬 위반 | posix_memalign() 사용, 블록 크기 배수 확인 |
-EINVAL | tmpfs/procfs 등 DIO 미지원 FS | 해당 FS에서는 Buffered I/O 사용 |
-ENOTBLK | 블록 디바이스가 아닌 파일에 DIO 시도 | 파일시스템 확인 |
| 짧은 읽기(short read) | 파일 끝 근처에서 비정렬 크기 | 반환값 확인, 루프 처리 |
| Buffered fallback | ext4에서 sub-block 쓰기 | 블록 크기 단위로 I/O |
| 성능 저하 | DIO 중 잦은 캐시 무효화 | 혼합 I/O 패턴 제거 |
-EAGAIN | O_NONBLOCK + DIO에서 즉시 완료 불가 | AIO/io_uring 사용 |
/* DIO 실패 진단 유틸리티 */
#include <fcntl.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/statfs.h>
void diagnose_dio(const char *path, void *buf,
size_t size, off_t offset)
{
struct statfs sfs;
statfs(path, &sfs);
printf("FS block size: %ld\n", sfs.f_bsize);
printf("Buffer addr: %p (aligned to %lu)\n",
buf, (unsigned long)buf % sfs.f_bsize);
printf("I/O size: %zu (mod %ld = %zu)\n",
size, sfs.f_bsize, size % sfs.f_bsize);
printf("Offset: %ld (mod %ld = %ld)\n",
offset, sfs.f_bsize, offset % sfs.f_bsize);
if ((unsigned long)buf % sfs.f_bsize)
fprintf(stderr, "ERROR: Buffer not aligned!\n");
if (size % sfs.f_bsize)
fprintf(stderr, "ERROR: Size not aligned!\n");
if (offset % sfs.f_bsize)
fprintf(stderr, "ERROR: Offset not aligned!\n");
}
O_DIRECT 쓰기가 블록 경계에 정렬되지 않으면,
커널은 자동으로 Buffered I/O로 fallback합니다 (에러를 반환하지 않음). 이 동작은 호환성을 위한 것이지만,
예상치 못한 Page Cache 사용과 성능 변동을 유발할 수 있습니다. XFS는 이런 경우 -EINVAL을 반환하여
문제를 명확히 알려줍니다.
DIO와 Copy-on-Write 파일시스템
Btrfs, XFS(reflink 모드) 같은 Copy-on-Write(CoW) 파일시스템에서 Direct I/O는 특수한 동작을 보입니다. CoW는 기존 블록을 덮어쓰지 않고 새 블록에 쓰기 때문에, DIO 쓰기 시 추가적인 블록 할당과 메타데이터 업데이트가 필요합니다.
/* Btrfs DIO 쓰기 경로 (간략화) */
static ssize_t btrfs_direct_write(struct kiocb *iocb,
struct iov_iter *from)
{
struct btrfs_inode *bi = BTRFS_I(inode);
/* CoW 파일: NOWAIT DIO 불가 (블록 할당 필요) */
if (!(bi->flags & BTRFS_INODE_NODATACOW)) {
/* CoW: 새 extent 할당 → 쓰기 → 메타데이터 업데이트 */
/* 이 과정에서 extent 할당이 블로킹될 수 있음 */
}
/* NOCOW 파일 (nodatacow 마운트 옵션): in-place 덮어쓰기 */
/* → 일반 DIO와 동일한 성능 */
return btrfs_dio_rw(iocb, from, written);
}
- Btrfs: CoW 모드에서 DIO 쓰기는 새 extent 할당이 필요하여 추가 지연 발생.
nodatacow마운트 옵션으로 in-place 쓰기 가능 (데이터 무결성은 유지되지만 스냅샷 기능 제한) - XFS reflink: 공유 extent에 DIO 쓰기 시 자동 CoW 발생. 초기 쓰기만 느리고 이후 in-place 가능
- 데이터베이스 권장: CoW FS에서 DB 데이터 파일은
chattr +C(Btrfs) 또는 reflink 비활성화 권장
Readahead와 DIO 상호작용
커널의 readahead(미리 읽기) 메커니즘은 Buffered I/O에서 순차 읽기 성능을 크게 향상시키지만, Direct I/O에서는 동작하지 않습니다. 이 차이가 워크로드 선택에 미치는 영향을 분석합니다.
# readahead 설정 확인
cat /sys/block/nvme0n1/queue/read_ahead_kb
# 128 (기본: 128KB = 32 pages)
# readahead 크기 조정 (Buffered I/O에만 영향)
echo 256 > /sys/block/nvme0n1/queue/read_ahead_kb
# Direct I/O 순차 읽기에서 readahead 효과를 대체하는 방법:
# 1. 대형 I/O 크기 사용 (128KB-1MB)
# 2. AIO/io_uring으로 미리 여러 I/O 제출 (파이프라인)
# 3. posix_fadvise(POSIX_FADV_SEQUENTIAL) → DIO에서는 무시됨
커널 내부 DIO 자료구조
Direct I/O 처리에 관여하는 커널 내부 핵심 자료구조를 상세히 살펴봅니다.
kiocb 구조체
/* include/linux/fs.h */
struct kiocb {
struct file *ki_filp; /* 파일 포인터 */
loff_t ki_pos; /* 현재 파일 위치 */
void (*ki_complete)(struct kiocb *, long);
/* 비동기 완료 콜백 */
int ki_flags; /* IOCB_DIRECT 등 */
u16 ki_ioprio; /* I/O 우선순위 */
union {
unsigned int ki_cookie; /* 폴링 쿠키 */
};
};
/* kiocb 플래그 */
#define IOCB_DIRECT (1 << 4) /* O_DIRECT I/O */
#define IOCB_DSYNC (1 << 5) /* O_DSYNC 동기화 */
#define IOCB_SYNC (1 << 6) /* O_SYNC 동기화 */
#define IOCB_NOWAIT (1 << 7) /* RWF_NOWAIT */
#define IOCB_HIPRI (1 << 8) /* 고우선순위 (폴링) */
iomap_dio 구조체
/* fs/iomap/direct-io.c */
struct iomap_dio {
struct kiocb *iocb; /* 연결된 kiocb */
const struct iomap_dio_ops *dops; /* 파일시스템 콜백 */
loff_t i_size; /* 원래 파일 크기 */
loff_t size; /* 전송 요청 크기 */
atomic_t ref; /* 참조 카운터 */
unsigned flags; /* DIO 플래그 */
int error; /* 에러 코드 */
/* submit_bio에서 완료까지 추적 */
struct {
struct iov_iter *iter; /* 유저 버퍼 반복자 */
struct task_struct *waiter; /* 대기 중인 태스크 */
struct bio *poll_bio; /* 폴링 대상 BIO */
} submit;
};
DIO 처리에 관여하는 주요 함수
| 함수 | 위치 | 역할 |
|---|---|---|
generic_file_read_iter() | mm/filemap.c | VFS read 진입점, IOCB_DIRECT 분기 |
generic_file_write_iter() | mm/filemap.c | VFS write 진입점, IOCB_DIRECT 분기 |
iomap_dio_rw() | fs/iomap/direct-io.c | iomap 기반 DIO 메인 루프 |
iomap_dio_bio_iter() | fs/iomap/direct-io.c | BIO 생성 + 유저 페이지 핀 |
iomap_dio_complete() | fs/iomap/direct-io.c | DIO 완료 처리, 결과 반환 |
__blockdev_direct_IO() | fs/direct-io.c | 레거시 DIO (get_block 기반) |
invalidate_inode_pages2_range() | mm/truncate.c | DIO 전 Page Cache 무효화 |
bio_iov_iter_get_pages() | block/bio.c | 유저 버퍼 → BIO 페이지 핀 |
DIO와 mmap 비교
파일 I/O의 또 다른 중요한 방법인 mmap()과 Direct I/O를 비교합니다.
mmap은 파일을 프로세스 가상 주소 공간에 직접 매핑하여, 메모리 접근만으로 파일 I/O를 수행하는 방식입니다.
I/O 방식별 상세 비교
| 항목 | read/write (Buffered) | mmap | O_DIRECT |
|---|---|---|---|
| 시스템 콜 | 매 I/O마다 필요 | 최초 mmap만 (이후 메모리 접근) | 매 I/O마다 필요 |
| 데이터 복사 | 2회 (커널↔유저) | 0회 (직접 매핑) | 0-1회 (DMA) |
| Page Cache | 사용 | 사용 (필수) | 우회 |
| page fault | 없음 | 최초 접근 시 fault | 없음 |
| TLB 오버헤드 | 없음 | 있음 (VMA 관리) | 없음 |
| 랜덤 접근 | 보통 | 우수 (포인터 연산) | 보통 |
| 대용량 순차 | 우수 (readahead) | 보통 (fault 오버헤드) | 우수 (대형 I/O) |
| 동시성 | 높음 | 높음 (읽기 전용 시) | 높음 |
| 영속성 제어 | fsync/fdatasync | msync | O_SYNC/fsync |
| 정렬 요구 | 없음 | 페이지 경계 (권장) | 블록 크기 정렬 필수 |
/* mmap vs DIO vs buffered read 성능 비교 코드 */
#include <sys/mman.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
/* 방법 1: Buffered read */
void test_buffered_read(const char *path, size_t size)
{
int fd = open(path, O_RDONLY);
char *buf = malloc(size);
read(fd, buf, size); /* 유저↔커널 복사 발생 */
free(buf);
close(fd);
}
/* 방법 2: mmap */
void test_mmap_read(const char *path, size_t size)
{
int fd = open(path, O_RDONLY);
char *map = mmap(NULL, size, PROT_READ, MAP_PRIVATE, fd, 0);
/* 데이터 접근: page fault → Page Cache 매핑 */
volatile char c;
for (size_t i = 0; i < size; i += 4096)
c = map[i]; /* 복사 없이 직접 접근 */
munmap(map, size);
close(fd);
}
/* 방법 3: Direct I/O */
void test_direct_read(const char *path, size_t size)
{
int fd = open(path, O_RDONLY | O_DIRECT);
void *buf;
posix_memalign(&buf, 4096, size);
read(fd, buf, size); /* DMA 직접 전송 */
free(buf);
close(fd);
}
mmap()은 Page Cache 위에 구축된 메커니즘입니다. 파일을 mmap하면 가상 주소가 Page Cache의 물리 페이지에 직접 매핑됩니다.
따라서 mmap과 O_DIRECT를 동시에 사용하면 심각한 일관성 문제가 발생합니다. 데이터베이스처럼 자체 캐시가 있는 경우
O_DIRECT를, 인덱스 파일처럼 랜덤 접근이 많은 경우 mmap을, 일반적인 순차 처리에는 Buffered I/O를 선택하는 것이 최적입니다.
DAX (Direct Access) - 차세대 mmap
비휘발성 메모리(PMEM, Intel Optane)를 사용하는 시스템에서는 DAX(Direct Access)를 통해 Page Cache를 우회하면서도 mmap의 편의성을 누릴 수 있습니다. DAX는 파일시스템이 PMEM의 물리 주소를 직접 프로세스 페이지 테이블에 매핑하여, 커널 버퍼 복사와 Page Cache를 모두 제거합니다.
/* DAX mmap 사용 예제 */
int fd = open("/mnt/pmem0/data", O_RDWR);
/* 파일시스템이 DAX 지원 시 (ext4 -o dax, XFS -o dax) */
void *map = mmap(NULL, size, PROT_READ | PROT_WRITE,
MAP_SHARED, fd, 0);
/* DAX: Page Cache 없이 PMEM에 직접 매핑 */
/* 쓰기: cache line flush + memory fence 필요 */
memcpy(map + offset, data, len);
_mm_clflush(map + offset); /* CPU 캐시 → PMEM flush */
_mm_sfence(); /* store fence */
| 특성 | 일반 mmap | DAX mmap | O_DIRECT |
|---|---|---|---|
| Page Cache | 사용 | 우회 | 우회 |
| 데이터 복사 | 0회 (매핑) | 0회 (직접 매핑) | 0-1회 (DMA) |
| 영속성 | msync 필요 | clflush + sfence | O_SYNC/fsync |
| 지연시간 | us (page fault) | ns (PMEM 직접) | us-ms (디스크 대기) |
| 적용 장치 | 모든 블록 장치 | PMEM만 | 모든 블록 장치 |
posix_fadvise와 Page Cache 제어
posix_fadvise()는 Buffered I/O에서 커널의 Page Cache 전략에 힌트를 제공하는 시스템 콜입니다.
Direct I/O를 사용할 수 없는 상황에서 Page Cache 동작을 최적화하는 대안으로 활용됩니다.
fadvise 옵션별 동작
| 옵션 | 동작 | 사용 사례 | DIO 관련성 |
|---|---|---|---|
POSIX_FADV_NORMAL | 기본 readahead 적용 | 일반 파일 접근 | 해당 없음 |
POSIX_FADV_SEQUENTIAL | readahead 윈도우 2배 확장 | 순차 읽기 (로그 분석) | DIO 대안: 순차 최적화 |
POSIX_FADV_RANDOM | readahead 비활성화 | 랜덤 접근 (DB 인덱스) | DIO 대안: 불필요한 readahead 방지 |
POSIX_FADV_WILLNEED | 지정 범위 미리 읽기 | 접근 예정 데이터 준비 | DIO에서는 무시됨 |
POSIX_FADV_DONTNEED | 지정 범위 캐시 해제 | 캐시 오염 방지 | DIO 대안: 읽기 후 캐시 해제 |
POSIX_FADV_NOREUSE | 재사용 안 함 힌트 | 일회성 순차 읽기 | 현재 구현은 NORMAL과 동일 |
/* fadvise로 DIO 없이 캐시 오염 방지 패턴 */
#include <fcntl.h>
#include <unistd.h>
void stream_read_no_pollution(const char *path)
{
int fd = open(path, O_RDONLY);
char buf[131072]; /* 128KB */
ssize_t n;
off_t offset = 0;
/* 순차 접근 힌트 */
posix_fadvise(fd, 0, 0, POSIX_FADV_SEQUENTIAL);
while ((n = read(fd, buf, sizeof(buf))) > 0) {
/* 데이터 처리 ... */
process_data(buf, n);
/* 처리 완료된 범위의 캐시 해제 (핵심!) */
posix_fadvise(fd, offset, n, POSIX_FADV_DONTNEED);
offset += n;
}
close(fd);
}
POSIX_FADV_DONTNEED를 이용한 "읽고 버리기" 패턴은 O_DIRECT의 대안으로 사용할 수 있습니다.
장점은 정렬 제약이 없고 mmap과 호환되며, Page Cache의 readahead 혜택을 받을 수 있습니다.
단점은 데이터가 한 번은 Page Cache를 통과하므로 메모리 복사 오버헤드가 존재하고,
DONTNEED는 힌트일 뿐이라 커널이 무시할 수 있습니다.
/* fadvise 커널 구현 핵심 */
/* mm/fadvise.c */
int generic_fadvise(struct file *file, loff_t offset, loff_t len,
int advice)
{
struct address_space *mapping = file->f_mapping;
switch (advice) {
case POSIX_FADV_SEQUENTIAL:
/* readahead 윈도우 확대 */
file->f_ra.ra_pages = mapping->host->i_sb->s_bdi->ra_pages * 2;
break;
case POSIX_FADV_RANDOM:
/* readahead 비활성화 */
file->f_ra.ra_pages = 0;
break;
case POSIX_FADV_WILLNEED:
/* 강제 readahead 시작 */
force_page_cache_readahead(mapping, file,
offset >> PAGE_SHIFT, nrpages);
break;
case POSIX_FADV_DONTNEED:
/* 지정 범위 캐시 해제 */
if (!inode_is_open_for_write(mapping->host))
invalidate_mapping_pages(mapping, start_index, end_index);
break;
}
return 0;
}
실전 벤치마크 방법론
Buffered I/O와 Direct I/O의 성능을 정확하게 비교하려면 체계적인 벤치마크 방법론이 필요합니다. 흔히 저지르는 실수와 올바른 측정 방법을 정리합니다.
벤치마크 시 주의사항
# 벤치마크 전 캐시 정리 절차
# 1. 파일시스템 캐시 동기화
sync
# 2. Page Cache + dentries/inodes 캐시 해제
echo 3 > /proc/sys/vm/drop_caches
# 3. (선택) 디스크 캐시도 초기화하려면 큰 파일 순차 읽기
dd if=/dev/zero of=/tmp/flush_disk bs=1M count=1024 oflag=direct
rm /tmp/flush_disk
# 4. fio 벤치마크 스크립트
cat << 'EOF' > /tmp/bench_compare.fio
[global]
filename=/dev/nvme0n1p2
runtime=60
time_based
group_reporting
size=4G
[buffered-seq-read]
ioengine=psync
direct=0
bs=4k
rw=read
stonewall
[direct-seq-read]
ioengine=psync
direct=1
bs=4k
rw=read
stonewall
[buffered-rand-write]
ioengine=psync
direct=0
bs=4k
rw=randwrite
fsync=100
stonewall
[direct-rand-write]
ioengine=psync
direct=1
bs=4k
rw=randwrite
stonewall
[uring-direct-rand-read]
ioengine=io_uring
direct=1
bs=4k
rw=randread
iodepth=64
stonewall
EOF
fio /tmp/bench_compare.fio
성능 지표 해석 가이드
| 지표 | 의미 | Buffered 특성 | Direct 특성 |
|---|---|---|---|
| IOPS | 초당 I/O 연산 수 | 매우 높음 (캐시 히트 시) | 디스크 능력에 비례 |
| BW (대역폭) | 초당 전송 바이트 | 높음 (readahead + 캐시) | I/O 크기에 비례 |
| lat (지연시간) | I/O 완료까지 시간 | 변동 크음 (히트/미스) | 안정적 (디스크 지연) |
| clat (완료 지연) | 제출~완료 구간 | 캐시 히트: ns, 미스: ms | us~ms (일정) |
| slat (제출 지연) | 시스템 콜~제출 구간 | 낮음 | GUP 오버헤드 포함 |
| usr% | 유저 CPU 사용률 | 높음 (memcpy) | 낮음 (DMA) |
| sys% | 커널 CPU 사용률 | 중간 | 중간 (BIO 처리) |
# perf를 이용한 I/O 경로 프로파일링
# Buffered read 핫스팟 분석
perf record -g -p $(pidof myapp) -- sleep 10
perf report --sort=symbol
# 예상 핫스팟: copy_to_user, filemap_read, page_cache_ra_unbounded
# Direct I/O 핫스팟 분석
perf record -g -p $(pidof myapp) -- sleep 10
perf report --sort=symbol
# 예상 핫스팟: iomap_dio_rw, bio_iov_iter_get_pages, submit_bio
# BPF를 이용한 I/O 크기 분포 확인
bpftrace -e '
tracepoint:block:block_rq_issue {
@io_sizes = hist(args->bytes);
@io_by_comm[comm] = count();
}'
I/O 크기별 성능 특성
| I/O 크기 | Buffered 순차 읽기 | Direct 순차 읽기 | Direct 랜덤 읽기 | 비고 |
|---|---|---|---|---|
| 512B | 2.5 GB/s | 50 MB/s | 25 MB/s | DIO에서 비효율적 |
| 4KB | 3.0 GB/s | 450 MB/s | 400 MB/s | 기본 블록 크기 |
| 16KB | 3.2 GB/s | 1.2 GB/s | 800 MB/s | DB 읽기에 적합 |
| 64KB | 3.3 GB/s | 2.5 GB/s | 1.5 GB/s | 좋은 효율 |
| 128KB | 3.3 GB/s | 3.0 GB/s | 2.0 GB/s | 순차 DIO 최적 |
| 1MB | 3.3 GB/s | 3.2 GB/s | 2.5 GB/s | 대역폭 포화 |
get_user_pages(), BIO 할당, 블록 매핑 조회 등의 고정 오버헤드가 발생하여 비효율적입니다.
I/O 크기를 64KB-128KB로 늘리면 오버헤드가 분산되어 대역폭이 크게 향상됩니다.
NVMe 장치에서 최대 대역폭을 달성하려면 128KB 이상의 I/O 크기를 사용하세요.
장애 시나리오와 데이터 복구
시스템 장애(정전, 커널 패닉) 시 Buffered I/O와 Direct I/O에서 데이터 손실 범위가 다릅니다. 각 시나리오별 위험도와 대응 방안을 정리합니다.
장애 시나리오별 대응
| 장애 유형 | Buffered I/O 영향 | Direct I/O 영향 | 대응 방안 |
|---|---|---|---|
| 정전 | dirty 페이지 전부 손실 | 진행 중 I/O만 손실 | UPS + 디스크 캐시 비활성화 |
| 커널 패닉 | dirty 페이지 손실 | 진행 중 I/O만 손실 | kdump 설정, 저널링 FS 사용 |
| 프로세스 kill | dirty 페이지는 커널이 writeback | 진행 중 I/O는 취소 | 시그널 핸들러에서 fsync |
| 디스크 오류 | 재시도 후 EIO | 즉시 EIO | RAID, 에러 핸들링 |
| 파일시스템 손상 | 저널 복구 | 저널 복구 | 저널링 FS (ext4, XFS) |
/* 안전한 파일 교체 패턴 (atomic file replacement) */
int safe_file_write(const char *path, const void *data,
size_t len)
{
char tmp_path[PATH_MAX];
snprintf(tmp_path, sizeof(tmp_path), "%s.tmp", path);
/* 1. 임시 파일에 O_DIRECT로 쓰기 */
int fd = open(tmp_path, O_WRONLY | O_CREAT | O_DIRECT, 0644);
void *aligned_buf;
posix_memalign(&aligned_buf, 4096, ALIGN_UP(len, 4096));
memcpy(aligned_buf, data, len);
pwrite(fd, aligned_buf, ALIGN_UP(len, 4096), 0);
/* 2. 파일 크기 맞추기 (패딩 제거) */
ftruncate(fd, len);
/* 3. 데이터 + 메타데이터 동기화 */
fsync(fd);
close(fd);
free(aligned_buf);
/* 4. 원자적 교체 */
rename(tmp_path, path);
/* 5. 디렉토리 엔트리 동기화 */
int dir_fd = open(dirname(path), O_RDONLY);
fsync(dir_fd);
close(dir_fd);
return 0;
}
O_DIRECT로 데이터를 전송해도
디스크 내부 캐시에만 기록되고 비휘발성 미디어에는 아직 기록되지 않았을 수 있습니다.
진정한 영속성을 보장하려면:
O_DSYNC또는O_SYNC사용 (커널이 FUA 또는 FLUSH 명령 발행)- 디스크 write-back 캐시 비활성화:
hdparm -W 0 /dev/sda - 배터리 백업 캐시(BBU/BBM)가 장착된 RAID 컨트롤러 사용
O_DIRECT 쓰기 중 정전이 발생해도:
data=ordered(기본): 데이터 블록이 먼저 기록된 후 메타데이터 커밋 → 일관성 보장data=journal: 데이터도 저널을 통과 → 최대 안전, 성능 저하data=writeback: 메타데이터만 저널 → 빠르지만 정전 시 쓰레기 데이터 노출 가능
iomap DIO 내부 구조 심화
iomap 프레임워크는 커널 4.10 이후 Direct I/O 처리의 핵심으로 자리잡았습니다. 파일시스템의 블록 매핑과 실제 I/O 제출을 깔끔하게 분리하는 이 프레임워크의 내부 동작을 상세히 분석합니다.
iomap_iter 루프의 상세 동작
iomap_dio_rw()는 iomap_iter() 루프를 통해 파일 범위를 extent 단위로 분할 처리합니다.
각 반복에서 파일시스템의 iomap_begin() 콜백이 현재 오프셋에 해당하는 블록 매핑을 반환하고,
iomap은 이 매핑 정보를 바탕으로 BIO를 생성하여 블록 레이어에 제출합니다.
/* fs/iomap/direct-io.c — iomap_dio_bio_iter 상세 */
static loff_t iomap_dio_bio_iter(const struct iomap_iter *iter,
struct iomap_dio *dio)
{
const struct iomap *iomap = &iter->iomap;
struct inode *inode = iter->inode;
loff_t pos = iter->pos;
loff_t length = iomap_length(iter);
struct bio *bio;
/* 1. HOLE 또는 UNWRITTEN 영역 처리 */
if (iomap->type == IOMAP_HOLE) {
if (dio->flags & IOMAP_DIO_WRITE)
return -EINVAL; /* 쓰기: hole에 직접 쓸 수 없음 */
/* 읽기: 제로로 채우기 */
iov_iter_zero(length, dio->submit.iter);
return length;
}
/* 2. BIO 할당 및 구성 */
nr_pages = bio_iov_vecs_to_alloc(dio->submit.iter, BIO_MAX_VECS);
bio = iomap_dio_alloc_bio(iter, dio, nr_pages,
iomap_dio_bio_end_io);
/* 3. 블록 디바이스 주소 설정 */
bio->bi_iter.bi_sector = iomap_sector(iomap, pos);
/* 4. 유저 페이지 핀 + BIO에 추가 */
do {
ret = bio_iov_iter_get_pages(bio, dio->submit.iter);
/* get_user_pages_fast()로 유저 버퍼 물리 페이지 확보 */
} while (ret > 0 && dio->submit.iter->count);
/* 5. FUA 플래그 설정 (O_DSYNC일 때) */
if (dio->flags & IOMAP_DIO_WRITE_FUA)
bio->bi_opf |= REQ_FUA;
/* 6. BIO 제출 */
iomap_dio_submit_bio(iter, dio, bio, pos);
return copied;
}
iomap 타입별 DIO 처리
| iomap.type | 읽기 동작 | 쓰기 동작 | 설명 |
|---|---|---|---|
IOMAP_MAPPED | BIO 생성 + 디스크 읽기 | BIO 생성 + 디스크 쓰기 | 일반 매핑된 extent |
IOMAP_HOLE | 제로 채우기 (디스크 I/O 없음) | 에러 (-EINVAL) | 할당되지 않은 영역 |
IOMAP_UNWRITTEN | 제로 채우기 | BIO 쓰기 + extent 변환 | fallocate로 예약된 영역 |
IOMAP_DELALLOC | 제로 채우기 | 실시간 블록 할당 + 쓰기 | 지연 할당 (ext4) |
IOMAP_INLINE | inode 내부 데이터 복사 | inode 내부 데이터 갱신 | 인라인 데이터 (작은 파일) |
/* struct iomap — 블록 매핑 정보 */
struct iomap {
u64 addr; /* 블록 디바이스 바이트 주소 */
loff_t offset; /* 파일 내 바이트 오프셋 */
u64 length; /* 매핑 범위 길이 (바이트) */
u16 type; /* IOMAP_HOLE/MAPPED/... */
u16 flags; /* IOMAP_F_NEW 등 */
struct block_device *bdev; /* 대상 블록 디바이스 */
struct dax_device *ddev; /* DAX 디바이스 (PMEM) */
void *inline_data; /* 인라인 데이터 포인터 */
};
/* iomap 플래그 */
#define IOMAP_F_NEW 0x01 /* 새로 할당된 extent */
#define IOMAP_F_DIRTY 0x02 /* 커밋 전 dirty 상태 */
#define IOMAP_F_SHARED 0x04 /* reflink 공유 extent */
#define IOMAP_F_MERGED 0x08 /* 병합된 extent */
#define IOMAP_F_ZONE_APPEND 0x10 /* zoned device append */
iomap_begin()이 IOMAP_F_SHARED 플래그와 함께 매핑을 반환합니다.
이 경우 iomap은 자동으로 CoW 경로를 선택하여 새 extent를 할당하고, 쓰기 완료 후
iomap_end()에서 extent 교체를 수행합니다. 이 과정은 파일시스템이 투명하게 처리하므로
사용자는 일반 DIO와 동일한 인터페이스를 사용합니다.
Dirty 비율 제어 심화
리눅스 커널의 dirty 비율 제어는 Buffered I/O의 성능과 안정성을 결정하는 핵심 메커니즘입니다.
balance_dirty_pages()의 내부 대역폭 추정, per-BDI throttling, 그리고 cgroup v2 기반
격리에 대해 심화 분석합니다.
/* mm/page-writeback.c — dirty throttle 대역폭 계산 */
static long wb_dirty_throttle(struct bdi_writeback *wb,
unsigned long dirty,
unsigned long thresh,
unsigned long bg_thresh)
{
unsigned long freerun = (thresh + bg_thresh) / 2;
unsigned long bw;
if (dirty <= freerun) {
/* freerun 구간: 제한 없음 */
return 0;
}
if (dirty >= thresh) {
/* hard throttle: 쓰기 중단, writeback 완료 대기 */
return 1; /* 최소 sleep */
}
/* 선형 throttle: dirty 비율에 비례하여 대역폭 축소
* bw = wb_bandwidth * (thresh - dirty) / (thresh - freerun)
* → dirty가 thresh에 가까울수록 bw가 0에 수렴 */
bw = wb->avg_write_bandwidth;
bw = bw * (thresh - dirty) / (thresh - freerun);
/* sleep 시간 = 전송량 / 허용 대역폭 */
return (pages_dirtied * HZ) / bw;
}
per-BDI dirty 제한
글로벌 dirty 제한 외에도 각 Block Device Interface(BDI)마다 독립적인 dirty 제한이 적용됩니다. 느린 USB 디스크의 dirty 페이지가 빠른 NVMe의 writeback을 방해하지 않도록 대역폭 비례 분배를 수행합니다.
| 파라미터 | 범위 | 기본값 | 설명 |
|---|---|---|---|
/sys/class/bdi/<bdi>/min_ratio | BDI별 | 0 | 이 BDI의 최소 dirty 비율 보장 |
/sys/class/bdi/<bdi>/max_ratio | BDI별 | 100 | 이 BDI의 최대 dirty 비율 제한 |
/sys/class/bdi/<bdi>/read_ahead_kb | BDI별 | 128 | readahead 크기 (DIO 무관) |
/sys/class/bdi/<bdi>/strict_limit | BDI별 | 0 | 1이면 max_ratio를 엄격 적용 |
# per-BDI dirty 제한 설정 예시
# NVMe 디스크: 높은 dirty 비율 허용
echo 70 > /sys/class/bdi/259:0/max_ratio
# USB 디스크: dirty 비율 제한
echo 10 > /sys/class/bdi/8:16/max_ratio
echo 1 > /sys/class/bdi/8:16/strict_limit
# BDI별 dirty 현황 확인
cat /sys/class/bdi/259:0/stat
# read_ahead_kb min_ratio max_ratio
cgroup v2 I/O writeback 격리
cgroup v2에서는 메모리 cgroup과 I/O cgroup이 연계되어 dirty writeback을 컨테이너 단위로 격리할 수 있습니다.
# cgroup v2 dirty writeback 격리 설정
# 1. 메모리 cgroup에 dirty 제한 설정
echo 104857600 > /sys/fs/cgroup/myapp/memory.dirty_limit # 100MB
# 2. I/O cgroup에 대역폭 제한 설정
echo "259:0 wbps=52428800" > /sys/fs/cgroup/myapp/io.max # 50MB/s
# 3. writeback 격리 확인
cat /sys/fs/cgroup/myapp/memory.stat | grep dirty
# file_dirty 15728640 ← 현재 cgroup의 dirty 페이지 바이트
# 커널 6.2+ : per-memcg dirty throttling 지원
# 각 cgroup이 독립적으로 balance_dirty_pages() 적용
memory.dirty_limit을 적절히 설정하세요.
O_SYNC/O_DSYNC 커널 내부 동작 심화
O_SYNC와 O_DSYNC는 write() 시스템 콜 반환 전에 데이터 영속성을 보장하는 플래그입니다.
Direct I/O와 조합될 때 FUA(Force Unit Access) 명령을 통해 디스크 캐시를 우회하는 메커니즘을 상세히 분석합니다.
/* fs/iomap/direct-io.c — FUA 설정 경로 */
static void iomap_dio_set_bio_flags(struct iomap_dio *dio,
struct bio *bio)
{
if (dio->flags & IOMAP_DIO_WRITE) {
bio->bi_opf = REQ_OP_WRITE;
/* O_DSYNC: REQ_FUA → 디스크 캐시 우회 쓰기 */
if (dio->iocb->ki_flags & IOCB_DSYNC) {
if (bdev_fua(iomap->bdev))
bio->bi_opf |= REQ_FUA;
else
dio->flags |= IOMAP_DIO_NEED_SYNC;
/* FUA 미지원 → 완료 후 FLUSH */
}
}
}
/* DIO 완료 시 FLUSH 처리 */
static ssize_t iomap_dio_complete(struct iomap_dio *dio)
{
if (dio->flags & IOMAP_DIO_NEED_SYNC) {
/* FUA 미지원 디바이스: 명시적 FLUSH 발행 */
blkdev_issue_flush(bdev);
}
if (dio->iocb->ki_flags & IOCB_SYNC) {
/* O_SYNC: 메타데이터(mtime, ctime)도 동기화 */
generic_write_sync(dio->iocb, ret);
}
return transferred;
}
FUA vs FLUSH 메커니즘 비교
| 메커니즘 | 동작 | 성능 영향 | 사용 조건 |
|---|---|---|---|
| REQ_FUA | 해당 I/O만 디스크 미디어에 직접 기록 | 낮음 (해당 I/O만) | 디바이스 FUA 지원 시 |
| REQ_PREFLUSH | 이전 모든 캐시 데이터 flush 후 쓰기 | 높음 (전체 캐시 flush) | FUA 미지원 시 |
| blkdev_issue_flush | 디바이스 캐시 전체 flush | 매우 높음 | fsync() 내부 |
| WRITE_FLUSH_FUA | PREFLUSH + FUA 조합 | 가장 높음 | 저널 커밋 (ext4 jbd2) |
O_DIRECT | O_DSYNC 조합은 NVMe에서 최적의 영속성 + 성능 균형을 제공합니다.
반면 구형 SATA SSD/HDD는 FUA를 지원하지 않아 FLUSH로 대체되므로 성능이 크게 떨어질 수 있습니다.
hdparm -I /dev/sda | grep FUA 또는 cat /sys/block/nvme0n1/queue/fua로 FUA 지원 여부를 확인하세요.
# FUA 지원 여부 확인
cat /sys/block/nvme0n1/queue/fua
# 1 ← FUA 지원
cat /sys/block/sda/queue/fua
# 0 ← FUA 미지원 (FLUSH로 fallback)
# O_SYNC vs O_DSYNC 성능 비교 (fio)
fio --name=osync --ioengine=psync --direct=1 \
--bs=4k --rw=randwrite --sync=1 --runtime=30 \
--filename=/dev/nvme0n1p2
# O_SYNC: 데이터 + 메타데이터 sync → 느림
fio --name=odsync --ioengine=psync --direct=1 \
--bs=4k --rw=randwrite --fdatasync=1 --runtime=30 \
--filename=/dev/nvme0n1p2
# O_DSYNC: 데이터만 sync → 더 빠름 (mtime 갱신 생략 가능)
AIO+DIO 커널 경로 심화
Linux AIO(io_submit/io_getevents)와 Direct I/O의 조합은 데이터베이스 엔진에서 수십 년간 사용된
비동기 I/O 패턴입니다. 커널 내부에서 AIO DIO가 실제로 어떻게 비동기적으로 처리되는지,
그리고 어떤 상황에서 블로킹으로 fallback하는지를 분석합니다.
/* fs/aio.c — AIO DIO 커널 경로 */
static int aio_read(struct kiocb *req, const struct iocb *iocb)
{
struct file *file = req->ki_filp;
struct iov_iter iter;
/* 1. 비동기 완료 콜백 설정 */
req->ki_complete = aio_complete_rw;
req->ki_flags |= IOCB_DIRECT; /* O_DIRECT일 때 */
/* 2. VFS read 호출 → iomap_dio_rw() 진입 */
ret = call_read_iter(file, req, &iter);
/* 3. 비동기 DIO: -EIOCBQUEUED 반환 */
if (ret == -EIOCBQUEUED)
return 0; /* I/O 진행 중, 나중에 완료 */
/* 4. 동기적으로 완료된 경우 (즉시 반환) */
aio_complete(req, ret);
return 0;
}
/* AIO DIO 완료 콜백 (IRQ 또는 워커 컨텍스트) */
static void aio_complete_rw(struct kiocb *kiocb, long res)
{
struct aio_kiocb *iocb = container_of(kiocb,
struct aio_kiocb, rw);
/* BIO 완료 → CQ에 결과 기록 */
iocb_put(iocb);
/* io_getevents()가 이 결과를 수확 */
}
AIO DIO가 블로킹되는 상황
| 상황 | 원인 | 결과 | 해결방법 |
|---|---|---|---|
| 메타데이터 조회 | extent 매핑을 위한 디스크 I/O | io_submit() 블로킹 | 파일 사전 할당 (fallocate) |
| 페이지 할당 실패 | 메모리 부족으로 GUP 실패 | 동기 fallback | mlock 또는 hugepage |
| inode 락 경합 | 동일 inode에 동시 DIO 쓰기 | io_submit() 블로킹 | 파일 분할 또는 io_uring |
| Buffered I/O 혼합 | 캐시 무효화(invalidate) 대기 | 동기 처리 | DIO 전용으로 통일 |
| 파일 확장 쓰기 | 새 블록 할당 필요 | io_submit() 블로킹 | fallocate 사전 할당 |
| 저널 커밋 대기 | ext4 저널 공간 부족 | 전체 AIO 지연 | 저널 크기 증가 |
O_DIRECT 없이 AIO를 사용하면, 커널은 내부적으로 동기 Buffered I/O를 수행합니다.
io_submit()이 I/O 완료까지 블로킹되어 비동기의 이점이 완전히 사라집니다.
이것은 libaio의 가장 큰 제약이며, 이를 해결하기 위해 io_uring이 개발되었습니다.
io_uring은 Buffered I/O에서도 워커 스레드를 통해 진정한 비동기 처리를 수행합니다.
/* AIO DIO 멀티큐 배치 제출 패턴 */
#define BATCH_SIZE 32
#define BLOCK_SIZE 4096
void aio_dio_batch_read(int fd, off_t *offsets,
int count)
{
io_context_t ctx = 0;
struct iocb cbs[BATCH_SIZE];
struct iocb *cbp[BATCH_SIZE];
struct io_event events[BATCH_SIZE];
void *bufs[BATCH_SIZE];
io_setup(BATCH_SIZE, &ctx);
for (int i = 0; i < count; i++) {
posix_memalign(&bufs[i], BLOCK_SIZE, BLOCK_SIZE);
io_prep_pread(&cbs[i], fd, bufs[i],
BLOCK_SIZE, offsets[i]);
cbp[i] = &cbs[i];
}
/* 배치 제출: 한 번의 syscall로 다수 I/O */
int submitted = io_submit(ctx, count, cbp);
/* 최소 1개 완료 대기 (나머지는 논블로킹 수확) */
int completed = io_getevents(ctx, 1, count,
events, NULL);
for (int i = 0; i < completed; i++) {
if (events[i].res < 0)
handle_error(events[i].res);
}
io_destroy(ctx);
}
io_uring + DIO 심화
io_uring은 Direct I/O와 조합했을 때 최대 성능을 발휘합니다. SQPOLL, IOPOLL, fixed files, registered buffers 등 고급 기능을 활용한 최적 설정과 커널 내부 처리 경로를 상세히 분석합니다.
/* io_uring + DIO 최적 설정 예제 (liburing) */
#include <liburing.h>
#include <fcntl.h>
#include <stdlib.h>
#define QD 128
#define BS 4096
#define NR_BUFS 128
int setup_optimal_dio(struct io_uring *ring)
{
struct io_uring_params params = {};
int ret;
/* 1. SQPOLL + IOPOLL 활성화 */
params.flags = IORING_SETUP_SQPOLL | /* 커널 폴링 스레드 */
IORING_SETUP_IOPOLL; /* BIO 완료 폴링 */
params.sq_thread_idle = 2000; /* 2초 유휴 후 sleep */
params.sq_thread_cpu = 3; /* CPU 3에 고정 */
ret = io_uring_queue_init_params(QD, ring, ¶ms);
/* 2. 파일 등록 (fget/fput 오버헤드 제거) */
int fds[1];
fds[0] = open("/data/db.dat", O_RDWR | O_DIRECT);
io_uring_register_files(ring, fds, 1);
/* 3. 버퍼 등록 (GUP 오버헤드 제거) */
struct iovec iovs[NR_BUFS];
for (int i = 0; i < NR_BUFS; i++) {
posix_memalign(&iovs[i].iov_base, BS, BS);
iovs[i].iov_len = BS;
}
io_uring_register_buffers(ring, iovs, NR_BUFS);
return 0;
}
void submit_fixed_dio(struct io_uring *ring,
int buf_idx, off_t offset)
{
struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
/* 고정 파일 + 고정 버퍼로 읽기 */
io_uring_prep_read_fixed(sqe, 0, /* fixed file idx */
NULL, BS, offset,
buf_idx); /* fixed buf idx */
sqe->flags |= IOSQE_FIXED_FILE;
/* SQPOLL 모드: submit 불필요 (커널이 자동 수확) */
/* 단, SQ가 가득 차면 io_uring_submit() 필요 */
}
io_uring DIO 최적화 조합별 성능 (NVMe, 4KB 랜덤 읽기)
| 설정 조합 | IOPS | 평균 지연(us) | p99 지연(us) | CPU 사용률 |
|---|---|---|---|---|
| 기본 io_uring + DIO | 900K | 68 | 95 | 45% |
| + Fixed Files | 950K | 63 | 90 | 42% |
| + Registered Buffers | 1.05M | 57 | 82 | 38% |
| + SQPOLL | 1.15M | 52 | 75 | 35% (+ 1 core) |
| + IOPOLL (전체 조합) | 1.35M | 42 | 60 | 33% (+ 1 core) |
IORING_SETUP_SQPOLL은 커널 스레드가 사용자를 대신하여 I/O를 처리합니다.
이 기능은 CAP_SYS_NICE 또는 CAP_SYS_ADMIN 권한이 필요하며(커널 5.12+),
일반 사용자는 IORING_SETUP_SQ_AFF로 CPU affinity를 설정할 수 없습니다.
컨테이너 환경에서는 seccomp 프로파일에서 io_uring_setup을 허용해야 하며,
Docker/Kubernetes에서는 별도 seccomp 정책이 필요합니다.
NVMe DIO 최적화 심화
NVMe 장치에서 Direct I/O 성능을 극대화하려면 블록 레이어의 multi-queue 아키텍처, IRQ affinity, NUMA 배치, I/O 크기 최적화 등 소프트웨어 스택 전반의 튜닝이 필요합니다.
blk-mq와 NVMe DIO의 상호작용
/* NVMe DIO: BIO -> blk-mq -> NVMe 드라이버 경로 */
/* 1. iomap_dio_submit_bio()에서 BIO 제출 */
submit_bio(bio);
/* 2. blk-mq: 현재 CPU의 하드웨어 큐 선택 */
/* block/blk-mq.c */
static void blk_mq_submit_bio(struct bio *bio)
{
struct request_queue *q = bio->bi_bdev->bd_disk->queue;
struct blk_mq_hw_ctx *hctx;
/* CPU → 하드웨어 큐 매핑 (per-CPU) */
hctx = q->queue_hw_ctx[blk_mq_map_queue(q,
bio->bi_opf, bio->bi_iter.bi_sector)];
/* NVMe: 스케줄러 없이 (none) 직접 dispatch */
blk_mq_try_issue_directly(hctx, rq);
}
/* 3. NVMe 드라이버: SQ에 명령 기록 */
/* drivers/nvme/host/pci.c */
static blk_status_t nvme_queue_rq(struct blk_mq_hw_ctx *hctx,
const struct blk_mq_queue_data *bd)
{
struct nvme_queue *nvmeq = hctx->driver_data;
struct nvme_command cmd;
/* NVMe 명령 구성 */
nvme_setup_rw(ns, req, &cmd);
/* SQ tail에 명령 기록 (doorbell ring) */
nvme_submit_cmd(nvmeq, &cmd);
return BLK_STS_OK;
}
NVMe DIO NUMA 최적화
| 최적화 항목 | 설정 방법 | 효과 | 확인 방법 |
|---|---|---|---|
| CPU affinity | NVMe IRQ를 로컬 NUMA 노드 CPU에 고정 | 크로스-NUMA 메모리 접근 제거 | cat /proc/interrupts |
| 메모리 할당 | numactl --membind 또는 MPOL_BIND | DMA 버퍼를 로컬 메모리에 배치 | numastat -p <pid> |
| io_uring CPU | SQPOLL sq_thread_cpu 설정 | 폴링 스레드를 NVMe NUMA에 배치 | taskset -cp <pid> |
| 큐 매핑 | managed IRQ affinity 자동 | CPU -> NVMe HW 큐 1:1 매핑 | cat /sys/block/nvme0n1/mq/*/cpu_list |
# NVMe NUMA 정보 확인 및 최적화
# 1. NVMe 장치의 NUMA 노드 확인
cat /sys/block/nvme0n1/device/numa_node
# 0
# 2. NVMe IRQ의 CPU affinity 확인
cat /proc/interrupts | grep nvme
# 35: ... nvme0q0 (admin queue)
# 36: ... nvme0q1 (I/O queue 1 → CPU 0)
# 37: ... nvme0q2 (I/O queue 2 → CPU 1)
# 3. 큐당 CPU 매핑 확인
ls /sys/block/nvme0n1/mq/
# 0 1 2 3 4 5 6 7 (CPU 수만큼)
cat /sys/block/nvme0n1/mq/0/cpu_list
# 0 (하드웨어 큐 0 → CPU 0)
# 4. NUMA 로컬 CPU에서 I/O 실행
numactl --cpunodebind=0 --membind=0 \
fio --name=nvme_local --ioengine=io_uring \
--direct=1 --bs=4k --iodepth=64 --rw=randread \
--filename=/dev/nvme0n1 --runtime=60
# 5. 크로스-NUMA에서 실행 (비교용)
numactl --cpunodebind=1 --membind=1 \
fio --name=nvme_remote --ioengine=io_uring \
--direct=1 --bs=4k --iodepth=64 --rw=randread \
--filename=/dev/nvme0n1 --runtime=60
# 크로스-NUMA: IOPS 10-20% 감소, p99 지연 증가
nvme create-ns 명령으로 네임스페이스를 생성하고,
각 네임스페이스에 대해 독립적인 DIO 최적화를 적용합니다.
데이터베이스 I/O 패턴 심화
데이터베이스 엔진별 I/O 아키텍처와 Direct I/O 활용 방식을 더 깊이 분석합니다. WAL(Write-Ahead Logging), 체크포인트, 더블 라이트 버퍼 등 핵심 메커니즘이 커널 I/O 경로와 어떻게 상호작용하는지 살펴봅니다.
데이터베이스별 DIO 구현 상세
| DB 엔진 | 데이터 파일 I/O | WAL/redo I/O | 체크포인트 | io_uring 지원 |
|---|---|---|---|---|
| MySQL InnoDB | O_DIRECT + fsync | O_DIRECT (옵션) | fuzzy checkpoint | 8.0.22+ (실험적) |
| PostgreSQL | Buffered (기본) | fdatasync/open_dsync | spread checkpoint | 16+ (io_method=io_uring) |
| Oracle | O_DIRECT + AIO | O_DSYNC | incremental | 미지원 |
| RocksDB | O_DIRECT (옵션) | O_DIRECT + fsync | compaction 시 | 7.0+ (실험적) |
| SQLite | Buffered | fdatasync | 체크포인트 (WAL) | 미지원 |
| ScyllaDB | O_DIRECT + io_uring | O_DIRECT + io_uring | commitlog flush | 완전 지원 |
MySQL InnoDB doublewrite 메커니즘
InnoDB의 doublewrite buffer는 부분 페이지 쓰기(torn page) 문제를 해결하는 핵심 메커니즘입니다. 16KB 페이지를 디스크에 쓰다가 정전이 발생하면, 페이지의 일부만 기록되어 복구 불가능한 상태가 됩니다.
/* InnoDB doublewrite 쓰기 흐름 (간략화) */
/* 1단계: doublewrite buffer에 순차 쓰기 */
for (i = 0; i < batch_size; i++) {
/* dirty 페이지를 doublewrite 영역에 O_DIRECT 쓰기 */
pwrite(dblwr_fd, page_data[i], 16384,
dblwr_offset + i * 16384);
}
/* doublewrite 영역 fsync */
fsync(dblwr_fd);
/* 2단계: 실제 데이터 파일에 랜덤 쓰기 */
for (i = 0; i < batch_size; i++) {
pwrite(data_fd, page_data[i], 16384,
page_offset[i]);
}
/* 데이터 파일 fsync */
fsync(data_fd);
/* 복구 시: doublewrite의 정상 복사본으로 torn page 복원 */
innodb_doublewrite=DETECT_ONLY 설정으로 doublewrite를 검증용으로만
사용하여 쓰기 증폭(write amplification)을 50% 줄일 수 있습니다.
nvme id-ctrl /dev/nvme0 | grep ps로 PLP 지원 여부를 확인하세요.
PostgreSQL의 DIO 전환 (16+)
# PostgreSQL 16+ Direct I/O 설정
# postgresql.conf
io_method = io_uring # DIO + io_uring (실험적)
debug_io_direct = 'data,wal' # DIO 적용 범위
shared_buffers = 16GB # DIO에서는 더 크게 (Page Cache 대체)
# DIO 활성화 시 변화:
# - shared_buffers가 유일한 캐시 (이중 캐싱 제거)
# - OS Page Cache 의존도 0 → 메모리 사용 예측 가능
# - checkpoint가 O_DIRECT로 데이터 파일에 직접 쓰기
# 성능 비교 (pgbench, 일반적인 OLTP)
# Buffered I/O: TPS 45,000 (shared_buffers=8GB, Page Cache=24GB)
# Direct I/O: TPS 42,000 (shared_buffers=32GB, Page Cache=N/A)
# → TPS는 비슷하지만 p99 지연이 DIO에서 30% 안정적
RocksDB LSM-Tree와 DIO
RocksDB(LevelDB 계열)는 Log-Structured Merge-Tree(LSM-Tree)를 사용합니다. 컴팩션 시 대용량 순차 I/O가 발생하여 Direct I/O가 특히 효과적입니다.
/* RocksDB DIO 설정 (C++ API) */
rocksdb::Options options;
/* WAL: Direct I/O + fsync */
options.use_direct_io_for_flush_and_compaction = true;
options.use_direct_reads = true;
/* 컴팩션: Direct I/O (대용량 순차 R/W) */
/* → Page Cache 오염 방지, 안정적 읽기 지연 */
/* rate limiter: 컴팩션 I/O 대역폭 제한 */
options.rate_limiter.reset(
rocksdb::NewGenericRateLimiter(
100 * 1024 * 1024, /* 100MB/s */
100 * 1000, /* 100ms 윈도우 */
10 /* fairness */
));
- XFS: DIO에 가장 최적화. extent 기반, 효율적인 fallocate, iomap 완전 지원
- ext4: DIO 지원 양호. sub-block DIO 시 buffered fallback 주의
- Btrfs: CoW 오버헤드. DB에서는
chattr +C또는nodatacow필수 - ZFS: O_DIRECT를 무시 (항상 ARC 캐시 사용). DB와의 조합 시 이중 캐싱 발생