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

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

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

앞서 글에서 살펴보았듯이 bio를 통해 전달된 I/O 연산 요청은 각 블록 장치 드라이버에서 제공하는
make_request_fn 콜백을 통해 처리되는데 일반적인 디스크 장치의 경우 __make_request() 함수가
request를 할당하고 buffer bouncing, plugging, merging 등의 공통적인 작업을 처리한 후
이를 (elevator라고도 부르는) I/O 스케줄러에게 넘겨주게 된다.
여기서는 이 __make_request() 함수에 대해서 알아보기로 할 것이다.

가장 먼저 blk_queue_bounce() 함수를 통해 디스크 장치의 특성에 따라 페이지를 더 할당하는데
오래된 ISA 방식의 디스크인 경우 디스크 장치가 DMA를 통해 접근할 수 있는 주소의 범위가
16MB (24bit) 밖에 되지 않기 때문이다. (동일한 문제는 64bit 시스템의 PCI 장치에서도
4GB 이상의 메모리가 존재하는 경우에 발생할 수 있다.)

이 경우 전달된 bio의 세그먼트에 해당하는 페이지를 장치가 접근할 수 없으므로
접근할 수 있는 영역의 페이지 (ZONE_DMA/DMA32)를 새로 할당한 후 (이를 bounce buffer라고 한다)
이를 이용하여 실제 I/O를 대신 처리하는 방법을 사용해야 한다.

이 과정이 완료되면 I/O 요청을 드라이버에게 전달하기 위해 request 구조체를 할당하게 되는데
그 전에 기존의 request에 현재 bio가 merge 될 수 있는지를 먼저 검사하게 된다.
일단 request 구조체에 대해서 먼저 간략히 살펴볼 필요가 있다.

request 구조체는 기본적으로는 (bio와 동일하게) 디스크 상에서 연속된 영역에 해당하는 I/O 연산 요청에 대한
정보를 포함하는데 추가적으로 드라이버에서 사용할 여러 low-level 자료 구조를 포함/참조하고 있다.
특히나 세그먼트 정보는 이미 bio 구조체에 저장되어 있으므로 이를 그대로 이용하며
만약 연속된 디스크 영역에 여러 bio가 전달된 경우 이를 하나의 리스트로 연결하여 관리한다.

아래는 전체 request 구조체 중에서 현재 관심있는 부분 만을 표시한 것이다.

include/linux/blkdev.h:
struct request {
    struct list_head queuelist;

    ...

    struct request_queue *q;

    unsigned int cmd_flags;
    enum rq_cmd_type_bits cmd_type;

    ...

    ⁄* the following two fields are internal, NEVER access directly *⁄
    unsigned int __data_len;    ⁄* total data len *⁄
    sector_t __sector;          ⁄* sector cursor *⁄

    struct bio *bio;
    struct bio *biotail;

    struct hlist_node hash;      ⁄* merge hash *⁄

    ⁄*
     * The rb_node is only used inside the io scheduler, requests
     * are pruned when moved to the dispatch queue. So let the
     * completion_data share space with the rb_node.
     *⁄
    union {
        struct rb_node rb_node;    ⁄* sort/lookup *⁄
        void *completion_data;
    };

    ...

    ⁄* Number of scatter-gather DMA addr+len pairs after
     * physical address coalescing is performed.
     *⁄
    unsigned short nr_phys_segments;

    ...
};
 
request는 궁극적으로 해당 장치에서 제공하는 request_queue로 전달되어 처리되는데
(실제로 전달되는 순서는 I/O 스케줄러에서 조정한다) q 필드는 이 request가 전달될 큐를 가리키며
queuelist 필드는 request_queue 내의 리스트를 관리하기 위해 필요한 포인터이다.

cmd_flags는 앞서 bio에서 살펴보았듯이 해당 I/O 연산의 특성을 알려주는 REQ_* 형태의 플래그이며
cmd_type은 일반적인 경우 REQ_TYPE_FS 값으로 설정된다. (filesystem 연산)

__sector는 해당 request가 접근하는 디스크 상의 위치를 섹터 단위로 저장한 것이며
__data_len은 해당 request가 처리하는 데이터의 길이를 바이트 단위로 저장한 것이다.
(이 필드들은 드라이버에서 요청을 처리하는 도중에 갱신될 수 있으므로 외부에서 접근하면 안된다)

bio와 biotail은 해당 request에 포함된 bio의 목록으로 merge 시에 확장될 수 있으며
hash는 merge할 request를 빨리 찾을 수 있도록 해시 테이블을 구성하기 위해 필요하다.
(merge 과정에 대해서는 잠시 후에 살펴볼 것이다.)

rb_node 필드는 I/O 스케줄러가 request를 디스크 상의 위치를 통해 정렬하기 위해 사용되며
nr_phys_segments는 해당 request가 포함하는 총 메모리 세그먼트의 수를 저장한다.

이제 merge 과정에 대해서 알아보기로 하자.
submit_bio() 함수를 통해 요청된 (최초) bio는 request 형태로 변경될 것이다.
그런데 바로 후에 (아마도 filesystem 계층에서) 디스크 상에서 연속된 영역에 대해
다시 submit_bio()를 호출하여 bio를 요청하는 경우가 있을 수 있다.

이 경우 최초에 생성된 request에 두 번째로 요청된 bio가 포함되게 되며
__sector 및 __data_len 필드는 필요에 따라 적절히 변경될 것이고
bio와 biotail 필드는 각각 첫번째 bio와 두번째 bio를 가리키게 될 것이다.
(각각의 bio는 내부의 bi_next 필드를 통해 연결된다)

