Home [Linux System Programming] Ch04 고급 버퍼 입출력
Post
Cancel

[Linux System Programming] Ch04 고급 버퍼 입출력

[Ch04 고급 버퍼 입출력]

2장에서는 파일입출력의 근본일 뿐만 아니라, 리눅스에서 일어나는 모든 통신의 토대인 기본 입출력 시스템 콜을 배웠다.

3장에서는 기본 입출력 시스템 콜에 사용자 영역 버퍼링이 필요한 때를 알아보고 해법으로 C언어의 표준 입출력 라이브러리에 대해 공부했다.

4장에서는 리눅스의 고급 입출력 시스템 콜에 대해 알아본다.

벡터 입출력

  • 한번의 호출로 여러 버퍼에서 데이터를 읽거나 쓸 수 있도록 해줌.
  • 다양한 자료구조를 단일 입출력 트랜젝션으로 다룰 때 유용하다.

epoll

  • poll()과 select() 시스템 콜을 개선한 시스템 콜이다.
  • 싱글 스레드에서 수백 개의 FD를 poll해야 하는 경우에 유용하다.

메모리맵 입출력

  • 파일을 메모리에 맵핑해서 간단한 메모리 조작을 통해 파일 입출력을 수행함.
  • 특정 패턴의 입출력에 유용하다.

파일 활용법 조언

  • 프로세스에서 파일을 사용하려**의도**를 커널에게 제공할수 있도록 하여, 입출력 성능을 향상시킴.

비동기식 입출력

  • 작업 완료를 기다리지 않는 입출력을 요청한다.
  • 스레드를 사용하지 않고 동시에 입출력 부하가 많은 작업을 처리할 경우 유용함

4.1 벡터 입출력

  • 한번의 시스템 콜을 사용해서 여러개의 버퍼 벡터에 쓰거나, 여러 개의 버퍼 벡터로 읽어 들일 때 사용하는 입출력 메서드
    • 2장의 표준 읽기와 쓰기는 선형 입출력이라고 함.
  • 벡터 입출력의 장점
    • 좀 더 자연스러운 코딩 패턴 - 미리 정의된 구조체의 여러 필드에 걸쳐서 데이터가 분리되어 있는 경우, 벡터 입출력을 사용하면 직관적인 방법으로 조작할 수 있음
    • 효율 - 한번의 사용으로 여러번의 선형 입출력 연산을 대체할 수 있음
    • 성능 - 시스템 콜 호출 횟수 ⬇️, 내부적으로 최적화된 구현을 제공
    • 원자성 - 벡터 입출력 연산 중에 다른 프로세스가 끼어들 수 없음

4.1.1 readv() 와 writev()

  • readv() 함수는 fd에서 데이터를 읽어서 count 개수만큼 iov 버퍼에 저장한다.
1
ssize_t readv (int fd, const struct iovec *iov, int count);
  • writev() 함수는 count 개수만큼 iov 버퍼에 있는 데이터를 fd에 기록함
1
ssize_t writev(int fd, const struct iovec *iov, int count);
  • readv()와 writev() 함수는 여러 개의 버퍼를 사용한다는 점에서 read(), write()와 구분됨
  • iovec 구조체는 세그먼트라고 하는 독립적으로 분리된 버퍼를 나타낸다.
1
2
3
4
struct iovec{
	void *iov_base; // 버퍼의 시작 포인터
	size_t iov_len; // 버퍼 크기 (바이트)
}
  • 이런 세그먼트의 집합을 벡터라고 한다.
  • 벡터의 각 세그먼트에는 데이터를 기록하거나 읽어올 메모리 공간의 주소와 크기가 저장되어 있다.
  • 두 함수는 각 버퍼에 iov_len 바이트만큼 데이터를 채우거나 쓴 다음, 다음 버퍼로 넘어간다.
  • 두 함수 모드 iov[0] 부터 시작해서 iov[1], 그리고 iov[count-1]까지 세그먼트 순서대로 동작한다.

반환값

  • 두 함수는 호출이 성공했을 때 읽거나 쓴 바이트 개수를 반환함
    • 반환값은 반드시 count * iov_len 값과 같아야함.
  • 에러 발생 시 -1을 반환, errno 를 설정
  • 각각 read(), write() 시스템 콜에서 발생 가능한 모든 종류의 에러가 발생할 수 있음

    • 추가로 두 가지 에러 상황을 정의하고 있음

      1. 반환값의 자료형이 ssize_t 이기 때문에, 만약 count * iov_len 값이 SSIZE_MAX보다 큰 경우에는 데이터가 전송되지 않고 -1을 반환하며 errno 는 EINVAL로 설정됨
      2. POSIX에서는 count가 0보다 크고 IOV_MAX(리눅스에서는 현재 1024로 정의하고 있음) 와 같거나 작아야 한다고 명시하고 있는데, 만약 count가 0이라면 readv()와 writev()는 0을 반환한다.

        만약 count 값이 IOV_MAX보다 크다면 데이터는 전송되지 않고 -1을 반환하며 errno는 EINVAL로 설정됨

최적 count 찾기

  • 벡터 입출력 작업을 할 때 리눅스 커널에서는 각 세그먼트를 위해 내부 데이터 구조체를 반드시 할당하게 됨!
  • 근데 이 할당은 count의 크기에 따라 동적으로 일어난다.
  • 만약 count값이 크지 않다면 스택에 미리 만들어둔 작은 세그먼트 배열을 사용해서, 동적 할당이 일어나지 않도록한다. → 성능 개선! (아주 효율적으로 동작함)
  • 그러니깐 벡터 입출력 연산을 사용할 때 세그먼트의 개수의 감이 오지 않는다면 8 이하로 시도~~

예제

  • writev() 예제
  • 3개의 벡터 세그먼트에 데이터를 쓰는 예제
    • 각각 크기가 다른 문자열을 담고 있음
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
int main(){
    struct iovec iov[3];
    ssize_t nr;
    int fd, i;

    char *buf[] = {
        "aaa",
        "bbbb",
        "cccccc"
    };

    fd = open("buccaneer.txt", O_WRONLY | OCREAT | O_TRUNC);
    if (fd == -1)
        // error
        return 1;

    // 세 iovec 구조체 값을 채운다
    for (i = 0; i<3;i++){
        iov[i].iov_base = buf[i];
        iov[i].iov_len = strlen(buf[i]) + 1;
    }

    // 단 한번의 호출로 세 iovec 내용을 모두 쓴다
    nr = writev (fd, iov, 3);
    if (nr == -1 )
        //error
        return 1;
    printf("wrote %d bytes\n", nr);

    if (close(fd))
        //error
        return 1;

    return 0
}
  • readv()예제
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <sys/uio.h>

int main ()
{
        char foo[48], bar[51], baz[49];
        struct iovec iov[3];
        ssize_t nr;
        int fd, i;

        fd = open ("buccaneer.txt", O_RDONLY);
        if (fd == 1) {
                perror ("open");
                return 1;
        }

        /* set up our iovec structures */
        iov[0].iov_base = foo;
        iov[0].iov_len = sizeof (foo);
        iov[1].iov_base = bar;
        iov[1].iov_len = sizeof (bar);
        iov[2].iov_base = baz;
        iov[2].iov_len = sizeof (baz);

        /* read into the structures with a single call */
        nr = readv (fd, iov, 3);
        if (nr == 1) {
                perror ("readv");
                return 1;
        }

        for (i = 0; i < 3; i++)
                printf ("%d: %s", i, (char *) iov[i].iov_base);

        if (close (fd)) {
                perror ("close");
                return 1;
        }

        return 0;
}
  • 구현
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <unistd.h>
#include <sys/uio.h>

