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를 이용한 정량 검증 방법까지 실전 의사결정에 필요한 내용을 종합적으로 다룹니다.

전제 조건: 커널 아키텍처 문서를 먼저 읽으세요. CPU, 메모리, 인터럽트의 기본 흐름을 알고 있으면 본 문서를 더 빠르게 이해할 수 있습니다. Page Cache블록 I/O 문서도 함께 참고하면 좋습니다.
일상 비유: Buffered I/O는 우체통에 편지를 넣는 것과 비슷합니다. 우체통에 넣으면(write) 즉시 돌아올 수 있지만, 실제 배달(디스크 기록)은 우체부(writeback 스레드)가 나중에 합니다. Direct I/O는 직접 상대방에게 전달하는 것입니다 - 기다려야 하지만, 확실히 전달됩니다.

핵심 요약

  • Page Cache — 디스크 데이터를 메모리에 캐싱하는 커널 계층. Buffered I/O의 핵심 메커니즘입니다.
  • O_DIRECT — Page Cache를 우회하여 DMA로 디스크와 직접 데이터를 주고받는 플래그입니다.
  • dirty writeback — Page Cache의 변경된 페이지를 비동기적으로 디스크에 기록하는 메커니즘입니다.
  • iomap — 최신 커널의 Direct I/O를 처리하는 통합 프레임워크. ext4, XFS 등에서 사용합니다.
  • O_SYNC/fsync — 데이터 영속성을 보장하는 동기화 메커니즘. DIO와 조합하여 사용합니다.

단계별 이해

  1. 구성요소 확인
    Page Cache, address_space, BIO, kiocb 등 핵심 자료구조와 주요 API를 먼저 식별합니다.
  2. 요청 흐름 추적
    Buffered read/write와 Direct read/write의 커널 내부 호출 경로를 순서대로 따라갑니다.
  3. 예외 경로 점검
    정렬 실패, 캐시 무효화, 부분 쓰기, fallback 등 경계 조건을 확인합니다.
  4. 성능/안정성 점검
    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 경로 비교

Buffered I/O Direct I/O User Buffer copy_from/to_user Page Cache address_space (xarray) writeback / readpage Block Layer (BIO) I/O Scheduler Storage Device User Buffer (aligned) DMA 직접 전송 (Page Cache 우회) Block Layer (BIO) I/O Scheduler Storage Device Page Cache 우회 (bypass) 2회 메모리 복사, 비동기 writeback 0회 복사, 동기 전송, 정렬 필수
핵심 차이: Buffered 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())를 호출하여 디스크에서 읽습니다.

