Block I/O 서브시스템

Linux 커널의 블록 I/O 계층을 사용자 공간의 read/write 요청부터 스토리지 디바이스 완료 인터럽트까지 end-to-end 관점으로 심층 분석합니다. VFS/Page Cache와 디바이스 드라이버 사이에서 요청이 bio로 분해되고 request로 병합되는 경로, blk-mq 멀티큐와 CPU/하드웨어 큐 매핑, I/O 스케줄러의 지연시간-처리량 트레이드오프, Direct I/O와 Buffered I/O 선택 기준, flush/fua/barrier 기반 데이터 무결성 보장, 블록 토폴로지(정렬 단위·물리 섹터·discard) 해석, tracepoint/blktrace/bpftrace 기반 병목 진단까지 실무 튜닝에 필요한 핵심 내용을 체계적으로 다룹니다.

관련 표준: NVMe Specification 2.0 (NVMe 인터페이스), SCSI SAM-6 (SCSI 아키텍처 모델), ATA/ACS-4 (ATA 명령 세트) — 블록 I/O 계층이 지원하는 스토리지 인터페이스 규격입니다. 종합 목록은 참고자료 — 표준 & 규격 섹션을 참고하세요.
전제 조건: VFS디스크 파티션 문서를 먼저 읽으세요. 스토리지 경로는 큐잉, 병합, 플러시 정책이 연쇄적으로 동작하므로, 요청 수명주기와 완료 경로를 먼저 추적해야 합니다.
일상 비유: Block I/O는 택배 물류 시스템과 비슷합니다. bio는 개별 소포, request는 같은 지역으로 가는 소포를 하나의 트럭에 모은 것, I/O 스케줄러는 배송 경로를 최적화하는 물류 관리자, blk-mq는 여러 배송 센터(큐)에서 동시에 처리하는 것입니다.

핵심 요약

  • bio — 블록 I/O의 기본 단위. 디스크 섹터와 메모리 페이지의 매핑을 기술합니다.
  • blk-mq — Multi-Queue Block Layer. NVMe 등 고속 디바이스를 위한 멀티큐 아키텍처입니다.
  • I/O 스케줄러 — mq-deadline, BFQ, kyber 등. I/O 요청 순서를 최적화합니다.
  • request — 인접한 bio를 병합한 I/O 요청. 디바이스 드라이버에 전달되는 단위입니다.
  • Direct I/O — 페이지 캐시를 우회하여 디바이스에 직접 읽기/쓰기합니다.

단계별 이해

  1. I/O 경로 파악 — 애플리케이션 → VFS → 페이지 캐시 → Block Layer → 디바이스 드라이버 → 하드웨어.

    Block Layer는 VFS와 드라이버 사이에서 I/O 요청을 관리합니다.

  2. bio 이해 — 파일시스템이 submit_bio()로 bio를 블록 계층에 제출합니다.

    각 bio는 디스크의 시작 섹터, 크기, 방향(읽기/쓰기), 메모리 페이지를 포함합니다.

  3. blk-mq 이해 — 하드웨어 큐와 소프트웨어 큐의 매핑을 관리합니다.

    cat /sys/block/nvme0n1/queue/nr_requests로 큐 깊이를 확인합니다.

  4. 모니터링iostat -x 1로 디바이스별 I/O 통계를 실시간 모니터링합니다.

    blktrace/blkparse로 I/O 요청의 상세 흐름을 추적할 수 있습니다.

Block I/O 계층 개요

블록 I/O 계층(Block Layer)은 VFS/Page Cache와 물리 디바이스 드라이버 사이에 위치하며, 디스크, SSD, NVMe 등 블록 디바이스에 대한 I/O 요청을 관리합니다. 상위 계층이 논리적 블록 단위로 데이터를 요청하면, 블록 계층이 이를 최적화하여 하드웨어에 전달합니다.

블록 계층의 역할

Legacy single-queue에서 blk-mq로

Linux 3.13(2014) 이전에는 단일 request_queue에 하나의 스핀락으로 모든 I/O를 직렬화했습니다. 이 single-queue 모델은 NVMe처럼 수백만 IOPS를 지원하는 디바이스에서 심각한 병목이 되었습니다. blk-mq(Multi-Queue Block I/O)는 per-CPU 소프트웨어 큐와 디바이스의 하드웨어 큐를 직접 매핑하여, 단일 락 경합 없이 병렬 I/O를 가능하게 합니다. Linux 5.0부터 legacy single-queue 코드가 완전히 제거되어, 모든 블록 드라이버가 blk-mq를 사용합니다.

VFS / Page Cache Block Layer struct bio I/O Scheduler blk-mq SW Queue (CPU0) SW Queue (CPU1) SW Queue (CPUn) HW Queue 0 HW Queue 1 Device Driver / HW

struct bio

struct bio는 블록 계층의 기본 I/O 단위입니다. 하나의 bio는 디스크의 연속된 섹터 범위와 메모리의 페이지 세그먼트 목록을 연결합니다. 파일시스템이나 Direct I/O 경로에서 생성되어 블록 계층을 통해 디바이스 드라이버까지 전달됩니다.

핵심 구조체

/* include/linux/bio.h */
struct bio {
    struct bio             *bi_next;       /* 리스트 연결 (request 내) */
    struct block_device    *bi_bdev;       /* 대상 블록 디바이스 */
    blk_opf_t               bi_opf;        /* 연산 + 플래그 (REQ_OP_* | REQ_*) */
    unsigned short          bi_flags;      /* BIO_* 상태 플래그 */
    struct bvec_iter        bi_iter;       /* 현재 반복 위치 */
    bio_end_io_t           *bi_end_io;     /* I/O 완료 콜백 */
    void                   *bi_private;    /* 콜백 전용 데이터 */
    unsigned short          bi_vcnt;       /* bio_vec 개수 */
    unsigned short          bi_max_vecs;   /* bio_vec 배열 최대 크기 */
    struct bio_vec         *bi_io_vec;     /* bio_vec 배열 */
    /* ... */
};

struct bio_vec {
    struct page    *bv_page;     /* 메모리 페이지 */
    unsigned int    bv_len;      /* 이 세그먼트의 바이트 수 */
    unsigned int    bv_offset;   /* 페이지 내 오프셋 */
};

struct bvec_iter {
    sector_t        bi_sector;   /* 디스크 시작 섹터 */
    unsigned int    bi_size;     /* 남은 I/O 바이트 */
    unsigned int    bi_idx;      /* 현재 bio_vec 인덱스 */
    unsigned int    bi_bvec_done; /* 현재 bio_vec 내 완료 바이트 */
};

bi_opf 플래그

연산 (REQ_OP_*)설명
REQ_OP_READ0블록 읽기
REQ_OP_WRITE1블록 쓰기
REQ_OP_FLUSH2디바이스 캐시 플러시
REQ_OP_DISCARD3블록 무효화 (TRIM)
REQ_OP_SECURE_ERASE5보안 삭제
REQ_OP_WRITE_ZEROES9제로 블록 쓰기 (하드웨어 최적화)

추가 플래그는 비트 OR로 결합됩니다: REQ_SYNC(동기 I/O), REQ_META(메타데이터), REQ_PRIO(우선순위), REQ_PREFLUSH(사전 플러시), REQ_FUA(Force Unit Access).

bio 생명주기

/* bio 할당 → 설정 → 제출 → 완료 */
struct bio *bio = bio_alloc(bdev, nr_vecs, opf, GFP_KERNEL);

bio->bi_iter.bi_sector = sector;       /* 시작 섹터 설정 */
bio_add_page(bio, page, len, offset);  /* 페이지 세그먼트 추가 */
bio->bi_end_io = my_end_io;             /* 완료 콜백 등록 */
bio->bi_private = private_data;

submit_bio(bio);  /* 블록 계층에 제출 — bio 소유권 이전 */

/* 완료 콜백에서: */
static void my_end_io(struct bio *bio)
{
    if (bio->bi_status)
        pr_err("I/O error: %d\\n", blk_status_to_errno(bio->bi_status));
    /* 후처리 ... */
    bio_put(bio);  /* 참조 카운트 감소, 0이면 해제 */
}
bio 생명주기 (submit → merge → dispatch → complete) 파일시스템 / 드라이버 Block Layer (blk-mq) I/O Scheduler (mq-deadline) Hardware (NVMe/SCSI) bio_alloc() 1. submit_bio(bio) 2. split/merge 검사 3. bio → request 4. 큐에 삽입 (정렬) 5. dispatch (hw queue) 6. DMA 전송 디스크 쓰기 7. 완료 인터럽트 8. request 완료 처리 9. bio_endio() 10. bi_end_io() 11. bio_put() 12. 요청 경로 완료 경로 (인터럽트) 전체 지연시간: submit_bio() → bi_end_io() 콜백
bio는 submit_bio()로 제출되면 블록 계층에서 병합/분할 후 request로 변환되고, I/O 스케줄러를 거쳐 하드웨어로 dispatch됩니다. 완료 시 인터럽트로 bi_end_io() 콜백이 호출됩니다.
struct bio bi_bdev → /dev/sda bi_opf = REQ_OP_WRITE bi_iter.bi_sector = 2048 bi_iter.bi_size = 12288 bi_vcnt = 3 bi_end_io → callback bi_io_vec → bio_vec[0] page=P1 len=4096 off=0 bio_vec[1] page=P2 len=4096 off=0 bio_vec[2] page=P3 len=4096 off=0 Page Frame (P1) Page Frame (P2) Page Frame (P3) → 디스크 섹터 2048~2071 (12KB, 연속 기록)

bio 순회: bio_for_each_segment()

드라이버나 파일시스템에서 bio의 모든 세그먼트를 순회할 때 bio_for_each_segment() 매크로를 사용합니다.

/* bio 세그먼트 순회 예제 */
static void process_bio_segments(struct bio *bio)
{
    struct bio_vec bvec;
    struct bvec_iter iter;
    void *kaddr;

    /* bio의 각 세그먼트 순회 */
    bio_for_each_segment(bvec, bio, iter) {
        /* 1. 세그먼트 정보 추출 */
        struct page *page = bvec.bv_page;
        unsigned int len = bvec.bv_len;      /* 이 세그먼트의 길이 */
        unsigned int offset = bvec.bv_offset; /* 페이지 내 오프셋 */

        pr_info("세그먼트: page=%p, len=%u, offset=%u, sector=%llu\\n",
                page, len, offset,
                (unsigned long long)iter.bi_sector);

        /* 2. 페이지를 커널 주소 공간에 매핑 */
        kaddr = kmap_local_page(page);

        /* 3. 데이터 처리 (예: 체크섬 계산) */
        u32 checksum = crc32(0, kaddr + offset, len);
        pr_info("  체크섬: 0x%08x\\n", checksum);

        /* 4. 매핑 해제 */
        kunmap_local(kaddr);
    }

    pr_info("전체 크기: %u bytes, 세그먼트 수: %u\\n",
            bio->bi_iter.bi_size, bio->bi_vcnt);
}

/* DMA를 위한 물리 주소 순회 (DMA 매핑 필요) */
static int setup_dma_for_bio(struct device *dev, struct bio *bio)
{
    struct bio_vec bvec;
    struct bvec_iter iter;
    dma_addr_t dma_addr;

    bio_for_each_segment(bvec, bio, iter) {
        /* 페이지 → DMA 가능 물리 주소 */
        dma_addr = dma_map_page(dev, bvec.bv_page,
                                    bvec.bv_offset, bvec.bv_len,
                                    bio_data_dir(bio) == WRITE ?
                                    DMA_TO_DEVICE : DMA_FROM_DEVICE);

        if (dma_mapping_error(dev, dma_addr)) {
            pr_err("DMA 매핑 실패\\n");
            return -EIO;
        }

        /* 하드웨어 SG 리스트에 추가 */
        hw_add_sg_entry(dma_addr, bvec.bv_len);
    }

    return 0;
}
bio_for_each_segment vs bio_for_each_bvec:
  • bio_for_each_segment(): bio의 현재 iterator 위치부터 순회 (일반적 사용)
  • bio_for_each_bvec(): 전체 bio_vec 배열을 처음부터 순회
  • bio_for_each_segment_all(): multi-page bio_vec도 개별 페이지로 분해하여 순회

bio 메모리 관리와 분할

블록 계층은 bio를 빈번하게 할당·해제하므로, 성능과 안정성을 위해 전용 메모리 풀과 슬랩 캐시를 사용합니다. 또한 디바이스의 큐 한도를 초과하는 큰 I/O는 자동으로 분할(split)되어 체이닝(chaining)됩니다.

bio 메모리 풀 (bioset)

bio 할당은 struct bio_set이 관리하는 메모리 풀에서 수행됩니다. 이는 메모리 부족 상황에서도 I/O 경로가 교착 상태에 빠지지 않도록 예비 bio를 보장합니다.

/* include/linux/bio.h — bio_set 구조 */
struct bio_set {
    struct kmem_cache *bio_slab;     /* bio 전용 슬랩 캐시 */
    unsigned int       front_pad;    /* bio 앞에 드라이버 전용 데이터 공간 */
    unsigned int       back_pad;     /* bio 뒤에 무결성 데이터 공간 */

    struct bio_alloc_cache __percpu *cache;  /* per-CPU 캐시 (6.0+) */

    /* 메모리 부족 시 폴백 풀 */
    struct mempool_s  bio_pool;      /* bio 구조체 비상 풀 */
    struct mempool_s  bvec_pool;     /* bio_vec 배열 비상 풀 */

    struct mempool_s  bio_integrity_pool; /* 무결성 메타 풀 */
    struct mempool_s  bvec_integrity_pool;
};

