본문 바로가기
【Fundamental Tech】/→ 🐧Kernel

[Linux] 블록 장치 I/O 동작 방식 (2) in Linux 3.1-rc4

반응형
*출처: http://studyfoss.egloos.com/5576850

앞서 블록 I/O 연산의 기본 자료 구조인 bio 구조체에 대해 살펴보았다.
이번에는 submit_bio() 함수를 통해 bio가 전달되는 과정을 들여다보기로 하자.

submit_bio() 함수는 주어진 I/O 연산의 종류를 bio 구조체에 저장한 뒤
generic_make_request() 함수를 호출하는데 이를 살펴보기 전에
I/O 연산의 종류에 대해서 먼저 간단히 알아볼 것이다.

가장 기초적인 연산은 당연히 READ와 WRITE이며
READ는 디스크의 데이터를 메모리로 읽어오는 동작을 뜻하고 WRITE는 그 반대이다.
READ의 경우 CPU 입장에서 보면 필요로하는 데이터를 아직 갖지 못한 상태이므로
I/O 연산이 종료되어야 실제 데이터를 입수해서 다음 동작을 수행할 수 있기 때문에
READ 연산은 synchronous하게 즉, I/O 연산이 끝날 때까지 다른 작업을 수행하지 않고
기다리게 되며 따라서 되도록 빨리 처리하려고 노력하게 된다.

WRITE의 경우 CPU는 이미 필요한 데이터를 가지고 있으므로 실제 I/O 연산이
언제 수행되는지는 크게 관심이 없으며 asynchronous 방식으로 처리하게 된다.
나중에 살펴보겠지만 I/O 스케줄러는 일반적으로 synchronous 연산을 우선적으로 처리한다.

하지만 WRITE 연산이라도 필요한 경우 synchronous 방식으로 처리하고 싶을 수도 있다.
예를 들어 사용자가 편집하던 파일을 저장하는 경우와 같이 실제로 WRITE 연산이
종료되기를 기다리는 상황에서는 WRITE_SYNC 연산을 사용하면 된다.

최근의 디스크 장치의 경우 장치 내부에 write 속도를 높이기 위한 캐시를 이용하는 경우가 있다.
하지만 이로 인해 WRITE_SYNC 연산을 통해 디스크에 write를 수행했다 하더라도
데이터가 내부 캐시에 남아있는 채로 실제 디스크 (플래터)에 기록되지 않았을 수가 있고
그 순간에 불의의 상황이 닥치면 데이터를 잃어버리게 될 가능성이 존재한다.

이를 위해 디스크 내부 캐시의 데이터를 실제로 디스크에 기록하는 명령을 제공하는데
리눅스에서는 WRITE_FLUSH 연산을 통해 이러한 작업을 수행할 수 있다.
또한 WRITE_FUA 연산은 디스크 내부 캐시를 거치지 않고(?) 직접 디스크에 데이터를 기록하는
FUA (Forced Unit Access) 기능을 제공하는 경우 사용할 수 있다.

또한 SSD 장치의 경우 더이상 사용되지 않은 영역을 장치 내부적으로 정리하여
접근 성능을 높일 수 있는 discard (혹은 trim) 연산을 지원하기도 한다.

이러한 I/O 연산의 종류 및 그에 따른 특성을 나타내기 위해 bio와 request 구조체는
REQ_* 형태의 플래그를 공유하며 이는 rq_flag_bits 열거형을 통해 정의되어 있고
위에서 설명한 I/O 연산 매크로들은 이 플래그들을 조합하여 만들어진다.

include/linux/fs.h:
#define RW_MASK          REQ_WRITE
#define RWA_MASK        REQ_RAHEAD

#define READ                 0
#define WRITE                RW_MASK
#define READA               RWA_MASK