ssize_t naive_writev (int fd, const struct iovec *iov, int count)
{
        ssize_t ret = 0;
        int i;

        for (i = 0; i < count; i++) {
                ssize_t nr;

                errno = 0;
                nr = write (fd, iov[i].iov_base, iov[i].iov_len);
                if (nr == 1) {
                        if (errno == EINTR)
                                continue;
                        ret = 1;
                        break;
                }
                ret += nr;
        }

        return ret;
}
  • readv()와 writev()는 사용자 영역에서 단순 루프를 사용해서 구현할 수 있음!
  • 사실 리눅스 커널 내부의 모든 입출력은 벡터 입출력이다.
    • read, write 구현 역시 하나짜리 세그먼트를 가지는 벡터 입출력으로 구현되어 있음.

4.2 epoll

  • poll과 select 의 한계에 대해서 인지하면서 커널 2.6버전에서는 epoll(event poll) 이라는 기능이 추가되었음
  • poll과 select
    • 실행할 때마다 전체 fd를 요구함
      • → 커널은 검사해야 할 모든 파일 리스트를 다 살펴봐야함.
      • → fd 리스트의 크기가 수백 ~ 수천까지 커지면 병목현상이 발생
  • epoll은 실제로 검사하는 부분과 검사할 fd를 등록하는 부분을 분리해서 위의 문제를 해결함
  • epoll은 세 가지 System call로 동작함
    1. epoll 컨텍스트를 초기화
    2. 검사해야 할 fd를 epoll 컨텍스트에 등록하거나 삭제함
    3. 실제 이벤트를 기다리도록 동작

4.2.1 새로운 epoll 인스턴스 생성하기

  • epoll 컨텍스트는 epoll_create1()을 통해서 생성됨
1
2
3
4
5
6
#include <sys/epoll.h>

int epoll_create1 (int flags);

/* deprecated. use epoll_create1() in new code. */
int epoll_create (int size);
  • 호출이 성공하면 새로운 epoll 인스턴스를 생성하고 그 인스턴스와 연관된 fd (epoll fd) 를 반환한다.
    • 요 fd는 실제 파일과는 아무런 관계가 없고 epoll 기능을 사용하는 다음 호출에 사용되는 핸들일 뿐임.
  • flag 인자는 epoll 동작을 조정하기 위한 것
    • 0을 쓰면 size 인자가 없어졌다는 점을 빼면 epoll_create()과 동일함!
      • epoll_fd 의 크기정보를 전달했었음
    • 현재는 EPOLL_CLOSEXEC 만 유효함
    • 새 프로세스가 실행될 때 이 파일을 자동적으로 닫아준다.
  • 에러가 발생하면 -1을 반환, errno를 설정

    • EINVAL
      • 잘못된 flags 인자
    • EMFILE
      • 사용자의 최대 파일 초과
    • ENFILE
      • 시스템의 최대 파일 초과
    • ENOMEM
      • 메모리 부족
  • 사용예제

    1
    2
    3
    4
    5
    
    int epfd;
    
    epfd = epoll_create1 (0);
    if (epfd < 0)
            perror ("epoll_create1");
    
    • epoll_create1()에서 반환하는 fd는 폴링이 끝난 뒤에 반드시 close()로 닫아줘야한다.

4.2.2 epoll 제어

  • epoll_ctl() 시스템 콜은 주어진 epoll 컨텍스트에 fd를 추가하거나 삭제할 때 사용한다.

    1
    2
    3
    4
    5
    6
    
    #include <sys/epoll.h>
    
    int epoll_ctl (int epfd,
                   int op,
                   int fd,
                   struct epoll_event *event);
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    struct epoll_event {
            __u32 events;  /* events */
            union {
                    void *ptr;
                    int fd;
                    __u32 u32;
                    __u64 u64;
            } data;
    };
    
  • epoll_ctl() 호출이 성공하면 해당 epoll 인스턴스는 epfd 파일 디스크립터와 연결된다.
  • epfd
    • 이전에 epoll_create1() 로 생성한 epoll fd
  • fd
    • 등록할 fd
  • op 인자는 fd가 가리키는 파일에 대한 작업을 명시한다.
    • 어떤 변경을 할지 결정하는 값
    • EPOLL_CTL_ADD
      • epfd와 연관된 epoll 인스턴스가 fd와 연관된 파일을 감시하도록 추가하며, 각 이벤트는 event 인자로 정의한다.
    • EPOLL_CTL_DEL
      • epfd와 연관된 epoll 인스턴스에 fd를 감시하지 않도록 삭제한다.
    • EPOLL_CTL_MOD
      • 기존에 감시하고 있는 fd에 대한 이벤트를 event에 명시된 내용으로 갱신한다.
  • event 인자는 그 작업의 동작에 대한 설명을 담고 있다.
    • 이벤트 유형
  • epoll_event 구조체의 events 필드는 주어진 fd에서 감시할 이벤트의 목록을 담고 있음
    • 여러가지 이벤트를 OR로 묶을 수 있다.
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      
      enum Events
      {
       EPOLLIN,   //수신할 데이터가 있다.
       EPOLLOUT,  //송신 가능하다.
       EPOLLPRI,  //중요한 데이터(OOB)가 발생.
       EPOLLRDHUD,//연결 종료 or Half-close 발생
       EPOLLERR,  //에러 발생
       EPOLLET,   //엣지 트리거 방식으로 설정
       EPOLLONESHOT, //한번만 이벤트 받음
      }
      
  • epoll_event 구조체의 data 필드는 사용자 데이터를 위한 필드이다.
    • 이 필드에 담긴 내용은 요청한 이벤트가 발생해서 사용자에게 반환될 때 함께 반환됨.
    • 일반적인 사용 예
      • event.data.fd를 fd로 채워서 이벤트가 발생했을 때 어떤 fd를 들여다 봐야 하는지 확인하는 용도
  • 성공 시 0을 반환하고 실패 시 -1을 반환, errno 설정
  • 예제 코드

    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    struct epoll_event event;
    int ret;
    
    event.data.fd = fd; /* return the fd to us later (from epoll_wait) */
    event.events = EPOLLIN | EPOLLOUT;
    
    ret = epoll_ctl (epfd, EPOLL_CTL_ADD, fd, &event);
    if (ret)
            perror ("epoll_ctl");
    
  • epfd와 연관된 fd에 설정된 기존 구독 이벤트를 변경하려면 아래와 같이 작성하면 됨

    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    struct epoll_event event;
    int ret;
    
    event.data.fd = fd; /* return the fd to us later */
    event.events = EPOLLIN;
    
    ret = epoll_ctl (epfd, EPOLL_CTL_MOD, fd, &event);
    if (ret)
            perror ("epoll_ctl");
    
  • 반대로 epoll 인스턴스 epfd에 등록된 fd에 연관된 기존 이벤트를 삭제하려면 아래와 같이!

    1
    2
    3
    4
    5
    6
    
    struct epoll_event event;
    int ret;
    
    ret = epoll_ctl (epfd, EPOLL_CTL_DEL, fd, &event);
    if (ret)
            perror ("epoll_ctl");
    
  • op 값이 EPOLL_CTL_DEL인 경우 이벤트 마스크가 없기 때문에 event 값이 NULL이 될 수도 있음.
    • 하지만, 호환성 문제 떄문에 유효한 포인터를 넘겨야함.