/* bio_alloc() 내부 동작 (간략화) */
struct bio *bio_alloc(struct block_device *bdev,
                      unsigned short nr_vecs,
                      blk_opf_t opf, gfp_t gfp_mask)
{
    struct bio_set *bs = &fs_bio_set;  /* 파일시스템 기본 bioset */
    struct bio *bio;

    /* 1단계: per-CPU 캐시에서 시도 (가장 빠름, 락 없음) */
    bio = bio_alloc_percpu_cache(bdev, nr_vecs, opf, gfp_mask, bs);
    if (bio)
        return bio;

    /* 2단계: 슬랩 캐시에서 할당 */
    bio = bio_alloc_bioset(bdev, nr_vecs, opf, gfp_mask, bs);
    if (bio)
        return bio;

    /* 3단계: mempool 폴백 (GFP_NOIO 상황, 절대 실패 불가) */
    bio = mempool_alloc(&bs->bio_pool, gfp_mask);
    /* mempool은 예비 객체를 보유하므로 OOM에서도 성공 */
    return bio;
}
per-CPU bio 캐시 (Linux 6.0+): bio_alloc_cache는 CPU별로 최대 512개의 bio를 캐싱하여, 슬랩 할당자 호출 없이 O(1) 할당/해제를 달성합니다. 고속 NVMe에서 수백만 IOPS를 처리할 때 할당 오버헤드를 사실상 제거합니다.

bio 분할 (bio_split)

파일시스템이 생성한 bio가 디바이스의 큐 한도(max_sectors, max_segments 등)를 초과하면, 블록 계층이 자동으로 bio_split()을 호출하여 분할합니다. 분할된 bio들은 체이닝(chaining)으로 연결되어, 모든 조각의 I/O가 완료된 후에 원래 bio의 bi_end_io가 호출됩니다.

/* block/blk-merge.c — bio 분할 핵심 로직 (간략화) */
struct bio *bio_split(struct bio *bio, int sectors,
                      gfp_t gfp, struct bio_set *bs)
{
    struct bio *split;

    /* 새 bio를 할당하여 앞쪽 sectors만큼 복사 */
    split = bio_alloc_clone(bio->bi_bdev, bio, gfp, bs);
    split->bi_iter.bi_size = sectors << SECTOR_SHIFT;

    /* 원본 bio는 나머지 섹터로 전진 */
    bio_advance(bio, split->bi_iter.bi_size);

    /* 분할된 bio를 원본에 체이닝 */
    bio_chain(split, bio);

    return split;  /* split을 먼저 제출, bio는 나중에 제출 */
}

/* bio_chain(): split → bio 완료 의존성 설정 */
void bio_chain(struct bio *bio, struct bio *parent)
{
    bio->bi_private = parent;
    bio->bi_end_io  = bio_chain_endio;
    /* split bio 완료 시 → parent의 남은 bytes 갱신 */
    /* 모든 조각 완료 시 → parent의 원래 bi_end_io 호출 */
}
bio 분할(split)과 체이닝(chain) 동작 원본 bio (sector 0~2047, 1MB) max_sectors = 512 (256KB) → 4개로 분할 필요 bio_split() split[0] sector 0~511 split[1] sector 512~1023 split[2] sector 1024~1535 remainder sector 1536~2047 chain chain chain submit_bio() → Block Layer → Device Driver → Hardware bio_chain_endio bio_chain_endio bio_chain_endio 원본 bi_end_io 모든 조각 완료 시 → 원본 bio의 bi_end_io() 콜백 호출
파일시스템이 생성한 큰 bio는 디바이스 한도에 맞게 자동 분할됩니다. 각 조각은 bio_chain()으로 연결되어, 마지막 조각이 완료되면 원본 bio의 콜백이 호출됩니다.

바운스 버퍼 (Bounce Buffer)

일부 구형 디바이스는 DMA 주소 공간이 제한되어 높은 물리 메모리(HIGHMEM) 페이지를 직접 전송할 수 없습니다. 이 경우 블록 계층은 바운스 버퍼를 사용하여 낮은 주소의 임시 버퍼에 데이터를 복사한 후 DMA를 수행합니다.

/* block/bounce.c — 바운스 버퍼 처리 (간략화) */
struct bio *__blk_queue_bounce(struct request_queue *q,
                                struct bio *bio)
{
    struct bio *bounce_bio;
    struct bio_vec *from, *to;

    /* 바운스가 필요한지 확인: 모든 페이지가 DMA 범위 내인지 */
    bio_for_each_segment(...) {
        if (page_to_pfn(bvec.bv_page) > q->limits.bounce_pfn) {
            /* DMA 범위 밖 → 바운스 필요 */
            needs_bounce = 1;
            break;
        }
    }

    if (!needs_bounce)
        return bio;  /* 바운스 불필요 */

    /* 낮은 메모리 영역에서 새 bio + 페이지 할당 */
    bounce_bio = bio_clone_fast(bio, GFP_NOIO, &bounce_bio_set);
    /* WRITE: 원본 → 바운스 버퍼로 복사 */
    /* READ: 완료 후 바운스 → 원본으로 복사 */
    return bounce_bio;
}
현대 시스템에서의 바운스 버퍼: 64비트 시스템에서는 전체 물리 메모리가 DMA 범위 안에 있으므로 바운스 버퍼가 거의 사용되지 않습니다. Linux 5.15 이후 CONFIG_BOUNCE가 기본 비활성화되어, 32비트 또는 특수 임베디드 환경에서만 활성화됩니다.

멀티페이지 bio_vec

Linux 5.1부터 도입된 멀티페이지 bio_vec은 물리적으로 연속된 여러 페이지를 하나의 bio_vec으로 표현합니다. 이전에는 4KB 페이지마다 별도 bio_vec이 필요했지만, 이제 연속된 페이지는 단일 세그먼트로 합쳐져 bio_vec 배열 크기가 크게 줄어듭니다.

/* 멀티페이지 bio_vec 예시 */
/*
 * 이전 (페이지별 bio_vec):
 *   bio_vec[0] = { page=P1, len=4096, offset=0 }
 *   bio_vec[1] = { page=P2, len=4096, offset=0 }
 *   bio_vec[2] = { page=P3, len=4096, offset=0 }
 *   bio_vec[3] = { page=P4, len=4096, offset=0 }
 *   → 4개의 bio_vec 필요
 *
 * 현재 (멀티페이지, P1~P4가 물리 연속):
 *   bio_vec[0] = { page=P1, len=16384, offset=0 }
 *   → 1개의 bio_vec으로 충분
 *
 * 장점:
 * - bio_vec 배열 메모리 절약
 * - Scatter-Gather 리스트 크기 감소
 * - DMA 매핑 호출 횟수 감소
 * - 특히 대형 I/O에서 성능 개선
 */

struct request와 request_queue

struct request는 하나 이상의 bio를 병합한 I/O 단위로, 실제 디바이스 드라이버에 전달되는 작업 단위입니다. 블록 계층은 인접한 bio들을 자동으로 병합하여 디바이스 호출 횟수를 줄입니다.

요청 병합 (Merge)

Plugging 메커니즘

커널은 blk_start_plug() / blk_finish_plug()으로 I/O를 일시적으로 보류(plug)했다가 한꺼번에 해제(unplug)하여 병합 기회를 극대화합니다. blk_plug 구조체에 per-task 리스트로 bio를 모았다가 unplug 시 일괄 제출합니다.

struct blk_plug plug;

blk_start_plug(&plug);
for (i = 0; i < nr_pages; i++) {
    bio = bio_alloc(...);
    /* bio 설정 */
    submit_bio(bio);  /* plug에 보류됨 */
}
blk_finish_plug(&plug);  /* 모아둔 bio를 병합 후 일괄 디스패치 */

Queue Limits

한도설명기본값 (예시)
max_hw_sectors하드웨어가 처리 가능한 최대 섹터2048 (1MB)
max_sectors단일 request의 최대 섹터1280 (640KB)
max_segmentsscatter-gather 최대 세그먼트 수128
max_segment_size단일 세그먼트 최대 바이트65536
logical_block_size디바이스의 최소 주소 단위512
physical_block_size디바이스 물리 블록 크기4096
/* request 내 bio_vec 세그먼트 순회 */
struct req_iterator iter;
struct bio_vec bvec;

rq_for_each_segment(bvec, rq, iter) {
    struct page *page = bvec.bv_page;
    unsigned int len = bvec.bv_len;
    unsigned int offset = bvec.bv_offset;
    /* 각 세그먼트 처리 */
}

요청 상태 머신 (Request State Machine)

struct request는 생성부터 완료까지 여러 상태를 거칩니다. blk-mq는 enum mq_rq_state로 각 request의 상태를 추적하며, 잘못된 상태 전이는 커널 BUG를 트리거합니다.

/* include/linux/blk-mq.h — request 상태 */
enum mq_rq_state {
    MQ_RQ_IDLE       = 0,  /* 태그 풀에서 할당됨, 아직 미사용 */
    MQ_RQ_IN_FLIGHT  = 1,  /* 드라이버에 전달됨, HW 처리 중 */
    MQ_RQ_COMPLETE   = 2,  /* 완료 인터럽트 수신, 후처리 대기 */
};

/* request 상태 전이:
 *
 *  IDLE ──blk_mq_start_request()──→ IN_FLIGHT
 *                                       │
 *                    ┌──────────────────┘
 *                    ▼
 *  blk_mq_complete_request() → COMPLETE
 *                    │
 *                    ▼
 *  blk_mq_end_request() → bio_endio() → IDLE (태그 반환)
 *
 *  타임아웃 경로:
 *  IN_FLIGHT ──timeout──→ blk_mq_ops.timeout()
 *    → BLK_EH_DONE: COMPLETE로 전이 (드라이버가 처리)
 *    → BLK_EH_RESET_TIMER: 타이머 재설정 (재시도)
 */

/* struct request 핵심 필드 (간략화) */
struct request {
    struct request_queue  *q;          /* 소속 큐 */
    struct blk_mq_ctx    *mq_ctx;     /* SW 큐 컨텍스트 */
    struct blk_mq_hw_ctx *mq_hctx;    /* HW 큐 컨텍스트 */

    blk_opf_t             cmd_flags;  /* REQ_OP_* | REQ_* */
    req_flags_t           rq_flags;   /* 내부 플래그 (RQF_*) */

    sector_t              __sector;   /* 시작 섹터 */
    unsigned int          __data_len; /* 전체 데이터 길이 */
    unsigned short        nr_phys_segments; /* SG 세그먼트 수 */

    struct bio           *bio;        /* 첫 번째 bio */
    struct bio           *biotail;    /* 마지막 bio */

    unsigned int          tag;        /* blk-mq 태그 (HW command ID) */
    unsigned int          internal_tag; /* 스케줄러 내부 태그 */

    u64                   deadline;   /* 타임아웃 데드라인 (jiffies) */
    enum mq_rq_state     state;      /* IDLE / IN_FLIGHT / COMPLETE */

    /* 드라이버 전용 명령 데이터 (tag_set.cmd_size) */
    char cmd[] __aligned(ARCH_DMA_MINALIGN);
};
struct request 상태 머신 (mq_rq_state) Tag Pool sbitmap 할당 IDLE 할당됨, 미전송 IN_FLIGHT HW 처리 중 COMPLETE 후처리 대기 alloc start_request complete blk_mq_end_request() → bio_endio() → 태그 반환 TIMEOUT 만료 RESET_TIMER EH_DONE 정상 경로 완료 경로 에러/타임아웃 경로 tag는 COMPLETE→IDLE 전이 시 반환
request는 Tag Pool에서 할당 후 IDLE → IN_FLIGHT → COMPLETE 순으로 전이합니다. 타임아웃 발생 시 드라이버의 timeout 콜백이 복구를 결정합니다.

내부 요청 플래그 (rq_flags)

플래그 (RQF_*)설명
RQF_STARTEDblk_mq_start_request() 호출됨, 타임아웃 타이머 활성
RQF_FLUSH_SEQflush 시퀀스의 일부 (PREFLUSH → DATA → POSTFLUSH)
RQF_MIXED_MERGE서로 다른 플래그의 bio가 병합된 request
RQF_MQ_INFLIGHTin-flight 카운팅 중 (통계 추적용)
RQF_SPECIAL_PAYLOADdiscard 등 특수 페이로드를 포함
RQF_ZONE_WRITE_PLUGGINGZoned 디바이스의 순차 쓰기 플러깅 중
RQF_TIMED_OUT타임아웃이 발생한 request
RQF_RESV예약된 태그 사용 (내부 명령 전용)

병합 상세 메커니즘

블록 계층은 새로운 bio가 제출될 때 기존 request와 병합 가능한지 여러 단계에서 검사합니다. 병합은 디스크 head 이동(seek)을 줄이고 DMA 전송 효율을 높이는 핵심 최적화입니다.

/* block/blk-merge.c — 병합 검사 순서 */
enum bio_merge_status blk_attempt_bio_merge(...)
{
    /* 1단계: plug 리스트에서 병합 (per-task, 가장 빠름) */
    if (blk_attempt_plug_merge(q, bio, nr_segs))
        return BIO_MERGE_OK;

    /* 2단계: 스케줄러의 병합 캐시 (last merge hint) */
    if (blk_mq_sched_bio_merge(q, bio, nr_segs))
        return BIO_MERGE_OK;

    /* 3단계: 디스패치 큐에서 병합 */
    /* 병합 불가 → 새 request 할당 */
    return BIO_MERGE_FAILED;
}

