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 기반 병목 진단까지 실무 튜닝에 필요한 핵심 내용을 체계적으로 다룹니다.
핵심 요약
- 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 — 페이지 캐시를 우회하여 디바이스에 직접 읽기/쓰기합니다.
단계별 이해
- I/O 경로 파악 — 애플리케이션 → VFS → 페이지 캐시 → Block Layer → 디바이스 드라이버 → 하드웨어.
Block Layer는 VFS와 드라이버 사이에서 I/O 요청을 관리합니다.
- bio 이해 — 파일시스템이
submit_bio()로 bio를 블록 계층에 제출합니다.각 bio는 디스크의 시작 섹터, 크기, 방향(읽기/쓰기), 메모리 페이지를 포함합니다.
- blk-mq 이해 — 하드웨어 큐와 소프트웨어 큐의 매핑을 관리합니다.
cat /sys/block/nvme0n1/queue/nr_requests로 큐 깊이를 확인합니다. - 모니터링 —
iostat -x 1로 디바이스별 I/O 통계를 실시간 모니터링합니다.blktrace/blkparse로 I/O 요청의 상세 흐름을 추적할 수 있습니다.
Block I/O 계층 개요
블록 I/O 계층(Block Layer)은 VFS/Page Cache와 물리 디바이스 드라이버 사이에 위치하며, 디스크, SSD, NVMe 등 블록 디바이스에 대한 I/O 요청을 관리합니다. 상위 계층이 논리적 블록 단위로 데이터를 요청하면, 블록 계층이 이를 최적화하여 하드웨어에 전달합니다.
블록 계층의 역할
- I/O 요청 표현:
struct bio를 통해 디스크 영역과 메모리 페이지의 매핑을 기술 - 요청 병합: 인접한 I/O를 하나의
struct request로 통합하여 디바이스 효율을 극대화 - I/O 스케줄링: 디바이스 특성에 맞는 전략으로 요청 순서를 조정
- 멀티큐 디스패치: blk-mq를 통해 멀티코어 환경에서 병렬 I/O 처리
- 디바이스 추상화: HDD, SSD, NVMe, 스택 디바이스(md, dm) 등을 통일된 인터페이스로 지원
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를 사용합니다.
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_READ | 0 | 블록 읽기 |
REQ_OP_WRITE | 1 | 블록 쓰기 |
REQ_OP_FLUSH | 2 | 디바이스 캐시 플러시 |
REQ_OP_DISCARD | 3 | 블록 무효화 (TRIM) |
REQ_OP_SECURE_ERASE | 5 | 보안 삭제 |
REQ_OP_WRITE_ZEROES | 9 | 제로 블록 쓰기 (하드웨어 최적화) |
추가 플래그는 비트 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 순회: 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(): 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;
}
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 호출 */
}
바운스 버퍼 (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;
}
멀티페이지 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)
- Back merge: 새 bio의 시작 섹터가 기존 request의 끝 섹터와 연속 → 뒤에 추가
- Front merge: 새 bio의 끝 섹터가 기존 request의 시작 섹터와 연속 → 앞에 추가
- Merge with next: 병합 후 인접한 request끼리 다시 결합
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_segments | scatter-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);
};
내부 요청 플래그 (rq_flags)
| 플래그 (RQF_*) | 설명 |
|---|---|
RQF_STARTED | blk_mq_start_request() 호출됨, 타임아웃 타이머 활성 |
RQF_FLUSH_SEQ | flush 시퀀스의 일부 (PREFLUSH → DATA → POSTFLUSH) |
RQF_MIXED_MERGE | 서로 다른 플래그의 bio가 병합된 request |
RQF_MQ_INFLIGHT | in-flight 카운팅 중 (통계 추적용) |
RQF_SPECIAL_PAYLOAD | discard 등 특수 페이로드를 포함 |
RQF_ZONE_WRITE_PLUGGING | Zoned 디바이스의 순차 쓰기 플러깅 중 |
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() 흐름
submit_bio(bio)→ 제출 회계(accounting) 시작submit_bio_noacct(bio)→ 스택 디바이스(dm, md) 재진입 시 직접 호출__submit_bio()→ 드라이버의submit_bio콜백 또는 기본blk_mq_submit_bio()- bio가 큐 한도(
max_sectors등)를 초과하면bio_split()으로 분할 후 재제출 - plug된 상태면
blk_mq_plug_issue_direct()/blk_add_rq_to_plug()으로 보류 - unplug 시 또는 직접 디스패치 시 하드웨어 큐로 전달
스택 디바이스 (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를 달성할 수 있게 합니다.
아키텍처
- Software Queue (ctx): per-CPU 큐. 각 CPU가 자신의 큐에 request를 넣어 락 경합을 제거
- Hardware Queue (hctx): 디바이스의 실제 제출 큐에 1:1 또는 N:1로 매핑
- Tag: 각 in-flight request에 고유 번호를 부여하여 완료 시 식별. NVMe command ID와 직접 매핑
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_MERGE | request 병합 활성화 (대부분의 디바이스) |
BLK_MQ_F_TAG_QUEUE_SHARED | 여러 장치가 tag set 공유 (SCSI HBA 등) |
BLK_MQ_F_BLOCKING | queue_rq에서 sleep 허용 (스케줄링 기반 동기 I/O) |
BLK_MQ_F_NO_SCHED | I/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별 워드 분리로 캐시라인 경합 최소화 */
}
공유 태그 (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, ¶ms);
/* 커널 내부: 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)
io_uring SQPOLL과 블록 I/O
io_uring의 SQPOLL 모드는 커널 스레드가 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 이하 지연 달성
*/
IORING_SETUP_SQ_AFF와 params.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, 가상 디바이스 | 없음 | 디바이스 자체 스케줄링에 위임 |
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를 선택합니다:
- 만료 확인: 읽기 FIFO 큐의 head가 데드라인을 초과했으면 즉시 디스패치 (읽기 우선)
- 쓰기 기아 방지: 읽기가
writes_starved회 연속 선택되면 쓰기를 강제 디스패치 - 쓰기 만료 확인: 쓰기 FIFO 큐의 head가 데드라인을 초과했으면 디스패치
- 정렬 순서: 만료된 요청이 없으면, 현재 방향의 정렬 큐에서 다음 섹터를 선택 (seek 최소화)
- 배치 제한: 한 방향으로
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_expire | queue/iosched/read_expire | 500 (ms) | 읽기 요청 최대 지연. 이 시간 내에 반드시 디스패치 |
write_expire | queue/iosched/write_expire | 5000 (ms) | 쓰기 요청 최대 지연 |
fifo_batch | queue/iosched/fifo_batch | 16 | 한 방향(읽기 또는 쓰기)에서 연속 디스패치 최대 수. 값이 크면 throughput 증가, seek이 많아질 수 있음 |
writes_starved | queue/iosched/writes_starved | 2 | 쓰기 양보 횟수. 읽기가 이 횟수만큼 연속 선택되면 쓰기를 강제 |
front_merges | queue/iosched/front_merges | 1 | 앞쪽 병합(front merge) 허용. HDD에서는 1, 일부 SSD에서는 0이 유리 |
prio_aging_expire | queue/iosched/prio_aging_expire | 10000 (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)이 할당됩니다. 예산은 해당 큐가 한 번에 디스패치할 수 있는 최대 섹터 수입니다.
- 예산 할당: 큐가 활성화되면 디바이스의 추정 처리율과 가중치에 비례하여 예산을 받음
- 독점 서비스: 예산이 있는 동안 해당 큐가 디스패치를 독점 (idling 포함)
- 예산 소진: 예산을 모두 사용하거나 유휴 타임아웃 발생 시 다음 큐로 전환
- 가상 시간: 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) 동안 대기합니다. 이는 두 가지 목적이 있습니다:
- 순차 접근 보호: 프로세스가 순차 읽기를 하는 경우, 다음 요청이 곧 도착할 것이므로 대기. 다른 프로세스의 랜덤 I/O가 끼어들면 seek이 발생하여 순차 성능이 급감
- 공정성 보장: 동기 I/O를 수행하는 프로세스는 이전 요청 완료 후 다음 요청을 생성하므로, 비동기 대량 I/O와 경쟁 시 기아 발생. idling이 이를 방지
BFQ의 오버헤드: 프로세스별 큐 관리, weight raising 추적, B-WF²Q+ 가상 시간 계산 등으로 인해 고속 NVMe 디바이스에서는 CPU 오버헤드가 병목이 될 수 있습니다. NVMe에서 100만 IOPS 이상을 처리해야 하는 경우 none 또는 kyber를 권장합니다.
BFQ 튜닝 파라미터
| 파라미터 | 경로 (sysfs) | 기본값 | 설명 |
|---|---|---|---|
slice_idle | queue/iosched/slice_idle | 8 (ms) | 큐 비어도 대기하는 시간. 0이면 idling 비활성. SSD에서는 0이 유리할 수 있음 |
slice_idle_us | queue/iosched/slice_idle_us | 8000 (us) | slice_idle의 마이크로초 버전 (정밀 제어) |
low_latency | queue/iosched/low_latency | 1 | 대화형 감지(weight raising) 활성화. 0이면 순수 공정 큐잉만 사용 |
timeout_sync | queue/iosched/timeout_sync | 124 (ms) | 동기 큐의 서비스 타임아웃 |
max_budget | queue/iosched/max_budget | 0 (자동) | 큐당 최대 예산. 0이면 디바이스 처리율 기반 자동 계산 |
strict_guarantees | queue/iosched/strict_guarantees | 0 | 1이면 엄격한 대역폭 보장 (추가 오버헤드 발생) |
# 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가 대기 큐에서 기다립니다.
- 토큰 획득: request가 디스패치되려면 해당 도메인(읽기/쓰기)의 토큰을 획득해야 함
- 지연 모니터링: 완료된 request의 실제 지연을 per-CPU 히스토그램에 기록
- 토큰 조절: 주기적으로(timer callback) 히스토그램을 분석하여 목표 지연을 초과한 비율이 높으면 토큰 수를 감소, 낮으면 증가
- 도메인 분리: 읽기와 쓰기의 토큰 풀이 독립이므로, 대량 쓰기가 읽기 지연에 미치는 영향을 최소화
/* 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_nsec | queue/iosched/read_lat_nsec | 2000000 (2ms) | 읽기 지연 목표. 이 값을 기준으로 동시 I/O 수를 자동 조절 |
write_lat_nsec | queue/iosched/write_lat_nsec | 10000000 (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이 최적인 경우:
- NVMe SSD: 하드웨어 큐 다수 보유, 내부 FTL이 최적화 수행
- 가상 디바이스: virtio-blk, Xen blkfront 등 호스트가 이미 스케줄링
- 스택 디바이스: dm, md 등 하위 디바이스에서 스케줄링 수행
- 극한 IOPS: 소프트웨어 오버헤드를 최소화해야 하는 경우
스케줄러 자동 선택과 설정 가이드
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) | 1 | mq-deadline | mq-deadline | seek 비용 높음, 정렬+데드라인 필요 |
| SATA SSD | 1 | mq-deadline | mq-deadline / bfq | 단일 큐이므로 소프트웨어 스케줄링 유효 |
| NVMe SSD | 다수 | none | none / kyber | HW 큐 충분, 소프트웨어 오버헤드 불필요 |
| 데스크톱 (HDD) | 1 | mq-deadline | bfq | 대화형 응답성 중요 |
| virtio-blk | 다수 | none | none | 호스트 측에서 스케줄링 |
| NVMe (혼합 워크로드) | 다수 | none | kyber | 읽기 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_RT | 1 | 0~7 (0=최고) | 실시간. 다른 모든 I/O보다 먼저 처리. root만 설정 가능 |
IOPRIO_CLASS_BE | 2 | 0~7 (0=최고) | Best-effort. 기본값. nice 값 기반으로 자동 매핑 |
IOPRIO_CLASS_IDLE | 3 | N/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를 수행합니다.
트레이드오프
- Buffered I/O: 읽기 캐싱으로 반복 접근 시 매우 빠름, 쓰기 병합으로 디바이스 효율 극대화. 단, 이중 복사(user ↔ page cache ↔ device)와 예측 불가 지연(dirty writeback) 존재
- Direct I/O: 예측 가능한 지연, 메모리 절약(대용량 데이터). 단, 랜덤 접근 시 캐시 미스, 정렬 요구사항 엄격. 데이터베이스(자체 캐시 보유), 대규모 순차 I/O에 적합
커널 내부 경로
/* 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_uring의 IORING_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/O | O | O | 없음 | 일반 파일 I/O |
| Direct I/O | X | X | 블록 정렬 필수 | DB, 대용량 순차 I/O |
| Uncached Buffered | O (일시적) | X | 없음 | 스트리밍, 백업, 일회성 대량 쓰기 |
하드웨어 래핑 암호화 키 (v6.15+)
커널 6.15에서 블록 계층에 하드웨어 래핑 암호화 키(hardware-wrapped keys) 지원이 추가되었습니다. 인라인 암호화 엔진(ICE)을 갖춘 스토리지 컨트롤러에서, 암호화 키가 소프트웨어에 노출되지 않고 하드웨어 내부에서만 사용됩니다.
/*
* 하드웨어 래핑 키 동작 흐름:
*
* 1. 사용자가 "래핑된 키 blob"을 제공
* 2. 스토리지 컨트롤러의 키 매니저가 blob을 언래핑
* 3. 실제 암호화 키는 하드웨어 내부에만 존재
* 4. 소프트웨어(커널 포함)는 실제 키에 접근 불가
*
* → 커널 메모리 덤프/취약점으로도 암호화 키 유출 불가
* → Android의 파일 기반 암호화(FBE)에서 활용
*/
I/O 배리어와 플러시
전원 손실 시 데이터 무결성을 보장하려면 디바이스의 쓰기 캐시에 있는 데이터가 영구 미디어에 기록되어야 합니다. Linux는 REQ_PREFLUSH와 REQ_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: 캐시 무시, 직접 미디어 기록)
* → 전원 손실 시에도 이전 데이터 + 커밋 블록 모두 영구 저장 보장
*/
블록 계층 데이터 무결성 (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는 호스트 메모리부터 디스크까지 전체 경로를 보호합니다.
커널 무결성 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를 최적화합니다.
블록 크기
- Logical Block Size: 디바이스가 주소 지정하는 최소 단위 (512B 또는 4KB). 이보다 작은 I/O는 불가
- Physical Block Size: 디바이스 내부의 실제 쓰기 단위. 논리 블록보다 클 수 있음 (512e: logical 512B, physical 4KB)
- Optimal I/O Size: RAID stripe 크기 등 최적 전송 단위
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.max | io.max | 절대적 상한선 (IOPS, BPS) | 멀티테넌트 격리, QoS 보장 |
| io.latency | io.latency | 지연 시간 목표 기반 스로틀링 | 지연에 민감한 워크로드 보호 |
| io.cost | io.cost.* | 비용 모델 기반 가중치 분배 | 이기종 디바이스, 공정한 대역폭 |
| io.bfq.weight | io.bfq.weight | BFQ 스케줄러 가중치 | 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
# 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_t | errno | 설명 | 복구 가능성 |
|---|---|---|---|
BLK_STS_OK | 0 | 성공 | - |
BLK_STS_NOTSUPP | -EOPNOTSUPP | 지원하지 않는 연산 (예: TRIM 미지원 디바이스) | 재시도 불필요 |
BLK_STS_TIMEOUT | -ETIMEDOUT | I/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 서브시스템은 계층적 에러 복구를 수행합니다:
- 명령 재시도: 같은 명령을 다시 전송 (
scsi_retry_command()) - 명령 abort: 타임아웃된 명령을 중단 (
scsi_abort_eh_cmnd()) - 디바이스 리셋: LUN 레벨 리셋 (
scsi_eh_device_reset()) - 타겟 리셋: 타겟(포트) 레벨 리셋
- 버스 리셋: SCSI 버스 전체 리셋
- 호스트 리셋: 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_requests | queue/nr_requests | 256 | SW 큐당 최대 request 수 | 높이면 throughput ↑, 지연 ↑ |
max_sectors_kb | queue/max_sectors_kb | 512 | 단일 request 최대 크기 (KB) | 순차 I/O 시 높이면 효율 ↑ |
read_ahead_kb | queue/read_ahead_kb | 128 | 순차 읽기 선행 크기 (KB) | 순차 읽기 시 높이면 throughput ↑ |
nomerges | queue/nomerges | 0 | 병합 비활성화 (0/1/2) | 벤치마크 시 2로 설정 |
rq_affinity | queue/rq_affinity | 1 | 완료 CPU 선택 (0/1/2) | 2=제출 CPU 강제, NUMA 최적 |
io_poll | queue/io_poll | 0 | 폴링 모드 (0/1/2) | 1=폴링, 2=하이브리드 |
wbt_lat_usec | queue/wbt_lat_usec | 75000 | Write-back 스로틀 지연 목표 (μs) | 낮추면 쓰기 지연 감소 |
rotational | queue/rotational | 자동 | 회전 디스크 여부 (0/1) | 잘못 감지된 SSD에 0 설정 |
add_random | queue/add_random | 0 | I/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 — 현재 쓰기 동시성 제한 */
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와 관련된 다른 주제를 더 깊이 이해하고 싶다면 다음 문서를 참고하세요.