그럼 문제는 주어진 bio를 merge할 request를 어떻게 찾아내느냐 인데
(위에서 설명한 아주 단순한 경우는 바로 이전에 생성된 request를 찾은 경우였지만
디스크 접근 패턴이 복잡한 경우는 여러 request들을 검색해 보아야 할 것이다.)
이를 위해 기본적으로 각 디스크의 I/O 스케줄러는 (위에서 언급한) 해시 테이블을 유지한다.

해시 테이블은 request가 접근하는 가장 마지막 섹터의 경계를 기준으로 구성하는데
이는 디스크 접근이 보통 섹터 번호가 증가하는 순으로 이루어지는 경우가 많기 때문일 것이다.
이 경우 원래의 request가 접근하는 제일 뒤쪽에 새로운 bio가 연결되므로 이를 back merge라고 부른다.
반대로 원래의 request보다 앞쪽에 위치하는 bio가 요청된 경우를 front merge라고 한다.
back merge의 경우는 항상 가능하지만 front merge의 경우는 I/O 스케줄러에 따라 허용하지 않을 수도 있다.
물론 이 외에도 merge가 되려면 해당 request와 bio는 호환가능한 속성을 가져야 한다.

또한 sysfs를 통해 I/O 스케줄러의 merge 시도 여부를 제어할 수가 있는데
예를 들어 sda라는 디스크의 경우 /sys/block/sda/queue/nomerges 파일의 값에
  • 0을 쓰면 항상 (해시 테이블을 검색하여) 가능한 경우 merge를 허용하고,
  • 1을 쓰면 바로 이전에 생성 또는 merge된 request와의 merge 만을 허용하며
  • 2를 쓰면 merge를 허용하지 않게 된다.

하지만 이러한 I/O 스케줄러의 해시 테이블은 각 디스크 별로 유지되기 때문에
해당 디스크에 접근하려는 여러 태스크는 동기화를 위해 lock을 필요로하게 된다.
이는 많은 디스크 I/O가 발생하는 시스템에서 성능 상 좋지 않은 효과를 줄 수 있는데
이를 위해 이러한 공유 해시 테이블에 접근하기 전에 먼저 각 태스크 별로 유지하는
plugged list를 검사하여 merge가 가능한 request가 존재하는지 확인하게 된다.

plugged list는 이른바 'block device plugging'이라는 기능을 구현한 것인데
이는 디스크의 동작 효율을 높이기 위한 기법으로, 디스크가 idle 상태였다면
request가 요청된 즉시 처리하지 않고 조금 더 기다림으로써 여러 request를 모아서
한꺼번에 처리하거나 merge될 시간을 벌어주는 효과를 얻게 된다.

즉, 디스크에 대한 접근이 발생하면 plugged 상태로 되어 I/O 스케줄러가 잠시 request를 보관하며
이후 특정 조건이 만족된 경우 (일정 시간이 경과하거나, 충분히 많은 I/O 요청이 발생한 경우)
장치가 (자동으로) unplug되어 주어진 request들을 실제로 처리하기 시작하는 형태였다.

하지만 2.6.39 버전부터 plugging 방식이 태스크가 직접 unplug 하는 식으로 변경되면서
태스크 별로 I/O 스케줄러에 request를 넘기기 전에 자신이 생성한 request를 리스트 형태로
유지하게 되었다. 따라서 이는 공유되지 않으므로 불필요한 lock contention을 줄일 수 있다.

단 이 per-task plugging 방식은 선택적인 것이므로 __make_request() 실행 당시
해당 태스크는 이 기능을 이용하지 않을 수도 있다.

이렇게 plugged list와 I/O 스케줄러 (혹은 엘리베이터)의 request를 검색한 후에도
merge할 마땅한 request를 찾지 못했다면 해당 bio를 위한 request를 새로 생성한다.
마찬가지로 request 구조체를 할당할 때도 GFP_NOIO 플래그를 사용하며
mempool 인터페이스를 사용하여 비상 시를 위한 여분의 구조체를 미리 준비해 둔다.

또한 각 디스크 (request_queue)에는 처리할 수 있는 request의 최대값이 정해져 있어서
그 이상 request를 생성하지 못하도록 제어하는데 기본값으로는 BLKDEV_MAX_RQ (128)이 사용되며
이에 따라 해당 디스크의 congestion 상태를 판단하기 위한 threshold 값이 결정된다.
이 경우 113개 이상의 request가 대기 중이면 디스크가 병목 현상을 겪고 있다고 판단하며
대기 중인 request의 수가 다시 103개 이하로 떨어지면 정상 상태로 회복되었음을 인식한다.

따라서 request 할당 시 이 threshold 값을 보고 적절히 디스크 상태를 설정하여
상위 계층에서 I/O 요청을 생성하는 속도를 조절할 수 있도록 하고 있다.

만약 병목 현상이 일어나고 있는 상황에서도 계속 I/O 요청이 발생하여 결국 할당된 request의 수가
최대값에 다다르면 디스크 (request_queue)가 가득찼음을 나타내는 플래그를 설정하여
더 이상 request를 생성하지 못하도록 하되, 단 현재 태스크는 batcher task로 설정하여
얼마간의 (함께 요청된) request를 더 생성할 수 있도록 배려하고 있다.
또한 request 할당 시 메모리 부족으로 인해 잠시 sleep되었던 경우에도
해당 태스크를 batcher task로 설정한다.

이렇게 request를 할당받고 난 후에는
per-task plugging을 이용하는 경우라면 해당 request를 plugged list에 연결하고
그렇지 않은 경우라면 I/O 스케줄러에 전달한 뒤 바로 디스크 드라이버에게 I/O를 요청한다.

=== 참조 문헌 ===

반응형