앱 read(fd, buf, size) VFS: vfs_read() file→f_op→read_iter() generic_file_read_iter() Page Cache 검색 Hit Miss 페이지 복사 → 유저 버퍼 readpage() → 디스크 읽기 페이지 → Page Cache 추가 copy_to_user() → 유저 버퍼
/* 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 스레드가 비동기적으로 처리합니다.

앱 write(fd, buf, size) VFS: vfs_write() file→f_op→write_iter() generic_file_write_iter() copy_from_user() → Page Cache 페이지 dirty 마킹 → return (비동기!) 나중에 pdflush/writeback dirty 페이지 → 디스크 return (즉시 반환) fsync() 호출 시 fsync() → 디스크 동기화
/* 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;
}
Page Cache의 장점:
  • 읽기 성능: 캐시 히트 시 디스크 접근 없이 메모리에서 즉시 반환
  • 쓰기 성능: write() 즉시 반환 (비동기 writeback)
  • Readahead: 순차 읽기 패턴 감지 시 미리 읽기
  • Write coalescing: 여러 작은 쓰기를 모아서 한 번에 디스크 기록
  • 통합 캐시: 프로세스 간 캐시 공유
Buffered I/O의 위험: write()가 성공적으로 반환되어도 데이터가 디스크에 기록된 것이 아닙니다. 정전이나 커널 패닉이 발생하면 Page Cache에만 있는 dirty 데이터는 소실됩니다. 데이터 영속성이 필요하면 반드시 fsync()/fdatasync() 또는 O_SYNC/O_DSYNC를 사용해야 합니다.

Page Cache 구조

Page Cache는 address_space 구조체로 관리됩니다. 각 inode마다 하나의 address_space가 연결되어 있으며, xarray(이전에는 radix tree)를 사용하여 파일 오프셋에서 물리 페이지로의 매핑을 관리합니다.

inode i_mapping → i_size, i_mode address_space host: *inode i_pages: xarray → nrpages: N a_ops: *operations writeback_index xarray (i_pages) index 0 → folio (page 0-3) index 4 → folio (page 4-7) index 8 → folio (page 8-11) ... struct folio (대형 페이지 지원) flags: PG_uptodate | PG_dirty | PG_writeback | PG_locked mapping: *address_space index: pgoff_t _refcount: atomic_t _mapcount: (mmap 매핑 수) private: (fs 메타데이터, buffer_head 등) a_ops (콜백) readpage() writepage() write_begin() write_end() direct_IO() readahead()

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 페이지 비율 정상 (0~bg_ratio) bg writeback (bg~dirty) throttle (dirty_ratio 초과) dirty_background_ratio (10%) dirty_ratio (20%) 주기적 writeback dirty_writeback_centisecs (5s) 만료 기반 writeback dirty_expire_centisecs (30s) 압력 기반 throttle balance_dirty_pages() wb_workfn() — writeback 워커 per-BDI writeback 스레드 writeback_single_inode() → a_ops->writepage() Block Layer → Storage Device

Dirty Writeback 파라미터

파라미터기본값단위설명
vm.dirty_background_ratio10%이 비율 초과 시 백그라운드 writeback 시작
vm.dirty_ratio20%이 비율 초과 시 write() 프로세스가 직접 writeback 수행 (throttle)
vm.dirty_background_bytes0bytesratio 대신 절대값 지정 (0이면 ratio 사용)
vm.dirty_bytes0bytesratio 대신 절대값 지정
vm.dirty_writeback_centisecs5001/100초writeback 스레드 깨우는 주기 (5초)
vm.dirty_expire_centisecs30001/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가 성능에 미치는 영향: 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를 구성하여 블록 레이어에 직접 제출합니다.

open(path, O_RDWR | O_DIRECT) pread/pwrite(fd, aligned_buf, size, offset) generic_file_read/write_iter() kiocb->ki_flags & IOCB_DIRECT 확인 정렬 검증 (buffer, size, offset) 실패 -EINVAL invalidate_inode_pages2_range() (캐시 일관성) get_user_pages() → BIO 구성 유저 버퍼 물리 페이지 핀 → DMA 주소 설정 submit_bio() → Block Layer → Storage 동기: dio_complete() 대기 / 비동기: ki_complete 콜백

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;
}
O_DIRECT 정렬 요구사항: Direct I/O는 다음 조건을 모두 만족해야 합니다 (안 지키면 -EINVAL):
  • 버퍼 주소: 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;
}
Page Cache 무효화가 필요한 이유: Direct I/O가 디스크에 데이터를 직접 쓰면 Page Cache에 캐싱된 데이터는 오래된(stale) 상태가 됩니다. 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이 자동으로 처리합니다.

파일시스템 (ext4/XFS) ext4_dio_read/write_iter() xfs_file_dio_read/write() iomap_dio_rw() iomap 프레임워크 진입점 fs/iomap/direct-io.c iomap_iter() 루프 for 각 extent: fs->iomap_begin() 호출 iomap_begin 블록 매핑 iomap_dio_bio_iter() BIO 생성 + bio_iov_iter_get_pages() submit_bio() → Block Layer iomap_dio_complete() → 유저 공간에 결과 반환 struct iomap addr: 블록 주소 offset: 파일 오프셋 length: 범위 길이 type: MAPPED/HOLE bdev: 블록 디바이스 flags: 속성
/* 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_IOiomap 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.cfs/iomap/direct-io.c
iomap의 장점: iomap은 파일시스템의 블록 매핑 로직(iomap_begin)과 I/O 제출 로직을 완전히 분리합니다. 파일시스템 개발자는 extent 매핑만 구현하면 되고, BIO 생성, 완료 처리, 에러 복구는 iomap 프레임워크가 담당합니다. 이로써 코드 중복이 줄고, 버그 수정이 한 곳에서 모든 파일시스템에 적용됩니다.

O_SYNC, O_DSYNC, fsync()

데이터 동기화 방법에도 여러 변형이 있습니다. 각 방법이 보장하는 범위와 성능 특성이 다르므로, 워크로드에 맞는 적절한 수준을 선택해야 합니다.

동기화 옵션 비교

방법메타데이터데이터시점성능
기본 (비동기)XX나중에 (pdflush)빠름
O_DSYNCXOwrite() 반환 전느림
O_SYNCOOwrite() 반환 전매우 느림
O_DIRECTXO (즉시)read/write 중변동
fsync()OOfsync() 호출 시배치 가능
fdatasync()X (필수만)Ofdatasync() 호출 시fsync()보다 빠름
sync_file_range()XO (범위)호출 시세밀한 제어

O_DIRECT와 동기화 플래그 조합

조합데이터 영속성메타데이터 영속성사용 사례
O_DIRECT 단독디스크 전송 완료 (캐시 무보장)X일반 DIO 읽기/쓰기
O_DIRECT | O_DSYNC디스크 영속 보장XDB 데이터 파일
O_DIRECT | O_SYNC디스크 영속 보장ODB WAL, 트랜잭션 로그
O_DIRECT + fsync()fsync 시점에 영속 보장O배치 동기화
O_DIRECT만으로는 영속성이 보장되지 않습니다: O_DIRECT는 Page Cache를 우회하여 디스크 컨트롤러까지 데이터를 전송하지만, 디스크 내부 휘발성 캐시(write-back cache)에 데이터가 머물 수 있습니다. 정전 시 이 데이터가 손실될 수 있으므로, 진정한 영속성이 필요하면 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 주의사항: 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 BSPhysical BS권장 정렬
HDD (Legacy)512512512
HDD (AF 4Kn)409640964096
SSD51240964096
NVMe51240964096 (페이지 크기)

파일시스템별 DIO 정렬 요구사항

파일시스템최소 정렬DIO 구현비고
ext4블록 크기 (보통 4KB)iomap (기본) / 레거시 fallbacksub-block DIO 시 buffered fallback 가능
XFS블록 크기 (512B~64KB)iomapreflink 파일은 CoW 발생
Btrfs섹터 크기자체 구현CoW 파일시스템, DIO 시 제약 있음
F2FS블록 크기 (4KB)자체 구현GC 중 DIO 대기 가능
tmpfs지원 안 함N/AO_DIRECT 무시 (항상 buffered)
STATX_DIOALIGN (커널 6.1+): 커널 6.1부터 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;
}
libaio의 한계:
  • 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를 달성합니다.

User Space SQ (Submission Queue) SQE: opcode=IORING_OP_READ fd, buf(aligned), len, off CQ (Completion Queue) CQE: res (전송 바이트) user_data (식별자) 애플리케이션 SQE 채우기 → CQE 읽기 Kernel Space io_uring 워커: SQE 처리 IOCB_DIRECT 설정 → iomap_dio_rw() iomap DIO → BIO 제출 → Storage ki_complete → CQE 생성
/* 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 고정 버퍼 (Registered Buffers): 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_DIRECTfsync()Buffer Pool이 자체 캐시 역할
redo 로그 (ib_logfile)O_DIRECT (O_DIRECT_NO_FSYNC 시)fsync() 또는 O_DSYNC트랜잭션 영속성
doublewrite bufferO_DIRECTfsync()부분 쓰기(torn page) 방지
undo 로그O_DIRECTfsync()롤백 데이터

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)
PostgreSQL과 Direct I/O: PostgreSQL은 전통적으로 Buffered I/O를 사용하고 Page Cache에 의존합니다. 이는 PostgreSQL의 shared_buffers가 Page Cache와 이중 캐싱(double buffering) 문제를 발생시킵니다. 커널 16+(2024)에서는 실험적으로 io_method=io_uring과 Direct I/O를 지원하기 시작했으며, 이를 통해 이중 캐싱 문제를 해결하고 대용량 데이터베이스에서 더 예측 가능한 성능을 제공합니다.

워크로드별 I/O 전략

워크로드I/O 패턴권장 모드이유
OLTP (짧은 트랜잭션)랜덤 4-16KB R/WO_DIRECT + fsync자체 캐시, 트랜잭션 보장
OLAP (분석 쿼리)순차 대용량 읽기O_DIRECT (대형 I/O)캐시 오염 방지, 예측 가능
WAL / redo 로그순차 append, 소량O_DIRECT | O_DSYNC즉시 영속성 보장
테이블스페이스 백업순차 대용량 읽기O_DIRECT운영 캐시 오염 방지
정적 웹 콘텐츠순차/랜덤 읽기Buffered반복 접근, 캐시 히트율 높음
로그 수집순차 appendBuffered + fsync 주기적작은 쓰기 병합, 적절한 영속성
비디오 스트리밍순차 대용량 읽기O_DIRECT재사용 없음, 캐시 낭비 방지

NVMe Direct I/O 최적화

NVMe SSD는 극히 낮은 지연시간(수십 마이크로초)과 높은 병렬성(64K+ 큐 깊이)을 제공합니다. 이러한 장치에서 Direct I/O의 성능을 최대한 활용하려면 소프트웨어 스택의 오버헤드를 최소화해야 합니다.

NVMe DIO 성능 최적화 경로 애플리케이션 io_uring + O_DIRECT 고정 버퍼 REGISTER_BUFFERS GUP 오버헤드 제거 SQPOLL 커널 폴링 스레드 syscall 오버헤드 제거 iomap DIO extent 단위 BIO 최소 BIO 분할 Block Layer (none 스케줄러) NVMe는 자체 큐 관리 → I/O 스케줄러 불필요 (none 권장) NVMe 드라이버 (blk-mq) per-CPU SQ/CQ → 락 없는 제출 → NVMe SSD 하드웨어 큐 IRQ 폴링(io_poll): BIO 완료를 폴링으로 확인 → 인터럽트 지연 제거 NVMe SSD (FTL → NAND Flash)
# 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 완료 확인
NVMe에서 DIO 성능 극대화 체크리스트:
  • 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 권장

Direct I/O 권장

데이터베이스가 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를 동시에 사용하면 데이터 불일치가 발생할 수 있습니다. 커널은 이 문제를 완화하기 위한 메커니즘을 제공하지만, 완벽하지 않습니다.

혼합 I/O 일관성 문제 시나리오 프로세스 A Buffered write("NEW") 프로세스 B Direct read Page Cache 데이터: "NEW" (dirty, 아직 flush 안 됨) 디스크 데이터: "OLD" (아직 writeback 미완료) Page Cache 우회! 결과: 프로세스 B는 "OLD" 데이터를 읽음 (stale read)
/* 커널의 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;
}
혼합 I/O 사용 금지: 같은 파일 영역에 대해 Buffered I/O와 Direct I/O를 동시에 사용하는 것은 매우 위험합니다. 커널이 DIO 전에 캐시를 무효화하지만, 경쟁 조건(race condition)이 존재합니다:
  • 프로세스 A가 buffered write → 프로세스 B가 DIO read: dirty 데이터 누락 가능
  • 프로세스 A가 DIO write → 프로세스 B가 buffered read: stale 캐시 반환 가능
  • 해결책: 파일 단위로 I/O 모드를 통일하거나, flock()/fcntl()으로 동기화

성능 비교

실제 벤치마크 결과입니다 (NVMe SSD 기준, fio 사용).

순차 읽기 (1GB 파일)

모드I/O 크기대역폭IOPSCPU
Buffered (cold)4KB500 MB/s128K25%
Buffered (hot)4KB3000 MB/s768K80%
Direct4KB450 MB/s115K15%
Direct128KB2000 MB/s16K10%

랜덤 쓰기 (4KB)

모드지연시간IOPSDurability
Buffered0.05 ms200K손실 가능 (crash)
Buffered + fsync5 ms200보장
O_DSYNC5 ms200보장
O_DIRECT1 ms10K보장

I/O 엔진별 NVMe 성능 비교 (fio, QD=64, 4KB 랜덤 읽기)

I/O 엔진모드IOPS평균 지연(us)p99 지연(us)
psync (동기)O_DIRECT약 80K1225
libaioO_DIRECT약 700K85120
io_uringO_DIRECT약 900K6895
io_uring (SQPOLL)O_DIRECT약 1.1M5580
io_uring (SQPOLL+IOPOLL)O_DIRECT약 1.3M4565
# 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)
DIO 여부 확인하는 빠른 방법: /proc/[pid]/io에서 rchar(총 읽기)와 read_bytes(실제 디스크 읽기)를 비교합니다. Buffered I/O는 rchar >> read_bytes (캐시 히트), Direct I/O는 rchar ≈ read_bytes입니다. 비슷하게 wcharwrite_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_ratiodirty_ratioexpire_cswriteback_cs이유
DB (OLTP)5101500500fsync 지연 최소화
DB (OLAP)10203000500기본값 적절
파일 서버5151500300빈번한 flush, 데이터 안전
대용량 복사310500100burst 억제, 안정적 throughput
로그 서버10406000500대량 버퍼링, 배치 flush
VM 호스트5101500500게스트 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 방지
대용량 복사 시 dirty_ratio 주의: ddcp로 수십 GB 파일을 복사할 때, 기본 dirty_ratio=20에서는 수 GB의 dirty 데이터가 Page Cache에 쌓인 후 한꺼번에 writeback이 시작되어 시스템 전체가 I/O 대기 상태에 빠질 수 있습니다 ("I/O stall"). 이런 워크로드에서는 dirty_background_bytesdirty_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() 사용, 블록 크기 배수 확인
-EINVALtmpfs/procfs 등 DIO 미지원 FS해당 FS에서는 Buffered I/O 사용
-ENOTBLK블록 디바이스가 아닌 파일에 DIO 시도파일시스템 확인
짧은 읽기(short read)파일 끝 근처에서 비정렬 크기반환값 확인, 루프 처리
Buffered fallbackext4에서 sub-block 쓰기블록 크기 단위로 I/O
성능 저하DIO 중 잦은 캐시 무효화혼합 I/O 패턴 제거
-EAGAINO_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");
}
ext4의 Buffered fallback: ext4에서 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 쓰기 시 추가적인 블록 할당과 메타데이터 업데이트가 필요합니다.

CoW 파일시스템에서 DIO 쓰기 vs 일반 DIO 쓰기 일반 DIO (ext4 non-CoW) 기존 블록 B에 덮어쓰기 DMA: 유저 버퍼 → 블록 B (in-place) 완료 (메타데이터 변경 없음) CoW DIO (Btrfs/XFS reflink) 새 블록 B' 할당 DMA: 유저 버퍼 → 새 블록 B' 메타데이터 업데이트: B → B' 기존 블록 B 해제 (또는 스냅샷 보존)
/* 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);
}
CoW 파일시스템에서 DIO 성능 고려사항:
  • 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에서는 동작하지 않습니다. 이 차이가 워크로드 선택에 미치는 영향을 분석합니다.

순차 읽기: Buffered (readahead) vs Direct 시간 → Buffered read 1 read 2 readahead 3-6 readahead 7-14 (윈도우 확장) read 3 캐시 히트! Direct read 1 디스크 대기 read 2 디스크 대기 read 3 디스크 대기 read 4 디스크 대기 Buffered: readahead 덕분에 3번째부터 캐시 히트 Direct: 매번 디스크 대기 (readahead 없음)
# 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에서 readahead가 동작하지 않으므로, 순차 읽기 성능을 높이려면 대형 I/O 크기(128KB-1MB)를 사용하거나, io_uring/AIO로 여러 I/O를 파이프라인 방식으로 미리 제출하여 디스크의 내부 캐시와 NCQ(Native Command Queuing)를 활용해야 합니다.

커널 내부 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.cVFS read 진입점, IOCB_DIRECT 분기
generic_file_write_iter()mm/filemap.cVFS write 진입점, IOCB_DIRECT 분기
iomap_dio_rw()fs/iomap/direct-io.ciomap 기반 DIO 메인 루프
iomap_dio_bio_iter()fs/iomap/direct-io.cBIO 생성 + 유저 페이지 핀
iomap_dio_complete()fs/iomap/direct-io.cDIO 완료 처리, 결과 반환
__blockdev_direct_IO()fs/direct-io.c레거시 DIO (get_block 기반)
invalidate_inode_pages2_range()mm/truncate.cDIO 전 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)mmapO_DIRECT
시스템 콜매 I/O마다 필요최초 mmap만 (이후 메모리 접근)매 I/O마다 필요
데이터 복사2회 (커널↔유저)0회 (직접 매핑)0-1회 (DMA)
Page Cache사용사용 (필수)우회
page fault없음최초 접근 시 fault없음
TLB 오버헤드없음있음 (VMA 관리)없음
랜덤 접근보통우수 (포인터 연산)보통
대용량 순차우수 (readahead)보통 (fault 오버헤드)우수 (대형 I/O)
동시성높음높음 (읽기 전용 시)높음
영속성 제어fsync/fdatasyncmsyncO_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과 DIO를 함께 사용할 수 없는 이유: 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 */
특성일반 mmapDAX mmapO_DIRECT
Page Cache사용우회우회
데이터 복사0회 (매핑)0회 (직접 매핑)0-1회 (DMA)
영속성msync 필요clflush + sfenceO_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_SEQUENTIALreadahead 윈도우 2배 확장순차 읽기 (로그 분석)DIO 대안: 순차 최적화
POSIX_FADV_RANDOMreadahead 비활성화랜덤 접근 (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);
}
DONTNEED 패턴 vs O_DIRECT: 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의 성능을 정확하게 비교하려면 체계적인 벤치마크 방법론이 필요합니다. 흔히 저지르는 실수와 올바른 측정 방법을 정리합니다.

