Skip to content

Latest commit

 

History

History
424 lines (291 loc) · 42 KB

File metadata and controls

424 lines (291 loc) · 42 KB

사용자가 발생시키는 읽기, 쓰기와 같은 I/O 작업은 가상 파일 시스템, 로컬 파일 시스템 등의 경로를 거친 후 블록 디바이스로 전달되기 전에 I/O 스케줄러를 거치게 된다. I/O 스케줄러는 상대적으로 접근 속도가 느린 디스크에 대한 성능을 최대화하기 위해 구현된 커널의 일부분이며, 모든 I/O 작업은 이 I/O 스케줄러를 통해서 블록 디바이스(디스크)에 전달된다. I/O 스케줄러는 기본적으로 병합과 정렬이라는 두 가지 방식을 이용해서 I/O 요청을 블록 디바이스에 전달하게 되는데, 서버에서 발생하는 워크로드와 I/O 스케줄러의 알고리즘에 따라 성능을 더 좋게 만들 수도, 나쁘게 만들 수도 있다.

11.1 I/O 스케줄러의 필요성

I/O 스케줄러를 알아보기 전에 디스크에 대해 먼저 살펴보자. 디스크는 크게 두 종류로 나눌 수 있다. 헤드와 플래터 등의 기계식 부품으로 이루어진 하드 디스크 드라이브(HDD)와 플래시 메모리를 기반으로 이루어진 솔리드 스테이트 드라이브(SSD)이다.

HDD는 플래터(Platter)라는 원판과 같은 자기 장치가 있으며, 디스크 헤드가 플래터 위를 움직이면서 데이터를 읽거나 쓰는 작업을 한다. HDD에 저장되어 있는 데이터를 읽기 위해서는 디스크 헤드를 플래터의 특정 위치로 움직이게 해야 한다. 헤드는 기계 장치이기 때문에 시스템의 다른 부품들과 달리 이동하는 데에 시간이 걸리고 시스템을 구성하는 많은 부품들 중에서 가장 많은 시간이 소요된다. 그래서 헤드의 움직임을 최소화하고 한번 움직일 때 최대한 처리해야 I/O 성능이 극대화될 수 있다.

반면에 SSD는 플래시 메모리를 기반으로 구성되어 있다. SSD는 기계식 디스크와는 달리 헤드와 플래터 없이 기판에 장착되어 있는 플래시 메모리에 데이터를 쓰거나 읽는다. 헤드 대신에 컨트롤러라는 장치를 통해서 디스크로 유입되는 I/O 작업을 조정한다. SSD는 장치를 움직이지 않고 전기적 신호를 이용해서 접근하기 때문에 임의로 특정 섹터에 접근할 때 소요되는 시간이 모두 동일하다. 반면에 HDD는 특정 섹터에 접근하기 위해 헤드와 플래터를 움직여야 하기 때문에 현재 헤드의 위치가 어디냐에 따라 특정 영역에 접근하는 데 필요한 시간이 달라진다.

이렇게 디스크와 관련된 작업은 시간이 오래 소요되기 때문에 커널은 I/O 스케줄러를 통해서 조금이라도 성능을 극대화하려 한다. 그리고 이런 성능 극대화 작업을 위해서 병합과 정렬이라는 두 가지 방법을 사용한다.

먼저 병합을 살펴보자. 병합은 여러 개의 요청을 하나로 합치는 것을 의미한다. 그림과 같은 경우를 가정해 보자. Request Queue에 총 3개의 요청이 인입되었는데, 첫 번째 요청은 10번 블록에서 1개의 블록 내용을 읽어오는 요청이고, 두 번째 요청은 11번 블록에서 1개의 블록 내용을 읽어오는 요청, 그리고 마지막 세 번째 요청은 12번 블록에서 1개의 블록 내용을 읽어오는 요청이다. 서로 다른 세 개의 요청이지만 접근 블록 주소가 인접해 있기 때문에 블록 디바이스의 Dispatch Queue에 3개의 요청을 넘겨주는 대신, 이 요청들을 하나의 큰 요청으로 합쳐서 넘겨주면 헤드의 움직임을 최소화할 수 있다. 10번 블록에 접근해서 3개의 블록만큼을 읽어오게 되면, 한 번의 요청으로 기존에 있던 3개의 I/O 요청을 처리할 수 있다. 이렇게 I/O 스케줄러는 병합을 통해서 디스크로의 명령 전달을 최소화하고 성능을 향상시킬 수 있다.

다음으로 정렬을 살펴보자. 정렬이란 여러 개의 요청을 섹터 순서대로 재배치하는 것을 의미한다. 그림과 같은 상황을 가정해보자. I/O 요청이 총 4개가 들어왔다. 1번 블록, 7번 블록, 3번 블록, 10번 블록의 순서대로 들어온 상태에서 이 요청들을 정렬하지 않고 들어온 순서대로 처리하게 되면, 총 17만큼의 헤더 이동이 필요하다. 특히 7번 블록에서 3번 블록으로 갈 때는 앞으로 갔다가 다시 뒤로 가게 되어 불필요한 이동이 발생하게 된다. 하지만 이 요청을 들어온 순서대로가 아니라 블록 접근 순서대로 정렬한다면 헤더의 움직임이 어떻게 바뀔까?

정렬하지 않으면 총 17만큼의 이동이 필요하지만, 정렬하면 총 9만큼의 이동이 필요함을 확인할 수 있다. 헤더의 이동을 절반에 가깝게 줄일 수 있다. 이렇게 섹터 순서대로 최적의 경로를 갈 수 있도록 재배치하는 것을 정렬이라고 한다. 이렇게 되면 먼저 발생한 요청이 더 늦게 처리되는 문제가 생길 수도 있다. 그래서 I/O 스케줄러는 다양한 알고리즘으로 이런 문제를 해결하고 있다.

SSD의 경우는 어떨까? 헤드가 없기 때문에 헤드를 이동하느라 발생하는 비용이 없다. 그렇기 때문에 사실 정렬이 별 의미가 없다. 오히려 정렬하는데 CPU 리소스를 쓸데없이 사용하기 때문에 성능이 나빠질 수 있다. 그래서 SSD는 HDD와는 조금 다른 I/O 스케줄러를 사용한다.