4.2.3 epoll로 이벤트 기다리기

  • epoll_wait() 시스템 콜은 epoll 인스턴스와 연관된 fd에 대한 이벤트를 기다린다.
1
2
3
4
5
6
7
#include <sys/epoll.h>

int epoll_wait( int efpd,                  //epoll_fd
                struct epoll_event* event, //event 버퍼의 주소
                int maxevents,             //버퍼에 들어갈 수 있는 구조체 최대 개수
                int timeout                //select의 timeout과 동일 단위는 1/1000
              );
  • epoll_wait 를 호출하면 timeout 밀리 초 동안 epoll 인스턴스인 epfd와 연관된 파일의 이벤트를 기다린다.
  • 성공할 경우 events에는 발생한 해당 이벤트 (파일이 읽어나 쓰기가 가능한 상태인지를 나타내는 epoll_event 구조체에 대한 포인터) 가 기록된다. 발생한 이벤트 개수를 반환
    • events의 data 필드에는 사용자가 epoll_ctl() 을 호출하기 전에 설정한 값이 담겨 있다.

      따라서 모든 fd에 대해 순회하면서 체크할 필요가 없음! 이벤트가 있는 fd들이 배열에 담겨오고 그 개수를 알 수 있으니 꼭 필요한 event 만 순회하면서 처리할 수 있다는 장점!

  • 에러가 발생할 경우 -1을 반환하고 errno 를 설정
  • timeout
    • 0이면 epoll_wait()는 이벤트가 발생하지 않아도 즉시 0을 반환함.
    • -1이면 이벤트가 발생할 때까지 해당 호출은 반환되지 않음
  • 예제
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#define MAX_EVENTS    64

struct epoll_event *events;
int nr_events, i, epfd;

events = malloc (sizeof (struct epoll_event) * MAX_EVENTS);
if (!events) {
        perror ("malloc");
        return 1;
}

nr_events = epoll_wait (epfd, events, MAX_EVENTS, 1);
if (nr_events < 0) {
        perror ("epoll_wait");
        free (events);
        return 1;
}

for (i = 0; i < nr_events; i++) {
        printf ("event=%ld on fd=%d\n",
                events[i].events,
                events[i].data.fd);
				/*
         * We now can, per events[i].events, operate on
         * events[i].data.fd without blocking.
         */
}

free (events);

4.2.4 에지 트리거와 레벨 트리거

Untitled

  • epoll_ctl()로 전달하는 event 인자의 events 필드를 EPOLLET로 설정하면 fd에 대한 이벤트 모니터가 레벨 트리거가 아닌 에지 트리거로 동작한다.
  • 유닉스 파이프 통신 입출력 예시
    1. 출력하는 쪽에서 파이프에 1KB만큼의 데이터를 씀
    2. 입력을 받는 쪽에서는 파이프에 대해서 epoll_wait()를 수행하고 파이프에 데이터가 들어와서 읽을 수 있는 상태가 되기를 기다림
      • 레벨 트리거일 경우
        • 2단계의 epoll_wait() 호출은 즉시 반환하며 파이프가 읽을 준비가 되었음을 알려줌
      • 에지 트리거일 경우
        • 1단계가 완료될 때까지 호출이 반환되지 않음.
        • 즉, epoll_wait()를 호출하는 시점에 파이프를 읽을 수 있는 상황이더라도 파이프에 데이터가 들어오기 전까지는 결과 반환 안함.
  • 기본 동작 방식은 레벨 트리거
    • poll()과 select()의 동작방식도 동일

4.3 메모리에 파일 맵핑하기

  • 리눅스 커널은 표준 파일 입출력의 대안으로 애플리케이션이 파일을 메모리에 맵핑할 수 있는 인터페이스를 제공한다.
    • 메모리 주소와 파일의 단어가 일대일 대응이 된다는 것을 의미
    • → 개발자가 메모리를 통해 파일에 직접 접근이 가능함.
      • → 메모리 주소에 직접 쓰는 것만으로 디스크에 있는 파일에 기록할 수 있음

4.3.1 mmap()

  • mmap()을 호출하면 fd가 가리키는 파일의 offset 위치에서 len 바이트만큼 메모리에 맵핑하도록 커널에 요청한다.
1
2
3
4
5
6
7
8
#include <sys/mman.h>

void * mmap (void *addr,
             size_t len,
             int prot,
             int flags,
             int fd,
             off_t offset);
  • addr
    • addr가 포함되면 메모리에서 해당 주소를 선호한다고 커널에 알려줌
    • 그저 힌트일 뿐이며 대부분 0을 넘겨줌
  • len
    • fd 가 가리키는 파일의 offset 위치에서 len 바이트만큼 메모리에 맵핑하도록 커널에 요청함.
  • prot
    • 접근권한을 지정
    • 맵핑에 원하는 메모리 보호 정책을 명시 PROT_NONE: 접근 불가 PROT_READ: 읽기 가능 PROT_WRITE: 쓰기 가능 PROT_EXEC: 실행 가능
  • flag
    • 맵핑의 유형과 그 동작에 관한 몇 가지 요소를 명시
    • MAP_FIXED : mmap()의 addr 인자를 힌트가 아니라 요구사항으로 취급하도록 함
    • MAP_PRIVATE : 맵핑이 공유되지 않음을 명시. 파일은 copy-on-write 로 맵핑됨.
    • MAP_SHARED : 같은 파일을 맵핑한 모든 프로세스와 맵핑을 공유
    • MAP_SHAREDMAP_PRIVATE를 함께 지정하면 안됨.
  • 반환
    • 메모리 맵핑의 실제 시작 주소를 반환한다.
  • fd를 맵핑하면 해당 파일의 참조 카운터가 증가한다. → 따라서 파일을 맵핑한 후에 fd를 닫더라도 프로세스는 여전히 맵핑된 주소에 접근할 수 있다.
  • 예시

    • fd가 가리키는 파일의 첫 바이트부터 len 바이트까지를 읽기 전용으로 맵핑한다.
    1
    2
    3
    4
    5
    
    void *p;
    
    p = mmap (0, len, PROT_READ, MAP_SHARED, fd, 0);
    if (p == MAP_FAILED)
            perror ("mmap");
    
    • mmap() 에 전달하는 인자가 맵핑하는 과정 Untitled

페이지 크기

  • 페이지는 메모리 관리 유닛 (MMU)에서 사용하는 최소 단위이다.
    • 별도의 접근 권한과 동작 방식을 따르는 가장 작은 메모리 단위라고 할 수 있음.
    • 메모리 맵핑을 구성하는 블록이자 프로세스 주소 공간을 구성하는 블록
  • mmap() 시스템 콜은 페이지를 다루기 때문에 addroffset 인자는 페이지 크기 단위(페이지 크기의 정수배)로 정렬되어야 한다.

    • 만약 len인자가 페이지 크기 단위로 정렬되지 않았다면 다음 크기의 페이지 정수배로 확장됨
    • 마지막 유효 바이트와 맵핑의 끝 사이에 추가된 메모리는 0으로 채워짐
  • 페이지 크기를 얻을 수 있는 표준 메서드는 sysconf()이다.

    1
    2
    3
    
    #include <unistd.h>
    
    long sysconf (int name);
    
  • POSIX는 페이지 크기를 바이트 단위로 _SC_PAGESIZE (or _SC_PAGE_SIZE) 로 정의함.

    • 런타임의 페이지 크기를 구하는 방법은 아래와 같다.
    1
    
    long page_size = sysconf (_SC_PAGESIZE);
    
  • 리눅스는 바이트 단위의 페이지 크기를 반환하는 getpagesize()를 제공함

    1
    2
    3
    
    #include <unistd.h>
    
    int getpagesize (void);
    
    1
    
    int page_size = getpagesize ();
    
  • PAGE_SIZE 매크로를 통해서도 페이지 크기를 구할 수 있는데, 런타임이 아닌 컴파일 시점에 시스템의 페이지 크기를 가져온다.
    1
    
    int page_size = PAGE_SIZE;
    