/* 병합 가능 조건:
 * - 같은 디바이스, 같은 연산 (read/write)
 * - 섹터가 연속 (back merge: new.start == rq.end)
 * - 병합 후 max_sectors, max_segments 미초과
 * - 동일 cgroup (blkcg 일치)
 * - 동일 암호화 컨텍스트 (인라인 암호화 사용 시)
 */
병합 통계 확인:
# 병합 횟수 모니터링
$ cat /sys/block/sda/stat | awk '{print "read_merges="$2, "write_merges="$6}'
# 또는
$ iostat -x 1 | grep sda  # rrqm/s, wrqm/s 컬럼

# 병합 비활성화 (벤치마크 시 유용)
$ echo 2 > /sys/block/sda/queue/nomerges
# 0=병합 활성, 1=단순 병합만, 2=완전 비활성

I/O 제출 경로

사용자 공간의 read()/write() 시스템 콜은 VFS → 파일시스템 → 블록 계층을 거쳐 디바이스에 도달합니다. 블록 계층 내부의 핵심 경로를 살펴봅니다.

submit_bio() 흐름

  1. submit_bio(bio) → 제출 회계(accounting) 시작
  2. submit_bio_noacct(bio) → 스택 디바이스(dm, md) 재진입 시 직접 호출
  3. __submit_bio() → 드라이버의 submit_bio 콜백 또는 기본 blk_mq_submit_bio()
  4. bio가 큐 한도(max_sectors 등)를 초과하면 bio_split()으로 분할 후 재제출
  5. plug된 상태면 blk_mq_plug_issue_direct() / blk_add_rq_to_plug()으로 보류
  6. unplug 시 또는 직접 디스패치 시 하드웨어 큐로 전달
submit_bio(bio) submit_bio_noacct(bio) bio > limit? Yes bio_split() No blk_mq_submit_bio() plugged? Yes plug 리스트 No HW Queue 전송

스택 디바이스 (md, dm, bcache)

Device Mapper(dm)나 md(software RAID)는 submit_bio 콜백에서 bio를 변환(remap)하여 하위 디바이스에 재제출합니다. 예를 들어, dm-linear는 섹터 오프셋을 조정하여 하위 디바이스의 submit_bio_noacct()를 호출합니다. 다단계 스택(dm 위에 dm)도 가능하며, 재진입 방지를 위해 current->bio_list 큐잉을 사용합니다.

blk-mq (Multi-Queue Block I/O)

blk-mq는 현대 멀티코어 시스템과 고속 저장 장치를 위해 설계된 블록 I/O 프레임워크입니다. 기존 single-queue의 단일 락 병목을 해결하여, NVMe 디바이스에서 수백만 IOPS를 달성할 수 있게 합니다.

아키텍처

CPU 0 CPU 1 CPU 2 CPU 3 CPU n SW Queue 0 SW Queue 1 SW Queue 2 SW Queue 3 SW Queue n I/O Scheduler (optional: mq-deadline, BFQ, Kyber, none) HW Queue 0 HW Queue 1 NVMe / SCSI / virtio-blk Device

blk_mq_ops 콜백

static const struct blk_mq_ops my_mq_ops = {
    .queue_rq    = my_queue_rq,     /* request를 HW에 전송 */
    .commit_rqs  = my_commit_rqs,   /* 배치 전송 완료 알림 (doorbell) */
    .complete    = my_complete,      /* softirq에서 완료 처리 */
    .init_hctx   = my_init_hctx,    /* HW queue 초기화 */
    .init_request = my_init_rq,     /* request 슬랩 초기화 */
    .timeout     = my_timeout,      /* 타임아웃 처리 */
    .map_queues  = my_map_queues,   /* CPU → HW queue 매핑 */
};

blk-mq 드라이버 스켈레톤

static blk_status_t my_queue_rq(struct blk_mq_hw_ctx *hctx,
                                  const struct blk_mq_queue_data *bd)
{
    struct request *rq = bd->rq;

    blk_mq_start_request(rq);  /* 타임아웃 타이머 시작 */

    /* 하드웨어에 커맨드 전송 */
    my_hw_submit(rq);

    return BLK_STS_OK;
}

static int my_blk_probe(struct platform_device *pdev)
{
    struct blk_mq_tag_set *tag_set;
    struct gendisk *disk;

    tag_set = kzalloc(sizeof(*tag_set), GFP_KERNEL);
    tag_set->ops = &my_mq_ops;
    tag_set->nr_hw_queues = 4;           /* HW 큐 수 */
    tag_set->queue_depth  = 256;         /* 큐당 최대 in-flight */
    tag_set->numa_node    = NUMA_NO_NODE;
    tag_set->cmd_size     = sizeof(struct my_cmd);
    tag_set->flags        = BLK_MQ_F_SHOULD_MERGE;

    blk_mq_alloc_tag_set(tag_set);

    disk = blk_mq_alloc_disk(tag_set, NULL, NULL);
    disk->major       = my_major;
    disk->first_minor = 0;
    disk->minors      = 16;
    disk->fops        = &my_fops;
    snprintf(disk->disk_name, DISK_NAME_LEN, "myblk0");
    set_capacity(disk, sectors);

    add_disk(disk);  /* /dev/myblk0 등록 */
    return 0;
}

blk-mq 태그셋 플래그

플래그설명
BLK_MQ_F_SHOULD_MERGErequest 병합 활성화 (대부분의 디바이스)
BLK_MQ_F_TAG_QUEUE_SHARED여러 장치가 tag set 공유 (SCSI HBA 등)
BLK_MQ_F_BLOCKINGqueue_rq에서 sleep 허용 (스케줄링 기반 동기 I/O)
BLK_MQ_F_NO_SCHEDI/O 스케줄러 비활성화 (NVMe에서 직접 디스패치)

완료 경로

디바이스가 I/O를 완료하면 인터럽트 핸들러에서 blk_mq_complete_request()를 호출합니다. 이 함수는 해당 request를 제출한 CPU의 softirq 컨텍스트에서 blk_mq_ops->complete를 실행하여, 캐시 친화성을 유지합니다. complete 콜백은 blk_mq_end_request()를 호출하여 bio 체인의 bi_end_io를 트리거하고, 태그를 반환합니다.

blk-mq 태그와 예산 관리

blk-mq의 태그(tag)는 in-flight request를 식별하는 고유 번호입니다. NVMe의 Command ID와 직접 매핑되며, 태그가 고갈되면 새 I/O가 블록됩니다. 효율적인 태그 할당은 고성능 I/O의 핵심입니다.

sbitmap: 스케일러블 비트맵

태그 할당은 sbitmap(Scalable Bitmap) 자료구조를 사용합니다. 단일 비트맵은 캐시라인 경합이 심하므로, sbitmap은 비트맵을 여러 워드(word)로 분할하고 CPU별로 다른 워드에서 할당을 시도합니다.

/* include/linux/sbitmap.h — sbitmap 구조 */
struct sbitmap_queue {
    struct sbitmap sb;
    unsigned int  min_shallow_depth;  /* 공유 태그 최소 보장 */
    struct sbq_wait_state *ws;       /* 태그 대기 큐 배열 */
    atomic_t ws_active;              /* 활성 대기자 수 */
};

struct sbitmap {
    unsigned int depth;       /* 전체 비트(태그) 수 */
    unsigned int shift;       /* 워드당 비트 수 (log2) */
    unsigned int map_nr;      /* 워드 수 */
    struct sbitmap_word *map;  /* 워드 배열 */
};

struct sbitmap_word {
    unsigned long word;       /* 실제 비트맵 */
    unsigned long cleared;    /* 해제 예정 비트 (지연 해제) */
} ____cacheline_aligned_in_smp; /* 워드별 캐시라인 정렬! */

/* 태그 할당 흐름 */
int __sbitmap_queue_get(struct sbitmap_queue *sbq)
{
    /* 1. 현재 CPU에 할당된 워드에서 빈 비트 검색 (test_and_set_bit_lock) */
    /* 2. 없으면 다음 워드로 이동 (라운드 로빈) */
    /* 3. 모든 워드에 빈 비트 없으면 → -1 반환 (대기 필요) */
    /* CPU별 워드 분리로 캐시라인 경합 최소화 */
}
sbitmap 태그 할당 (캐시라인별 워드 분리) CPU 0 CPU 1 CPU 2 CPU 3 Word 0 (cacheline 0) 1 1 0 1 0 1 0 0 Word 1 (cacheline 1) 1 0 0 1 0 0 0 0 Word 2 (cacheline 2) 1 1 1 0 0 0 0 0 Word 3 (cacheline 3) 0 0 0 0 0 0 0 0 동작 원리 = 사용 중 (in-flight) = 비어있음 (할당 가능) 1. 각 CPU는 자신에게 할당된 워드에서 빈 비트를 검색 (test_and_set_bit_lock) 2. 해당 워드가 가득 차면 다음 워드로 이동 (라운드 로빈) 3. 워드별 캐시라인 정렬 → CPU 간 false sharing 방지 → 락 없는 병렬 할당
sbitmap은 비트맵을 캐시라인 크기로 분할하여 CPU별 워드에서 독립적으로 태그를 할당합니다. 이 구조는 수십 코어 시스템에서도 태그 할당 경합을 최소화합니다.

공유 태그 (Shared Tags)

SCSI HBA처럼 여러 LUN(디바이스)이 하나의 호스트 어댑터를 공유하는 경우, BLK_MQ_F_TAG_QUEUE_SHARED 플래그로 태그 세트를 공유합니다. 이 경우 한 디바이스가 모든 태그를 독점하지 못하도록 shallow depth 메커니즘이 적용됩니다.

/* 공유 태그 보호: 디바이스별 최소 태그 보장 */
/*
 * nr_hw_queues = 1 (SCSI HBA), queue_depth = 256
 * 4개 디스크가 공유 → 디바이스당 최소 64개 보장
 *
 * min_shallow_depth = queue_depth / nr_shared_devices
 *
 * 디바이스 A가 200개 사용 중이면,
 * 디바이스 B/C/D도 최소 (256-200)/3 = 18개 이상 확보 가능
 * → 한 디바이스의 대량 I/O가 다른 디바이스를 기아시키지 않음
 */

예약 태그 (Reserved Tags)

드라이버는 tag_set.reserved_tags로 일부 태그를 예약할 수 있습니다. 예약 태그는 일반 I/O와 독립적으로 관리되어, 디바이스 리셋·abort 명령 등 내부 관리 명령이 태그 부족으로 실패하지 않도록 보장합니다.

/* 예약 태그 사용 예 (NVMe admin 명령) */
tag_set->reserved_tags = 4;  /* 4개 태그를 관리 명령용으로 예약 */
tag_set->queue_depth   = 1024;

/* 일반 I/O: tag 4~1027 사용 (1024개) */
/* 관리 명령: tag 0~3 사용 (4개, 항상 가용) */

/* 예약 태그로 request 할당 */
rq = blk_mq_alloc_request(q, REQ_OP_DRV_OUT, BLK_MQ_REQ_RESERVED);
/* BLK_MQ_REQ_RESERVED 플래그로 예약 풀에서 할당 */

I/O 완료 경로와 폴링

I/O 완료 처리 방식은 성능에 직접적인 영향을 미칩니다. Linux는 전통적인 인터럽트 기반 완료와 저지연을 위한 폴링 기반 완료, 그리고 양쪽의 장점을 결합한 하이브리드 폴링을 지원합니다.

인터럽트 기반 완료 (기본)

/* 인터럽트 기반 완료 경로 (NVMe 예시) */
/*
 * 1. NVMe 디바이스가 Completion Queue Entry(CQE) 기록
 * 2. MSI-X 인터럽트 발생 → irq handler
 * 3. nvme_irq() → nvme_process_cq()
 * 4. blk_mq_complete_request(rq)
 *    → 제출 CPU에 IPI 전송 (캐시 친화성)
 * 5. 해당 CPU의 softirq에서 blk_mq_ops.complete() 실행
 * 6. blk_mq_end_request() → bio_endio() → bi_end_io()
 * 7. 태그 반환, 대기 중인 request 깨움
 */

/* 완료 CPU 선택 전략 */
void blk_mq_complete_request(struct request *rq)
{
    /* request를 제출한 CPU에서 완료 처리 (캐시 친화성) */
    if (blk_mq_complete_need_ipi(rq)) {
        /* 다른 CPU에서 인터럽트 처리 → IPI로 원래 CPU에 전달 */
        smp_call_function_single_async(rq->mq_ctx->cpu,
                                        &rq->csd);
    } else {
        /* 같은 CPU → softirq에서 직접 처리 */
        raise_softirq(BLOCK_SOFTIRQ);
    }
}

폴링 모드 (io_poll)

NVMe 등 μs 단위 지연 디바이스에서 인터럽트 오버헤드(컨텍스트 전환, IPI, softirq 지연)가 I/O 지연의 상당 부분을 차지합니다. 폴링 모드는 인터럽트를 비활성화하고, CPU가 Completion Queue를 직접 폴링하여 완료를 즉시 감지합니다.

/* 폴링 완료 경로 */
/*
 * 1. bio 제출 시 REQ_POLLED 플래그 설정
 * 2. 인터럽트 대신 CPU가 CQ를 반복 확인
 * 3. io_uring: IORING_SETUP_IOPOLL 플래그로 활성화
 * 4. 직접 호출: blk_mq_poll(q, cookie)
 *
 * 장점: 인터럽트 없이 μs 이하 지연 달성
 * 단점: 폴링 중 CPU 100% 사용 (다른 작업 불가)
 */