11.2 I/O 스케줄러 설정

현재 시스템에 적용 가능한 I/O 스케줄러와 설정되어 있는 I/O 스케줄러 정보는 다음과 같이 확인할 수 있다.

ubuntu@ip-172-31-0-62:~$ cd /sys/block/xvda/queue
ubuntu@ip-172-31-0-62:/sys/block/xvda/queue$ cat scheduler
none [mq-deadline]

[]로 표시되어 있는 부분이 현재 설정되어 있는 I/O 스케줄러이고, 그 외의 것들이 설정 가능한 I/O 스케줄러이다. 변경은 echo 명령을 이용하면 된다.

좋아 👌 아주 정확하게 잘 보고 있어.

지금 결과:

none [mq-deadline]

이걸 하나씩 해석해보자.


1️⃣ 대괄호 [ ] 의 의미

리눅스 블록 디바이스에서:

none [mq-deadline]

👉 대괄호 안에 있는 게 현재 적용된 I/O 스케줄러

즉,

✅ 현재 xvda에 적용된 스케줄러는 mq-deadline


2️⃣ 그럼 앞에 none은 뭐야?

none

“스케줄링을 거의 하지 않는 모드”

를 의미해.

즉 선택 가능한 스케줄러 목록이:

  • none
  • mq-deadline

이 두 개이고

현재는 mq-deadline이 선택되어 있는 상태야.


3️⃣ mq-deadline이 뭐냐?

mq-deadline은:

  • multi-queue 환경에서 사용하는 Deadline 기반 스케줄러
  • 요청에 deadline(기한)을 둬서 starvation 방지
  • 읽기 요청을 우선 처리
  • 예측 가능한 latency 제공

예전 single-queue deadline의 blk-mq 버전이라고 보면 돼.


4️⃣ 왜 bfq, kyber는 없을까?

EC2의 xvda는:

  • Xen virtual block device
  • 커널 설정 또는 디바이스 특성상
  • 지원 스케줄러가 제한될 수 있음

그래서 현재 커널에서는

none
mq-deadline

만 제공하는 상태야.


5️⃣ 구조적으로 보면

현재 I/O 흐름은:

User write()
  ↓
VFS
  ↓
ext4
  ↓
page cache
  ↓
blk-mq
  ↓
mq-deadline (현재)
  ↓
xvda (Xen virtual disk)
  ↓
AWS storage backend

6️⃣ EC2에서 스케줄러 의미

EC2는:

  • 실제 물리 디스크를 직접 제어하지 않음
  • 하이퍼바이저가 중간에 존재
  • AWS 스토리지 레이어가 자체적으로 스케줄링

그래서:

스케줄러 영향은 물리 서버보다 작음


🎯 최종 해석 한 줄

none [mq-deadline]

👉 사용 가능한 스케줄러는 none, mq-deadline

👉 현재 적용된 것은 mq-deadline


지금 커널 I/O 경로 공부 중인 것 같은데 👀

다음 단계로 가볼까?

  • 🔥 mq-deadline 내부 동작 방식
  • 🔥 blk-mq에서 request가 hw queue로 내려가는 과정
  • 🔥 Xen 가상 디스크에서 I/O가 실제 AWS 스토리지까지 가는 구조

어디까지 깊게 들어가볼까?

root@ip-172-31-0-62:/sys/devices/vbd-768/block/xvda/queue# echo none > ./scheduler
root@ip-172-31-0-62:/sys/devices/vbd-768/block/xvda/queue# cat scheduler
[none] mq-deadline 

변경하면 재밌는 것을 볼 수 있는데, 각각의 I/O 스케줄러별로 튜닝 가능한 파라미터들이 바뀐다는 것이다. I/O 스케줄러를 변경하면 하위 디렉터리인 iosched 디렉터리에서도 값이 바뀐다.

root@ip-172-31-0-62:/sys/devices/vbd-768/block/xvda/queue# cat scheduler
none [mq-deadline] 
root@ip-172-31-0-62:/sys/devices/vbd-768/block/xvda/queue# ls ./iosched
async_depth  fifo_batch  front_merges  prio_aging_expire  read_expire  write_expire  writes_starved

none으로 변경하면 스케줄링을 하지 않으므로 iosched 디렉터리가 존재하지 않는다.

11.3 cfq I/O 스케줄러

cfq는 Completely Fair Queueing의 약자로, 우리말로 하면 ‘완전 공정 큐잉’ I/O 스케줄러를 말한다. 공정하다는 단어에서 유추할 수 있듯이 프로세스들이 발생시키는 I/O 요청들이 모든 프로세스에서 공정하게 실행되는 것이 특징이다.

우선 각각의 프로세스에서 발생시킨 I/O는 Block I/O Layer를 거친 후 실제 디바이스로 내려가기 전에 cfq I/O 스케줄러를 거치게 된다. cfq I/O는 특성에 따라 각각 RT(Real Time), BE(Best Effort), IDLE 중 하나로 I/O 요청을 정의한다. 이 값은 ionice 명령을 이용해서 변경할 수 있으며, 대부분의 I/O 요청은 기본적으로 BE에 속한다. 위 형태는 I/O 처리의 우선순위를 설정하기 위해 나눈 것으로, RT에 속한 요청들을 가장 먼저 처리하고 IDLE에 속한 요청들을 가장 나중에 처리한다. I/O 우선순위에 따라 RT, BE, IDLE 셋 중 하나로 분류한 다음에는 service tree라 불리는 워크로드별 그룹으로 다시 나눈다. SYNC는 순차적인 동기화 I/O 작업, 주로 순차 읽기를 의미한다. 여기에 속한 큐들은 각각의 큐에 대한 처리를 완료한 후 일정 시간 동안 대기하게 된다. 순차적인 작업이기 때문에 향후에 들어오는 I/O 요청도 현재 디스크 헤드와 가까운 위치의 작업이 들어올 확률이 높다. 그래서 약간의 대기 시간이 있더라도 가까운 곳의 I/O 요청을 처리하게 되면 더 좋은 성능을 내는 데 도움이 되기 때문에 대기하게 된다. slice_idle 값으로 이 대기 시간을 설정할 수 있다. SYNC_NOIDLE은 임의적인 동기화 I/O 작업, 주로 임의 읽기를 의미한다. 여기에 속한 큐들은 SYNC 워크로드와는 달리 각각의 큐에 대한 처리를 완료한 후 대기 시간 없이 바로 다음 큐에 대한 I/O 작업을 시작한다. 임의 작업은 디스크 헤드를 많이 움직이게 하기 때문에 굳이 다음 요청을 기다려서 얻게 되는 성능상의 이점이 없기 때문이다. 그래서 기다리지 않는다는 뜻으로 NOIDLE이라는 이름을 붙인다. 마지막으로 ASYNC는 비동기화 I/O 작업, 주로 쓰기 작업을 의미한다. 이곳에는 각각의 프로세스에서 발생하는 쓰기 작업이 같이 모여서 처리된다. cfq는 프로세스별로 큐를 가지고 있지만 이 큐는 동기화 I/O 작업만을 처리하는 것이고, 비동기화 I/O 작업은 ASYNC 서비스 트리 밑에 하나로 모아놓고 한꺼번에 작업한다.