벤치마크 시 주의사항

Page Cache 영향 제거: Buffered I/O 벤치마크에서 가장 흔한 실수는 "warm cache" 상태에서 측정하여 비현실적으로 높은 성능을 얻는 것입니다. 반대로 Direct I/O를 측정할 때는 이전 Buffered 테스트의 Page Cache가 디스크 캐시를 오염시킬 수 있습니다. 각 테스트 전에 반드시 캐시를 정리하세요.
# 벤치마크 전 캐시 정리 절차

# 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, 미스: msus~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 랜덤 읽기비고
512B2.5 GB/s50 MB/s25 MB/sDIO에서 비효율적
4KB3.0 GB/s450 MB/s400 MB/s기본 블록 크기
16KB3.2 GB/s1.2 GB/s800 MB/sDB 읽기에 적합
64KB3.3 GB/s2.5 GB/s1.5 GB/s좋은 효율
128KB3.3 GB/s3.0 GB/s2.0 GB/s순차 DIO 최적
1MB3.3 GB/s3.2 GB/s2.5 GB/s대역폭 포화
I/O 크기와 DIO 성능의 관계: Direct I/O에서 작은 I/O(512B-4KB)는 매 요청마다 get_user_pages(), BIO 할당, 블록 매핑 조회 등의 고정 오버헤드가 발생하여 비효율적입니다. I/O 크기를 64KB-128KB로 늘리면 오버헤드가 분산되어 대역폭이 크게 향상됩니다. NVMe 장치에서 최대 대역폭을 달성하려면 128KB 이상의 I/O 크기를 사용하세요.