반환값과 에러

  • mmap() 호출이 성공하면 맵핑된 주소를 반환한다.
  • 실패하면 MAP_FAILED(-1) 를 반환하고 errno를 설정한다.
  • 절대 0을 반환하지 않음.

관련 시그널

  • SIGBUS
    • 프로세스가 더 이상 유효하지 않은 맵핑 영역에 접근하려고 할 때 발생함.
    • 맵핑된 후에 파일이 잘렸을 경우에 이 시그널이 발생함.
  • SIGSEGV
    • 프로세스가 읽기 전용으로 맵핑된 영역에 쓰려고 할 때 발생

4.3.2 munmap()

  • mmap()으로 생성한 맵핑을 해제하기 위한 munmap() 시스템 콜을 제공함.
1
2
3
#include <sys/mman.h>

int munmap (void *addr, size_t len);
  • 페이지 크기로 정렬된 addr에서 시작해서 len 바이트만큼 이어지는 프로세스 주소 공간에 존재하는 페이지를 포함하는 맵핑을 해제함.
    • 맵핑 해제하고 다시 접근하면 SIGSEGV 시그널이 발생함.
  • 성공 시 0을 반환, 실패 시 -1을 반환하고 errno 설정
  • 예제
    • [addr, addr+len] 사이에 포함된 페이지를 담고 있는 메모리 영역에 대한 맵핑을 해제함.
      1
      2
      
      if (munmap (addr, len) == 1)
            perror ("munmap");
      

4.3.3 맵핑 예제

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/mman.h>

// 인자로 파일 이름을 받음
int main (int argc, char *argv[])
{
        struct stat sb;
        off_t len;
        char *p;
        int fd;

        if (argc < 2) {
                fprintf (stderr, "usage: %s <file>\n", argv[0]);
                return 1;
        }

        fd = open (argv[1], O_RDONLY);      // 인자로 넘겨받은 파일을 연다
        if (fd == 1) {
                perror ("open");
                return 1;
        }

        if (fstat (fd, &sb) == 1) {        // fstat : 주어진 파일에 대한 정보 반환
                perror ("fstat");
                return 1;
        }

        if (!S_ISREG (sb.st_mode)) {        // 주어진 파일이 디바이스 파일이나 디렉터리가 아닌 일반 파일인지 점검
                fprintf (stderr, "%s is not a file\n", argv[1]);
                return 1;
        }

        p = mmap (0, sb.st_size, PROT_READ, MAP_SHARED, fd, 0); // 맵핑 수행
        if (p == MAP_FAILED) {
                perror ("mmap");
                return 1;
        }

        if (close (fd) == 1) {
                perror ("close");
                return 1;
        }

        for (len = 0; len < sb.st_size; len++)
                putchar (p[len]);

        if (munmap (p, sb.st_size) == 1) {
                perror ("munmap");
                return 1;
        }

        return 0;
}

4.3.4 mmap()의 장점

  • read()write() 시스템 콜을 사용하는 것보다 mmap()을 이용해서 파일을 조작하는 것이 좀 더 유용하다.
  1. read, write 시스템 콜 사용할 때 발생하는 불필요한 복사를 방지할 수 있음.
    • 사용자 영역의 버퍼로 데이터를 읽고 써야 하기 때문에 추가적인 복사가 발생함.
  2. (잠재적인 페이지 폴트 가능성을 제외하면) 시스템 콜 호출이나 컨텍스트 스위칭 오버헤드가 발생하지 않음
  3. 여러 개의 프로세스가 같은 객체를 메모리에 맵핑한다면 데이터는 모든 프로세스 사이에서 공유된다.
  4. lseek() 같은 시스템 콜을 사용하지 않고도 맵핑영역 탐색 가능

4.3.5 mmap()의 단점

  1. 메모리 맵핑은 항상 페이지 크기의 정수배만 가능하다.
  2. 메모리 맵핑은 반드시 프로세스의 주소 공간에 딱 맞아야한다.
    • 다양한 사이즈의 맵핑이 있다면 파편화가 일어남
  3. 메모리 맵핑과 관련 자료구조를 커널 내부에서 생성, 유지하는데 오버헤드가 발생한다.
    • 이중 복사 제거 방법으로 방지할 수 있음

      읽기 요청마다 표준 입출력 버퍼를 가리키는 포인터를 반환하는 대체 구현을 통해 데이터를 표준 입출력 버퍼에서 직접 읽을 수 있음 → 불필요한 복사 피함

4.3.6 맵핑 크기 변경하기

  • 리눅스는 주어진 메모리 맵핑 영역의 크기를 확장하거나 축소하기 위한 mremap() 시스템 콜을 제공함.
1
2
3
4
5
6
#define _GNU_SOURCE

#include <sys/mman.h>

void * mremap (void *addr, size_t old_size,
               size_t new_size, unsigned long flags);
  • mrepap()은 [addr, addr + old_size) 에 맵핑된 영역을 new_size 만큼의 크기로 변경한다.
  • flag
    • 0
    • MREMAP_MAYMOVE : 크기 변경 요청을 수행하는데 필요하다면 맵핑의 위치를 이동해도 괜찮다고 커널에 알려준다.
      • 맵핑 위치를 이동시킬 수 있다면 큰 크기 변경 요청이 성공할 가능성이 높아짐
  • 성공 시 조정된 메모리 맵핑의 시작 주소를 반환함.
  • 실패할 경우 MAP_FAILED 를 반환하며 errno 를 설정
  • 예제

    • glibc 같은 라이브러리는 malloc()으로 할당한 메모리의 크기를 변경하기 위한 realloc()을 효율적으로 구현하기 위해 mremap()을 자주 사용함
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    
    void * realloc (void *addr, size_t len)
    {
            size_t old_size = look_up_mapping_size (addr);
            void *p;
    
            p = mremap (addr, old_size, len, MREMAP_MAYMOVE);
            if (p == MAP_FAILED)
                    return NULL;
            return p;
    }
    

4.3.7 맵핑의 보호 모드 변경하기

  • POSIX는 기존 메모리 영역에 대한 접근 권한을 변경할 수 있는 mprotect() 인터페이스를 저으히함
1
2
3
4
5
#include <sys/mman.h>

int mprotect (const void *addr,
              size_t len,
              int prot);
  • [addr, addr+len) 영역내에 포함된 메모리 페이지의 보호 모드를 변경한다.
  • prot
    • mmap()에 사용한 prot 와 같은 값을 사용할 수 있다.
    • 즉, 메모리 영역이 읽기가 가능한 상태에서 prot로 PROT_WRITE를 설정한다면 쓰기만 가능해짐!
  • 어떤 시스템에서는 mmap()으로 생성한 메모리 맵핑에 대해서만 mprotect()를 쓸 수 있지만, 리눅스에서는 어떤 메모리 영역에도 사용할 수 있다.
  • 성공 시 0반환, 실패 시 -1 반환하고 errno 설정