ionice(1) - Linux manual page

이렇게 I/O 우선순위와 워크로드에 따라 분류된 I/O 요청을 어떤 프로세스에서 발생시켰느냐에 따라 어떤 큐에 들어갈 것인지 최종적으로 정해진다. A 프로세스에서 발생시킨 요청은 cfq queue(A)에 속하게 되며, B 프로세스에서 발생시킨 요청은 cfq queue(B)에 속하게 된다. 만약 A 프로세스에서 쓰기 요청을 발생시켰다면 cfg queue (A)에 속하지 않고 ASYNC 서비스 트리 밑에 만들어진 큐에 들어가게 된다. cfq I/O 스케줄러는 이렇게 나뉜 I/O 요청들을 cfg queue에 넣고 각각 동등한 time slice를 할당한 다음 이 값을 기준으로 큐들을 순차적으로 처리한다. 그래서 cfg I/O 스케줄러는 모든 프로세스들에 치우침 없이 동등한 I/O 요청 처리 기회를 주지만, 일부 I/O를 많이 일으키거나 더 빨리 처리되어야 하는 I/O를 가진 프로세스들의 경우에 자신의 차례가 될 때까지 기다려야 하기 때문에 I/O 요청에 대한 성능이 낮아질 가능성이 있다.

첫번째 값은 back_seek_max이다. 이 값은 현재 디스크의 헤드가 위치한 곳을 기준으로 backward seeking(섹터 번호가 감소하는 방향, 디스크 섹터가 뒤로 이동하는 것)의 최댓값을 의미한다. backward seking 값의 기준 안에 들어오는 요청은 바로 다음 요청으로 간주되어 처리된다. 예를 들어 현재 헤드의 위치를 10이라고 하고, back_seek_max값을 5라고 가정한다면 헤드의 위치 10에서 5를 뺀 섹터 5번 ~ 9번까지의 요청들이 바로 다음 요청으로 간주되어 처리된다. 이 값은 인접한 섹터의 요청이 들어왔을 때 바로 처리할 수 있게 되어 헤더의 움직임을 최소화하는 데 도움이 되지만, 그만큼 다른 요청들이 밀릴 수 있기 때문에 적당한 값을 유지하는 것이 좋다. 만약 시스템에 순차 쓰기가 주를 이룬다면 이 값을 줄이는게 도움이 된다.

두번째 값은 back_seek_penalty이다. back_seek_max와 비슷하지만 이 값은 backward seeking의 패널티를 정의한다. 헤더를 움직이는 비용은 매우 크기 때문에 가능하다면 순서대로 움직이는 것이 좋다. 즉 1→2→3과 같은 식으로 계속해서 증가하는 추세로 동작하는 것이 1→3→2→4와 같이 뒤로 가는 것보다 성능에 더 도움이 된다. 그렇기 때문에 뒤로 갈 때는 back_seek_penalty 값으로 패널티를 적용한다. 예를 들어 현재 헤더의 위치가 10이고, 5번 섹터로 가는 요청과 15번 섹터로 가는 요청이 있다고 가정해보자. 둘 다 현재 헤더를 기준으로 5만큼 이동하겠지만 cfq는 여기에 back_seek_penalty 값을 곱해서 서로의 거리를 다르게 본다. 즉, 똑같이 5만큼 이동하지만 5번 섹터로 가는 것이 더 무겁고 성능에 좋지 않은 영향을 주는 작업이라고 인지하는 것이다.

세 번째 값은 fifo_expire_async이다. cfq에도 시간을 기준으로 한 fifo 리스트가 존재하고 그 중에서 async 요청에 대한 만료 시간을 정의한다. async는 비동기적이라는 의미로, 주로 쓰기 요청을 나타낸다. 애플리케이션의 쓰기 작업은 커널에 의해서 dirty page 쓰기 작업만으로 끝날 수 있다. 실제 디스크로의 쓰기 요청까지 발생하지 않고 메모리에 쓰기 작업을 하는 것만으로도 쓰기 작업이 완료되었다고 끝낼 수 있으며, 그렇기 때문에 대부분의 쓰기 작업은 애플리케이션을 블록시키지 않고 비동기적으로 완료된다. 이 값은 비동기적인 I/O 작업에 대한 만료 시간을 정의한다.

네번째 값은 fifo_expire_sync 값이다. fifo_expire_async 값과 유사하지만 sync 요청에 대한 만료 시간을 의미한다. sync는 동기적이라는 의미로, 주로 읽기 요청을 나타낸다. 만약 애플리케이션이 특정 파일의 내용을 읽는 작업을 한다면, 대부분의 경우는 읽기가 완료되어야만 다음 작업을 진행할 수 있다. 예를 들어 애플리케이션이 환경 변수가 설정되어 있는 설정 파일을 읽는다고 가정했을 때, 일부분만 읽어서는 애플리케이션이 동작할 수 없고 전체를 읽어 들여야 동작할 수 있다. 그래서 읽기 동작이 완료될 때까지 애플리케이션을 블록시킨다. 이 값은 동기적인 I/O 작업에 대한 만료 시간을 정의한다.