/* io_uring 폴링 사용 예시 */
/* 사용자 공간: */
struct io_uring_params params = {
    .flags = IORING_SETUP_IOPOLL,  /* 폴링 모드 활성화 */
};
io_uring_setup(depth, &params);

/* 커널 내부: io_uring의 io_do_iopoll() */
static int io_do_iopoll(struct io_ring_ctx *ctx, bool force_nonspin)
{
    /* 각 HW 큐의 completion queue를 직접 폴링 */
    blk_mq_poll(q, blk_qc_t_cookie, &iob, flags);
    /* 완료된 CQE를 즉시 처리 */
}

하이브리드 폴링

하이브리드 폴링은 I/O 제출 후 예상 완료 시간의 절반까지 sleep한 다음 폴링을 시작합니다. 이렇게 하면 CPU를 절약하면서도 인터럽트 방식보다 낮은 지연을 달성합니다.

# 폴링 모드 설정 (sysfs)
# 0 = 인터럽트 기반 (기본)
# 1 = 클래식 폴링 (busy-wait)
# 2 = 하이브리드 폴링 (sleep + poll)
$ echo 1 > /sys/block/nvme0n1/queue/io_poll
$ echo 2 > /sys/block/nvme0n1/queue/io_poll  # 하이브리드

# 하이브리드 폴링 슬립 시간 (나노초, 자동 조절)
$ cat /sys/block/nvme0n1/queue/io_poll_delay
-1  # -1 = 자동 (완료 시간의 50% sleep)
I/O 완료 모드 비교 (NVMe, 10μs I/O) submit HW 완료 인터럽트 (기본) CPU 자유 (다른 작업 가능) IRQ softirq endio ~15μs 클래식 폴링 CPU busy-wait (폴링 중, 100% 사용) 발견 endio ~10μs 하이브리드 폴링 sleep (~5μs) poll (~5μs) 발견 endio ~11μs 인터럽트: CPU 효율 최고 | 클래식 폴링: 지연 최저 | 하이브리드: CPU 효율 + 저지연 절충
인터럽트 모드는 CPU를 절약하지만 IRQ/softirq 오버헤드가 있고, 폴링 모드는 지연을 최소화하지만 CPU를 점유합니다. 하이브리드 폴링은 두 방식의 균형을 제공합니다.

io_uring SQPOLL과 블록 I/O

io_uringSQPOLL 모드는 커널 스레드가 Submission Queue를 폴링하여 시스템 콜 없이 I/O를 제출합니다. IORING_SETUP_IOPOLL과 함께 사용하면 제출과 완료 모두 폴링으로 처리되어, 시스템 콜 오버헤드를 완전히 제거합니다.

/* io_uring SQPOLL + IOPOLL: 완전 무시스콜 I/O 경로 */
struct io_uring_params params = {
    .flags = IORING_SETUP_SQPOLL  /* 커널 스레드가 SQ 폴링 */
           | IORING_SETUP_IOPOLL, /* 완료도 폴링 */
    .sq_thread_idle = 2000,       /* 2초 유휴 시 스레드 슬립 */
};

/*
 * 데이터 경로:
 * 1. 사용자: SQE를 SQ 링에 기록 (메모리 쓰기만, 시스콜 없음)
 * 2. SQPOLL 스레드: SQ를 폴링하여 SQE 발견
 * 3. submit_bio() → blk-mq → NVMe HW
 * 4. IOPOLL: CQ를 폴링하여 완료 감지
 * 5. CQE를 CQ 링에 기록
 * 6. 사용자: CQ 링에서 CQE 수확 (메모리 읽기만)
 *
 * → 전체 경로에서 시스템 콜 0회
 * → 인터럽트 0회
 * → NVMe에서 수백만 IOPS, μs 이하 지연 달성
 */
SQPOLL CPU 고정: IORING_SETUP_SQ_AFFparams.sq_thread_cpu로 SQPOLL 스레드를 특정 CPU에 고정할 수 있습니다. 이 CPU는 I/O 전용으로 격리하여(isolcpus) 다른 작업의 간섭을 방지하면, NVMe의 이론적 최대 성능에 근접할 수 있습니다.

I/O 스케줄러

I/O 스케줄러는 blk-mq의 소프트웨어 큐와 하드웨어 큐 사이에 위치하여, request의 제출 순서를 조정합니다. Linux 커널은 4가지 스케줄러를 제공하며, 디바이스 유형에 따라 자동 선택됩니다.

스케줄러 비교 요약

스케줄러알고리즘적합 디바이스오버헤드특징
mq-deadline데드라인 기반 FIFO + 정렬 큐HDD, 범용낮음읽기/쓰기 데드라인 보장, starvation 방지
bfq예산 기반 공정 큐잉 (Budget Fair Queueing)데스크톱, 느린 디바이스높음프로세스별 대역폭 공정 분배, 대화형 작업 우선
kyber토큰 기반 지연 목표 (latency target)고속 SSD/NVMe매우 낮음읽기/쓰기 대기시간 목표로 자동 조절
none스케줄링 없음 (FIFO 직행)NVMe, 가상 디바이스없음디바이스 자체 스케줄링에 위임
SW Queues (per-CPU) I/O Scheduler (elevator) mq-deadline sorted + FIFO 이중 큐 bfq budget 기반 공정 큐잉 kyber 토큰 기반 latency 목표 none FIFO 직행 (bypass) dispatch → HW Queue HW Queues hctx[0] hctx[1] hctx[2] ... hctx[N] per-CPU SW 큐 → I/O 스케줄러(elevator) → 하드웨어 디스패치 큐

elevator 프레임워크

커널의 I/O 스케줄러는 elevator 프레임워크 위에 구현됩니다. 각 스케줄러는 struct elevator_mq_ops에 콜백을 등록하며, blk-mq 디스패치 루프가 이 콜백을 호출하여 다음에 처리할 request를 결정합니다.

/* include/linux/elevator.h — elevator 콜백 구조체 (주요 멤버) */
struct elevator_mq_ops {
    int  (*init_sched)(struct request_queue *, struct elevator_type *);
    void (*exit_sched)(struct elevator_queue *);

    /* bio → request 삽입 */
    void (*insert_requests)(struct blk_mq_hw_ctx *hctx,
                            struct list_head *list, blk_insert_t flags);

    /* 다음 디스패치할 request 반환 */
    struct request *(*dispatch_request)(struct blk_mq_hw_ctx *hctx);

    /* 요청 병합 가능 여부 */
    bool (*allow_merge)(struct request_queue *, struct request *,
                        struct bio *);

    /* 완료 통지 (latency 추적 등) */
    void (*completed_request)(struct request *, u64 now);

    /* I/O 우선순위 변경 시 호출 */
    void (*depth_updated)(struct blk_mq_hw_ctx *hctx);

    /* sysfs 파라미터 */
    const struct blk_mq_debugfs_attr *queue_debugfs_attrs;
};

/* elevator 등록 예시 (mq-deadline) */
static struct elevator_type mq_deadline = {
    .ops = {
        .insert_requests  = dd_insert_requests,
        .dispatch_request = dd_dispatch_request,
        .completed_request= dd_completed_request,
        .init_sched       = dd_init_sched,
        .exit_sched       = dd_exit_sched,
        /* ... */
    },
    .elevator_name = "mq-deadline",
    .elevator_alias = "deadline",
};

blk-mq 디스패치 루프의 핵심 경로는 다음과 같습니다:

/* block/blk-mq-sched.c — 디스패치 핵심 루프 (간략화) */
bool blk_mq_sched_dispatch_requests(struct blk_mq_hw_ctx *hctx)
{
    struct elevator_queue *e = hctx->queue->elevator;

    /* 1. 스케줄러가 있으면 스케줄러에게 다음 request 요청 */
    if (e && e->type->ops.dispatch_request) {
        struct request *rq;
        while ((rq = e->type->ops.dispatch_request(hctx)) != NULL) {
            blk_mq_dispatch_rq_list(hctx, &list, count);
        }
    } else {
        /* 2. none 스케줄러: SW 큐에서 직접 가져와 디스패치 */
        blk_mq_flush_busy_ctxs(hctx, &list);
        blk_mq_dispatch_rq_list(hctx, &list, count);
    }
}

mq-deadline 심층 분석

mq-deadline은 Linux 블록 계층의 기본 스케줄러로, 요청의 데드라인(만료 시간)을 보장하면서도 디스크 탐색(seek)을 최소화하는 이중 큐 구조를 사용합니다. 원래의 단일큐 deadline 스케줄러를 blk-mq에 맞게 재설계한 것입니다.

자료구조

/* block/mq-deadline.c — 핵심 자료구조 */
struct deadline_data {
    /*
     * 읽기/쓰기 각각 2개의 큐 = 총 4개의 큐를 관리
     * [DD_READ]  — 읽기용 정렬큐 + FIFO큐
     * [DD_WRITE] — 쓰기용 정렬큐 + FIFO큐
     */
    struct rb_root sort_list[2];     /* RB 트리: 섹터 번호 순 정렬 */
    struct list_head fifo_list[2];  /* FIFO: 제출 순서 (만료 시간 순) */

    struct request *next_rq[2];    /* 다음 정렬 순서 요청 (캐싱) */

    /* 튜닝 파라미터 */
    unsigned int batching;          /* 현재 배치에서 디스패치한 수 */
    unsigned int fifo_batch;        /* 한 방향 연속 디스패치 최대 수 (기본 16) */
    int fifo_expire[2];             /* [READ]=500ms, [WRITE]=5000ms */
    int writes_starved;              /* 쓰기 양보 횟수 (기본 2) */
    int front_merges;                /* 앞쪽 병합 허용 여부 (기본 1) */

    int starved;                     /* 현재 쓰기 기아 카운터 */
    int last_dir;                    /* 마지막 디스패치 방향 (READ/WRITE) */

    struct dd_per_prio per_prio[3]; /* IOPRIO_CLASS_RT/BE/IDLE 별 큐 */
    spinlock_t lock;                 /* zone lock (ZNS 디바이스) */
};

디스패치 알고리즘

mq-deadline의 디스패치 로직은 다음 우선순위로 request를 선택합니다:

  1. 만료 확인: 읽기 FIFO 큐의 head가 데드라인을 초과했으면 즉시 디스패치 (읽기 우선)
  2. 쓰기 기아 방지: 읽기가 writes_starved회 연속 선택되면 쓰기를 강제 디스패치
  3. 쓰기 만료 확인: 쓰기 FIFO 큐의 head가 데드라인을 초과했으면 디스패치
  4. 정렬 순서: 만료된 요청이 없으면, 현재 방향의 정렬 큐에서 다음 섹터를 선택 (seek 최소화)
  5. 배치 제한: 한 방향으로 fifo_batch개를 초과하면 방향 전환
/* block/mq-deadline.c — dd_dispatch_request() 간략화 */
static struct request *dd_dispatch_request(struct blk_mq_hw_ctx *hctx)
{
    struct deadline_data *dd = hctx->queue->elevator->elevator_data;
    struct request *rq;
    enum dd_data_dir data_dir;

    /* 우선순위 순서: RT → BE → IDLE */
    for (prio = DD_RT_PRIO; prio <= DD_IDLE_PRIO; prio++) {
        /* 읽기 FIFO에 만료된 요청이 있는가? */
        if (deadline_fifo_request(dd, DD_READ) &&
            deadline_check_fifo(dd, DD_READ)) {
            rq = deadline_fifo_request(dd, DD_READ);
            data_dir = DD_READ;
            goto dispatch;
        }

        /* 쓰기가 writes_starved번 이상 양보했으면 쓰기 우선 */
        if (dd->starved > dd->writes_starved) {
            data_dir = DD_WRITE;
        } else {
            data_dir = DD_READ;
            if (!deadline_next_request(dd, DD_READ))
                data_dir = DD_WRITE;  /* 읽기 큐 비었으면 쓰기 */
        }

        /* 정렬 큐에서 다음 섹터 순서 request 선택 */
        rq = deadline_next_request(dd, data_dir);
dispatch:
        dd->batching++;
        dd->last_dir = data_dir;
        return rq;
    }
    return NULL;
}
ℹ️

읽기 우선 정책의 이유: 읽기 요청은 대부분 동기적으로 프로세스를 블록시키므로 지연에 민감합니다. 쓰기는 Page Cache에 의해 비동기로 처리되어 지연에 관대합니다. 따라서 읽기 데드라인(500ms)이 쓰기(5000ms)보다 10배 짧습니다.

mq-deadline 튜닝 파라미터

파라미터경로 (sysfs)기본값설명
read_expirequeue/iosched/read_expire500 (ms)읽기 요청 최대 지연. 이 시간 내에 반드시 디스패치
write_expirequeue/iosched/write_expire5000 (ms)쓰기 요청 최대 지연
fifo_batchqueue/iosched/fifo_batch16한 방향(읽기 또는 쓰기)에서 연속 디스패치 최대 수. 값이 크면 throughput 증가, seek이 많아질 수 있음
writes_starvedqueue/iosched/writes_starved2쓰기 양보 횟수. 읽기가 이 횟수만큼 연속 선택되면 쓰기를 강제
front_mergesqueue/iosched/front_merges1앞쪽 병합(front merge) 허용. HDD에서는 1, 일부 SSD에서는 0이 유리
prio_aging_expirequeue/iosched/prio_aging_expire10000 (ms)낮은 I/O 우선순위 요청의 에이징 시간. 이 시간 경과 시 우선순위 승격
# mq-deadline 튜닝 예시: HDD 순차 처리량 최적화
# fifo_batch를 높여 한 방향 연속 처리 증가 → seek 감소
$ echo 32 > /sys/block/sda/queue/iosched/fifo_batch

