Block I/O 서브시스템
Linux 커널의 블록 I/O 계층을 사용자 공간(User Space)의 read/write 요청부터 스토리지 디바이스 완료 인터럽트(Interrupt)까지 end-to-end 관점으로 심층 분석합니다. VFS/Page Cache와 디바이스 드라이버 사이에서 요청이 bio로 분해되고 request로 병합되는 경로, blk-mq 멀티큐와 CPU/하드웨어 큐 매핑(Mapping), I/O 스케줄러(Scheduler)의 지연(Latency)시간-처리량(Throughput) 트레이드오프, Direct I/O와 Buffered I/O 선택 기준, flush/fua/barrier 기반 데이터 무결성(Integrity) 보장, 블록 토폴로지(Topology)(정렬 단위·물리 섹터·discard) 해석, tracepoint/blktrace/bpftrace 기반 병목(Bottleneck) 진단까지 실무 튜닝에 필요한 핵심 내용을 체계적으로 다룹니다.
핵심 요약
- bio — 블록 I/O의 기본 단위. 디스크 섹터와 메모리 페이지(Page)의 매핑을 기술합니다.
- blk-mq — Multi-Queue Block Layer. NVMe 등 고속 디바이스를 위한 멀티큐 아키텍처입니다.
- I/O 스케줄러 — mq-deadline, BFQ, kyber 등. I/O 요청 순서를 최적화합니다.
- request — 인접한 bio를 병합한 I/O 요청. 디바이스 드라이버에 전달되는 단위입니다.
- Direct I/O — 페이지 캐시(Page Cache)를 우회하여 디바이스에 직접 읽기/쓰기합니다.
단계별 이해
- I/O 경로 파악 — 애플리케이션 → VFS → 페이지 캐시 → Block Layer → 디바이스 드라이버 → 하드웨어.
Block Layer는 VFS와 드라이버 사이에서 I/O 요청을 관리합니다.
- bio 이해 — 파일시스템(Filesystem)이
submit_bio()로 bio를 블록 계층에 제출합니다.각 bio는 디스크의 시작 섹터, 크기, 방향(읽기/쓰기), 메모리 페이지를 포함합니다.
- blk-mq 이해 — 하드웨어 큐와 소프트웨어 큐의 매핑을 관리합니다.
cat /sys/block/nvme0n1/queue/nr_requests로 큐 깊이를 확인합니다. - 모니터링 —
iostat -x 1로 디바이스별 I/O 통계를 실시간(Real-time) 모니터링합니다.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 스케줄링: 디바이스 특성에 맞는 전략으로 요청 순서를 조정
- 멀티큐 디스패치(Dispatch): blk-mq를 통해 멀티코어 환경에서 병렬 I/O 처리
- 디바이스 추상화: HDD, SSD, NVMe, 스택 디바이스(md, dm) 등을 통일된 인터페이스로 지원
Legacy single-queue에서 blk-mq로
Linux 3.13(2014) 이전에는 단일 request_queue에 하나의 스핀락(Spinlock)으로 모든 I/O를 직렬화(Serialization)했습니다. 이 single-queue 모델은 NVMe처럼 수백만 IOPS를 지원하는 디바이스에서 심각한 병목이 되었습니다. blk-mq(Multi-Queue Block I/O)는 per-CPU 소프트웨어 큐와 디바이스의 하드웨어 큐를 직접 매핑하여, 단일 잠금 경합(Contention) 없이 병렬 I/O를 가능하게 합니다. Linux 5.0부터 legacy single-queue 코드가 완전히 제거되어, 모든 블록 드라이버가 blk-mq를 사용합니다.
struct bio
struct bio는 블록 계층의 기본 I/O 단위입니다. 하나의 bio는 디스크의 연속된 섹터 범위와 메모리의 페이지 세그먼트 목록을 연결합니다. 파일시스템이나 Direct I/O 경로에서 생성되어 블록 계층을 통해 디바이스 드라이버까지 전달됩니다.
핵심 구조체(Struct)
/* 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 | 디바이스 캐시(Cache) 플러시 |
REQ_OP_DISCARD | 3 | 블록 무효화(Invalidation) (TRIM) |
REQ_OP_SECURE_ERASE | 5 | 보안 삭제 |
REQ_OP_WRITE_ZEROES | 9 | 제로 블록 쓰기 (하드웨어 최적화) |
추가 플래그는 비트 OR로 결합됩니다: REQ_SYNC(동기 I/O), REQ_META(메타데이터), REQ_PRIO(우선순위(Priority)), 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() 매크로(Macro)를 사용합니다.
/* 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 메모리 관리(Memory Management)와 분할
블록 계층은 bio를 빈번하게 할당·해제하므로, 성능과 안정성을 위해 전용 메모리 풀과 슬랩 캐시를 사용합니다. 또한 디바이스의 큐 한도를 초과하는 큰 I/O는 자동으로 분할(split)되어 체이닝(chaining)됩니다.
bio 메모리 풀 (bioset)
bio 할당은 struct bio_set이 관리하는 메모리 풀에서 수행됩니다. 이는 메모리 부족 상황에서도 I/O 경로가 교착 상태(Deadlock)에 빠지지 않도록 예비 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를 캐싱하여, 슬랩 할당자(Slab Allocator) 호출 없이 O(1) 할당/해제를 달성합니다. 고속 NVMe에서 수백만 IOPS를 처리할 때 할당 오버헤드(Overhead)를 사실상 제거합니다.
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 주소 공간(Address Space)이 제한되어 높은 물리 메모리(Physical Memory)(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의 장점은 다음과 같습니다:
- 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() 호출됨, 타임아웃 타이머(Timer) 활성 |
RQF_FLUSH_SEQ | flush 시퀀스의 일부 (PREFLUSH → DATA → POSTFLUSH) |
RQF_MIXED_MERGE | 서로 다른 플래그의 bio가 병합된 request |
RQF_MQ_INFLIGHT | in-flight 카운팅 중 (통계 추적용) |
RQF_SPECIAL_PAYLOAD | discard 등 특수 페이로드(Payload)를 포함 |
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() 시스템 콜(System Call)은 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는 섹터 오프셋(Offset)을 조정하여 하위 디바이스의 submit_bio_noacct()를 호출합니다. 다단계 스택(dm 위에 dm)도 가능하며, 재진입 방지를 위해 current->bio_list 큐잉을 사용합니다.
blk-mq (Multi-Queue Block I/O)
blk-mq는 현대 멀티코어 시스템과 고속 저장 장치를 위해 설계된 블록 I/O 프레임워크입니다. 기존 single-queue의 단일 잠금(Lock) 병목을 해결하여, 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를 완료하면 인터럽트 핸들러(Handler)에서 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: 스케일러블 비트맵(Bitmap)
태그 할당은 sbitmap(Scalable Bitmap) 자료구조를 사용합니다. 단일 비트맵은 캐시라인 경합(Contention)이 심하므로, 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가 다른 디바이스를 기아(starvation) 시키지 않습니다.
예약 태그 (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 완료 경로와 폴링(Polling)
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를 즉시 처리 */
}
폴링 sysfs ABI 주의사항
오래된 자료에는 클래식 폴링과 하이브리드 폴링을 별도 sysfs 값으로 설명하는 경우가 많지만, 최신 stable sysfs ABI 문서에서는 /sys/block/<disk>/queue/io_poll을 0/비0로만 다루는 것이 안전한 사용법입니다. 또한 io_poll_delay는 deprecated 상태이며 최신 stable에서는 -1로 고정됩니다.
# 폴링 모드 설정 (sysfs)
# 0 = 인터럽트 기반 (기본)
# 1 이상 = 폴링 활성화
$ echo 1 > /sys/block/nvme0n1/queue/io_poll
$ cat /sys/block/nvme0n1/queue/io_poll
1
# io_poll_delay는 deprecated이며 최신 stable에서는 -1 고정
$ cat /sys/block/nvme0n1/queue/io_poll_delay
-1
io_uring SQPOLL과 블록 I/O
io_uring의 SQPOLL 모드는 커널 스레드(Kernel Thread)가 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 스레드(Thread)를 특정 CPU에 고정할 수 있습니다. 이 CPU는 I/O 전용으로 격리(Isolation)하여(isolcpus) 다른 작업의 간섭을 방지하면, NVMe의 이론적 최대 성능에 근접할 수 있습니다.
I/O 스케줄러
I/O 스케줄러는 blk-mq의 소프트웨어 큐와 하드웨어 큐 사이에 위치하여, request의 제출 순서를 조정합니다. Linux 커널은 4가지 스케줄러를 제공하며, 디바이스 유형에 따라 자동 선택됩니다.
스케줄러 비교 요약
| 스케줄러 | 알고리즘 | 적합 디바이스 | 오버헤드 | 특징 |
|---|---|---|---|---|
mq-deadline | 데드라인 기반 FIFO + 정렬 큐 | HDD, 범용 | 낮음 | 읽기/쓰기 데드라인 보장, starvation 방지 |
bfq | 예산 기반 공정 큐잉 (Budget Fair Queueing) | 데스크톱, 느린 디바이스 | 높음 | 프로세스별 대역폭(Bandwidth) 공정 분배, 대화형 작업 우선 |
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가 데드라인을 초과했으면 즉시 디스패치 (읽기 우선)
- 쓰기 기아(Starvation) 방지: 읽기가
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;
}
읽기 우선 정책의 이유: 읽기 요청은 대부분 동기적으로 프로세스(Process)를 블록시키므로 지연에 민감합니다. 쓰기는 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 | 쓰기 양보(Yield) 횟수. 읽기가 이 횟수만큼 연속 선택되면 쓰기를 강제 |
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를 통해 그룹 단위 대역폭 제어를 지원합니다. 이를 통해 컨테이너(Container) 간 I/O 격리가 가능합니다.
# cgroup v2에서 BFQ 가중치 설정
$ echo "8:0 200" > /sys/fs/cgroup/my_group/io.bfq.weight
큐 idling 메커니즘
BFQ는 현재 서비스 중인 큐가 비어도 일정 시간(slice_idle) 동안 대기합니다. 이는 두 가지 목적이 있습니다:
- 순차 접근 보호: 프로세스가 순차 읽기를 하는 경우, 다음 요청이 곧 도착할 것이므로 대기. 다른 프로세스의 랜덤 I/O가 끼어들면 seek이 발생하여 순차 성능이 급감
- 공정성(Fairness) 보장: 동기 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가 대기 큐(Wait Queue)에서 기다립니다.
- 토큰 획득: 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,
.submit_bio = my_submit_bio, /* bio 기반 제출 경로 */
.poll_bio = my_poll_bio, /* polled I/O 완료 확인 */
.open = my_open, /* 디바이스 열기 */
.release = my_release, /* 디바이스 닫기 */
.ioctl = my_ioctl, /* ioctl 처리 */
.compat_ioctl = my_compat_ioctl, /* 32비트 호환 ioctl */
.getgeo = my_getgeo, /* 디스크 기하 정보 (fdisk용) */
.free_disk = my_free_disk, /* gendisk 해제 훅 */
.report_zones = my_report_zones, /* Zoned 디바이스 존 보고 */
};
최신 커널의 struct block_device_operations는 .submit_bio와 .poll_bio를 포함합니다. 스택 디바이스(Device Mapper, md, zoned 변환층 등)는 .submit_bio에서 bio를 재매핑할 수 있고, 폴링 완료를 지원하는 경로는 .poll_bio를 통해 blk_mq_poll() 및 io_uring IOPOLL과 연결됩니다.
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와 파티션 관리에 대한 상세 내용은 디스크 파티션 페이지를 참조하세요.
Userspace Block Device Driver (ublk)
최신 공식 블록 문서에는 ublk가 독립 문서로 포함됩니다. ublk는 /dev/ublkb*를 blk-mq 기반 블록 장치(Block Device)로 노출하되, 실제 I/O 처리 로직은 사용자 공간의 ublk server가 io_uring passthrough command로 수행합니다. 즉, 블록 계층의 request/tag 모델은 그대로 유지하면서 loop, nbd, qcow2류 가상 블록 장치의 정책과 디버깅을 사용자 공간으로 밀어내는 방식입니다.
- 장점 — 커널 패닉 위험을 줄이고, 사용자 공간 라이브러리와 디버거를 활용할 수 있습니다.
- 성능 구조 —
/dev/ublkb*요청은 blk-mq request/tag와 1:1로 대응되고, 전달 및 완료는io_uring명령으로 왕복합니다. - 실무 용도 — 테스트용 가상 디스크, 사용자 공간 loop/NBD 백엔드, 실험적 스토리지 포맷 프로토타이핑에 적합합니다.
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 하나만 보면 끝나지 않습니다. 실제 제약은 파일시스템과 블록 디바이스가 함께 결정하며, 최신 커널은 statx()의 STATX_DIOALIGN와 STATX_DIO_READ_ALIGN로 사용자 공간이 메모리 정렬과 오프셋 정렬 요구를 직접 조회할 수 있게 했습니다. io_uring의 IORING_OP_READ_FIXED는 등록된 버퍼를 사용하여 매번 pin/unpin 오버헤드를 제거합니다.
statx()로 정렬 요구와 원자적(Atomic) 쓰기 capability 조회
최신 UAPI는 statx()를 통해 Direct I/O 정렬과 원자적(Atomic) 쓰기 capability를 사용자 공간에 노출합니다. 즉, 애플리케이션은 추측으로 O_DIRECT를 쓰는 대신, 파일 또는 블록 디바이스 노드에 대해 실제 커널이 요구하는 정렬과 지원 범위를 질의할 수 있습니다.
struct statx stx = { 0 };
unsigned int mask = STATX_DIOALIGN | STATX_WRITE_ATOMIC;
if (statx(AT_FDCWD, path, AT_STATX_DONT_SYNC, mask, &stx) == 0) {
if (stx.stx_mask & STATX_DIOALIGN)
printf("mem_align=%u offset_align=%u read_offset_align=%u\\n",
stx.stx_dio_mem_align,
stx.stx_dio_offset_align,
stx.stx_dio_read_offset_align);
if (stx.stx_mask & STATX_WRITE_ATOMIC)
printf("atomic min=%u max=%u segs=%u\\n",
stx.stx_atomic_write_unit_min,
stx.stx_atomic_write_unit_max,
stx.stx_atomic_write_segments_max);
}
| 방식 | Page Cache 사용 | 캐시 유지 | 정렬 요구 | 적합한 용도 |
|---|---|---|---|---|
| Buffered I/O | O | O | 없음 | 일반 파일 I/O |
| Direct I/O | X | X | 블록 정렬 필수 | DB, 대용량 순차 I/O |
| 원자적 쓰기 | 경로에 따라 다름 | 경로에 따라 다름 | 단위/세그먼트 제한 존재 | DB WAL, 저널, 부분 기록 금지 워크로드 |
statx()를 호출하면 커널은 Direct I/O 메모리 정렬을 dma_alignment + 1, 오프셋 정렬을 logical_block_size 기준으로 채웁니다. 일반 파일에는 파일시스템별 제약이 추가될 수 있으므로, 가능하면 실제 대상 파일에 대해 statx()를 호출하는 것이 정확합니다.
원자적(Atomic) 쓰기 — 커널 6.11+
커널 6.11에서 블록 계층에 정식 원자적 쓰기(Atomic Writes) API가 도입되었고, 6.13에서 ext4/XFS의 단일 fs 블록 원자적 쓰기가 지원되었습니다. NVMe의 Atomic Write Unit(AWUN/AWUPF)과 SCSI의 Atomic Write Block 하드웨어 기능을 그대로 노출하여, 전원 장애나 중간 실패가 발생해도 찢어진 쓰기(torn write)가 관찰되지 않는 단위 쓰기를 제공합니다. 데이터베이스(PostgreSQL, MySQL, SQLite)의 WAL·double-write 버퍼 오버헤드를 제거하는 핵심 기반입니다.
# 블록 디바이스의 원자적 쓰기 한도(바이트 단위)를 sysfs에서 확인
$ cat /sys/block/nvme0n1/queue/atomic_write_unit_min_bytes
$ cat /sys/block/nvme0n1/queue/atomic_write_unit_max_bytes
$ cat /sys/block/nvme0n1/queue/atomic_write_max_bytes
$ cat /sys/block/nvme0n1/queue/atomic_write_boundary_bytes
/* 사용자 공간 사용 패턴 — statx로 한도 조회 후 pwritev2(RWF_ATOMIC) */
#include <sys/stat.h>
#include <sys/uio.h>
struct statx stx;
unsigned int mask = STATX_DIOALIGN | STATX_WRITE_ATOMIC;
statx(AT_FDCWD, "/mnt/db/wal.log", 0, mask, &stx);
if (!(stx.stx_mask & STATX_WRITE_ATOMIC)) {
/* 디바이스·FS가 원자적 쓰기를 지원하지 않음 → 기존 경로로 폴백 */
}
size_t unit_min = stx.stx_atomic_write_unit_min; /* 보통 LBA size (4K) */
size_t unit_max = stx.stx_atomic_write_unit_max; /* 디바이스별 상한 */
unsigned int segs = stx.stx_atomic_write_segments_max; /* vectored I/O 세그먼트 수 */
/* 요구사항: 오프셋/길이가 unit의 정배수, 단일 unit 이내 boundary 교차 금지 */
struct iovec iov = { .iov_base = buf, .iov_len = unit_max };
ssize_t n = pwritev2(fd, &iov, 1, offset, RWF_ATOMIC | RWF_SYNC);
정렬·경계 제약: 쓰기 길이와 파일 오프셋은 모두 atomic_write_unit_*_bytes의 정수배여야 하고, 단일 쓰기가 atomic_write_boundary_bytes를 교차하면 -EINVAL이 발생합니다. 최신 공식 문서 기준으로 ext4는 단일 fs 블록 원자적 쓰기뿐 아니라 bigalloc을 사용한 멀티블록 원자적 쓰기를 지원하며, XFS도 max_atomic_write와 장치 한도에 맞춰 더 큰 원자적 쓰기 구성을 노출합니다.
커널 내부적으로는 REQ_ATOMIC 플래그가 bio에 실려 내려가고, blk-mq가 병합·분할을 금지합니다. NVMe 드라이버는 이를 NVMe 커맨드의 NLB/FUA와 AWUPF 한도로 변환하며, SCSI는 Block Limits VPD 페이지에서 읽어온 AWB/AWB 정보를 사용합니다.
하드웨어 래핑 암호화(Encryption) 키 (v6.15+)
커널 6.15에서 블록 계층에 하드웨어 래핑 암호화 키(hardware-wrapped keys) 지원이 추가되었습니다. 인라인 암호화 엔진(ICE)을 갖춘 스토리지 컨트롤러에서, 암호화 키가 소프트웨어에 노출되지 않고 하드웨어 내부에서만 사용됩니다.
하드웨어 래핑 키 동작 흐름:
- 사용자가 "래핑된 키 blob"을 제공합니다.
- 스토리지 컨트롤러의 키 매니저가 blob을 언래핑합니다.
- 실제 암호화 키는 하드웨어 내부에만 존재합니다.
- 소프트웨어(커널 포함)는 실제 키에 접근할 수 없습니다.
따라서 커널 메모리 덤프(Dump)나 취약점(Vulnerability)으로도 암호화 키 유출이 불가능합니다. Android의 파일 기반 암호화(FBE)에서 이 방식을 활용합니다.
I/O 배리어와 플러시
전원 손실 시 데이터 무결성을 보장하려면 디바이스의 쓰기 캐시에 있는 데이터가 영구 미디어에 기록되어야 합니다. Linux는 REQ_PREFLUSH와 REQ_FUA 플래그로 이를 제어합니다.
쓰기 순서 보장(Ordering) 플래그
| 플래그 | 동작 | 사용 예 |
|---|---|---|
REQ_PREFLUSH | 이 요청 전에 디바이스 캐시를 플러시 | 저널 커밋 전 데이터 기록 보장 |
REQ_FUA | 이 쓰기를 디바이스 캐시를 거치지 않고 직접 미디어에 기록 | 저널 커밋 블록 자체 |
REQ_PREFLUSH | REQ_FUA | 캐시 플러시 후 FUA 쓰기 (가장 강력한 보장) | 파일시스템 배리어 (fsync) |
| 플래그 없음 | 순서 보장 없음, 디바이스 캐시에 머물 수 있음 | 일반 데이터 쓰기 |
queue_limits의 BLK_FEAT_WRITE_CACHE와 BLK_FEAT_FUA로 capability를 알립니다. blk-mq 경로에서는 블록 계층이 필요하면 REQ_OP_FLUSH + 데이터 쓰기 + post-flush 시퀀스로 자동 분해합니다. payload가 없는 순수 flush는 blkdev_issue_flush() helper를 사용하는 편이 안전합니다.
사용 예제
/* 파일시스템 저널 커밋: 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 체크섬(Checksum), 논리 블록 주소 태그, 애플리케이션 태그가 포함됩니다.
/* 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 지원 여부와 최대 크기를 확인합니다.
최신 stable 커널은 discard와 zero-fill의 의미를 분리해서 다룹니다. Discard는 "이 논리 블록 범위는 더 이상 유효 데이터로 취급하지 않아도 된다"는 공간 회수 힌트에 가깝고, Write Zeroes는 "이후 읽기에서 0이 관찰되어야 한다"는 관찰 가능한 의미를 제공합니다. 프로토콜마다 명령 이름은 달라도 커널 블록 계층은 이를 REQ_OP_DISCARD 또는 REQ_OP_WRITE_ZEROES로 추상화합니다.
| 연산 | 블록 계층 op | 관찰 가능한 의미 | 공간 회수 가능성 | 대표 진입점(Entry Point) |
|---|---|---|---|---|
| Discard / TRIM / UNMAP | REQ_OP_DISCARD | 이후 읽기 결과를 보장하지 않음 | 높음 | FITRIM, BLKDISCARD, hole punch |
| Write Zeroes | REQ_OP_WRITE_ZEROES | 이후 읽기에서 0을 관찰 | 디바이스/플래그 의존 | BLKZEROOUT, FALLOC_FL_ZERO_RANGE, FALLOC_FL_WRITE_ZEROES, blkdev_issue_zeroout() |
| Secure Erase | REQ_OP_SECURE_ERASE | 삭제 목적의 별도 연산 | 높음 | BLKSECDISCARD, blkdiscard -s |
/sys/block/<disk>/queue/discard_zeroes_data가 항상 0을 반환한다고 명시하며, 이 값을 읽어 동작을 추론하지 말라고 경고합니다. 같은 맥락으로 최신 block/ioctl.c에서 BLKDISCARDZEROES도 항상 0을 반환합니다. 즉 discard 후 읽기 결과가 0이라고 가정하면 안 되며, 0 보장이 필요하면 write zeroes 경로를 선택해야 합니다.큐 한도와 정렬 해석
실제 동작은 디바이스의 하드웨어 capability와 커널이 노출하는 소프트웨어 한도를 함께 봐야 합니다. 특히 discard는 정렬과 요청 크기를, write zeroes는 제로 보장과 공간 회수 가능 여부를 별도로 해석해야 합니다.
discard_granularity— 내부 할당 단위 크기입니다. 0이면 discard를 지원하지 않습니다.discard_alignment— 파티션 또는 디바이스 시작점이 내부 할당 단위의 자연 정렬에서 얼마나 어긋났는지 나타냅니다.discard_max_hw_bytes/discard_max_bytes— 전자는 하드웨어 한도, 후자는 지연 폭주를 줄이기 위한 소프트웨어 제한입니다.max_discard_segments— 하나의 discard 요청에 담을 수 있는 scatter/gather 엔트리 최대 수입니다.write_zeroes_max_bytes— 한 번의 write zeroes 명령으로 0을 쓸 수 있는 최대 크기입니다. 0이면 미지원입니다.write_zeroes_unmap_max_hw_bytes/write_zeroes_unmap_max_bytes— 0 읽기 보장은 유지하면서 내부적으로 물리 매체에 0을 쓰지 않을 수도 있는 최적화 범위입니다. 속도는 best-effort이며, misalignment나 작은 범위에서는 실제 매체 기록으로 돌아갈 수 있습니다.
# discard capability와 소프트웨어 throttle 확인
$ cat /sys/block/nvme0n1/queue/discard_granularity
$ cat /sys/block/nvme0n1/queue/discard_max_hw_bytes
$ cat /sys/block/nvme0n1/queue/discard_max_bytes
$ cat /sys/block/nvme0n1/queue/max_discard_segments
$ cat /sys/block/nvme0n1/discard_alignment
# write zeroes capability 확인
$ cat /sys/block/nvme0n1/queue/write_zeroes_max_bytes
$ cat /sys/block/nvme0n1/queue/write_zeroes_unmap_max_hw_bytes
$ cat /sys/block/nvme0n1/queue/write_zeroes_unmap_max_bytes
유저 공간(User Space) 진입점
FITRIM— 파일시스템(File System)의 자유 공간을 trim합니다. 최신 UAPI의struct fstrim_range { start, len, minlen }는 시작 오프셋, 길이, 최소 연속 길이를 받습니다.BLKDISCARD— raw block range discard입니다. 최신 구현은 page cache를 무효화하고truncate_bdev_range()이후 discard bio를 발행합니다.BLKZEROOUT— raw block range zero-fill입니다. 최신block/ioctl.c는BLKDEV_ZERO_NOUNMAP를 함께 넘겨 0 읽기 보장은 제공하되, thin provisioning 공간 회수는 의도적으로 하지 않습니다.FALLOC_FL_PUNCH_HOLE— sparse hole을 만듭니다. 하부에서 discard가 발행될 수는 있지만, 우선 의미는 "파일 논리 범위의 deallocate"입니다.FALLOC_FL_ZERO_RANGE— 가능하면 데이터 I/O 없이 0 읽기 보장 범위를 만듭니다. hole 구간은 preallocate 또는 unwritten extent로 바뀔 수 있습니다.FALLOC_FL_WRITE_ZEROES— 최신 UAPI의 명시적 zeroing 모드입니다. 이후 overwrite 시 추가 mapping metadata 변경을 줄이기 위한 목적이며,FALLOC_FL_KEEP_SIZE와 함께 사용할 수 없습니다.
커널 구현 포인트
blkdev_issue_zeroout()는 우선 하드웨어 offload를 시도하고, 필요하면 zero page 기반 일반 쓰기로 fallback합니다. 단, BLKDEV_ZERO_NOFALLBACK를 주면 offload가 없을 때 -EOPNOTSUPP를 반환합니다. 반대로 BLKDEV_ZERO_NOUNMAP는 "0이 읽혀야 하지만 backing space는 해제하지 말라"는 의도를 전달합니다.
unsigned int max_disc = bdev_max_discard_sectors(bdev);
unsigned int disc_gran = bdev_discard_granularity(bdev);
unsigned int max_wzero = bdev_write_zeroes_sectors(bdev);
unsigned int wz_unmap = bdev_write_zeroes_unmap_sectors(bdev);
/* 자유 공간 회수 목적 */
if (max_disc)
blkdev_issue_discard(bdev, sector, nr_sects, GFP_KERNEL);
/* 0 읽기 보장 + backing space 유지 */
if (max_wzero)
blkdev_issue_zeroout(bdev, sector, nr_sects, GFP_KERNEL,
BLKDEV_ZERO_NOUNMAP);
/* 진짜 하드웨어 zeroing offload만 허용 */
if (max_wzero)
blkdev_issue_zeroout(bdev, sector, nr_sects, GFP_KERNEL,
BLKDEV_ZERO_NOUNMAP |
BLKDEV_ZERO_NOFALLBACK);
wz_unmap가 0보다 크면 디바이스는 0 읽기 보장을 유지하면서도 내부적으로는 allocation 해제나 메타데이터 최적화로 처리할 수 있습니다. 하지만 최신 ABI도 이것을 best-effort optimization으로 설명하므로, 성능 향상 폭을 미리 가정하면 안 됩니다.실무 분석과 선택 기준
| 상황 | 권장 연산 | 이유 |
|---|---|---|
| 씬 프로비저닝 LUN, Device Mapper, 가상 디스크에서 실제 backing space를 회수하고 싶음 | discard / FITRIM | 의미 자체가 공간 회수 힌트이므로 over-provisioning 회수에 적합합니다. |
| 데이터베이스 WAL, VM 이미지, 테스트 초기화처럼 "반드시 0이 읽혀야" 함 | BLKZEROOUT, REQ_OP_WRITE_ZEROES, FALLOC_FL_ZERO_RANGE, FALLOC_FL_WRITE_ZEROES | discard와 달리 관찰 가능한 zero-fill semantics를 제공합니다. |
| 온라인 서비스 경로에서 큰 discard로 지연 꼬리가 튐 | discard_max_bytes 하향, 주기적 fstrim | 최신 stable ABI도 큰 discard가 큰 latency를 유발할 수 있다고 명시합니다. |
| 민감한 데이터 삭제나 폐기 검증이 필요함 | secure erase | discard와 write zeroes는 sanitization API가 아니며, 공간 회수나 zero-fill 의미와 삭제 보장은 별개입니다. |
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); /* discard 최대 크기 (섹터 단위) */
unsigned int dgran = bdev_discard_granularity(bdev); /* discard 내부 할당 단위 */
unsigned int dalign = bdev_discard_alignment(bdev); /* discard 정렬 오프셋 */
unsigned int max_wz = bdev_write_zeroes_sectors(bdev); /* zero-fill 최대 크기 (섹터 단위) */
unsigned int wz_unmap = bdev_write_zeroes_unmap_sectors(bdev); /* zero-fill 중 unmap 최적화 가능 범위 */
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를 확인할 수 있습니다.
Zone Write Plugging (ZWP) — 커널 6.10+
커널 6.10에서 기존 Zone Write Locking(ZWL)을 대체하는 Zone Write Plugging(ZWP) 메커니즘이 도입되었습니다. 과거에는 mq-deadline 스케줄러가 존 단위 zone_lock으로 순차 쓰기 순서를 강제했기 때문에 Zoned 블록 디바이스에서 스케줄러 선택이 제한되고 요청 병렬성이 제약되었습니다. ZWP는 블록 계층 자체가 존별 blk_zone_wplug 구조체로 발행 순서를 재조립하므로, 어떤 스케줄러(none, bfq, kyber)에서도 ZBC/ZAC/ZNS 디바이스를 그대로 사용할 수 있습니다.
- 자료구조:
struct gendisk에 매달린 해시 테이블(Hash Table)이 존 번호를blk_zone_wplug에 매핑합니다. 각 wplug는 pending bio 리스트와wp_offset(다음 기록 위치)을 유지합니다. - 플래그: request 플래그
RQF_ZONE_WRITE_PLUGGING와 bio 플래그BIO_ZONE_WRITE_PLUGGING가 함께 설정되어, 해당 I/O가 ZWP 경로를 거쳤음을 표시합니다. - Zone Append:
REQ_OP_ZONE_APPEND는 장치 지원이 없을 때 ZWP가 에뮬레이션(일반 쓰기로 변환 후 완료 시 실제 기록 LBA 보고)을 제공합니다. NVMe ZNS처럼 하드웨어 지원이 있으면 네이티브 경로로 발행됩니다. - dm-zoned/분할: 6.12.64 stable에서
blk_zone_reset_all_bio_endio()NULL 역참조(Dereference)가 원자 카운터 기반으로 수정되었으며, 이는 Device Mapper 위에서 존 플러깅을 사용하지 않는 장치에도 영향을 줍니다.
/* include/linux/blkdev.h — 존 플러그 관측 API */
bool bdev_is_zoned(struct block_device *bdev);
unsigned int bdev_zone_no(struct block_device *bdev, sector_t sec);
sector_t bdev_zone_sectors(struct block_device *bdev);
/* /sys/block/<dev>/queue/max_active_zones, max_open_zones
* /sys/block/<dev>/queue/zoned → "host-managed" / "host-aware" / "none"
* /sys/block/<dev>/queue/chunk_sectors → 존 크기(섹터)
*/
ZWP 도입으로 zoned 디바이스에서도 일반 멀티큐 스케줄링 + 대규모 병렬 bio 제출이 가능해졌고, blktrace에는 존 플러그/언플러그 이벤트가 추가되어 순서 재조립 지연을 진단할 수 있습니다.
폴리오 기반 bio API
커널 6.8 이후 블록 계층이 struct folio 중심으로 전환되면서, 드라이버가 권장하는 bio 구성 헬퍼도 폴리오 친화적으로 교체되었습니다. 기존 bio_add_page()는 단일 페이지 단위이지만, bio_add_folio()·bio_add_folio_nofail()은 여러 페이지에 걸친 대형 폴리오(예: 2 MB THP, bs>ps 파일시스템)도 한 번에 첨부할 수 있어 iomap/XFS/ext4의 대형 폴리오 DIO 경로에서 bio_for_each_segment() 비용을 줄입니다.
/* iomap DIO 경로의 전형적인 사용 패턴 */
struct folio *folio = iomap_get_folio(iter, pos, len);
size_t off = offset_in_folio(folio, pos);
if (!bio_add_folio(bio, folio, len, off)) {
/* 현재 bio가 가득 찼음 → 분할 후 다음 bio에 첨부 */
bio = bio_split_to_limits(bio);
submit_bio(bio);
}
bio_split_to_limits()는 과거의 blk_queue_split()을 대체하는 권장 스플리터로, 디바이스 큐의 max_sectors·max_segments·원자적 쓰기 단위·존 경계를 한 번에 고려합니다. 또한 blk-wbt의 지연 튜닝 파라미터였던 dirty_sleep이 struct bdi_writeback에서 struct backing_dev_info로 옮겨져, 이제 쓰기 스로틀링이 철저히 디바이스 단위로 적용됩니다.
NVMe (Non-Volatile Memory Express)
NVMe는 PCIe 직결 고속 저장 장치를 위한 프로토콜로, blk-mq의 가장 직접적인 사용자입니다. 수만 개의 병렬 I/O 큐와 μs 단위 지연을 실현합니다.
NVMe에 대한 상세 내용은 NVMe 서브시스템 전용 페이지를 참고하세요. SQ/CQ 아키텍처, 커맨드 구조, Linux NVMe 드라이버, blk-mq 매핑, 네임스페이스(Namespace), PRP/SGL, Multipath, NVMe-oF, ZNS, 성능 튜닝 등을 종합적으로 다룹니다.
블록 I/O 디버깅(Debugging)
블록 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는 여러 I/O 인터페이스를 통해, 컨테이너·서비스 단위로 I/O 자원을 격리하고 제어합니다. 최신 공식 문서 기준으로는 비례 배분(io.weight), 절대 상한(io.max), 지연 시간 보호(io.latency), 비용 모델 기반 제어(io.cost.*), cgroup 단위 I/O 우선순위 클래스(io.prio.class)를 함께 이해하는 것이 좋습니다.
I/O 컨트롤러 비교
| 컨트롤러 | 파일 | 방식 | 적합한 용도 |
|---|---|---|---|
| io.weight | io.weight | 형제 cgroup 간 상대 비율 배분 | 일반적인 서비스 간 공정성 |
| io.max | io.max | 절대적 상한선 (IOPS, BPS) | 멀티테넌트 격리, QoS 보장 |
| io.latency | io.latency | 지연 시간 목표 기반 스로틀링 | 지연에 민감한 워크로드 보호 |
| io.cost | io.cost.* | 비용 모델 기반 가중치 분배 | 이기종 디바이스, 공정한 대역폭 |
| io.prio.class | io.prio.class | cgroup 단위 I/O priority class 재기록 | 백업/배치 작업 우선순위 하향, 지연 민감 그룹 승격 |
io.bfq.weight는 BFQ 스케줄러를 사용할 때 보이는 scheduler-specific knob입니다. cgroup v2 공통 인터페이스를 먼저 이해하려면 io.weight, io.max, io.latency, io.cost.*, io.prio.class를 우선 보는 편이 좋습니다.
io.weight: 상대적 비율 배분
io.weight는 non-root cgroup에서 형제 cgroup 사이의 상대적 I/O 시간 비율을 정의합니다. 기본값은 default 100이며, 범위는 1~10000입니다.
# database 그룹의 기본 가중치를 200으로 상향
$ echo 200 > /sys/fs/cgroup/database/io.weight
# backup 그룹은 특정 디바이스(8:0)에 대해 50으로 하향
$ echo "8:0 50" > /sys/fs/cgroup/backup/io.weight
# 확인
$ cat /sys/fs/cgroup/database/io.weight
default 200
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와 달리 자신을 제한하는 것이 아니라, 다른 그룹을 억제하여 자신의 지연을 보호하는 방식입니다.
io.latency는 같은 부모를 공유하는 peer cgroup 사이에서만 적용됩니다. 최신 공식 문서는 먼저 io.stat의 avg_lat를 관찰하고, 실제 target은 그 값보다 약 10~15% 높게 시작할 것을 권장합니다.
# 데이터베이스 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.weight는 각 non-root cgroup에 설정하고, io.cost.model 및 io.cost.qos는 root cgroup에만 존재합니다.
# 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
io.prio.class: cgroup 단위 I/O 우선순위 클래스
최신 cgroup v2는 io.prio.class로 cgroup 전체의 I/O priority class를 다시 기록할 수 있습니다. 대표 값은 no-change, promote-to-rt, restrict-to-be, idle입니다.
# 백업 작업을 가장 낮은 우선순위 클래스로 강등
$ echo idle > /sys/fs/cgroup/backup/io.prio.class
# 지연 민감 그룹을 RT 클래스로 승격
$ echo promote-to-rt > /sys/fs/cgroup/database/io.prio.class
# 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()) - 타겟 리셋: 타겟(포트) 레벨 리셋
- 버스(Bus) 리셋: 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가 급증하는 문제를 해결합니다.
Write-Back Throttling 원리 (block/blk-wbt.c):
wbt_lat_usec(기본 75ms) 목표를 설정합니다.- 최근 읽기 지연의 이동 평균을 계산합니다.
- 읽기 지연 > 목표이면
write_inflight제한을 감소시킵니다. - 읽기 지연 < 목표이면
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, 커널 패닉(Kernel Panic) | submit_bio() 후 bio 소유권은 블록 계층에 이전됨. 재사용 금지 |
| blk_mq_end_request() 이중 호출 | 태그 이중 해제(Double Free), 메모리 오염 | request 당 정확히 한 번만 호출. 타임아웃과 정상 완료 경로의 경합 주의 |
| TRIM/discard를 security erase로 오인 | 민감한 데이터 잔존 | REQ_OP_DISCARD는 데이터 삭제를 보장하지 않음. REQ_OP_SECURE_ERASE 또는 blkdiscard -s 사용 |
| discard 후 0 읽기 보장 가정 | 과거 데이터 노출 또는 장치별 비결정 동작 | discard_zeroes_data와 BLKDISCARDZEROES는 최신 커널에서 신뢰 대상이 아님. 0 보장이 필요하면 BLKZEROOUT, REQ_OP_WRITE_ZEROES, FALLOC_FL_ZERO_RANGE 사용 |
| 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 시 지연 증가
커널 6.12~6.14 블록 I/O 주요 변경
io_uring 비동기 discard/zeroing (6.12)
커널 6.12부터 블록 계층의 discard(REQ_OP_DISCARD)와 write-zeroes(REQ_OP_WRITE_ZEROES) 연산을 io_uring을 통해 비동기적으로 발행할 수 있게 되었습니다. 이전에는 ioctl(BLKDISCARD)나 fallocate(FALLOC_FL_PUNCH_HOLE)이 블로킹 시스템 콜이었기 때문에, 스토리지 정리 혹은 SSD TRIM 워크로드를 아이들 타임에만 수행하기 어려웠습니다.
커널 6.12부터: IORING_OP_URING_CMD를 통한 BLOCK_URING_CMD_DISCARD 인터페이스가 추가되었습니다. 파일시스템 레벨에서는 fallocate(FALLOC_FL_PUNCH_HOLE)의 io_uring 경로도 개선되어 비동기 범위 해제가 가능합니다.
/* io_uring 비동기 discard 예제 (6.12+) */
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_uring_cmd(sqe, blkfd, BLOCK_URING_CMD_DISCARD, 0);
sqe->addr = offset; /* discard 시작 바이트 오프셋 */
sqe->len = length; /* discard 길이 */
io_uring_submit(&ring);
MD RAID 원자적 쓰기 지원 (6.13)
커널 6.13에서 MD(Multiple Devices) 서브시스템의 RAID 0·1·10 수준이 원자적 쓰기를 지원하도록 확장되었습니다. 이전까지는 하드웨어 NVMe나 단일 SCSI 디바이스에서만 RWF_ATOMIC이 의미가 있었지만, 이제 소프트웨어 RAID 어레이 위에서도 동일한 사용자 API가 동작합니다.
- RAID 1/10: 멤버 디바이스가 모두 동일한 원자 쓰기 단위를 지원하면, RAID 미러 전체에 걸쳐 원자적으로 쓰기가 완료되거나 전혀 기록되지 않습니다.
- RAID 0: 스트라이프 청크(chunk) 크기가 디바이스 원자 단위의 배수일 때, 스트라이프 단위로 원자 쓰기가 노출됩니다.
queue_limits의atomic_write_unit_min/atomic_write_unit_max가 MD 레이어에서 자동으로 조율됩니다.
커널 6.13부터: /sys/block/md0/queue/atomic_write_unit_min_bytes가 0이 아닌 값을 반환하면 해당 MD 어레이에서 원자적 쓰기가 가능합니다. statx(STATX_WRITE_ATOMIC)으로 파일시스템 파일에서도 지원 여부를 확인하세요.
대형 폴리오(Folio) 쓰기 경로 전환 (6.12)
커널 6.12에서 버퍼 쓰기 경로의 핵심 콜백인 write_begin()/write_end()가 struct folio 기반으로 전환되었습니다. 파일시스템들이 이 인터페이스를 채택함에 따라, bs > ps(블록 크기 > 페이지 크기) 환경(예: x86_64에서 16 KB XFS)에서 대형 폴리오가 할당·유지되어 I/O 요청 횟수가 줄고 세그먼트 병합 효율이 향상됩니다.
커널 6.12부터: XFS에서 블록 크기가 페이지 크기보다 큰 경우(bs > ps)에 대한 지원이 안정화되었습니다. mkfs.xfs -b size=16384로 생성한 파일시스템을 x86_64(4K 페이지) 에서도 마운트할 수 있으며, 커널이 16 KB 폴리오를 할당해 쓰기를 처리합니다.
blk-throttle 메타데이터 우선처리 (6.12)
커널 6.12에서 blk-throttle이 메타데이터(Metadata) I/O 우선처리를 지원합니다. 스로틀링 경쟁이 심할 때 파일시스템 메타데이터(슈퍼블록(Superblock), 저널, 디렉터리 등) 쓰기가 일반 데이터 쓰기보다 우선 처리되도록 설정할 수 있어, 파일시스템 일관성 유지에 유리합니다.
커널 6.12부터: blk-throttle이 REQ_META 플래그가 설정된 메타데이터 I/O를 식별하여 스로틀 큐에서 우선 처리합니다. 이 동작은 커널 내부에서 자동으로 적용되며, 파일시스템이 메타데이터 bio에 REQ_META를 적절히 설정하는 경우 별도의 사용자 설정 없이 효과를 얻을 수 있습니다.
io_uring + FUSE 성능 향상 (6.14)
커널 6.14에서 FUSE(Filesystem in Userspace)가 io_uring 기반 커널-유저 통신을 지원합니다. 기존 FUSE는 커널-유저 전환 횟수가 많아 블록 I/O 경로에 비해 지연이 상당했으나, io_uring 큐를 직접 사용함으로써 컨텍스트 스위치가 줄고 처리량이 크게 향상됩니다.
커널 6.14부터: FUSE 데몬이 IORING_OP_URING_CMD를 통한 io_uring SQE/CQE 쌍으로 마운트(Mount) 요청을 처리합니다. CPU별 링 큐를 배치해 NUMA 친화성을 높이며, 고성능 사용자 공간 파일시스템(예: libfuse 기반)에서 효과적입니다.
참고자료
- docs.kernel.org — Block Layer — 커널 공식 블록 계층 문서 인덱스입니다.
- docs.kernel.org — blk-mq — 멀티큐 블록 I/O 계층 공식 문서입니다.
- docs.kernel.org — Switching Scheduler — mq-deadline, none, BFQ, Kyber 전환 방법을 설명합니다.
- docs.kernel.org — Explicit volatile write back cache control —
REQ_PREFLUSH,REQ_FUA, flush sequencing 공식 문서입니다. - docs.kernel.org — ublk — 사용자 공간 블록 디바이스 드라이버 공식 문서입니다.
- docs.kernel.org — cgroup v2 —
io.weight,io.max,io.latency,io.cost,io.prio.class설명입니다. - docs.kernel.org — stable/sysfs-block ABI —
discard_granularity,discard_max_bytes,write_zeroes_max_bytes,discard_zeroes_data등 최신 stable sysfs ABI 설명입니다. - docs.kernel.org — blkdev_issue_discard() — discard helper 공식 API 문서입니다.
- docs.kernel.org — blkdev_issue_zeroout() — zero-fill helper와 fallback 동작 공식 API 문서입니다.
- docs.kernel.org — ext4 atomic writes —
RWF_ATOMIC및STATX_WRITE_ATOMIC설명입니다. - git.kernel.org — block/ioctl.c —
BLKDISCARD,BLKZEROOUT,BLKDISCARDZEROES최신 구현입니다. - git.kernel.org — include/uapi/linux/fs.h —
FITRIM,BLKDISCARD,BLKZEROOUTUAPI 정의입니다. - git.kernel.org — include/uapi/linux/falloc.h —
FALLOC_FL_PUNCH_HOLE,FALLOC_FL_ZERO_RANGE,FALLOC_FL_WRITE_ZEROESUAPI 정의입니다. - block/blk-mq.c — Bootlin Elixir — blk-mq 핵심 구현 소스 코드입니다.
- block/blk-core.c — Bootlin Elixir — 블록 계층 코어 로직 (submit_bio 등) 소스 코드입니다.
- include/linux/bio.h — Bootlin Elixir — struct bio 및 관련 매크로 정의입니다.
- block/mq-deadline.c — Bootlin Elixir — mq-deadline I/O 스케줄러 구현체입니다.
- block/bfq-iosched.c — Bootlin Elixir — BFQ I/O 스케줄러 구현체입니다.
- LWN: Multi-queue block layer — blk-mq 아키텍처 설계를 다룬 핵심 기사입니다.
- LWN: A block layer introduction (Part 1) — 블록 I/O 계층의 구조와 흐름을 설명하는 입문 시리즈입니다.
- LWN: A block layer introduction (Part 2) — 블록 계층 입문 시리즈 두 번째 파트입니다.
- man blktrace(8) — 블록 I/O 추적 도구 매뉴얼 페이지입니다.
- man ionice(1) — I/O 스케줄링 우선순위 설정 도구 매뉴얼입니다.
관련 문서
Block I/O와 관련된 다른 주제를 더 깊이 이해하고 싶다면 다음 문서를 참고하세요.