다섯번째 값은 group_idle이다. cfq는 원래 프로세스별로 할당된 큐를 이동할 때 해당 큐에 할당된 시간을 전부 사용하지 않았지만 I/O 요청이 전부 처리되어 이동해야 한다면 해당 큐는 idle 상태가 된다. 당장은 I/O 요청이 없지만 혹시라도 추가로 발생하게 될지 모르기 때문이다. 이 과정 중에서도 group_idle이 설정되어 있다면 같은 cgroup 안에 포함된 프로세스들에 대해서는 큐를 이동할 때 기다리지 않고 바로 다음 큐로 넘어가도록 동작한다. 하지만 같은 cgroup 안에 I.O 요청을 처리하고 다른 cgroup으로 넘어가려고 할 때에는 group_idle에 정의된 시간만큼을 대기한다.

여섯 번째는 group_isolation이다. 이 값 역시 cgroup과 관련이 있다. 단어가 의미하는 것처럼 서로 다른 cgroup 간의 차이를 더 명확하게 해주는 역할을 한다. 기본값은 0이며, 이 값이 0이 되면 위에서 언급한 서비스 트리 중 SYNC_NOIDLE에 속하는 큐들을 cgorup의 루트 그룹으로 이동시킨다. SYNC_NOIDLE은 임의 접근이기 때문에 디스크 헤드를 많이 움직이게 되고, 순차 접근에 비해 I/O 요청 완료까지 걸리는 시간이 상대적으로 오래 걸린다. 만약 각각의 cgroup 별로 SYNC_NOIDLE을 처리해야 한다면, SYNC_NOIDLE이 많은 경우 cgroup 별로 처리해야 하기 때문에 전체적인 성능이 낮아진다. cgroup 별로 임의 접근을 위한 SYNC_NOIDLE 서비스 트리를 만들지 않고 시스템의 모든 임의 접근을 하나의 SYNC_NOIDLE 서비스 트리에 모아놓고 한 번에 처리하면 I/O 성능이 더 좋아진다. 그리고 이때 임의 접근을 루트 cgroup에 있는 SYNC_NOIDLE에 모은다. 하지만 이렇게 되면 cgroup별로 설정한 값이 임의 접근에는 적용되지 않는다. 이때 group_isolation을 1로 설정하면 임의 접근도 cgroup 값의 영향을 받게 되고 각각의 cgroup 분류가 더 명확해진다. 따라서 이 값은 cgroup을 활용하는 환경에서는 성능에 큰 영향을 미치는 중요한 값이다.

일곱 번째는 low_latency이다. I/O 요청을 처리하다가 발생할 수 있는 대기 시간을 줄이는 역할을 한다. 이 값은 boolean 으로 0이나 1을 의미한다. cfq는 현재 프로세스별로 별도의 큐를 할당하고, 이 큐들은 그 성격에 따라 RT, BE, IDLE 이렇게 세 개의 큐로 다시 그룹화된다. 만약 I/O를 일으키는 5개의 프로세스가 있다고 가정했을 때, 아무 설정을 하지 않으면 이 5개의 프로세스는 각각 sorted, fifo 2개씩 큐를 할당 받고, 이 큐들은 BE 그룹으로 묶인다. 이때 low_latency가 설정되어 있다면 각 큐에 time slice를 할당하기 전에 각 그룹별로 요청이 몇 개씩 있는지 확인한다. 각 그룹별로 있는 큐의 요청들을 전부 합친 후에 time_slice를 곱하게 되는데, 이때 expect_latency 값이 생성된다. 즉, 해당 그룹의 큐를 모두 처리하는데 걸릴 시간을 계산하고, 이 계산 결과가 target_latency 값보다 크다면 이 값을 넘지 않도록 조절한다. 만약 low_latency가 켜져 있지 않으면 그룹의 큐를 확인하지 않게 되고, 큐에 I/O 요청이 많을수록 한 번의 time slice로는 처리가 어렵다. 이렇게 소요 시간이 많이 걸리는 요청들은 자신의 time slice를 다 쓰고 다시 차례가 돌아오기를 기다려야 하기 때문에 처리하는 데 많은 시간이 소요된다. low_latency 설정 값을 통해서 전체적인 I/O 요청을 바탕으로 time slice를 조절한다.

여덟 번째 값은 slice_idle이다. cfq는 큐 처리를 완료하고 바로 다음 큐로 넘어가지 않고 slice_idle에 설정된 시간만큼 기다린다. 보통 I/O 요청은 random access 보다는 sequential access가 주를 이룬다. 이곳저곳을 막 읽거나 쓰지 않고, 읽던 파일을 계속 읽거나 쓰던 파일에 계속해서 쓰기 때문이다. 그래서 cfq는 현재 요청 중인 큐를 모두 처리한 다음 혹시라도 또 I/O 요청이 들어오지 않을까 대기하고, I/O 요청이 들어온다면 해당 요청을 처리한다. 이렇게 하면 디스크 헤드의 움직임을 최소화할 수 있기 때문에 디스크의 접근 시간을 줄일 수 있다. 하지만 기다리는 동안 아무 요청도 들어오지 않는다면 기다리는 시간은 버려지는 시간이 된다. 과거에는 기본값이 6이었는데 최근 커널에는 기본값이 0으로 설정된다.

아홉 번째 값은 slice_sync이다. 앞에서도 이야기한 것처럼 cfq는 큐 별로 time slice를 할당해서 I/O를 처리하는 와중에 해당 설정 시간을 넘기면 다음번 큐를 처리하도록 동작한다. slice_sync는 이때 사용되는 time slice의 기준 값 중 하나로, sync 요청에 대한 time slice 기준이 된다. sync 요청은 실행하는 동안 애플리케이션이 블록되는, 즉 애플리케이션이 그 결과를 기다리게 되는 읽기와 같은 작업을 의미한다.

열 번째 값은 slice_async이다. slice_sync와 기능은 같지만 큐에 async 요청이 있을 때 설정되는 time slice의 기준 값이다. 대부분 쓰기 작업을 의미한다.

열한 번째 값은 slice_async_rq이다. cfq가 큐에서 한번에 꺼내서 dispatch queue에 넘기는 async 요청의 최대 개수이다.