# 데이터베이스 서버: 읽기 데드라인을 짧게 설정
$ echo 200 > /sys/block/sda/queue/iosched/read_expire
$ echo 2000 > /sys/block/sda/queue/iosched/write_expire

# 쓰기 집중 워크로드: 쓰기 기아 방지 강화
$ echo 1 > /sys/block/sda/queue/iosched/writes_starved

BFQ (Budget Fair Queueing) 심층 분석

BFQ는 프로세스별 예산(budget)을 할당하여 대역폭을 공정하게 분배하는 I/O 스케줄러입니다. CFQ(Completely Fair Queueing)의 blk-mq 후속으로, 특히 대화형 작업의 응답성을 극대화하도록 설계되었습니다. 데스크톱 환경에서 백그라운드 I/O가 발생해도 전경 애플리케이션의 체감 성능을 유지합니다.

BFQ 내부 아키텍처

/* block/bfq-iosched.h — BFQ 핵심 자료구조 */
struct bfq_data {
    struct request_queue *queue;

    /* B-WF²Q+ 스케줄링 트리 (가중치 공정 큐잉) */
    struct bfq_sched_data root_group->sched_data;

    unsigned int num_bfqq;          /* 활성 bfq_queue 수 */
    unsigned int peak_rate;          /* 디바이스 최대 처리율 추정치 */
    unsigned int hw_tag;             /* HW 큐 태그 여부 */
    bool strict_guarantees;           /* 엄격한 대역폭 보장 모드 */

    /* 대화형 감지 */
    unsigned long bfq_wr_max_time;   /* weight raising 최대 시간 */
    unsigned long bfq_wr_min_idle_time; /* 대화형 판별 유휴 임계 */
};

/* 프로세스별 큐 */
struct bfq_queue {
    struct rb_root sort_list;      /* 섹터 순 정렬된 request */
    struct list_head fifo;         /* 제출 순서 FIFO */

    int entity.budget;               /* 현재 할당 예산 (섹터 단위) */
    unsigned short entity.weight;    /* 가중치 (100 기본, ionice로 설정) */
    bool wr_coeff;                   /* weight raising 배수 (대화형 부스트) */

    unsigned long last_idle_time;    /* 마지막 유휴 시간 (대화형 판별) */
    pid_t pid;                       /* 소유 프로세스 PID */
};

B-WF²Q+ 스케줄링

BFQ는 B-WF²Q+ (Budget Worst-case Fair Weighted Fair Queueing) 알고리즘을 사용합니다. 각 프로세스는 bfq_queue를 갖고, 이 큐에 예산(budget)이 할당됩니다. 예산은 해당 큐가 한 번에 디스패치할 수 있는 최대 섹터 수입니다.

  1. 예산 할당: 큐가 활성화되면 디바이스의 추정 처리율과 가중치에 비례하여 예산을 받음
  2. 독점 서비스: 예산이 있는 동안 해당 큐가 디스패치를 독점 (idling 포함)
  3. 예산 소진: 예산을 모두 사용하거나 유휴 타임아웃 발생 시 다음 큐로 전환
  4. 가상 시간: WF²Q+ 알고리즘의 가상 시간을 기반으로 다음 서비스할 큐를 결정 (가중치에 비례하는 대역폭 분배)

대화형 작업 감지 (Weight Raising)

BFQ의 가장 중요한 기능은 weight raising입니다. 대화형 프로세스(짧은 I/O를 간헐적으로 발생시키는 패턴)를 자동으로 감지하여 가중치를 일시적으로 높여, 백그라운드의 대량 I/O 중에도 빠른 응답을 보장합니다.

/* 대화형 판별 조건 (간략화) */
/*
 * 1. 큐가 유휴 상태에서 새 요청 도착
 * 2. 유휴 기간이 bfq_wr_min_idle_time 이상
 * 3. 이전 서비스에서 적은 양의 I/O만 수행
 * → "대화형"으로 판별, wr_coeff를 30배까지 증가
 * → bfq_wr_max_time 동안 유지 후 점진적 감소
 */
static bool bfq_bfqq_update_budg_for_activation(...)
{
    if (bfq_bfqq_non_blocking_wait_rq(bfqq) &&
        idle_for >= bfqd->bfq_wr_min_idle_time) {
        /* weight raising 적용 */
        bfqq->wr_coeff = bfqd->bfq_wr_coeff;  /* 기본 30 */
        bfqq->wr_cur_max_time = bfqd->bfq_wr_max_time;
    }
}
💡

BFQ + cgroups: BFQ는 cgroup v2의 io.bfq.weight를 통해 그룹 단위 대역폭 제어를 지원합니다. 이를 통해 컨테이너 간 I/O 격리가 가능합니다.

# cgroup v2에서 BFQ 가중치 설정
$ echo "8:0 200" > /sys/fs/cgroup/my_group/io.bfq.weight

큐 idling 메커니즘

BFQ는 현재 서비스 중인 큐가 비어도 일정 시간(slice_idle) 동안 대기합니다. 이는 두 가지 목적이 있습니다:

⚠️

BFQ의 오버헤드: 프로세스별 큐 관리, weight raising 추적, B-WF²Q+ 가상 시간 계산 등으로 인해 고속 NVMe 디바이스에서는 CPU 오버헤드가 병목이 될 수 있습니다. NVMe에서 100만 IOPS 이상을 처리해야 하는 경우 none 또는 kyber를 권장합니다.

BFQ 튜닝 파라미터

파라미터경로 (sysfs)기본값설명
slice_idlequeue/iosched/slice_idle8 (ms)큐 비어도 대기하는 시간. 0이면 idling 비활성. SSD에서는 0이 유리할 수 있음
slice_idle_usqueue/iosched/slice_idle_us8000 (us)slice_idle의 마이크로초 버전 (정밀 제어)
low_latencyqueue/iosched/low_latency1대화형 감지(weight raising) 활성화. 0이면 순수 공정 큐잉만 사용
timeout_syncqueue/iosched/timeout_sync124 (ms)동기 큐의 서비스 타임아웃
max_budgetqueue/iosched/max_budget0 (자동)큐당 최대 예산. 0이면 디바이스 처리율 기반 자동 계산
strict_guaranteesqueue/iosched/strict_guarantees01이면 엄격한 대역폭 보장 (추가 오버헤드 발생)
# BFQ 튜닝 예시: 데스크톱 환경 최적화
$ echo bfq > /sys/block/sda/queue/scheduler

# SSD에서 idling 비활성 (seek 비용 없으므로)
$ echo 0 > /sys/block/sda/queue/iosched/slice_idle

# 서버에서 공정 큐잉만 사용 (weight raising 비활성)
$ echo 0 > /sys/block/sda/queue/iosched/low_latency

Kyber 심층 분석

Kyber는 고속 SSD/NVMe를 위해 설계된 경량 I/O 스케줄러입니다. 프로세스별 큐나 정렬 트리 없이, 토큰 기반 입장 제어(admission control)로 I/O 요청의 동시 실행 수를 조절하여 지연 시간(latency) 목표를 달성합니다.

토큰 기반 아키텍처

/* block/kyber-iosched.c — Kyber 자료구조 */
enum {
    KYBER_READ,     /* 읽기 도메인 */
    KYBER_WRITE,    /* 쓰기 도메인 */
    KYBER_DISCARD,  /* discard/trim 도메인 */
    KYBER_OTHER,    /* flush 등 기타 */
    KYBER_NUM_DOMAINS,
};

struct kyber_queue_data {
    struct request_queue *q;

    /* 도메인별 토큰 수 (동시 실행 가능 I/O 수) */
    unsigned int async_depth;        /* HW 큐 깊이 기반 */
    struct sbitmap_queue domain_tokens[KYBER_NUM_DOMAINS];

    /* 지연 목표 */
    u64 read_lat_nsec;               /* 읽기 지연 목표 (기본 2ms) */
    u64 write_lat_nsec;              /* 쓰기 지연 목표 (기본 10ms) */

    /* 지연 히스토그램 — 완료 지연 분포 추적 */
    struct kyber_cpu_latency __percpu *cpu_latency;
};

/* per-HW-ctx 디스패치 큐 */
struct kyber_hctx_data {
    /* 도메인별 대기 큐 (토큰 대기 중인 request) */
    struct list_head rqs[KYBER_NUM_DOMAINS];
    unsigned int cur_domain;          /* 라운드 로빈 디스패치 */
    unsigned int batching;            /* 현재 도메인 연속 디스패치 수 */
};

동작 메커니즘

Kyber의 핵심 아이디어는 단순합니다: 동시 실행 I/O 수를 줄이면 지연이 감소한다. 토큰이 부족하면 새 I/O가 대기 큐에서 기다립니다.

  1. 토큰 획득: request가 디스패치되려면 해당 도메인(읽기/쓰기)의 토큰을 획득해야 함
  2. 지연 모니터링: 완료된 request의 실제 지연을 per-CPU 히스토그램에 기록
  3. 토큰 조절: 주기적으로(timer callback) 히스토그램을 분석하여 목표 지연을 초과한 비율이 높으면 토큰 수를 감소, 낮으면 증가
  4. 도메인 분리: 읽기와 쓰기의 토큰 풀이 독립이므로, 대량 쓰기가 읽기 지연에 미치는 영향을 최소화
/* block/kyber-iosched.c — 토큰 조절 로직 (간략화) */
static void kyber_timer_fn(struct timer_list *t)
{
    struct kyber_queue_data *kqd = from_timer(kqd, t, timer);
    unsigned int orig_depth, depth;

    /* 각 도메인에 대해 */
    for (domain = 0; domain < KYBER_NUM_DOMAINS; domain++) {
        /* per-CPU 히스토그램을 취합하여 지연 분포 계산 */
        good = kyber_lat_percentage(kqd, domain, GOOD);
        bad  = kyber_lat_percentage(kqd, domain, BAD);

        orig_depth = kqd->domain_tokens[domain].depth;

        if (bad > KYBER_LAT_BAD_THRESHOLD) {
            /* 지연 초과 비율 높음 → 깊이 감소 (최소 1) */
            depth = max(orig_depth - 1, 1U);
        } else if (good > KYBER_LAT_GOOD_THRESHOLD) {
            /* 지연이 양호 → 깊이 증가 (최대 async_depth) */
            depth = min(orig_depth + 1, kqd->async_depth);
        }

        if (depth != orig_depth)
            sbitmap_queue_resize(&kqd->domain_tokens[domain], depth);
    }
}
💡

Kyber가 적합한 환경: 하드웨어 큐가 충분하고 자체 스케줄링을 잘 하지만, 소프트웨어 레벨에서 읽기/쓰기 간 간섭을 줄이고 싶을 때 유용합니다. none보다 약간의 오버헤드가 있지만, 혼합 워크로드에서 읽기 tail latency를 크게 개선할 수 있습니다.

Kyber 튜닝 파라미터

파라미터경로 (sysfs)기본값설명
read_lat_nsecqueue/iosched/read_lat_nsec2000000 (2ms)읽기 지연 목표. 이 값을 기준으로 동시 I/O 수를 자동 조절
write_lat_nsecqueue/iosched/write_lat_nsec10000000 (10ms)쓰기 지연 목표
# Kyber 튜닝 예시
$ echo kyber > /sys/block/nvme0n1/queue/scheduler

# 읽기 지연 목표를 1ms로 강화 (고성능 NVMe)
$ echo 1000000 > /sys/block/nvme0n1/queue/iosched/read_lat_nsec

# 쓰기 지연 목표를 5ms로 강화
$ echo 5000000 > /sys/block/nvme0n1/queue/iosched/write_lat_nsec

none 스케줄러

none은 스케줄러를 완전히 우회하여, per-CPU 소프트웨어 큐의 request를 FIFO 순서 그대로 하드웨어 큐에 전달합니다. 정렬, 병합(기본 병합 제외), 우선순위 조정 어떤 것도 하지 않습니다.

💡

NVMe에서 none 스케줄러를 권장하는 이유: NVMe 컨트롤러는 자체적으로 수천 개의 I/O를 병렬 처리하며, 하드웨어 큐가 충분합니다. 소프트웨어 스케줄러의 추가 오버헤드가 오히려 지연을 증가시킵니다. 반면 HDD는 디스크 헤드의 탐색(seek) 비용이 크므로 mq-deadline으로 요청을 정렬하는 것이 효과적입니다.

none이 최적인 경우:

스케줄러 자동 선택과 설정 가이드

Linux 커널은 디바이스 등록 시 elevator_get_default()를 통해 스케줄러를 자동 선택합니다. 선택 기준은 하드웨어 큐 수와 디바이스 유형입니다:

/* block/elevator.c — 기본 스케줄러 선택 로직 (간략화) */
static struct elevator_type *elevator_get_default(struct request_queue *q)
{
    /* 단일 HW 큐(예: HDD) → mq-deadline */
    if (q->nr_hw_queues == 1)
        return elevator_find("mq-deadline");

    /* 다중 HW 큐(예: NVMe) 또는 BLK_MQ_F_NO_SCHED → none */
    return NULL;  /* none */
}
디바이스 유형HW 큐 수기본 스케줄러권장 스케줄러이유
HDD (SATA/SAS)1mq-deadlinemq-deadlineseek 비용 높음, 정렬+데드라인 필요
SATA SSD1mq-deadlinemq-deadline / bfq단일 큐이므로 소프트웨어 스케줄링 유효
NVMe SSD다수nonenone / kyberHW 큐 충분, 소프트웨어 오버헤드 불필요
데스크톱 (HDD)1mq-deadlinebfq대화형 응답성 중요
virtio-blk다수nonenone호스트 측에서 스케줄링
NVMe (혼합 워크로드)다수nonekyber읽기 tail latency 개선