#define READ_SYNC            (READ | REQ_SYNC)
#define READ_META            (READ | REQ_META)
#define WRITE_SYNC           (WRITE | REQ_SYNC | REQ_NOIDLE)
#define WRITE_ODIRECT      (WRITE | REQ_SYNC)
#define WRITE_META           (WRITE | REQ_META)
#define WRITE_FLUSH          (WRITE | REQ_SYNC | REQ_NOIDLE | REQ_FLUSH)
#define WRITE_FUA              (WRITE | REQ_SYNC | REQ_NOIDLE | REQ_FUA)
#define WRITE_FLUSH_FUA   (WRITE | REQ_SYNC | REQ_NOIDLE | REQ_FLUSH | REQ_FUA)

generic_make_request() 함수는 주어진 bio에 대해 장치 드라이버에 제공하는 방식
(make_request_fn 콜백)을 통해 request를 만들어내는 작업을 수행한다.
여기서 bio는 앞서 살펴보았듯이 상위 계층 (VFS)에서 요청한 블록 I/O 연산에 대한 정보를
담고 있는 것이며 request는 실제로 장치 드라이버에서 장치와 실제 I/O 작업을 수행하는 것에
필요한 정보를 담고 있는 구조체이다.

이전 글에서 언급했듯이 블록 장치는 상대적으로 연산 속도가 매우 느리기 때문에
상위 계층에서 요청한 작업을 즉시 수행하지 않고 (I/O 스케줄러를 통해) 순서를 조정하게 되며
이 과정에서 여러 번에 걸쳐 요청된 bio들이 하나의 request로 합쳐지게 되는 경우도 있다.

이러한 작업들을 모두 처리하는 함수가 generic_make_request() 함수로써
장치 드라이버에서 I/O 연산에 필요한 여러 준비 작업들을 수행하게 되는데
몇몇 특별한 장치의 경우 이 과정이 재귀적으로 일어날 수 있기 때문에 이에 대한 대비를 위해
실제 처리는 __generic_make_request() 함수로 분리하였다.

S/W RAID (리눅스 커널에서는 MD (Multple Disks)라고 부른다) 또는 DM (Device Mapper)과
같은 장치는 커널에서 제공하는 특수 장치로 여러 물리적인 디스크 장치를 묶어서
마치 하나의 장치인 것 처럼 관리하는데, 이러한 장치에 대한 I/O 연산은 하위에 존재하는
여러 개의 실제 장치에 대한 I/O 연산으로 변경(clone)되어 수행되기도 하므로
이에 대한 재귀적인 처리 과정에서 커널 스택이 소진되는 문제가 발생할 수 있다.
(direct-reclaim 시의 writeback과 같은 경우 이미 많은 양의 커널 스택이 사용된 상황일 것이다)

참고로 블록 계층에서의 메모리 할당은 매우 조심스럽게(?) 이루어지는데
앞서 말했다시피 이미 시스템의 메모리가 부족해진 상황에서 캐시로 사용되던 페이지들을
다른 용도로 재사용하기 위해 기존의 내용을 디스크에 기록해야 하는 경우가 많은데
이 때 디스크 I/O가 처리되기 때문이다. 즉, 메모리가 부족한 상황에서 메모리를 회수해야 하는
태스크가 (I/O 처리 과정에 필요한) 새로운 메모리를 요청하게 되는데 이미 메모리가 부족하므로
할당이 성공할 수 없고 따라서 해당 태스크가 대기 상태로 빠져 deadlock이 발생할 수 있는 문제를 안게 된다.

그래서 블록 I/O 처리 경로에서의 메모리 할당은 일반적으로 사용하는 GFP_KERNEL 매크로가 아닌,
(I/O를 발생시키지 않는) GFP_NOIO 매크로를 통해 이루어지며
많은 경우 memory pool과 같은 기법을 이용하여 최악의 상황에서도 사용할 수 있도록
필요한 객체들을 사전에 미리 할당해 두는 방식을 사용한다.