마지막 열 두번째 값은 quantum이다. 이 값은 slice_async_rq와 반대로 sync 요청을 꺼내서 dispatch에 넘기는 최대 개수이다. 이 값이 커진다면 큐에서 한 번에 꺼낼 수 있는 요청의 개수가 증가하겠지만 그만큼 하나의 큐가 실행될 때 걸리는 시간이 늘어나기 때문에 경우에 따라서는 성능이 저하될 수 있다.

Block I/O Operation

[리눅스] Ch14. The Block I/O Layer

11.4 deadline I/O 스케줄러

deadline은 I/O 요청별로 완료되어야 하는 deadline을 가지고 있는 I/O 스케줄러이다. 예를 들어 읽기 요청은 500ms 안에 완료되어야 하는 등의 요청별 deadline이 주어지며, 가능한 한 해당 deadline을 넘기지 않도록 동작한다. 이 deadline은 반드시 그 시간 안에 완수될 수 있다는 것을 보장하는 값은 아니다.

I/O 스케줄러에는 2개의 sorted list와 2개의 fifo list가 있다. 2개의 sorted list는 각각 읽기 요청과 쓰기 요청을 저장하고 있으며 섹터 기준으로 정렬된다. 2개의 fifo list 역시 읽기 요청, 쓰기 요청을 저장하고 있으며, 섹터 기준이 아닌 요청이 발생한 시간을 기준으로 정렬된다.

평상시에는 sorted list에서 정렬된 상태의 요청을 꺼내서 처리하게 되지만, fifo list에 있는 요청들 중 deadline을 넘긴 요청이 있다면 fifo list에 있는 요청을 꺼내서 처리한다. 그리고 fifo list에서 요청이 처리되고나면 처리된 요청을 기준으로 sorted list를 재정렬해서 그 이후의 I/O 요청을 처리한다.

deadline I/O 스케줄러에서 튜닝이 가능한 값은 총 5가지가 있다.

첫번째 값은 fifo_batch이다. 이 값은 한번에 dispatch queue를 통해서 실행할 I/O 요청의 개수이다. deadline은 batch라는 이름으로 다수의 I/O 요청을 처리하는데, 이떄 몇 개의 I/O 요청을 전달할 것인지 결정하는 값이다. 기본값은 16개로, 16개의 I/O 요청이 하나의 batch로 간주되어 처리된다.

두번째 값은 front_merges이다. I/O 작업은 계속해서 섹터가 증가하는 구조이다. 로그 파일 쓰기를 생각해 보자. 새로운 로그는 기존 로그의 뒤에 추가되는데 이를 디바이스의 관점에서 본다면 계속해서 섹터가 증가하는 추세가 될 것이다. 중간에 특별히 다른 로직을 넣지 않는다면 이렇게 섹터가 증가하는 I/O 요청이 가장 자연스러운 요청의 형태이다. 하지만 파일의 앞에, 즉 현재 헤더 위치의 뒤가 아닌 앞쪽으로 I/O 요청이 발생하는 경우도 있는데, 이를 탐색하는 것이 front_merges값이다. 이 값은 0 혹은 1의 boolean 형태이며 기본값은 1이다. 만약 현재 시스템에서 발생하는 워크로드가 순차 쓰기, 혹은 순차 읽기가 주를 이루고 있다면 이 값을 0으로 해서 성능을 조금 더 향상시킬 수 있다.

세번째 값은 read_expire이다. 발생한 I/O 요청 중 read 요청에 대해 deadline을 결정할 때 사용되는 값으로 기본값은 500이며 단위는 ms이다. 즉, 읽기 요청은 500ms 동안 처리되지 않으면 만료된다.

네 번째 값은 write_expire이다. read_expire과 동일한 역할을 하며, write 요청에 대한 deadline을 결정할 때 사용된다. 단위는 역시 ms이며 기본값은 5000이다. 값을 보면 짐작할 수 있겠지만 deadline I/O 스케줄러는 읽기 요청 deadline이 더 짧아서 읽기 요청에 대한 처리를 좀 더 선호하도록 설계되어 있다.

마지막 다섯 번째 값은 writes_starved이다. 위에서도 언급했지만 deadline I/O 스케줄러는 읽기 요청에 대한 처리를 더 선호하기 때문에 자칫 잘못하면 쓰기 요청의 처리가 계속해서 지연될 수 있다. 그래서 이 값은 읽기 요청과 쓰기 요청 사이의 밸런스를 설정한다. 단위는 횟수이며 기본값은 2이다. 즉, 읽기 요청을 2번 처리하면 쓰기 요청을 1번 처리하는 식으로 동작한다. 이때의 요청은 하나의 요청을 의미하는 것이 아니라 한 번의 batch를 의미한다. 2번의 읽기 요청에 대한 batch 이후에 1번의 쓰기 요청에 대한 batch를 처리한다.

지금까지의 값을 토대로 살펴보면 deadline I/O 스케줄러는 쓰기 요청보다 읽기 요청을 더 많이 처리하도록 설계되었으며, batch와 같은 형태로 한번에 다수의 I/O 요청을 처리하고 있음을 알 수 있다. 하지만 파라미터를 변경해서 조금 더 워크로드에 맞도록 수정할 수도 있다. 예를 들어 write_expireread_expire와 동일한 값을 주고 writes_starved를 1로 주면 읽기와 쓰기 요청을 동등하게 처리한다.

11.5 noop I/O 스케줄러

noop 스케줄러는 간단한 형태의 I/O 스케줄러이다. 흔히 아무것도 하지 않는다고 생각할 수 있지만 noop 스케줄러도 병합 작업을 한다. 위에서 언급한 I/O 스케줄러의 가장 중요한 병합과 정렬 두 가지 역할 중 정렬은 하지 않고 병합 작업만 하는 I/O 스케줄러이다.

그림과 같이 큐도 하나만 존재하며, 인입된 I/O 요청에 대해 병합할 수 있는지의 여부만 확인한 후 병합이 가능하면 병합 작업을 진행한다. 별도의 섹터별 정렬 작업은 하지 않는다.