영구적 스케줄러 설정

# udev rule로 디바이스 유형별 영구 설정
# /etc/udev/rules.d/60-ioscheduler.rules

# 회전 디스크(HDD) → mq-deadline
ACTION=="add|change", KERNEL=="sd[a-z]", ATTR{queue/rotational}=="1", \
    ATTR{queue/scheduler}="mq-deadline"

# 비회전 디스크(SSD) → mq-deadline 또는 none
ACTION=="add|change", KERNEL=="sd[a-z]", ATTR{queue/rotational}=="0", \
    ATTR{queue/scheduler}="none"

# NVMe → none
ACTION=="add|change", KERNEL=="nvme[0-9]*n[0-9]*", \
    ATTR{queue/scheduler}="none"

# 커널 부트 파라미터로 전역 기본값 변경
# GRUB: GRUB_CMDLINE_LINUX="elevator=bfq"

I/O 우선순위 (ioprio)

Linux는 프로세스별로 I/O 우선순위를 설정할 수 있습니다. I/O 스케줄러(특히 mq-deadline, BFQ)가 이 우선순위를 참고하여 디스패치 순서를 결정합니다.

클래스범위설명
IOPRIO_CLASS_RT10~7 (0=최고)실시간. 다른 모든 I/O보다 먼저 처리. root만 설정 가능
IOPRIO_CLASS_BE20~7 (0=최고)Best-effort. 기본값. nice 값 기반으로 자동 매핑
IOPRIO_CLASS_IDLE3N/A다른 모든 I/O가 없을 때만 처리. 백그라운드 작업에 적합
# ionice 유틸리티로 I/O 우선순위 설정
# 실시간 클래스, 우선순위 0 (최고)
$ ionice -c 1 -n 0 dd if=/dev/sda of=/dev/null bs=1M

# Best-effort 클래스, 우선순위 7 (최저)
$ ionice -c 2 -n 7 rsync -a /src /dst

# Idle 클래스 (다른 I/O 없을 때만 실행)
$ ionice -c 3 updatedb

# 실행 중 프로세스의 I/O 우선순위 변경
$ ionice -c 2 -n 4 -p 1234

# 현재 프로세스의 I/O 우선순위 확인
$ ionice -p $$
best-effort: prio 4
/* 커널 내부: ioprio 시스템 콜 */
#include <linux/ioprio.h>

/* I/O 우선순위 설정 */
syscall(SYS_ioprio_set, IOPRIO_WHO_PROCESS, pid,
        IOPRIO_PRIO_VALUE(IOPRIO_CLASS_BE, 4));

/* mq-deadline에서의 우선순위 처리:
 * - RT/BE/IDLE 각각 별도의 dd_per_prio 큐 세트를 가짐
 * - RT 큐 → BE 큐 → IDLE 큐 순서로 디스패치
 * - prio_aging_expire(기본 10초) 경과 시 낮은 우선순위도 서비스 */

스케줄러 변경 및 확인

# 현재 스케줄러 확인 (대괄호 안이 활성 스케줄러)
$ cat /sys/block/sda/queue/scheduler
[mq-deadline] kyber bfq none

# 스케줄러 변경
$ echo bfq > /sys/block/sda/queue/scheduler

# 현재 스케줄러의 튜닝 파라미터 목록
$ ls /sys/block/sda/queue/iosched/
fifo_batch  front_merges  read_expire  write_expire  writes_starved

# 읽기 데드라인을 100ms로 설정
$ echo 100 > /sys/block/sda/queue/iosched/read_expire

# Kyber 읽기/쓰기 지연 목표 (나노초)
$ cat /sys/block/nvme0n1/queue/iosched/read_lat_nsec
2000000  # 2ms

# 사용 가능한 스케줄러 커널 모듈 확인
$ ls /lib/modules/$(uname -r)/kernel/block/
bfq-iosched.ko  kyber-iosched.ko  mq-deadline.ko

Block Device Operations

struct block_device_operations는 블록 디바이스의 파일 연산 콜백을 정의합니다. VFS의 file_operations가 파일에 대한 것이라면, 이 구조체는 디바이스 전체에 대한 연산을 담당합니다.

static const struct block_device_operations my_fops = {
    .owner         = THIS_MODULE,
    .open          = my_open,          /* 디바이스 열기 */
    .release       = my_release,       /* 디바이스 닫기 */
    .ioctl         = my_ioctl,         /* ioctl 처리 */
    .compat_ioctl  = my_compat_ioctl,  /* 32비트 호환 ioctl */
    .getgeo        = my_getgeo,        /* 디스크 기하 정보 (fdisk용) */
    .report_zones  = my_report_zones,  /* Zoned 디바이스 존 보고 */
};

gendisk 생명주기

/* 1. 디스크 할당 (blk-mq 기반) */
struct gendisk *disk = blk_mq_alloc_disk(tag_set, NULL, NULL);

/* 2. 디스크 속성 설정 */
disk->major       = my_major;
disk->first_minor = 0;
disk->minors      = 16;             /* 파티션 수 */
disk->fops        = &my_fops;
set_capacity(disk, nr_sectors);      /* 512바이트 섹터 단위 */

/* 3. 시스템에 등록 → /dev/myblk0, /sys/block/myblk0 생성 */
add_disk(disk);

/* 4. 제거 시 */
del_gendisk(disk);     /* /dev에서 제거, 진행 중 I/O 완료 대기 */
put_disk(disk);         /* 참조 카운트 감소, 최종 해제 */
ℹ️

gendisk와 파티션 관리에 대한 상세 내용은 디스크 파티션 페이지를 참조하세요.

Direct I/O vs Buffered I/O

일반적인 파일 I/O는 Page Cache를 경유하여 커널이 읽기 선행(readahead)과 쓰기 지연(write-back)을 최적화합니다. 반면 Direct I/O(O_DIRECT)는 Page Cache를 우회하여 사용자 버퍼와 디바이스 간 직접 DMA를 수행합니다.

트레이드오프

커널 내부 경로

/* fs/direct-io.c — Direct I/O 기본 경로 */
/*
 * 파일시스템의 .read_iter / .write_iter에서 IOCB_DIRECT 플래그 감지 시
 * iomap 기반 Direct I/O 또는 레거시 __blockdev_direct_IO() 호출
 */

/* iomap 기반 (현대 파일시스템: ext4, XFS, btrfs) */
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);

/* 1. iomap_begin()으로 파일 오프셋 → 블록 매핑 획득
 * 2. bio 생성: 사용자 페이지를 직접 bio_vec에 연결 (pin_user_pages)
 * 3. submit_bio()로 블록 계층에 제출
 * 4. 동기(IOCB_WAITQ) 또는 비동기(IOCB_NOWAIT) 완료 */
⚠️

정렬 요구사항: Direct I/O는 버퍼 주소, 오프셋, 크기가 모두 디바이스의 logical_block_size(보통 512B 또는 4KB)로 정렬되어야 합니다. 정렬되지 않은 요청은 -EINVAL을 반환합니다. io_uringIORING_OP_READ_FIXED는 등록된 버퍼를 사용하여 매번 pin/unpin 오버헤드를 제거합니다.

Uncached Buffered I/O (v6.14+)

커널 6.14에서 도입된 uncached buffered I/O는 Buffered I/O와 Direct I/O의 중간 형태입니다. 데이터를 Page Cache를 통해 기록하되, 기록 완료 후 해당 페이지를 캐시에 유지하지 않고 즉시 무효화합니다.

방식Page Cache 사용캐시 유지정렬 요구적합한 용도
Buffered I/OOO없음일반 파일 I/O
Direct I/OXX블록 정렬 필수DB, 대용량 순차 I/O
Uncached BufferedO (일시적)X없음스트리밍, 백업, 일회성 대량 쓰기
장점: Direct I/O의 정렬 제약 없이 캐시 오염을 방지합니다. 대규모 순차 쓰기(백업, 로그 아카이빙)에서 다른 워크로드의 Page Cache를 보호하면서도 정렬 걱정 없이 사용할 수 있습니다.

하드웨어 래핑 암호화 키 (v6.15+)

커널 6.15에서 블록 계층에 하드웨어 래핑 암호화 키(hardware-wrapped keys) 지원이 추가되었습니다. 인라인 암호화 엔진(ICE)을 갖춘 스토리지 컨트롤러에서, 암호화 키가 소프트웨어에 노출되지 않고 하드웨어 내부에서만 사용됩니다.

/*
 * 하드웨어 래핑 키 동작 흐름:
 *
 * 1. 사용자가 "래핑된 키 blob"을 제공
 * 2. 스토리지 컨트롤러의 키 매니저가 blob을 언래핑
 * 3. 실제 암호화 키는 하드웨어 내부에만 존재
 * 4. 소프트웨어(커널 포함)는 실제 키에 접근 불가
 *
 * → 커널 메모리 덤프/취약점으로도 암호화 키 유출 불가
 * → Android의 파일 기반 암호화(FBE)에서 활용
 */

I/O 배리어와 플러시

전원 손실 시 데이터 무결성을 보장하려면 디바이스의 쓰기 캐시에 있는 데이터가 영구 미디어에 기록되어야 합니다. Linux는 REQ_PREFLUSHREQ_FUA 플래그로 이를 제어합니다.

쓰기 순서 보장 플래그

플래그동작사용 예
REQ_PREFLUSH이 요청 전에 디바이스 캐시를 플러시저널 커밋 전 데이터 기록 보장
REQ_FUA이 쓰기를 디바이스 캐시를 거치지 않고 직접 미디어에 기록저널 커밋 블록 자체
REQ_PREFLUSH | REQ_FUA캐시 플러시 후 FUA 쓰기 (가장 강력한 보장)파일시스템 배리어 (fsync)
플래그 없음순서 보장 없음, 디바이스 캐시에 머물 수 있음일반 데이터 쓰기

사용 예제

/* 파일시스템 저널 커밋: PREFLUSH + FUA */
struct bio *bio = bio_alloc(bdev, 1,
                            REQ_OP_WRITE | REQ_PREFLUSH | REQ_FUA,
                            GFP_NOIO);
bio->bi_iter.bi_sector = journal_sector;
bio_add_page(bio, commit_page, 4096, 0);
bio->bi_end_io = journal_commit_endio;
submit_bio(bio);

/*
 * 실행 순서:
 * 1. 디바이스 쓰기 캐시 전체 플러시 (PREFLUSH)
 * 2. 커밋 블록 쓰기 (FUA: 캐시 무시, 직접 미디어 기록)
 * → 전원 손실 시에도 이전 데이터 + 커밋 블록 모두 영구 저장 보장
 */
Data Writes (캐시에 존재) PREFLUSH 캐시 → 미디어 FUA Write 직접 미디어 기록 Complete 데이터가 캐시에 축적 캐시 전체를 영구 기록 커밋 블록을 미디어에 직접 기록 완료 알림

블록 계층 데이터 무결성 (DIF/DIX)

디스크와 메모리 사이의 데이터 전송 경로에서 Silent Data Corruption(무음 데이터 손상)이 발생할 수 있습니다. 케이블 결함, 컨트롤러 버그, 메모리 비트 플립 등이 원인입니다. Linux 블록 계층은 T10 DIF/DIX 표준을 통해 end-to-end 데이터 무결성을 보장합니다.

T10 DIF (Data Integrity Field)

DIF는 512바이트 데이터 블록마다 8바이트 보호 정보(Protection Information, PI)를 추가합니다. 이 8바이트에는 CRC 체크섬, 논리 블록 주소 태그, 애플리케이션 태그가 포함됩니다.

/* include/linux/t10-pi.h — T10 보호 정보 구조 */
struct t10_pi_tuple {
    __be16 guard_tag;   /* CRC-16 체크섬 (데이터 무결성) */
    __be16 app_tag;     /* 애플리케이션 정의 태그 */
    __be32 ref_tag;     /* 논리 블록 주소 (참조 태그) */
};
/* 총 8바이트: 512B 데이터 + 8B PI = 520B/섹터 */

/* guard_tag 계산: CRC-16 또는 IP 체크섬 */
/* Type 1: ref_tag = 논리 블록 주소 (LBA) */
/* Type 2: ref_tag = 초기값 + 오프셋 (32비트 LBA) */
/* Type 3: ref_tag = 0xFFFFFFFF (검증 안 함) */

DIX (Data Integrity Extensions)

DIX는 DIF를 호스트-컨트롤러 인터페이스까지 확장합니다. DIF가 컨트롤러-디스크 구간만 보호하는 반면, DIX는 호스트 메모리부터 디스크까지 전체 경로를 보호합니다.

DIF / DIX 데이터 무결성 보호 범위 Application 사용자 공간 OS / FS 블록 계층 HBA 컨트롤러 Disk 영구 미디어 T10 DIF (HBA ↔ Disk) 컨트롤러가 PI 생성·검증, 디스크가 저장 DIX (OS ↔ HBA ↔ Disk) 블록 계층이 PI 생성, HBA·디스크가 검증 — end-to-end 보호
DIF는 HBA와 디스크 사이 구간만 보호하지만, DIX는 호스트의 블록 계층부터 디스크까지 전체 데이터 경로를 보호합니다.

커널 무결성 API