4.3.8 파일과 맵핑의 동기화

  • POSIX 는 2장에서 살펴본 fsync() 시스템 콜의 메모리 맵핑 버전인 msync()를 제공한다.
1
2
3
#include <sys/mman.h>

int msync (void *addr, size_t len, int flags);
  • msync()는 mmap()으로 맵핑된 파일에 대한 변경 내용을 디스크에 기록하여 파일과 맵핑을 동기화한다.
    • 구체적으로 살펴보면 메모리 주소 addr에서부터 len 바이트 만큼 맵핑된 파일이나 파일 일부를 디스크로 동기화함.
    • 이때 addr 값은 반드시 페이지 크기로 정렬되어야 한다. 보통은 mmap()에서 반환한 값을 사용함
  • msync()를 호출하지 않으면 맵핑이 해제되기 전까지는 맵핑된 메모리에 쓰여진 내용이 디스크로 반영된다는 보장을 할 수가 없다.
    • 쓰기 과정 중에 갱신된 버퍼를 디스크에 쓰도록 큐에 밀어넣는 write()와는 동작방식이 다름
  • flag
    • MS_SYNC : 디스크에 모든 페이지를 기록하기 전까지 msync()는 반환하지 않는다.
    • MS_ASYNC : 비동기 방식으로 동기화한다.
    • MS_INVALIDATE : 맵핑의 캐시 복사본을 모두 무효화한다.
    • OR로 명시할 수 있지만, MS_SYNCMS_ASYNC 중 하나는 반드시 해야함. (둘을 함께하는 것은 안됨)
  • 예제
    • [addr, addr+len) 영역에 맵핑된 파일을 디스크로 동기화한다.
    • fsync()에 비해서 10배 빠름 (메모리라서)
      1
      2
      
      if (msync (addr, len, MS_ASYNC) == 1)
            perror ("msync");
      
  • 성공하면 0 반환, 실패하면 -1반환하고 errno 설정

4.3.9 맵핑의 사용처 알려주기

  • 리눅스는 프로세스가 맵핑을 어떻게 사용할 것인지 커널에 알려주는 madvise() 시스템 콜을 제공한다.
  • 커널이 이를 통해 얻는 힌트를 사용해서 최적화가 가능함. 부하가 걸리는 상황에서 필요한 캐시와 미리 읽기 방식을 확실히 보장할 수 있게 된다.
1
2
3
4
5
#include <sys/mman.h>

int madvise (void *addr,
             size_t len,
             int advice);
  • addr 로 시작해서 len 바이트의 크기를 가지는 메모리 맵핑 내의 페이지와 관련된 동작 방식에 대한 힌트를 커널에 제공함.
  • len
    • 0 이라면 커널은 addr에서 시작하는 전체 맵핑에 힌트를 적용한다.
  • advice
    • MADV_NORMAL : 이 메모리 영역에 대한 특별한 힌트를 제공하지 않는다.
    • MADV_RANDOM : 이 영역의 페이지는 랜덤하게 접근한다.
    • MADV_SEQUENTIAL : 이 영역의 페이지는 낮은 주소에서 높은 주소로 순차적으로 접근한다.
    • MADV_WILLNEED : 이 영역의 페이지는 곧 접근한다.
    • MADV_DONTNEED : 이 영역의 페이지는 당분간 접근하지 않는다.
  • POSIX는 힌트에 대한 의미만 정의하고 있다. 리눅스 커널 2.6 버전 부터는 각 힌트에 대해 조금 다르게 대응한다.

  • madvise 예시

    • [addr, addr + len) 메모리 영역을 순차적으로 접근할 것이라고 커널에 알려줌
    1
    2
    3
    4
    5
    
    int ret;
    
    ret = madvise (addr, len, MADV_SEQUENTIAL);
    if (ret < 0)
            perror ("madvise");
    
  • 성공하면 0을 반환, 실패 시 -1을 반환하고 errno 설정

4.4 일반 파일 입출력에 대한 힌트

  • 위에서는 메모리 맵핑을 사용하는데 힌트를 제공하는 방법에 대해서 알아봤음.
  • 4.4 에서는 커널에 일반적인 파일 입출력에 대한 힌트를 제공하는 방법에 대해서 알아본다.

4.4.1 posix_fadvise() 시스템 콜

1
2
3
4
5
6
#include <fcntl.h>

int posix_fadvise (int fd,
                   off_t offset,
                   off_t len,
                   int advice);
  • fd[offset, offset + len) 범위에 대한 힌트를 커널에 제공한다.
  • len
    • 0이면 파일 전체인 [offset, 파일 길이] 에 적용된다.
      • len과 offset을 0으로 넘기면 전체 파일에 대한 힌트제공
  • advise
    • madvise와 유사함. 한 가지 설정만 가능하다.
    • POSIX_FADV_NORMAL : 힌트 제공 안함
    • POSIX_FADV_RANDOM : 데이터에 랜덤하게 접근
    • POSIX_FADV_SEQUENTIAL : 낮은 주소에서 높은 주소로 순차적
    • POSIX_FADV_WILLNEED : 곧 접근
    • POSIX_FADV_NOREUSE : 한번만 접근
    • POSIX_FADV_DONTNEED : 당분간 접근안함
    • madvise와 동일하게 커널이 이런 힌트에 대응하는 방법은 구현에 따라 다른다. (심지어는 커널 버전에 따라 다르게 동작함.)
  • 예제

    • 커널에게 fd가 가리키는 전체 파일에 랜덤하게 접근하겠다고 알려줌
    1
    2
    3
    4
    5
    
    int ret;
    
    ret = posix_fadvise (fd, 0, 0, POSIX_FADV_RANDOM);
    if (ret == 1)
            perror ("posix_fadvise");
    
  • 성공하면 0을 반환, 실패하면 -1 반환하고 errno 설정

4.4.2 readahead() 시스템 콜

  • POSIX_FADV_WILLNEED 힌트와 동일한 동작 방식을 제공하기 위해 사용
  • 리눅스 전용 인터페이스이다.
1
2
3
4
5
6
7
#define _GNU_SOURCE

#include <fcntl.h>

ssize_t readahead (int fd,
                   off64_t offset,
                   size_t count);
  • fd가 가리키는 파일의 [offset, offset+count) 영역의 페이지 캐시를 생성한다.
  • 성공하면 0 반환, 실패 시 -1반환하고 errno 설정