헤더를 움직여야 하는 기계식 디스크가 아닌 플래시 메모리를 기반으로 한 플래시 디스크는 헤더가 없기 때문에 특정 섹터에 도달하는 데 필요한 시간이 모두 동일하다. 반면에 헤더를 움직여야 하는 기계식 디스크는 현재 헤더의 위치가 어디냐에 따라서 섹터별로 접근하는 소요 시간이 달라진다. 이렇게 플래시 디스크는 어느 섹터에 언제 접근하든 소요되는 시간이 같이 때문에 굳이 정렬을 해서 헤더의 움직임을 최소화할 필요가 없다. 오히려 정렬하는 데 시간을 빼앗겨서 성능이 더 안 좋아질 수 있다. 그렇기 때문에 플래시 디스크의 경우는 noop I/O 스케줄러 사용을 권고하고 있다. 이 I/O 스케줄러의 경우는 튜닝 가능한 값도 존재하지 않는다.

11.6 cfq와 deadline의 성능 테스트

예를 들어 I/O를 일으키는 프로세스가 하나만 있다면 cfq와 deadline사이에는 큰 차이가 없을 것이다. 하지만 I/O를 일으키는 프로세스가 여러개인 상황에서는 cfq와 deadline은 차이가 있다. cfq는 다수의 프로세스들의 요청을 공평하게 분배해서 처리하는 한편, deadline은 요청이 어떤 프로세스로부터 발생했느냐가 아니라 언제 발생했느냐를 기준으로 처리하기 때문이다. 또한, 임의 접근이 많이 발생하느냐, 순차 접근이 많이 발생하느냐의 워크로드 차이도 성능의 차이를 만들어낼 수 있다.

fio는 다양한 옵션을 이용해서 실제 서버와 유사한 워크로드를 만들어낼 수 있다. 먼저 웹 서버의 워크로드를 시뮬레이션해보자. 웹 서버에서 발생하는 가장 많은 I/O 요청은 사용자의 요청에 대한 로그 기록이다. 로그 기록을 로그 파일의 끝에 계속해서 붙여 나가는 방식이기 때문에 순차 접근이 많으며, 프로세스가 로그 파일 디스크립터를 여러 개 열어서 사용하지 않기 때문에 발생하는 I/O 요청 자체도 그리 많지는 않을 것이다. 다음은 웹 서버의 워크로드를 시뮬레이션하기 위해 사용한 파라미터 값이다.

root@ip-172-31-0-63:/home/ubuntu# fio --ioengine=libaio --name=test --runtime=60 --time_based --clocksource=clock_gettime --numjobs=1 --rw=readwrite --bs=4k --size=16g --filename=fio_test.tmp --ioscheduler=cfq -
-iodepth=1 --direct=1
test: (g=0): rw=rw, bs=(R) 4096B-4096B, (W) 4096B-4096B, (T) 4096B-4096B, ioengine=libaio, iodepth=1
fio-3.36
Starting 1 process
test: Laying out IO file (1 file / 16384MiB)
fio: ENOSPC on laying out file, stopping
fio: pid=0, err=28/file:filesetup.c:240, func=write, error=No space left on device

Run status group 0 (all jobs):
root@ip-172-31-0-63:/home/ubuntu# fio --ioengine=libaio --name=test --runtime=60 --time_based --clocksource=clock_gettime --numjobs=1 --rw=readwrite --bs=4k --size=16m --filename=fio_test.tmp --ioscheduler=cfq -
-iodepth=1 --direct=1
test: (g=0): rw=rw, bs=(R) 4096B-4096B, (W) 4096B-4096B, (T) 4096B-4096B, ioengine=libaio, iodepth=1
fio-3.36
Starting 1 process
test: Laying out IO file (1 file / 16MiB)
fio: unable to set io scheduler to cfq
fio: pid=2723, err=22/file:backend.c:1467, func=iosched_switch, error=Invalid argument

Run status group 0 (all jobs):

Disk stats (read/write):
  xvda: ios=0/0, sectors=0/0, merge=0/0, ticks=0/0, in_queue=0, util=0.00%
root@ip-172-31-0-63:/home/ubuntu# fio --ioengine=libaio --name=test --runtime=60 --time_based --clocksource=clock_gettime --numjobs=1 --rw=readwrite --bs=4k --size=16m --filename=fio_test.tmp --ioscheduler=mq-deadline --iodepth=1 --direct=1
test: (g=0): rw=rw, bs=(R) 4096B-4096B, (W) 4096B-4096B, (T) 4096B-4096B, ioengine=libaio, iodepth=1
fio-3.36
Starting 1 process
Jobs: 1 (f=1): [M(1)][100.0%][r=2836KiB/s,w=2684KiB/s][r=709,w=671 IOPS][eta 00m:00s]
test: (groupid=0, jobs=1): err= 0: pid=2736: Sat Feb 28 14:14:31 2026
  read: IOPS=722, BW=2890KiB/s (2959kB/s)(169MiB/60001msec)
    slat (usec): min=9, max=139, avg=17.14, stdev= 4.92
    clat (usec): min=341, max=30072, avg=569.22, stdev=248.87
     lat (usec): min=356, max=30088, avg=586.36, stdev=249.03
    clat percentiles (usec):
     |  1.00th=[  383],  5.00th=[  408], 10.00th=[  424], 20.00th=[  465],
     | 30.00th=[  502], 40.00th=[  529], 50.00th=[  553], 60.00th=[  578],
     | 70.00th=[  611], 80.00th=[  660], 90.00th=[  701], 95.00th=[  725],
     | 99.00th=[  807], 99.50th=[ 1029], 99.90th=[ 2409], 99.95th=[ 5145],
     | 99.99th=[ 8717]
   bw (  KiB/s): min= 2576, max= 3264, per=100.00%, avg=2892.17, stdev=148.80, samples=119
   iops        : min=  644, max=  816, avg=723.04, stdev=37.20, samples=119
  write: IOPS=717, BW=2868KiB/s (2937kB/s)(168MiB/60001msec); 0 zone resets
    slat (usec): min=12, max=190, avg=18.68, stdev= 5.24
    clat (usec): min=470, max=30984, avg=778.46, stdev=315.38
     lat (usec): min=487, max=31000, avg=797.14, stdev=315.53
    clat percentiles (usec):
     |  1.00th=[  519],  5.00th=[  545], 10.00th=[  570], 20.00th=[  611],
     | 30.00th=[  660], 40.00th=[  709], 50.00th=[  766], 60.00th=[  807],
     | 70.00th=[  857], 80.00th=[  930], 90.00th=[ 1004], 95.00th=[ 1020],
     | 99.00th=[ 1090], 99.50th=[ 1205], 99.90th=[ 3916], 99.95th=[ 5473],
     | 99.99th=[ 9241]
   bw (  KiB/s): min= 2544, max= 3232, per=100.00%, avg=2870.92, stdev=150.45, samples=119
   iops        : min=  636, max=  808, avg=717.73, stdev=37.61, samples=119
  lat (usec)   : 500=14.68%, 750=57.43%, 1000=22.53%
  lat (msec)   : 2=5.21%, 4=0.06%, 10=0.08%, 20=0.01%, 50=0.01%
  cpu          : usr=1.36%, sys=3.38%, ctx=86391, majf=0, minf=12
  IO depths    : 1=100.0%, 2=0.0%, 4=0.0%, 8=0.0%, 16=0.0%, 32=0.0%, >=64=0.0%
     submit    : 0=0.0%, 4=100.0%, 8=0.0%, 16=0.0%, 32=0.0%, 64=0.0%, >=64=0.0%
     complete  : 0=0.0%, 4=100.0%, 8=0.0%, 16=0.0%, 32=0.0%, 64=0.0%, >=64=0.0%
     issued rwts: total=43344,43023,0,0 short=0,0,0,0 dropped=0,0,0,0
     latency   : target=0, window=0, percentile=100.00%, depth=1