/* block/bio-integrity.c — bio 무결성 설정 */
struct bio_integrity_payload {
    struct bio        *bip_bio;     /* 소속 bio */
    struct bvec_iter   bip_iter;    /* PI 데이터 위치 */
    unsigned short     bip_vcnt;    /* PI bio_vec 수 */
    unsigned short     bip_flags;   /* BIP_* 플래그 */
    struct bio_vec    *bip_vec;     /* PI 데이터 bio_vec */
};

/* 파일시스템에서 무결성 활성화 */
if (bio_integrity_prep(bio)) {
    /* guard tag (CRC-16) 계산 및 PI 버퍼 할당 */
    /* bio에 bip(bio_integrity_payload) 첨부 */
    /* 쓰기: OS가 PI 생성 → HBA/디스크가 검증 */
    /* 읽기: 디스크/HBA가 PI 포함 전송 → OS가 검증 */
}

/* 디바이스 무결성 프로필 등록 (드라이버) */
static const struct blk_integrity_profile my_integrity = {
    .name             = "T10-DIF-TYPE1-CRC",
    .generate_fn      = t10_pi_type1_generate_crc,
    .verify_fn        = t10_pi_type1_verify_crc,
    .prepare_fn       = t10_pi_type1_prepare,
    .complete_fn      = t10_pi_type1_complete,
};

blk_integrity_register(disk, &my_integrity);
무결성 지원 확인:
# 디바이스의 무결성 지원 확인
$ cat /sys/block/sda/integrity/format
T10-DIF-TYPE1-CRC
$ cat /sys/block/sda/integrity/tag_size
8
$ cat /sys/block/sda/integrity/protection_interval_bytes
512

# 무결성 활성화된 파일시스템 마운트
$ mkfs.xfs -m crc=1 /dev/sda1
$ mount -o dioread_lock /dev/sda1 /mnt

블록 디바이스 토폴로지

블록 디바이스는 논리/물리 블록 크기, 정렬, 최적 I/O 크기 등의 토폴로지 정보를 커널에 보고합니다. 파일시스템과 블록 계층은 이 정보를 활용하여 I/O를 최적화합니다.

블록 크기

Discard / TRIM / Write Zeroes

SSD의 TRIM (REQ_OP_DISCARD)은 사용하지 않는 블록을 디바이스에 알려 가비지 컬렉션을 돕습니다. REQ_OP_WRITE_ZEROES는 하드웨어가 효율적으로 제로 블록을 기록합니다. 커널은 큐 한도에서 디바이스의 discard 지원 여부와 최대 크기를 확인합니다.

Zoned Block Devices (ZBC/ZAC)

SMR(Shingled Magnetic Recording) HDD와 ZNS(Zoned Namespace) SSD는 순차 쓰기 전용 존을 가집니다. 커널은 blk_revalidate_disk_zones()로 존 정보를 관리하며, 파일시스템(btrfs, f2fs, zonefs)이 존 특성에 맞춰 I/O를 발행합니다.

/* queue 한도 조회 API */
struct request_queue *q = bdev_get_queue(bdev);

unsigned int lb_size  = bdev_logical_block_size(bdev);    /* 512 or 4096 */
unsigned int pb_size  = bdev_physical_block_size(bdev);   /* 4096 (4Kn/512e) */
unsigned int io_opt   = bdev_io_opt(bdev);                /* RAID stripe 등 */
unsigned int max_disc = bdev_max_discard_sectors(bdev);    /* 최대 TRIM 크기 */
bool zoned            = bdev_is_zoned(bdev);              /* Zoned 디바이스? */
💡

4Kn vs 512e: 4Kn(4K native) 디바이스는 논리/물리 블록 모두 4KB입니다. 512e(512-byte emulation)는 논리 블록이 512B이지만 물리 블록은 4KB로, 정렬되지 않은 쓰기 시 Read-Modify-Write 패널티가 발생합니다. lsblk -t로 디바이스의 PHY-SEC/LOG-SEC를 확인할 수 있습니다.

NVMe (Non-Volatile Memory Express)

NVMe는 PCIe 직결 고속 저장 장치를 위한 프로토콜로, blk-mq의 가장 직접적인 사용자입니다. 수만 개의 병렬 I/O 큐와 μs 단위 지연을 실현합니다.

💡

NVMe에 대한 상세 내용은 NVMe 서브시스템 전용 페이지를 참고하세요. SQ/CQ 아키텍처, 커맨드 구조, Linux NVMe 드라이버, blk-mq 매핑, 네임스페이스, PRP/SGL, Multipath, NVMe-oF, ZNS, 성능 튜닝 등을 종합적으로 다룹니다.

블록 I/O 디버깅

블록 I/O 문제 진단을 위한 커널 도구와 사용법을 정리합니다.

blktrace / blkparse

blktrace는 블록 계층의 이벤트를 캡처하고, blkparse가 이를 사람이 읽을 수 있는 형태로 출력합니다.

# blktrace 시작 (10초간 /dev/sda 추적)
$ blktrace -d /dev/sda -w 10 -o trace

# 결과 파싱
$ blkparse -i trace -d trace.bin

# 출력 예시:
#  8,0  0  1  0.000000000  1234  Q  WS 2048 + 8 [dd]     ← Queue
#  8,0  0  2  0.000001234  1234  G  WS 2048 + 8 [dd]     ← Get request
#  8,0  0  3  0.000002345  1234  I  WS 2048 + 8 [dd]     ← Insert to scheduler
#  8,0  0  4  0.000003456  1234  D  WS 2048 + 8 [dd]     ← Dispatch to driver
#  8,0  0  5  0.000100000  0     C  WS 2048 + 8 [0]      ← Complete

# btt(Block Trace Timeline)로 통계 분석
$ btt -i trace.bin
# Q2C (전체 지연), D2C (디바이스 지연), Q2D (스케줄러 지연) 등

/sys/block/*/stat 통계

# 블록 디바이스 I/O 통계
$ cat /sys/block/sda/stat
#  read_ios read_merges read_sectors read_ticks
#  write_ios write_merges write_sectors write_ticks
#  in_flight io_ticks time_in_queue
#  discard_ios discard_merges discard_sectors discard_ticks
#  flush_ios flush_ticks

# 더 읽기 쉬운 형식:
$ iostat -x 1
# r/s, w/s, rkB/s, wkB/s, await, %util 등

blk-mq debugfs

# blk-mq 내부 상태 확인
$ ls /sys/kernel/debug/block/sda/
hctx0/  hctx1/  state  requeue_list  ...

# 하드웨어 큐 0의 상태
$ cat /sys/kernel/debug/block/sda/hctx0/tags
# nr_tags, nr_reserved_tags, active_queues 등

$ cat /sys/kernel/debug/block/sda/hctx0/dispatch
# 디스패치 대기 중인 request 목록

$ cat /sys/kernel/debug/block/sda/hctx0/cpu_list
# 이 HW 큐에 매핑된 CPU 목록

BPF 기반 도구

# biolatency: bio 지연 분포 히스토그램
$ biolatency -D
# disk = sda
#     usecs               : count     distribution
#         0 -> 1          : 0        |                    |
#         2 -> 3          : 0        |                    |
#         4 -> 7          : 12       |**                  |
#         8 -> 15         : 156      |**********************|
#        16 -> 31         : 89       |************         |

# biosnoop: 개별 bio 이벤트 추적
$ biosnoop
# TIME(s)  COMM     PID    DISK  T  SECTOR   BYTES  LAT(ms)
# 0.000    dd       1234   sda   W  2048     4096   0.12

# biotop: 프로세스별 블록 I/O 사용량 (top 형식)
$ biotop
# PID    COMM       D MAJ MIN  DISK   I/O    Kbytes  AVGms
# 1234   postgres   R 8   0    sda    45     180     0.85

cgroup v2 I/O 제어

Linux의 cgroup v2는 3개의 블록 I/O 컨트롤러를 제공하여, 컨테이너·서비스 단위로 I/O 자원을 격리하고 제어합니다. Docker, systemd, Kubernetes가 이를 활용합니다.

I/O 컨트롤러 비교

컨트롤러파일방식적합한 용도
io.maxio.max절대적 상한선 (IOPS, BPS)멀티테넌트 격리, QoS 보장
io.latencyio.latency지연 시간 목표 기반 스로틀링지연에 민감한 워크로드 보호
io.costio.cost.*비용 모델 기반 가중치 분배이기종 디바이스, 공정한 대역폭
io.bfq.weightio.bfq.weightBFQ 스케줄러 가중치BFQ 사용 시 프로세스 그룹별 공정성

io.max: 절대적 I/O 상한

io.max는 cgroup의 I/O를 IOPS 또는 바이트/초 단위로 제한합니다. 제한을 초과하면 I/O가 스로틀링(지연)됩니다.

# io.max 설정: 디바이스 8:0에 대해
# 읽기 1000 IOPS, 쓰기 500 IOPS, 읽기 10MB/s, 쓰기 5MB/s
$ echo "8:0 riops=1000 wiops=500 rbps=10485760 wbps=5242880" \
    > /sys/fs/cgroup/my_container/io.max

# 확인
$ cat /sys/fs/cgroup/my_container/io.max
8:0 rbps=10485760 wbps=5242880 riops=1000 wiops=500

# 제한 해제
$ echo "8:0 rbps=max wbps=max riops=max wiops=max" \
    > /sys/fs/cgroup/my_container/io.max

# systemd 서비스에서 설정
# /etc/systemd/system/my-service.service.d/io-limit.conf
[Service]
IOReadIOPSMax=/dev/sda 1000
IOWriteBandwidthMax=/dev/sda 10M

io.latency: 지연 시간 목표

io.latency는 cgroup의 I/O 지연 시간 목표를 설정합니다. 목표를 초과하면 다른 cgroup의 I/O를 스로틀링하여 보호합니다. io.max와 달리 자신을 제한하는 것이 아니라, 다른 그룹을 억제하여 자신의 지연을 보호하는 방식입니다.

# 데이터베이스 cgroup: 50ms 이하 지연 보장
$ echo "8:0 target=50000" > /sys/fs/cgroup/database/io.latency
# target 단위: 마이크로초 (50000 = 50ms)

# 백그라운드 cgroup: 200ms 지연 허용
$ echo "8:0 target=200000" > /sys/fs/cgroup/backup/io.latency

# 효과: backup의 I/O가 database의 지연을 50ms 이상 올리면
#       backup이 자동으로 스로틀링됨

io.cost: 비용 모델 기반 제어

io.cost (blk-iocost)는 디바이스별 비용 모델을 정의하여, 순차/랜덤, 읽기/쓰기의 실제 비용 차이를 반영한 공정한 대역폭 분배를 제공합니다. io.max의 단순 카운팅보다 훨씬 정밀합니다.

# io.cost 가중치 설정 (기본 100)
$ echo "8:0 100" > /sys/fs/cgroup/webserver/io.cost.weight
$ echo "8:0 50" > /sys/fs/cgroup/logger/io.cost.weight
# webserver:logger = 2:1 비율로 대역폭 분배

# 디바이스 비용 모델 파라미터 (자동 또는 수동 설정)
$ cat /sys/fs/cgroup/io.cost.model
8:0 ctrl=auto model=linear \
    rbps=2706339840 rseqiops=389828 rrandiops=404044 \
    wbps=1257561088 wseqiops=135498 wrandiops=134674

# 자동 QoS 파라미터
$ cat /sys/fs/cgroup/io.cost.qos
8:0 enable=1 ctrl=auto \
    rpct=95.00 rlat=2500 wpct=95.00 wlat=5000 \
    min=50.00 max=150.00
cgroup v2 I/O 제어 계층 root cgroup database io.cost.weight = 200 io.latency = 50ms webserver io.cost.weight = 100 io.max = 5000 IOPS backup io.cost.weight = 50 io.latency = 200ms blk-throttle / blk-iolatency / blk-iocost NVMe / SCSI Device
cgroup v2의 I/O 컨트롤러는 계층적으로 동작합니다. 각 cgroup은 독립적으로 io.max, io.latency, io.cost 정책을 설정할 수 있으며, 블록 계층이 이를 집행합니다.
systemd와 cgroup I/O 제어:
# systemd 서비스별 I/O 제한
$ systemctl set-property my-service.service \
    IOWeight=200 \
    IOReadBandwidthMax="/dev/nvme0n1 100M" \
    IOWriteBandwidthMax="/dev/nvme0n1 50M" \
    IOReadIOPSMax="/dev/nvme0n1 10000"

# I/O 통계 확인
$ cat /sys/fs/cgroup/system.slice/my-service.service/io.stat
8:0 rbytes=1234567 wbytes=890123 rios=456 wios=789 \
    dbytes=0 dios=0

에러 처리와 타임아웃

블록 I/O 에러는 미디어 결함, 링크 장애, 디바이스 리셋 등 다양한 원인으로 발생합니다. Linux 블록 계층은 blk_status_t 타입과 타임아웃 메커니즘으로 에러를 체계적으로 처리합니다.

blk_status_t 에러 코드

blk_status_terrno설명복구 가능성
BLK_STS_OK0성공-
BLK_STS_NOTSUPP-EOPNOTSUPP지원하지 않는 연산 (예: TRIM 미지원 디바이스)재시도 불필요
BLK_STS_TIMEOUT-ETIMEDOUTI/O 타임아웃리셋 후 재시도
BLK_STS_NOSPC-ENOSPC디바이스 공간 부족 (thin provision)공간 확보 후
BLK_STS_TRANSPORT-ENOLINK전송 계층 에러 (SCSI/NVMe 링크)경로 전환 가능
BLK_STS_TARGET-EREMOTEIO디바이스 대상 에러디바이스 의존
BLK_STS_MEDIUM-ENODATA미디어 에러 (배드 섹터)해당 영역 불가
BLK_STS_PROTECTION-EILSEQ무결성 검증 실패 (DIF/DIX)재시도 가능
BLK_STS_RESOURCE-ENOMEM자원 부족 (태그, 메모리)잠시 후 재시도
BLK_STS_AGAIN-EAGAIN일시적 실패 (디바이스 바쁨)즉시 재시도
BLK_STS_IOERR-EIO일반 I/O 에러상황 의존

타임아웃 메커니즘

/* blk-mq 타임아웃 처리 흐름 */
/*
 * 1. blk_mq_start_request()에서 request.deadline 설정
 *    deadline = jiffies + q->rq_timeout
 *
 * 2. blk_mq_timeout_work()가 주기적으로 in-flight request 검사
 *    (per-HW-ctx 타이머)
 *
 * 3. deadline 초과 시 드라이버의 timeout 콜백 호출:
 */