4.4.3 부담 없이 힌트를 사용하자 !!

  • 일반적으로 애플리케이션에서 발생하는 일부 부하는 커널에 힌트를 제공함으로써 쉽게 개선할 수 있음!
    • 힌트는 입출력의 부하를 완화시킨다.
  • 파일 조각을 읽기 전에 POSIX_FADV_WILLNEED(곧 접근) 힌트를 제공하여 커널이 읽으려는 파일을 페이지 캐시에 밀어 넣을 수 있음
    • 입출력은 백그라운드에서 비동기식으로 일어남. 애플리케이션이 최종적으로 파일에 접근하면 입출력을 블록킹하지 않고 원하는 작업을 완료할 수 있다.
  • 많은 데이터를 연속적으로 디스크에 기록하는 경우 POSIX_FADV_DONTNEED(당분간 접근 X) 힌트를 제공하면 파일 조각을 페이지 캐시에서 제거할 수도 있다.
    • 다시 접근하지 않으면, 불필요한 데이터로 가득 차있을 수 있기 때문에 주기적으로 캐시에서 스트림 데이터를 제거하는것이 합리적
  • 파일 전체를 읽을 때는 POSIX_FADV_SEQUENTIAL(순차적) 힌트를 사용해서 커널에 미리읽기를 공격적으로 수행하도록 할 수 있다.
  • 파일을 랜덤하게 접근하거나 파일의 이곳 저곳을 읽어야 한다면 POSIX_FADV_RANDOM(랜덤하게 접근) 힌트를 사용해서 불필요한 미리읽기를 방지할 수 있음

4.5 동기화, 동기식, 비동기식 연산

  • 동기식(Synchronous)과 동기화(Synchroinized)는 크게 다르지 않음
  • 동기식
    • 쓰기 연산
      • 동기식 쓰기 연산은 최소한 쓰고자 하는 데이터가 커널의 버퍼 캐시에 기록되기 전까지는 반환되지 않는다.
      • 비동기식 쓰기 연산은 데이터가 사용자 영역에 머무르고 있을지라도 즉시 반환될 수 있다.
    • 읽기 연산
      • 동기식 읽기 연산은 읽고자 하는 데이터가 애플리케이션에서 제공하는 사용자 영역의 버퍼에 저장되기 전까지는 반환되지 않는다.
      • 비동기식 읽기 연산은 읽으려는 데이터가 미처 준비되기도 전에 반환될 수 있다.
  • 비동기식 연산은 나중을 위해 요청을 큐에 넣을 뿐 실제로 요청된 작업을 수행하지 않음!
  • 동기화 연산은 단순 동기식 연산보다 좀 더 제약적이지만 더 안전하다.
    • 동기화 쓰기 연산은 데이터를 디스크에 기록해서 커널 버퍼에 있던 데이터와 디스크에 기록된 데이터가 동기화되도록 보장한다.
    • 동기화 읽기 연산은 항상 데이터의 최신 복사본을 반환하며 이 복사본은 디스크에서 읽어낼 가능성이 높다.
  • → 동기식과 비동기식이라는 용어는 입출력 연산이 반환하기 전에 데이터 저장과 같은 이벤트를 기다리는지의 여부를 나타냄
  • → 동기화와 비동기화는 데이터를 디스크에 기록하는 것과 같은 정확한 이벤트가 발생해야 함을 나타냄

  • 보통 유닉스의 쓰기 연산은 동기식이자 비동기화 연산임
    • 특징들의 모든 가능한 조합으로 동작이 가능함 | | 동기화 | 비동기화 | | ——– | ————————————————————————————— | ——————————————————————————————————– | | 동기식 | 데이터를 디스크에 다 비우기 전에는 반환되지 않음. O_SYNC 플래그 명시했을 때 이렇게 동작 | 데이터가 커널 버퍼에 저장되기 전까지 반환되지 않음. 일반적인 동작 | | 비동기식 | 요청이 큐에 들어가자마자 반환됨. 최종적으로 쓰기 연산이 실행되어야 디스크에 기록된다. | 요청이 큐에 들어가자마자 반환됨. 최종적으로 쓰기 연산이 실행되어야 적어도 데이터가 커널 버퍼에 저장된다. |
  • 읽기 연산은 동기식이면서 동기화 연산이다.
    • 오랜 데이터를 읽는 것이 의미가 없으므로 항상 동기화 방식으로 동작함 | | 동기화 | | ——– | ——————————————————————————————– | | 동기식 | 최신 데이터가 제공된 버퍼로 읽어오기 전에는 반환하지 않는다. 일반적 동작 | | 비동기식 | 요청이 큐에 들어가자마자 반환된다. 하지만 최종적으로 연산이 실행되어야 최신 데이터를 반환함. |

4.5.1 비동기식 입출력

  • 비동기식 입출력을 수행하려면 커널의 최하위 레벨에서부터 지원이 필요하다.
  • aio 인터페이스가 정의되어 있으며 리눅스에서 구현하고 있다.
  • 이는 비동기식 입출력을 요청하고 작업이 완료되면 알림을 받는 함수를 제공함.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <aio.h>

/* asynchronous I/O control block */
struct aiocb {
        int aio_fildes;               /* file descriptor */
        int aio_lio_opcode;           /* operation to perform */
        int aio_reqprio;              /* request priority offset */
        volatile void *aio_buf;       /* pointer to buffer */
        size_t aio_nbytes;            /* length of operation */
        struct sigevent aio_sigevent; /* signal number and value */

        /* internal, private members follow... */
};

int aio_read (struct aiocb *aiocbp);
int aio_write (struct aiocb *aiocbp);
int aio_error (const struct aiocb *aiocbp);
int aio_return (struct aiocb *aiocbp);
int aio_cancel (int fd, struct aiocb *aiocbp);
int aio_fsync (int op, struct aiocb *aiocbp);
int aio_suspend (const struct aiocb * const cblist[],
                 int n,
                 const struct timespec *timeout);

4.6 입출력 스케줄러와 성능

  • 디스크 성능을 가장 떨어뜨리는 부분은 seek 이라고 하는 하드 디스크에서 데이터를 읽고 쓰는 헤드를 이동시키는 과정이다.
    • 프로세스의 사이클 하나보다 25,000,000배나 더 오래 걸리는 시간.
    • 입출력 요청을 순서대로 디스크로 보내는 방식은 비효율적임
  • → 입출력 스케줄러를 통해서 디스크 탐색 횟수를 최소화 함.

4.6.1 디스크 주소 지정 방식

  • 하드 디스크는 실린더, 헤드, 섹터 또는 CHS 주소 지정방식을 사용함
  • 하드 디스크는 플래터 여러 장으로 구성되어 있으며, 각 플래터는 하나의 디스크, 스핀들, 그리고 read/write 헤더로 구성되어 있다.
  • 플래터를 CD로 생각할 수 있다
  • 각각의 플래터는 CD 처럼 원형의 트랙으로 나뉘어져 있다. 그 트랙들은 정수 개의 섹터로 나뉘어져 있음

Untitled

  • 특정 데이터가 저장되어 있는 디스크의 위치를 찾을 때 하드 디스크는 실린더, 헤드, 섹터 값을 필요로 함.
    • 어떤 플래터의 어느 트랙, 어느 섹터에 데이터가 있는지 알아야 함
    • 실린더 값 : 데이터가 위치한 트랙을 나타냄
    • 헤드 값 : 요청한 읽기/쓰기 헤드(정확한 플래터)의 정확한 값을 구분함
    • 섹터 값 : 트랙에 위치한 정확한 섹터
  • 요즘 HD는 유일한 블록 번호를 맵핑해서 하나의 블록이 특정 섹터에 대응되도록 한다.
  • 반면 파일 시스템은 소프트웨어로만 존재함.
    • 논리 블록이라는 독자적인 단위를 사용해서 동작함.
    • 파일 시스템의 논리 블록은 디스크의 하나 이상의 물리 블록에 맵핑되어 있다.