Run status group 0 (all jobs):
   READ: bw=2890KiB/s (2959kB/s), 2890KiB/s-2890KiB/s (2959kB/s-2959kB/s), io=169MiB (178MB), run=60001-60001msec
  WRITE: bw=2868KiB/s (2937kB/s), 2868KiB/s-2868KiB/s (2937kB/s-2937kB/s), io=168MiB (176MB), run=60001-60001msec

Disk stats (read/write):
  xvda: ios=43269/43030, sectors=346152/344480, merge=0/19, ticks=24686/33299, in_queue=57984, util=97.88%

output (1).png

output.png

프로세스의 개수가 증가함에 따라 I/O 처리 성능도 함께 증가했으며, cfq와 deadline 사이의 성능 차이가 거의 없다. 순차 접근 자체가 헤드의 움직임이 최소화 되어 있고 프로세스들이 발생시키는 I/O 요청 자체도 많지 않기 때문이다. 그래서 웹 서버에서는 I/O 스케줄러가 성능에 큰 영향을 주지는 않는다.

다음으로 파일 서버의 워크로드를 시뮬레이션해보자. 파일 서버에서는 다양한 파일들에 대한 접근이 이루어지기 때문에 순차 접근보다는 임의 접근이 많이 발생한다. 또한, 사용자의 요청이 많을 경우 많은 양의 I/O가 발생할 수 있다. 다음은 파일 서버의 워크로드를 시뮬레이션하기 위해 사용한 파라미터 값이다.

root@ip-172-31-0-63:/home/ubuntu# fio --ioengine=libaio --name=test --runtime=60 --time_based --clocksource=clock_gettime --numjobs=1 --rw=randrw --bs=4k --size=16m --filename=fio_test.tmp --ioscheduler=mq-deadl
ine --iodepth=64 --direct=1
test: (g=0): rw=randrw, bs=(R) 4096B-4096B, (W) 4096B-4096B, (T) 4096B-4096B, ioengine=libaio, iodepth=64
fio-3.36
Starting 1 process
Jobs: 1 (f=1): [m(1)][100.0%][r=5953KiB/s,w=6550KiB/s][r=1488,w=1637 IOPS][eta 00m:00s]
test: (groupid=0, jobs=1): err= 0: pid=2748: Sat Feb 28 14:20:22 2026
  read: IOPS=1597, BW=6390KiB/s (6544kB/s)(375MiB/60020msec)
    slat (usec): min=2, max=1652, avg=14.24, stdev=42.64
    clat (usec): min=553, max=60161, avg=15274.74, stdev=4621.32
     lat (usec): min=563, max=60168, avg=15288.98, stdev=4622.08
    clat percentiles (usec):
     |  1.00th=[ 1254],  5.00th=[11076], 10.00th=[11338], 20.00th=[11994],
     | 30.00th=[12780], 40.00th=[13566], 50.00th=[14353], 60.00th=[15401],
     | 70.00th=[16581], 80.00th=[17957], 90.00th=[20579], 95.00th=[23462],
     | 99.00th=[31327], 99.50th=[34341], 99.90th=[42206], 99.95th=[44827],
     | 99.99th=[51643]
   bw (  KiB/s): min= 5768, max=18432, per=100.00%, avg=6397.38, stdev=1129.01, samples=119
   iops        : min= 1442, max= 4608, avg=1599.34, stdev=282.25, samples=119
  write: IOPS=1594, BW=6376KiB/s (6529kB/s)(374MiB/60020msec); 0 zone resets
    slat (usec): min=3, max=20804, avg=15.53, stdev=104.26
    clat (usec): min=572, max=146555, avg=24803.27, stdev=11700.08
     lat (usec): min=645, max=146567, avg=24818.79, stdev=11700.10
    clat percentiles (usec):
     |  1.00th=[  1745],  5.00th=[ 11731], 10.00th=[ 13042], 20.00th=[ 15401],
     | 30.00th=[ 17957], 40.00th=[ 20579], 50.00th=[ 23462], 60.00th=[ 26084],
     | 70.00th=[ 28967], 80.00th=[ 32375], 90.00th=[ 36963], 95.00th=[ 41681],
     | 99.00th=[ 69731], 99.50th=[ 81265], 99.90th=[105382], 99.95th=[112722],
     | 99.99th=[129500]
   bw (  KiB/s): min= 5688, max=19144, per=100.00%, avg=6380.37, stdev=1194.80, samples=119
   iops        : min= 1422, max= 4786, avg=1595.09, stdev=298.70, samples=119
  lat (usec)   : 750=0.03%, 1000=0.28%
  lat (msec)   : 2=1.16%, 4=0.32%, 10=0.13%, 20=61.20%, 50=35.48%
  lat (msec)   : 100=1.33%, 250=0.07%
  cpu          : usr=1.70%, sys=4.32%, ctx=182707, majf=0, minf=11
  IO depths    : 1=0.1%, 2=0.1%, 4=0.1%, 8=0.1%, 16=0.1%, 32=0.1%, >=64=100.0%
     submit    : 0=0.0%, 4=100.0%, 8=0.0%, 16=0.0%, 32=0.0%, 64=0.0%, >=64=0.0%
     complete  : 0=0.0%, 4=100.0%, 8=0.0%, 16=0.0%, 32=0.0%, 64=0.1%, >=64=0.0%
     issued rwts: total=95885,95676,0,0 short=0,0,0,0 dropped=0,0,0,0
     latency   : target=0, window=0, percentile=100.00%, depth=64