generic_make_request() 함수는 현재 실행되는 태스크가 해당 함수를 재귀적으로 호출했는지
검사하기 위해 먼저 task_struct의 bio_list 필드를 검사한다.
이 값이 NULL이 아니라면 재귀적으로 호출된 경우이므로 리스트에 현재 bio를 추가하고 종료한다.
그렇지 않다면 최초 호출이므로 스택에 할당된 bio_list 구조체로 bio_list 필드를 설정하고
실제로 요청을 처리하기 위해 __generic_make_request() 함수를 호출하며
호출이 완료된 후에는 그 사이에 재귀적으로 추가된 bio가 있는지 검사하여 있다면 이를 다시 수행한다.
리스트 내에 더 이상 bio가 존재하지 않는다면 bio_list 필드를 NULL로 설정하고 종료한다.

__generic_make_request() 함수도 또한 하나의 loop로 구현되어 있는데
마찬가지로 MD 혹은 DM과 같은 장치에서 해당 장치에 대한 I/O 요청을 그 하위의 실제 장치에 대한
I/O 요청으로 변경(remap)하는 경우가 있기 때문이다. 장치 드라이버는 주어진 bio를
실제 장치가 처리하기 위한 request로 만들기 위해 make_request_fn 콜백을 제공하는데
정상적인 경우 이 콜백 함수는 0을 리턴하여 loop 내부를 1번 만 수행하고 바로 종료한다.
하지만 위에서 말한 특수한 장치의 경우 0이 아닌 값을 리턴하여 bio가 다른 장치로 remap 되었음을
알려주면 다시 loop 내부를 수행하여 새로운 장치에 대해 필요한 검사를 수행한다.

loop 내부에서는 bio가 요청한 장치가 현재 사용 가능한 상태인지, 요청한 블록이 장치의 범위를 넘어서는지,
FLUSH, FUA, DISCARD와 같은 특수 기능을 장치가 제공하는지 등을 검사하며
I/O를 요청한 장치가 디스크 파티션이라면 이를 전체 디스크에 대한 위치로 재조정한다.
또한 fault injection에 의한 I/O 요청 실패 상황을 검사하거나
block throttling 정책에 따라 현재 요청된 I/O를 잠시 대기 시킬 것인지 여부를 결정하게 된다.

이러한 모든 단계가 정상적으로 완료되면 드라이버에서 제공하는 make_request_fn 콜백을 호출한다.
일반적인 디스크 장치는 기본 구현인 __make_request() 함수를 콜백으로 등록하게 되며
이 과정에서 현재 bio를 장치에 전달하기 위해 필요한 request를 찾거나 새로 생성한다.

하지만 위에서 말한 MD 및 DM과 같은 복잡한 장치들은 물론
일반 파일을 디스크처럼 다루는 loop 장치와 메모리를 다루는 RAM 디스크 장치 (brd 모듈) 등은
request를 생성하지 않고 bio 구조체를 직접 이용하여 I/O 연산을 수행한다.

예를 들어 MD 장치의 구성 중에 여러 디스크를 마치 하나의 디스크인 것처럼 연결하는 linear 모드
(MD의 용어로는 personality라고 한다)가 있다. 이 경우 MD 장치로 들어온 요청은
make_request_fn 콜백으로 등록된 md_make_request() 함수에서 처리되는 데
이는 다시 해당 장치의 personality에서 제공하는 make_request 콜백을 호출하여
결국 linear_make_request() 함수가 호출되게 된다.

linear_make_request() 함수는 MD 장치의 블록 번호에 해당하는 실제 장치를 찾은 후에
bio의 장치 및 섹터 정보를 적절히 변경하고 1을 리턴한다.
그러면 __generic_make_request() 함수 내의 loop가 새로운 장치에 대해 다시 수행되어
실제 디스크 장치로 I/O 요청이 전달되는 것이다.

만일 MD 장치에 대한 요청이 linear 모드로 연결된 실제 장치의 경계에 걸친 경우
이는 내부적으로 두 개의 bio로 분할되고 (bio_split() 함수 참고), 각각의 장치에 대해 다시
generic_make_request() 함수를 호출하므로 task_struct의 bio_list에 연결된 후 차례로 처리될 것이다.

반응형