4.6.2 입출력 스케줄러의 동작 방식

  • 입출력 스케줄러는 병합정렬이라는 두 가지 기본 동작을 수행한다.
    • 병합
      • 둘 이상의 인접한 입출력요청을 단일 요청으로 합치는 과정
      • 예시 : 하나는 5번을 읽으려 하고, 하나는 6,7번까지 읽으려고 할 때 합쳐서 수행함. 연산 횟수는 절반으로 줄어듬
    • 정렬
      • 대기 중인 입출력 요청을 블록 순서의 오름차순으로 정렬하는 것이다.
      • 예시 : 52, 109, 7에 대한 입출력 연산이 들어오면 입출력 스케줄러는 이 요청을 7, 52, 109 순서대로 정렬함.
        • 만약 81번 블록에 대한 새로운 요청이 들어오면 52번과 109번 연산 요청 사이에 끼워넣음
      • 선형적인 방법으로 부드럽게 이동시킬 수 있게 하여서 디스크의 헤드 움직임을 최소화 한다.

4.6.3 읽기 개선

  • 읽기 요청은 반드시 최신 데이터를 반환해야 함
  • 따라서 요청한 데이터가 페이지 캐시에 존재하지 않으면 디스크에서 데이터를 읽어올 때까지 블록되어야 하며 시간이 오래 걸릴 수 있음
  • 읽기 Latency 라고 한다.
  • 읽기의 경우 나중에 들어온 요청은 앞선 요청의 완료에 의존적이다.
  • 이에 반해 쓰기 요청은 디스크 성능에 방해가 되지 않는 스트림을 사용하는데, 이는 커널과 디스크의 주의를 독차지 할 수 있다. → 이렇게 되면 읽기 문제가 복잡해지는데 이를 “Writes-starving-reads problem”이라고 한다
  • 만약 입출력 스케줄러가 항상 요청이 들어온 순서에 따라 새로운 요청을 끼워 넣는다면 멀리 떨어진 블록에 대한 요청을 무기한으로 굶겨 죽일 수 있음.

  • 리누스 엘리베이터 같은 단순한 접근 방식은 큐에 충분히 오래된 요청이 있다면 삽입-정렬 기능을 멈춘다.
    • 전체 성능을 희생하여 요청에 대한 공정석을 유지하고 읽기 요청인 경우 레이턴시를 개선한다.
    • 문제는 이 휴리스틱이 너무 단순하다는 것

데드라인 입출력 스케줄러

  • 전통적인 엘리베이터 알고리즘의 일반적인 문제를 해결하기 위해 도입되었다.
  • 리누스 엘리베이터는 대기 중인 입출력 요청을 정렬된 목록()으로 유지한다.
  • 데드라인 입출력 스케줄러는 이 큐를 유지하고 읽기 FIFO 큐쓰기 FIFO 큐라는 두 가지 추가 큐를 도입해서 문제를 해결한다.
    • 각 큐에 들어있는 각 요청은 만료기간이 할당되어 있음.
    • 읽기 500밀리초, 쓰기 5초
  • 새로운 입출력 요청이 들어오면 표준 큐에 삽입-정렬되고, 읽기 or 쓰기 FIFO 큐의 끝 부분에 위치한다.
    • 일반적으로 표준 큐가 블록 번호로 정렬되어 있으므로 탐색을 최소화하여 전체 처리량을 최대로 높임
  • 만약 읽기 쓰기 FIFO 큐 앞부분에 있는 아이템이 해당 표준 큐의 만료기간보다 오래되면 입출력 스케줄러는 포준 큐에서 입출력 요청을 처리하지 않고 해당 FIFO 큐에서 요청을 처리하기 시작함.

  • 입출력 요청에 대해서 말랑한 데드라인을 강제한다.
  • 비록 만료전에 처리된다고 보장할 수는 없지만, 이반적으로 거의 요청 만료시간 안에 처리함.
  • 읽기 요청의 만료시간이 좀 더 짧기 떄문에 쓰기가 읽기를 굶겨 죽이는 문제도 최소화 한다.

예측 입출력 스케줄러

  • 데드라인 입출력 스케줄러의 문제점
    1. 연속된 읽기 요청이 계속 들어올 경우, 정렬된 큐의 요청을 처리하기 위해서 앞뒤로 계속 왔다 갔다함.
    2. 새로운 읽기 요청은 앞선 요청이 반환되어야만 처리되는데, 그렇게 되면 데이터를 읽어서 서비스 하는 데 한번, 다시 되돌리는데 한번해서 총 두번의 탐색을 낭비함.
  • 위의 문제점들을 해결하기 위해서 예측 입출력 스케줄러는 데드라인 입출력 스케줄러에다가 예측 매커니즘을 추가하였다.
  • 예측 입출력 스케줄러는 읽기 요청이 들어오면 평소처럼 만료시간 내에 처리한다. 하지만 요청을 처리하고 아무것도 하지 않고 6밀리 초까지 기다림.
  • → 6밀리 초는 애플리케이션이 파일 시스템의 동일한 부분에 대한 새로운 읽기를 요청할 충분한 시간이다.
    • 6밀리 초 까지 요청이 없다면 예측이 잘못되었음을 인정하고 이전 작업 내용을 반환한다.
  • 대부분의 읽기는 의존적이므로 에측을 통해 시간을 많이 아낄 수 있음

CFQ 입출력 스케줄러

  • Complete Fair Queuing
  • 프로세스마다 독자적인 큐를 할당하고, 각 큐는 시간을 할당받는다.
  • Round Robin 방식으로 각 큐를 순회하면서 큐의 허락된 시간이 다 지나거나, 요청이 남아 있지 않을 때 큐에 있는 요청을 처리함.
    • 시간이 남았지만, 더이상 요청이 큐에 없다면 CFQ 스케줄러는 짧은 시간 동안 (default = 10밀리초) 그 큐의 새로운 요청을 기다림.
      • 예측이 맞으면 탐색을 피하고, 틀리면 다음 프로세스의 큐로 간다.
  • 프로세스의 개별 큐 안에서 동기화된 요청(읽기 요청…) 은 동기화되지 않은 요청보다 더 높은 우선순위를 가짐.
    • → 읽기 요청을 배려해서 쓰기 요청이 읽기를 굶겨 죽이는 문제를 회피한다.
  • 대부분의 업무 부하에 적합하며 가장 먼저 고려해볼 만하다.

Noop 입출력 스케줄러

  • 가장 기본적인 스케줄러
  • 정렬을 수행하지 않고 병합만 수행함.
    • → 정렬할 필요가 없거나, 정렬을 하지 않는 장치에 특화된 스케줄러

4.6.4 입출력 스케줄러 선택과 설정

  • 기본 입출력 스케줄러는 부팅 시 커널 명령행 인자인 iosched 를 통해서 선택할 수 있다.
  • 유효한 값으로는 cfq, deadline, noop 이 있다.
  • 실행 중에도 각 장치에 대해 /sys/block/[device]/queue/scheduler 값을 변경해서 선택할 수 있음
    • device : 블록 디바이스를 의미
  • 입출력 스케줄러 설정 예시
    1
    
    # echo cfq > /sys/block/hda/queue/scheduler
    

4.6.5 입출력 성능 최적화

  • 디스크 입출력은 많이 느리기 떄문에 성능 극대화는 매우 중요함
  • 여러가지 기법들
    1. 자잘한 연산을 묶어 몇 개로 합쳐서 연산 최소화 하기
    2. 입출력을 블록 크기에 정렬되도록 수행하기
    3. 사용자 버퍼링을 사용하기
    4. 벡터 입출력
    5. 위치를 지정한 입출력
    6. 비동기식 입출력

