사용자가 발생시키는 읽기, 쓰기와 같은 I/O 작업은 가상 파일 시스템, 로컬 파일 시스템 등의 경로를 거친 후 블록 디바이스로 전달 전에 I/O 스케줄러를 거치게 된다.
I/O 스케줄러는 상대적으로 접근 속도가 느린 디스크에 대한 성능을 최대화하기 위해 구현된 커널의 일부이며 모든 I/O 작업은 I/O 스케줄러를 통해서 블록 디바이스에 전달된다.
I/O 스케줄러는 기본적으로 병합과 정렬이라는 두 가지 방식을 이용해서 I/O 요청을 블록 디바이스에 전달하게 되는데, 서버에서 발생하는 워크로드와 I/O 스케줄러의 알고리즘에 따라 성능을 더 좋게 만들수도, 더 나쁘게 만들수도 있다.
여기에서는 I/O 스케줄러가 뭐고 어떤 역할을 하는지, 그리고 어떤 알고리즘이 있는지 살펴보고 마지막으로 시스템에서 발생하고 있는 I/O 워크로드를 확인해보자.
I/O 스케줄러를 알아보기 전에 디스크에 대해서 먼저 살펴보자. 디스크는 크게 두 종류로 나눌 수 있다. 헤드와 플레터 등의 기계식 부품으로 이뤄진 하드 디스크 드라이브 (HDD) 와 플래시 메모리를 기반으로 이뤄진 솔리드 스테이트 드라이브 (SDD) 이다.
HDD 는 플래터 (Platter) 라는 원판과 같은 자기 장치가 있고 디스크 헤더가 플래터 위를 움직이면서 데이터를 읽거나 쓰기 작업을 한다.
HDD 에 저장되어 있는 데이터를 읽기 위해서는 디스크 헤드를 플래터의 특정 위치로 움직여야 한다.
헤드는 기계 장치이기 때문에 시스템 내의 다른 부품과 다르게 (CPU 나 메모리) 이동 시간이 걸리고 많은 시간을 소요한다. 그래서 헤드의 움직임을 최소화하고 한번 움직일 때 최대한 처리해야 I/O 성능이 극대화 될 수 있다.
반면에 SDD 는 플래시 메모리를 기반으로 구성되어 있다. SDD 는 기계식 디스크와 다르게 헤드와 플래터 없이 기판에 정착되어있는 플래시 메모리에 데이터를 쓰거나 읽는다. 헤드 대신에 컨트롤러라는 장치를 통해서 디스크로 유입되는 I/O 작업을 조정한다. SDD 는 장치를 움직이지 않고 전기적 신호를 이용해서 접근하기 때문에 임의로 특정 섹터에 접근할 때 소요되는 시간은 모두 동일하다.
즉 HDD 는 헤더의 위치에 따라서 어떤 데이터에 접근하느냐에 따라서 속도가 다르다면 SDD 는 동일하다.
디스크와 관련된 작업은 시간이 오래 걸리기 떄문에 커널은 I/O 스케줄러를 통해서 조금이라도 성능을 올리려고 한다. 이런 성능 극대화를 위해서 병합과 정렬이라는 방법을 이용한다.
먼저 병합을 보자.여러 개의 요청을 하나로 합치는 걸 병합이라고 한다.
다음과 같은 요청은 하나로 합칠 수 있다.
- 접근 블록 주소: 12 읽어와야 하는 블록: 1
- 접근 블록 주소: 11 읽어와야 하는 블록: 1
- 접근 블록 주소: 10 읽어와야 하는 블록: 1
→ 접근 블록 주소: 10 읽어와야 하는 블록: 3
즉 접근 블록 주소가 인접하다면 합칠 수 있다.
이렇게 병합을 통해서 디스크에 내리는 명령 전달을 최소화 해서 성능을 향상시킬 수 있다.
다음으로 정렬을 살펴보자. 정렬이란 여러 개의 요청을 섹터 순서대로 재배치하는 걸 말한다. 정렬을 통해서 헤더의 움직임을 최소하 하는게 가능하다.
다음과 같은 가정을 해보자. I/O 요청이 총 4 개가 들어왔다.
- 1 번 블록
- 7 번 블록
- 3 번 블록
- 10 번 블록
정렬을 하지 않는다면 총 이동 거리는 다음과 같다. (0번부터 시작 가정) = 1 + 6 + 4 + 7 = 18
하지만 정렬을 하면 1 + 2 + 4 + 3 = 10
이렇게 섹터 순서대로 최적의 경로로 갈 수 있도록 재배치 하는 걸 정렬이라고 한다. 이 방법의 단점은 먼저 들어온 요청이 늦게 배치될 수 있다는 단점이 있는데 이를 위해서 I/O 스케줄러는 다양한 알고리즘으로 이를 해결한다.
SSD 의 경우는 어떨까? 헤드가 없기 때문에 헤드를 이동하는 비용이 없다. 그래서 사실상 정렬이 의미없다. 그래서 SSD 는 조금 다른 I/O 스케줄러를 쓰는데 이는 뒤에서 알아보자.
I/O 스케줄러에 대한 내용을 본격적으로 다루기 전에 현재 설정된 I/O 스케줄러를 확인하는 방법과 변경하는 방법을 보자.
현재 시스템에 적용 가능한 I/O 스케줄러와 설정되어 있는 정보는 다음과 같은 코드로 확인할 수 있다
cat /sys/block/{{device}}/queue/scheduler
[none] mq-deadline
cfq 는 Completely Fair Queueing 의 약자로 우리말로 하면 ‘완전 큐잉’ I/O 스케줄러를 말한다.
공정이라는 단어에서 유추할 수 있듯이 프로세스들이 발생하는 I/O 요청들이 모든 프로세스에서 공정하게 실행되는 것이 특징이다.
cfq 는 각각의 프로세스에서 발생시킨 I/O 는 Block I/O Layer 를 거친 후 실제 디바이스로 내려가는데 블록 디바이스로 내려가기 전에 cfq I/O 스케줄러를 거치게 된다. cfq I/O 는 특성에 따라서 RT (Real Time), BE (Best Effort), IDLE 로 I/O 요청을 우선순위 대로 나누는데 기본적으로 I/O 요청은 대부분 BE 에 속한다. RT 에 속한 요청들을 가장 먼저 처리하고 IDLE 에 속한 요청을 가장 나중에 처리한다.
I/O 우선순위에 따라 나눈 다음에는 Service Tree 라는 워크로드별 그룹으로 또 나눈다.
이 그룹은 SYNC, SYNC_NOIDLE, ASYNC 로 나뉘는데 하나씩 보자.
SYNC 는 순차적인 동기화 I/O 작업으로 여기에 속한 큐들은 처리를 완료하기 전에 일정시간 동안 대기한다. 순차적인 작업이므로 가까운 범위의 작업이 들어올 여지가 많기 때문에 기다린다.
대기하는 시간은 slice_idle
값으로 바꾸는게 가능하다.
SYNC_NOIDLE 은 임의적인 동기화 I/O 요청으로 임의 읽기를 하는 경우에 속하는 경우이며 여기에 있는 건 기다리지 않고 바로 처리한다. 임의 읽기기 때문에 굳이 기다릴 필요는 없다.
ASYNC 는 비동기화 I/O 작업으로 주로 쓰기 작업을 할 때 모아서 처리하는게 더 성능이 좋으므로 ASYNC 에서 모아놓고 처리한다.
이렇게 I/O 우선순위와 워크로드에 따라 분류된 I/O 요청은 어떤 프로세스에서 발생시켰느냐에 따라 어떤 큐에 들어갈 것인지 최종적으로 결정된다.
A 프로세스에서 발생시킨 요청은 cfq queue (A) 에 속하게 되고 B 프로세스에서 발생시킨 요청은 cfq queue (B) 에 속한다. 하지만 쓰기 요청을 발생시켰다면 ASYNC 서비스 트리 밑에 만들어진 큐에 공통적으로 들어간다.
cfq I/O 스케줄러는 이렇게 나뉜 I/O 요청들을 각 cfq queue 에 넣고 동등하게 time slice 를 할당한다. 그리고 치우침 없이 순차적으로 처리한다.
그러므로 일부 I/O 를 일으키거나 더 빨리 처리되어야 할 I/O 가 있다면 차례를 기다려야 하기 떄문에 성능이 낮아질 가능성이 있다.
cfq I/O 스케줄러에서 튜닝이 가능한 값은 총 12 가지가 있다.
back_seek_max
: 현재 디스크의 헤드가 위치한 곳을 기준으로 backward seeking 의 최댓값을 의미한다. 이 값 안에 들어온 요청의 경우 바로 다음 요청으로 간주해서 처리된다. 예를 들어서 헤드의 위치가 10 이고back_seek_max
가 5이다. 그러면 5 ~ 9 의 요청들은 바로 다음 요청으로 간주되면서 처리할 수 있다.- 두 번째 값은
back_seek_penalty
이다.back_seek_max
와 비슷하지만 이 값은 backward seeking 의 패널티를 정의한다. 헤더의 경우는 순차적으로 증가하는 추세가 가장 움직임이 덜하다. 즉 뒤로 가는 요청의 경우에는 앞으로 가는 요청보다 같은 값이라도 좀 더 무겁게 여긴다. 그래서 이 계산을 위해서back_seek_penalty
가 있다. 현재 헤더에서 뒤로 가는 요청이 5 의 값이 온다면 그 값과 이 패널티를 곱해서 실제적으로 이동 거리를 계산한다. - 세 번째 값은
fifo_expire_async
이다. cfq 에도 시간을 기준으로 fifo 리스트가 존재하고 그 중에서도 async 요청에 대한 만료시간을 정의한다. - 네 번째 값은
fifo_expire_sync
이다. 이 값은 sync 요청에 대한 만료 시간을 의미한다. - 다섯 번째 값은
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 에 속하는 큐들을 cgroup 의 루트 그룹으로 이동시킨다. cgroup 별로 임의 접근을 위한 SYNC_NOIDLE 서비스 트리를 만들지 않고 모든 임의 접근을 하나의 SYNC_NOIDLE 서비스 트리에 모아놓고 한번에 처리한다면 I/O 성능이 좋을 것이다. - 일곱 번째는
low_latency
이다. I/O 요청을 처리하다가 발생할 수 있는 대기시간을 줄이는 역할을 한다. 이 값은 boolean 으로 0 과 1 즉 disable/enable 을 의미한다. cfq 는 현재 프로세스별로 별도의 큐를 할당하고, 이 큐들은 각각의 성격에 따라 RT, BE, IDLE 로 분류된다. 아무 설정을 하지 않는다면 각 그룹별로 잇는 큐의 요청들을 전부 합친 후 time_slice 를 곱해서 expect_latency 를 생성하게 되는데 해당 그룹의 큐를 모두 처리하는데 걸릴 시간을 계산하고 이 결과가 target_latency 를 넘지 않도록 조절한다. 만약 low_latency 가 켜져 있지 않으면 그룹의 큐를 확인하지 않게 되고 큐에 I/O 요청이 많을수록 한 번의 time slice 로는 처리가 어렵다. - 여덟 번째는
slice_idle
이다. cfq 는 큐 처리를 완료하고 바로 다음 큐로 넘어가지 않고slice_idle
에 설정된 시간만큼 기다린다. 보통 순차접근 요청이 많아서 현재 요청을 처리한 후에도 다음 I/O 요청이 또 오지 않을까 대기하고 들어온다면 해당 요청을 처리한다. 이것도 디스크의 접근 시간을 줄이는 방법이다. - 아홉 번째 값은
slice_sync
이다. cfq 는 큐 별로 time slice 를 할당해서 I/O 를 처리하는데 설정 시간을 넘기면 다음 큐를 처리하도록 동작한다.slice_sync
는 time slice 의 기준 값중 하나로 sync 에 대한 요청에 대한 time slice 를 한다. - 열 번째 값은
slice_async
이다.slice sync
와 기능은 같지만 async 요청이 있을 때 설정되는 time slice 값이다. - 열한 번째 값은
slice_async_rq
로 cfq 가 큐에서 한번에 꺼내서 dispatch queue 에 넘기는 async 요청의 최대 개수이다. - 마지막 값은 quantum 이다. 이 값은
slice_async_rq
와는 반대로 sync 요청을 꺼내서 dispatch 에 넘기는 최대 개수이다.