장애 시나리오와 데이터 복구

시스템 장애(정전, 커널 패닉) 시 Buffered I/O와 Direct I/O에서 데이터 손실 범위가 다릅니다. 각 시나리오별 위험도와 대응 방안을 정리합니다.

정전 시 데이터 상태 (I/O 모드별) I/O 모드 CPU 캐시 Page Cache 디스크 Buffered (fsync 없음) 손실 손실 (dirty 데이터) 이전 상태 유지 Buffered + fsync N/A flush 완료 보장 O_DIRECT (단독) N/A N/A (우회) 디스크 캐시에 있을 수 있음 O_DIRECT | O_DSYNC N/A N/A (우회) 영속 보장 (FUA) 안전 부분 위험 손실 위험

장애 시나리오별 대응

장애 유형Buffered I/O 영향Direct I/O 영향대응 방안
정전dirty 페이지 전부 손실진행 중 I/O만 손실UPS + 디스크 캐시 비활성화
커널 패닉dirty 페이지 손실진행 중 I/O만 손실kdump 설정, 저널링 FS 사용
프로세스 killdirty 페이지는 커널이 writeback진행 중 I/O는 취소시그널 핸들러에서 fsync
디스크 오류재시도 후 EIO즉시 EIORAID, 에러 핸들링
파일시스템 손상저널 복구저널 복구저널링 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;
}
디스크 write-back 캐시 주의: 대부분의 디스크(HDD, SSD)는 내부 휘발성 캐시를 가지고 있습니다. O_DIRECT로 데이터를 전송해도 디스크 내부 캐시에만 기록되고 비휘발성 미디어에는 아직 기록되지 않았을 수 있습니다. 진정한 영속성을 보장하려면:
  • O_DSYNC 또는 O_SYNC 사용 (커널이 FUA 또는 FLUSH 명령 발행)
  • 디스크 write-back 캐시 비활성화: hdparm -W 0 /dev/sda
  • 배터리 백업 캐시(BBU/BBM)가 장착된 RAID 컨트롤러 사용