사용자 영역에서 입출력 스케줄링하기

  • 엄청난 입출력을 처리해야 하는 애플리케이션은 입출력 요청을 정렬하고 병합해서 조금이라도 더 성능을 높여야함.
    • (입출력이 많지 않은 애플리케이션에서 정렬하는 것은 어리석은 짓)
  • 만약 입출력 요청이 계속 들어오고 있는 상황에서 중간에 정렬을 하는 것은 비효율적이다. 따라서 요청을 제출하기 전에 정렬을 해주면 원하는 순서대로 수행이 가능함.

경로로 정렬하기

  • 파일 경로로 정렬하는 방법은 가장 쉽지만, 효과는 적은 방법이다. (블록 단위 정렬을 흉내내는 방식)
  • 대부분의 파일시스템의 배치 알고리즘에 의해 디렉터리 내의 파일 혹은 부모 디렉터리를 공유하는 디렉터리들은 디스크에서 인접하는 경향이 있음.
  • → 파일의 물리적인 위치를 얼추 비슷하게 맞출 수 있다.
  • 장점
    • 적어도 모든 파일 시스템에 적용 가능한 방법
    • 일시적인 지역성 덕분에 중간 정도의 정확도를 기대할 수 있음.
    • 구현하기 쉬움
  • 단점
    • 파편화를 고려하지 않았음

inode로 정렬하기

  • inode 는 개별 파일과 관련된 메타데이터를 담고 있는 유닉스의 구성 요소이다.
  • 파일의 데이터가 물리 디스크 블록을 여러개 점유하고 있다고 해도, 하나의 inode만을 가짐.
  • inode 는 유일한 번호가 할당됨.
1
2
3
4
5
파일 i inode 번호 < 파일 j inode 번호

==

파일 i 물리블록 < 파일 j 물리블록
  • inode의 번호는 stat() 시스템 콜을 통해서 얻을 수 있음
  • 주어진 파일의 inode 번호 출력 프로그램 예시

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    
    #include <stdio.h>
    #include <stdlib.h>
    #include <fcntl.h>
    #include <sys/types.h>
    #include <sys/stat.h>
    
    /*
     * get_inode - returns the inode of the file associated
     * with the given file descriptor, or −1 on failure
     */
    int get_inode (int fd)
    {
            struct stat buf;
            int ret;
    
            ret = fstat (fd, &buf);
            if (ret < 0) {
                    perror ("fstat");
                    return 1;
            }
    
            return buf.st_ino;
    }
    
    int main (int argc, char *argv[])
    {
            int fd, inode;
    
            if (argc < 2) {
                    fprintf (stderr, "usage: %s <file>\n", argv[0]);
                    return 1;
            }
    
            fd = open (argv[1], O_RDONLY);
            if (fd < 0) {
                    perror ("open");
                    return 1;
            }
    
            inode = get_inode (fd);
            printf ("%d\n", inode);
    
            return 0;
    }
    
  • inode 정렬 장점
    • inode번호는 쉽게 얻을 수 있고 정렬도 쉬움
    • 물리적인 파일 배치를 추측할 수 있는 좋은 지표
  • 단점
    • 파편화에 따라 추측이 틀릴 수 있음
    • 유닉스 파일 시스템이 아닌 경우 정확도가 떨어짐
  • 사용자 영역에서 입출력 요청을 스케줄링하기 위해서 가장 흔히 사용되는 방법

물리 블록으로 정렬하기

  • 최적의 방법은 물리적인 디스크 블록으로 정렬하는 것임
  • 각 파일은 파일 시스템에서 가장 작은 할당 단위인 논리 블록 단위로 쪼개짐.
    • (논리 블록 크기는 파일 시스템 마다 다르다.)
  • 각각의 논리 블록은 하나의 물리 블록에 맵핑되어 있다.
  • 커널은 파일의 논리 블록에서 물리 디스크 블록을 알아내는 메서드를 제공한다.

    1
    2
    3
    
    ret = ioctl (fd, FIBMAP, &block);
    if (ret < 0)
            perror ("ioctl");
    
    • block
      • 찾고 싶은 물리 블록에 대한 논리 블록
      • block은 0부터 시작하는 파일에 상대적인 값.
    • 성공하면 block 은 물리 블록 번호로 바뀐다.
  • 논리 블록과 물리 블록의 맵핑을 찾으려면 2단계가 필요함.
  1. 주어진 파일의 블록 개수를 구함
    • stat() 시스템 콜로 구할 수 있다.
  2. 각 논리 블록을 가지고 ioctl()을 통해 이에 상응하는 물리 블록을 구한다.
  • 예제

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    
    #include <stdio.h>
    #include <stdlib.h>
    #include <fcntl.h>
    #include <sys/types.h>
    #include <sys/stat.h>
    #include <sys/ioctl.h>
    #include <linux/fs.h>
    
    /*
     * get_block - for the file associated with the given fd, returns
     * the physical block mapping to logical_block
     */
    int get_block (int fd, int logical_block)
    {
            int ret;
    
            ret = ioctl (fd, FIBMAP, &logical_block);
            if (ret < 0) {
                    perror ("ioctl");
                    return 1;
            }
    
            return logical_block;
    }
    
    /*
     * get_nr_blocks - returns the number of logical blocks
     * consumed by the file associated with fd
     */
    int get_nr_blocks (int fd)
    {
            struct stat buf;
            int ret;
    
            ret = fstat (fd, &buf);
            if (ret < 0) {
                    perror ("fstat");
                    return 1;
            }
            return buf.st_blocks;
    }
    
    /*
     * print_blocks - for each logical block consumed by the file
     * associated with fd, prints to standard out the tuple
     * "(logical block, physical block)"
     */
    void print_blocks (int fd)
    {
            int nr_blocks, i;
    
            nr_blocks = get_nr_blocks (fd);
            if (nr_blocks < 0) {
                    fprintf (stderr, "get_nr_blocks failed!\n");
                    return;
            }
    
            if (nr_blocks == 0) {
                    printf ("no allocated blocks\n");
                    return;
            } else if (nr_blocks == 1)
                    printf ("1 block\n\n");
            else
                    printf ("%d blocks\n\n", nr_blocks);
    
            for (i = 0; i < nr_blocks; i++) {
                    int phys_block;
    
                    phys_block = get_block (fd, i);
                    if (phys_block < 0) {
                            fprintf (stderr, "get_block failed!\n");
                            return;
                    }
                    if (!phys_block)
                            continue;
    
                    printf ("(%u, %u) ", i, phys_block);
            }
    
            putchar ('\N');
    }
    
    int main (int argc, char *argv[])
    {
            int fd;
    
            if (argc < 2) {
                    fprintf (stderr, "usage: %s <file>\n", argv[0]);
                    return 1;
            }
    
            fd = open (argv[1], O_RDONLY);
            if (fd < 0) {
                    perror ("open");
                    return 1;
            }
    
            print_blocks (fd);
    
            return 0;
    }
    
  • 장점
    • 정확히 정렬하고 싶은 대상인 파일이 실제 존재하는 물리 디스크 블록을 반환한다
  • 단점
    • root 권한이 필요함.
    • ioctl의 FIBMAP이 root 권한이 필요한 CAP_SYS_RAWIO 기능을 요구함
This post is licensed under CC BY 4.0 by the author.