static enum blk_eh_timer_return
my_timeout(struct request *rq)
{
    struct my_cmd *cmd = blk_mq_rq_to_pdu(rq);

    /* 옵션 1: 드라이버가 직접 에러 완료 처리 */
    if (try_abort_command(cmd)) {
        blk_mq_end_request(rq, BLK_STS_TIMEOUT);
        return BLK_EH_DONE;  /* 완료 처리 완료 */
    }

    /* 옵션 2: 타이머만 연장 (디바이스 바쁨) */
    return BLK_EH_RESET_TIMER;

    /* 옵션 3: 컨트롤러 리셋 후 모든 명령 재제출
     * → 드라이버가 blk_mq_complete_request()로 모든 in-flight 완료
     * → BLK_EH_DONE 반환 */
}

/* 타임아웃 값 설정 */
/* SCSI: /sys/block/sda/device/timeout (기본 30초) */
/* NVMe: /sys/module/nvme_core/parameters/io_timeout (기본 30초) */

SCSI 에러 복구 (EH)

SCSI 서브시스템은 계층적 에러 복구를 수행합니다:

  1. 명령 재시도: 같은 명령을 다시 전송 (scsi_retry_command())
  2. 명령 abort: 타임아웃된 명령을 중단 (scsi_abort_eh_cmnd())
  3. 디바이스 리셋: LUN 레벨 리셋 (scsi_eh_device_reset())
  4. 타겟 리셋: 타겟(포트) 레벨 리셋
  5. 버스 리셋: SCSI 버스 전체 리셋
  6. 호스트 리셋: HBA 전체 리셋 (최후 수단)
# SCSI 에러 복구 관련 sysfs
$ cat /sys/block/sda/device/timeout
30  # 초
$ echo 60 > /sys/block/sda/device/timeout  # 느린 디바이스에 적합

# 에러 핸들링 정책
$ cat /sys/block/sda/device/eh_timeout
10  # EH 명령 타임아웃 (초)

# 재시도 횟수 (커널 기본: 5회)
# dmesg에서 에러 로그 확인
$ dmesg | grep -i "scsi.*error"
[12345.678] sd 0:0:0:0: [sda] tag#42 FAILED Result: hostbyte=DID_OK driverbyte=DRIVER_SENSE
[12345.678] sd 0:0:0:0: [sda] Sense Key : Medium Error [current]
[12345.678] sd 0:0:0:0: [sda] Add. Sense: Unrecovered read error

실전 성능 튜닝 가이드

블록 I/O 성능을 최적화하기 위한 핵심 sysfs 파라미터, 벤치마크 방법론, 워크로드별 최적 설정을 체계적으로 정리합니다.

핵심 sysfs 튜닝 파라미터

파라미터경로 (sysfs)기본값설명튜닝 방향
nr_requestsqueue/nr_requests256SW 큐당 최대 request 수높이면 throughput ↑, 지연 ↑
max_sectors_kbqueue/max_sectors_kb512단일 request 최대 크기 (KB)순차 I/O 시 높이면 효율 ↑
read_ahead_kbqueue/read_ahead_kb128순차 읽기 선행 크기 (KB)순차 읽기 시 높이면 throughput ↑
nomergesqueue/nomerges0병합 비활성화 (0/1/2)벤치마크 시 2로 설정
rq_affinityqueue/rq_affinity1완료 CPU 선택 (0/1/2)2=제출 CPU 강제, NUMA 최적
io_pollqueue/io_poll0폴링 모드 (0/1/2)1=폴링, 2=하이브리드
wbt_lat_usecqueue/wbt_lat_usec75000Write-back 스로틀 지연 목표 (μs)낮추면 쓰기 지연 감소
rotationalqueue/rotational자동회전 디스크 여부 (0/1)잘못 감지된 SSD에 0 설정
add_randomqueue/add_random0I/O 이벤트를 엔트로피 풀에 추가고성능 시 0 (오버헤드 제거)

워크로드별 튜닝 프로파일

# ===== 1. 데이터베이스 서버 (NVMe SSD) =====
DEV=nvme0n1
# 스케줄러: none (NVMe는 자체 스케줄링)
echo none > /sys/block/$DEV/queue/scheduler
# 완료 CPU: 제출 CPU 강제 (NUMA 최적화)
echo 2 > /sys/block/$DEV/queue/rq_affinity
# 읽기 선행: 데이터베이스는 자체 캐시 사용, 커널 선행 최소화
echo 32 > /sys/block/$DEV/queue/read_ahead_kb
# 쓰기 스로틀 비활성 (DB가 자체 제어)
echo 0 > /sys/block/$DEV/queue/wbt_lat_usec

# ===== 2. 파일 서버 (SATA HDD) =====
DEV=sda
# 스케줄러: mq-deadline (seek 최적화)
echo mq-deadline > /sys/block/$DEV/queue/scheduler
# 읽기 데드라인 짧게 (읽기 응답성)
echo 200 > /sys/block/$DEV/queue/iosched/read_expire
# 읽기 선행 크게 (순차 읽기 많은 파일 서버)
echo 4096 > /sys/block/$DEV/queue/read_ahead_kb
# max_sectors_kb 최대로 (대형 전송 효율)
echo 1024 > /sys/block/$DEV/queue/max_sectors_kb

# ===== 3. 가상화 호스트 (virtio-blk) =====
DEV=vda
# 스케줄러: none (호스트가 스케줄링)
echo none > /sys/block/$DEV/queue/scheduler
# 엔트로피 기여 비활성 (불필요한 오버헤드)
echo 0 > /sys/block/$DEV/queue/add_random
# 큐 깊이 확대 (가상화 배치 최적화)
echo 1024 > /sys/block/$DEV/queue/nr_requests

# ===== 4. 스트리밍 / 백업 (대용량 순차 쓰기) =====
DEV=sdb
echo 8192 > /sys/block/$DEV/queue/read_ahead_kb
echo 2048 > /sys/block/$DEV/queue/max_sectors_kb
# 쓰기 스로틀 지연 늘리기 (throughput 우선)
echo 200000 > /sys/block/$DEV/queue/wbt_lat_usec

fio 벤치마크 패턴

# ===== fio 벤치마크 기본 패턴 =====

# 1. 순차 읽기 throughput (MB/s)
$ fio --name=seq_read --rw=read --bs=1M --size=10G \
    --direct=1 --numjobs=4 --iodepth=32 \
    --ioengine=io_uring --filename=/dev/nvme0n1

# 2. 랜덤 읽기 IOPS
$ fio --name=rand_read --rw=randread --bs=4K --size=10G \
    --direct=1 --numjobs=8 --iodepth=64 \
    --ioengine=io_uring --filename=/dev/nvme0n1

# 3. 혼합 워크로드 (읽기 70%, 쓰기 30%)
$ fio --name=mixed --rw=randrw --rwmixread=70 \
    --bs=4K --size=10G --direct=1 \
    --numjobs=8 --iodepth=32 \
    --ioengine=io_uring --filename=/dev/nvme0n1

# 4. 지연 시간 측정 (p99 latency)
$ fio --name=lat_test --rw=randread --bs=4K --size=1G \
    --direct=1 --numjobs=1 --iodepth=1 \
    --ioengine=io_uring --lat_percentiles=1 \
    --percentile_list=50:90:95:99:99.9:99.99 \
    --filename=/dev/nvme0n1

# 5. io_uring 폴링 모드 성능 비교
$ fio --name=poll_test --rw=randread --bs=512 --size=1G \
    --direct=1 --numjobs=1 --iodepth=1 \
    --ioengine=io_uring --hipri=1 --sqthread_poll=1 \
    --filename=/dev/nvme0n1

Write-Back Throttling (WBT)

WBT(wbt_lat_usec)는 buffered write가 read 지연을 과도하게 증가시키지 않도록 쓰기 동시성을 자동으로 제한합니다. 쓰기가 많은 워크로드에서 읽기 tail latency가 급증하는 문제를 해결합니다.

/* block/blk-wbt.c — Write-Back Throttling 원리 */
/*
 * 1. wbt_lat_usec (기본 75ms) 목표 설정
 * 2. 최근 읽기 지연의 이동 평균 계산
 * 3. 읽기 지연 > 목표 → write_inflight 제한 감소
 * 4. 읽기 지연 < 목표 → write_inflight 제한 증가
 *
 * 효과: 대량 쓰기 중에도 읽기 p99 지연을 목표 이하로 유지
 */

/* WBT 상태 확인 */
/* /sys/block/sda/queue/wbt_lat_usec — 목표 지연 */
/* /sys/kernel/debug/block/sda/rqos/wbt/inflight — 현재 in-flight 쓰기 수 */
/* /sys/kernel/debug/block/sda/rqos/wbt/limit — 현재 쓰기 동시성 제한 */
WBT와 cgroup 스로틀링 충돌: WBT와 io.latency/io.cost를 동시에 사용하면 이중 스로틀링이 발생할 수 있습니다. cgroup I/O 제어를 사용하는 경우 wbt_lat_usec=0으로 WBT를 비활성화하는 것이 권장됩니다.

흔한 실수와 안티패턴

실수증상해결법
NVMe에 mq-deadline 스케줄러 사용 IOPS 30~50% 감소, CPU 사용량 증가 echo none > /sys/block/nvme0n1/queue/scheduler
O_DIRECT에서 정렬 미준수 read()/write()-EINVAL 반환 버퍼를 posix_memalign()으로 할당, 크기·오프셋을 블록 크기 배수로
fsync 없이 데이터 무결성 가정 전원 손실 시 데이터 손실 커밋 시점마다 fsync() 또는 fdatasync() 호출
과도한 sync I/O (매 write마다 fsync) 극단적 성능 저하, 디바이스 수명 감소 배치 커밋, 또는 O_DSYNC/RWF_DSYNC 사용
512e SSD에서 정렬 안 된 파티션 물리 블록 경계 미정렬 → Read-Modify-Write 패널티 parted -a optimal 사용, lsblk -t로 PHY-SEC 확인
HDD에서 큐 깊이 과다 설정 seek 폭주로 throughput 오히려 감소 HDD의 nr_requests는 32~128 범위가 적정
bio를 submit 후 재사용 use-after-free, 커널 패닉 submit_bio() 후 bio 소유권은 블록 계층에 이전됨. 재사용 금지
blk_mq_end_request() 이중 호출 태그 이중 해제, 메모리 오염 request 당 정확히 한 번만 호출. 타임아웃과 정상 완료 경로의 경합 주의
TRIM/discard를 security erase로 오인 민감한 데이터 잔존 REQ_OP_DISCARD는 데이터 삭제를 보장하지 않음. REQ_OP_SECURE_ERASE 또는 blkdiscard -s 사용
read_ahead_kb가 0인 데이터베이스 순차 스캔 시 극심한 성능 저하 DB의 테이블 풀 스캔이 필요한 경우 적절한 read_ahead (128~512KB) 유지

성능 문제 진단 체크리스트

# === 블록 I/O 성능 문제 진단 체크리스트 ===

# 1. 현재 I/O 상태 확인
$ iostat -xz 1 5
# 확인: %util, await, r_await, w_await, avgqu-sz
#   %util > 95%: 디바이스 포화 (HDD) 또는 큐 포화 (SSD)
#   await > 10ms (NVMe): 비정상적 지연
#   avgqu-sz > nr_requests: 큐 가득 참

# 2. 스케줄러 확인
$ cat /sys/block/*/queue/scheduler
# NVMe인데 mq-deadline이면 none으로 변경

# 3. 큐 깊이 확인
$ cat /sys/block/nvme0n1/queue/nr_requests
$ cat /sys/block/nvme0n1/queue/nr_hw_queues
# NVMe: nr_hw_queues가 CPU 수에 근접해야 함

# 4. 병합 효율 확인
$ iostat -x 1 | awk '/nvme/{print "rrqm/s="$4, "wrqm/s="$5}'
# 순차 워크로드인데 병합 0이면 nomerges 확인

# 5. I/O 지연 분포 (BPF)
$ biolatency -D 5
# p99가 비정상적이면 스로틀링/WBT/cgroup 확인

# 6. 프로세스별 I/O 추적
$ biotop 5
# 어떤 프로세스가 I/O를 독점하는지 확인

# 7. blktrace로 상세 추적
$ blktrace -d /dev/nvme0n1 -w 5 -o trace && blkparse -i trace
# Q→G→I→D→C 각 단계의 소요 시간 분석

# 8. NUMA 확인 (다중 소켓)
$ numactl --hardware
$ cat /sys/block/nvme0n1/device/numa_node
# NVMe 디바이스와 다른 NUMA 노드에서 I/O 시 지연 증가

Block I/O와 관련된 다른 주제를 더 깊이 이해하고 싶다면 다음 문서를 참고하세요.