ext4 저널과 DIO의 상호작용: ext4의 저널은 메타데이터 일관성을 보장합니다. Direct I/O 쓰기로 인한 메타데이터 변경(블록 할당, inode 갱신)은 저널에 기록된 후 커밋됩니다. 따라서 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를 생성하여 블록 레이어에 제출합니다.

iomap_iter() 루프 상세 처리 흐름 iomap_dio_rw() 진입 filemap_write_and_wait_range() + invalidate while (iomap_iter(&iomi, ops) > 0) fs->iomap_begin() 파일 오프셋 -> struct iomap 반환 iomap.type 분기 IOMAP_MAPPED -> BIO 생성 IOMAP_HOLE -> 제로 채우기 (읽기) IOMAP_UNWRITTEN -> 쓰기 후 변환 iomap_dio_bio_iter() bio_iov_iter_get_pages() + submit_bio() fs->iomap_end() (선택적 정리) 다음 extent iomap_dio_complete() -> 결과 반환
/* 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_MAPPEDBIO 생성 + 디스크 읽기BIO 생성 + 디스크 쓰기일반 매핑된 extent
IOMAP_HOLE제로 채우기 (디스크 I/O 없음)에러 (-EINVAL)할당되지 않은 영역
IOMAP_UNWRITTEN제로 채우기BIO 쓰기 + extent 변환fallocate로 예약된 영역
IOMAP_DELALLOC제로 채우기실시간 블록 할당 + 쓰기지연 할당 (ext4)
IOMAP_INLINEinode 내부 데이터 복사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_F_SHARED와 CoW DIO: XFS에서 reflink로 공유된 extent에 DIO 쓰기를 시도하면, 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 기반 격리에 대해 심화 분석합니다.

balance_dirty_pages 대역폭 기반 throttle dirty 페이지 비율 (% of dirtyable memory) 쓰기 허용 대역폭 bg_thresh (10%) freerun thresh (20%) 최대 선형 throttle 정지 (stall) freerun 무제한 쓰기 bg writeback 대역폭 비례 제한 hard throttle io_schedule_timeout
/* 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_ratioBDI별0이 BDI의 최소 dirty 비율 보장
/sys/class/bdi/<bdi>/max_ratioBDI별100이 BDI의 최대 dirty 비율 제한
/sys/class/bdi/<bdi>/read_ahead_kbBDI별128readahead 크기 (DIO 무관)
/sys/class/bdi/<bdi>/strict_limitBDI별01이면 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() 적용
cgroup v1 vs v2 dirty writeback: cgroup v1에서는 dirty writeback이 글로벌하게 동작하여 한 cgroup의 대량 쓰기가 다른 cgroup의 I/O를 방해할 수 있었습니다. cgroup v2에서는 메모리 cgroup 단위로 dirty 비율을 추적하고 throttle을 적용하여 워크로드 간 격리가 크게 개선되었습니다. 컨테이너 환경에서는 반드시 cgroup v2를 사용하고 memory.dirty_limit을 적절히 설정하세요.

O_SYNC/O_DSYNC 커널 내부 동작 심화

O_SYNCO_DSYNC는 write() 시스템 콜 반환 전에 데이터 영속성을 보장하는 플래그입니다. Direct I/O와 조합될 때 FUA(Force Unit Access) 명령을 통해 디스크 캐시를 우회하는 메커니즘을 상세히 분석합니다.

O_SYNC/O_DSYNC + DIO 커널 경로 비교 write(fd, buf, size) 진입 kiocb->ki_flags 확인: IOCB_DIRECT | IOCB_DSYNC | IOCB_SYNC O_DIRECT 단독 DMA 전송, 디스크 캐시에 도달 디스크 volatile 캐시까지 O_DIRECT | O_DSYNC DMA + REQ_FUA 플래그 설정 FUA (Force Unit Access) 비휘발성 미디어에 직접 기록 O_DIRECT | O_SYNC DMA + REQ_FUA + 메타데이터 sync FUA + 저널 커밋 데이터 + inode/mtime 영속 Storage Device volatile cache | non-volatile media (NAND/platter) volatile 캐시까지만 비휘발성 미디어까지 미디어 + 메타데이터
/* 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_FUAPREFLUSH + FUA 조합가장 높음저널 커밋 (ext4 jbd2)
NVMe에서 FUA 효율성: NVMe 스펙에서 FUA 비트는 컨트롤러에게 해당 데이터를 비휘발성 미디어에 직접 기록하도록 지시합니다. 대부분의 NVMe SSD는 FUA를 효율적으로 처리하여, 전체 캐시 flush보다 훨씬 빠릅니다. 따라서 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/Oio_submit() 블로킹파일 사전 할당 (fallocate)
페이지 할당 실패메모리 부족으로 GUP 실패동기 fallbackmlock 또는 hugepage
inode 락 경합동일 inode에 동시 DIO 쓰기io_submit() 블로킹파일 분할 또는 io_uring
Buffered I/O 혼합캐시 무효화(invalidate) 대기동기 처리DIO 전용으로 통일
파일 확장 쓰기새 블록 할당 필요io_submit() 블로킹fallocate 사전 할당
저널 커밋 대기ext4 저널 공간 부족전체 AIO 지연저널 크기 증가
AIO + Buffered I/O의 함정: libaio에서 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 최적화 파이프라인 Fixed Files REGISTER_FILES fget/fput 오버헤드 제거 atomic_inc/dec 회피 Registered Buffers REGISTER_BUFFERS GUP 오버헤드 제거 페이지 핀 재사용 SQPOLL 커널 폴링 스레드 syscall 오버헤드 제거 io_uring_enter 불필요 IOPOLL BIO 완료 폴링 인터럽트 지연 제거 blk_mq_poll() 제거된 오버헤드: fget/fput + GUP + syscall + IRQ = 기존 대비 50-70% 지연 감소 iomap_dio_rw() -> BIO 제출 -> NVMe SQ per-CPU NVMe 큐에 직접 제출 (락 없음) NVMe SSD 처리 (10-50us) blk_mq_poll() -> CQE 생성 (인터럽트 없이 완료 확인) 총 지연: 15-60us (기존 psync DIO: 100-200us)
/* 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, &params);

    /* 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 + DIO900K689545%
+ Fixed Files950K639042%
+ Registered Buffers1.05M578238%
+ SQPOLL1.15M527535% (+ 1 core)
+ IOPOLL (전체 조합)1.35M426033% (+ 1 core)
SQPOLL 보안 주의: 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 affinityNVMe IRQ를 로컬 NUMA 노드 CPU에 고정크로스-NUMA 메모리 접근 제거cat /proc/interrupts
메모리 할당numactl --membind 또는 MPOL_BINDDMA 버퍼를 로컬 메모리에 배치numastat -p <pid>
io_uring CPUSQPOLL 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 Namespace Separation: NVMe 1.4+ 장치에서 여러 네임스페이스를 생성하면, 각 네임스페이스를 독립적인 블록 장치로 사용할 수 있습니다. 데이터베이스의 데이터 파일과 WAL을 별도 네임스페이스에 배치하면 I/O 경합을 줄이고 QoS를 향상시킬 수 있습니다. nvme create-ns 명령으로 네임스페이스를 생성하고, 각 네임스페이스에 대해 독립적인 DIO 최적화를 적용합니다.

데이터베이스 I/O 패턴 심화

데이터베이스 엔진별 I/O 아키텍처와 Direct I/O 활용 방식을 더 깊이 분석합니다. WAL(Write-Ahead Logging), 체크포인트, 더블 라이트 버퍼 등 핵심 메커니즘이 커널 I/O 경로와 어떻게 상호작용하는지 살펴봅니다.

데이터베이스 WAL + DIO 영속성 보장 흐름 BEGIN; UPDATE ...; COMMIT; WAL 로그 쓰기 O_DIRECT | O_DSYNC (또는 fdatasync) 디스크에 영속 (FUA/FLUSH) COMMIT 완료 (클라이언트 응답) Buffer Pool 갱신 메모리 내 페이지 수정 (dirty) 나중에 (체크포인트) 체크포인트: O_DIRECT 쓰기 dirty 페이지 -> 데이터 파일 장애 복구: WAL 로그 재생 (redo) 커밋된 트랜잭션 복구, 미커밋 트랜잭션 롤백 WAL (동기, 즉시) 데이터 (비동기, 나중에) 클라이언트 응답

데이터베이스별 DIO 구현 상세

DB 엔진데이터 파일 I/OWAL/redo I/O체크포인트io_uring 지원
MySQL InnoDBO_DIRECT + fsyncO_DIRECT (옵션)fuzzy checkpoint8.0.22+ (실험적)
PostgreSQLBuffered (기본)fdatasync/open_dsyncspread checkpoint16+ (io_method=io_uring)
OracleO_DIRECT + AIOO_DSYNCincremental미지원
RocksDBO_DIRECT (옵션)O_DIRECT + fsynccompaction 시7.0+ (실험적)
SQLiteBufferedfdatasync체크포인트 (WAL)미지원
ScyllaDBO_DIRECT + io_uringO_DIRECT + io_uringcommitlog 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 복원 */
NVMe에서 doublewrite 불필요론: NVMe SSD는 전원 보호(Power Loss Protection, PLP) 기능을 갖춘 경우가 많습니다. PLP가 보장되면 진행 중인 쓰기가 원자적으로 완료되어 torn page 문제가 발생하지 않습니다. MySQL 8.0.30+에서는 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 */
    ));
DIO와 파일시스템 선택: 데이터베이스에서 DIO를 사용할 때 파일시스템 선택이 매우 중요합니다:
  • XFS: DIO에 가장 최적화. extent 기반, 효율적인 fallocate, iomap 완전 지원
  • ext4: DIO 지원 양호. sub-block DIO 시 buffered fallback 주의
  • Btrfs: CoW 오버헤드. DB에서는 chattr +C 또는 nodatacow 필수
  • ZFS: O_DIRECT를 무시 (항상 ARC 캐시 사용). DB와의 조합 시 이중 캐싱 발생
대부분의 DB 벤치마크에서 XFS + O_DIRECT 조합이 가장 안정적인 성능을 보여줍니다.