Run status group 0 (all jobs):
   READ: bw=6390KiB/s (6544kB/s), 6390KiB/s-6390KiB/s (6544kB/s-6544kB/s), io=375MiB (393MB), run=60020-60020msec
  WRITE: bw=6376KiB/s (6529kB/s), 6376KiB/s-6376KiB/s (6529kB/s-6529kB/s), io=374MiB (392MB), run=60020-60020msec

Disk stats (read/write):
  xvda: ios=94096/92236, sectors=765432/764456, merge=1585/3312, ticks=1435614/2297410, in_queue=3733024, util=95.54%

순차 접근과는 사뭇 다른 결과를 볼 수 있다. 프로세스의 개수가 하나일 때는 둘 다 비슷하지만 프로세스의 개수가 많아질수록 두 I/O 스케줄러의 성능 차이는 벌어지기 시작한다.

cfq I/O 스케줄러는 프로세스 A에 할당된 큐를 모두 처리한 후 프로세스 B에 할당된 큐를 처리해야 한다. 그래서 A의 I/O 요청 순서대로 10. 50, 100번 블록을 처리한 후 B의 I/O 요청 순서인 30, 60, 80번 블록을 처리한다. 그림을 보면 각각의 큐에서는 정렬된 상태였지만 전체적으로 봤을 때는 정렬되지 않고 헤드가 움직인다. 하지만 deadline I/O 스케줄러는 A에서 발생한 I/O 요청과 B에서 발생한 I/O 요청을 함께 정렬해서 전달한다. 그래서 전체적으로 블록 순으로 정렬이 가능해지고 cfq에 비해 헤드의 움직임이 더 적어진다.

이렇게만 이야기하면 cfq가 deadline보다 좋지 않은 스케줄러처럼 보일 수도 있지만 그렇지 않다. 자신의 타임 슬라이스가 다 소비되면 다른 큐로 넘어간다는 이야기는 바꿔 말하면 I/O 스케줄러가 하나의 I/O 요청을 전적으로 처리해 주는 시간이 반드시 있다는 것을 의미한다. 그래서 다수의 프로세스가 동등하게 I/O 요청을 일으키는 경우라면 cfq I/O 스케줄러가 조금 더 좋은 성능을 보여줄 수 있다. 여기서의 좋은 성능이란 더 빠른 응답속도보다는 모든 프로세스가 비슷한 수준으로 I/O 요청을 처리하는 것을 의미한다. 주로 동영상 스트리밍이나 인코딩을 처리하는 서버에 유리한 환경이다. 다수의 프로세스가 다수의 사용자로부터 받은 스트리밍 혹은 인코딩 요청을 처리하게 되는데 그 프로세스들이 균등하게 I/O 요청을 처리할 수 있다면 특정 사용자의 요청이 빨리 처리되거나 하는 불균등 현상을 줄일 수 있다.

반대로 DB 서버와 같이 특정 프로세스가 많은 양의 I/O 요청을 일으키는 경우에는 deadline I/O 스케줄러가 더 효율적이다. 타임 슬라이스에 따라서 특정 프로세스의 I/O 요청이 처리되지 않는 idle 타임이 존재하지 않고 I/O 요청의 발생 시간을 기준으로 처리되기 때문이다.

11.7 I/O 워크로드 살펴보기

https://www.hpe.com/kr/ko/what-is/workload.html#:~:text=워크로드는 컴퓨팅 리소스,과 양을 의미합니다.

그렇다면 현재 시스템에서 발생하는 I/O 워크로드는 어떻게 살펴볼 수 있을까? 먼저 얼마나 많은 프로세스가 I/O를 일으키는지에 대한 워크로드를 살펴보자. 다수의 프로세스가 I/O를 일으킨다면 cfq가 deadline보다 상대적으로 유리할 수 있다.

I/O를 일으키는 프로세스들을 가장 효과적으로 파악할 수 있는 명령어는 iotop이다. iotop 명령을 입력하면 얼마나 많은 프로세스들이 I/O를 일으키는지 확인할 수 있다.

코드를 보면 현재 시스템에서는 sysbench 프로세스만 I/O를 발생시키는 것을 확인할 수 있다. 코드는 웹 서버의 역할을 하고 있는 서버에서의 iotop 결과이다. 다수의 프로세스가 I/O 요청을 일으키는 것을 확인할 수 있다. 웹 서버의 경우는 사용자의 요청을 처리하는 프로세스들이 모두 공평하게 I/O를 사용하도록 보장해주면 전체적인 성능에 도움이 되기 때문에 cfq I/O 스케줄러를 사용하는 것이 좋다.

다음으로는 임의 요청이 많은지 순차 요청이 많은지를 살펴보자. 오픈소스 툴 중에서 perf-tools를 사용할 것이다. 이 프로젝트에는 많은 툴들이 포함되어 있지만 iosnoop이란 툴을 사용할 것이다.

코드는 sysbench로 순차 읽기 테스트를 진행했을 때의 결과이다. BLOCK의 값이 순차적으로 증가하는 것을 볼 수 있다. 디스크로의 접근이 섹터의 크기별로 순차적으로 증가하고 있으며 헤드의 움직임이 최소화되는 워크로드다.

코드는 sysbench로 임의 읽기/쓰기에 대한 테스트를 했을 때의 결과이다. BLOCK의 숫자가 앞뒤로 꽤 큰 차이를 두고 변하는 것을 알 수 있다. 그에 따라서 소요 시간도 기존과는 달리 크게 벌어진다. 이렇게 임의 읽기/쓰기가 많아지면 헤드의 움직임이 많아지고 이에 따라 요청 처리 시간도 길어진다.