메뉴 여닫기
환경 설정 메뉴 여닫기
개인 메뉴 여닫기
로그인하지 않음
지금 편집한다면 당신의 IP 주소가 공개될 수 있습니다.

Robust I/O package: 두 판 사이의 차이

noriwiki
Pinkgo (토론 | 기여)
새 문서: 상위 문서: System-Level I/O ==개요== '''RIO(Robust I/O)'''는 효율적이고 굳건한(robust) I/O를 제공하는 래퍼(wrapper) 함수들의 집합으로, 특히 short counts 문제에 취약한 응용 프로그램을 위해 사용된다. RIO는 두 가지 종류의 함수들을 제공한다. * Unbuffered 입출력 함수: 해당 함수들은 메모리와 파일 사이에 데이터를 직접 전송하며, 응용 프로그램 수준의 버퍼링(application-level b...
 
Pinkgo (토론 | 기여)
184번째 줄: 184번째 줄:
</syntaxhighlight>
</syntaxhighlight>
위에서는 STDIN을 한줄 씩 읽어들인 다음, 이를 내부 버퍼에 저장한 후 STDOUT에 출력하는 간단한 예시이다. 이때 <code>rio_writen()</code> 함수가 사용되었다.
위에서는 STDIN을 한줄 씩 읽어들인 다음, 이를 내부 버퍼에 저장한 후 STDOUT에 출력하는 간단한 예시이다. 이때 <code>rio_writen()</code> 함수가 사용되었다.
==각주==
==각주==
[[분류:컴퓨터 시스템]]
[[분류:컴퓨터 시스템]]

2025년 4월 17일 (목) 17:23 판

상위 문서: System-Level I/O

개요

RIO(Robust I/O)는 효율적이고 굳건한(robust) I/O를 제공하는 래퍼(wrapper) 함수들의 집합으로, 특히 short counts 문제에 취약한 응용 프로그램을 위해 사용된다. RIO는 두 가지 종류의 함수들을 제공한다.

  • Unbuffered 입출력 함수: 해당 함수들은 메모리와 파일 사이에 데이터를 직접 전송하며, 응용 프로그램 수준의 버퍼링(application-level buffering)은 없다. 주로 네트워크로부터 binary 데이터를 읽고 쓸때에 유용하다.
  • Buffered 입력 함수: 해당 함수들은 표준 I/O 함수들(printf() 함수 등)이 제공하는 것과 유사하게 응용 프로그램 수준의 버퍼 내에 저장된(cached in an application- level buffer) binary 데이터들과 텍스트 라인들을 읽도록 한다.
    • 표준 I/O 함수들과는 달리, RIO buffered 함수들은 thread-safe하며, 동일한 파일 디스크립터에 대해서 임의로 교차적으로(interleaved) 사용이 될 수 있다. 예를 들어 파일 디스크립터로부터 텍스트 라인 몇 줄을 읽고, 그 다음에 binary 데이터를 읽고, 다시 텍스트 라인을 읽는 것이 가능하다.

Unbuffered RIO Input and Output

응용 프로그램은 rio_readn()rio_writen() 함수를 호출하여 메모리와 파일 사이에 데이터를 직접 전송할 수 있다.

#include "csapp.h"
ssize_t rio_readn(int fd, void *usrbuf, size_t n); 
//Returns: number of bytes transferred if OK, 0 on EOF, −1 on error
ssize_t rio_writen(int fd, void *usrbuf, size_t n);
//Returns: number of bytes transferred if OK, −1 on error

rio_readn()함수는 디스크립터 fd의 현재 파일 위치로부터 최대 n 바이트를 버퍼 usrbuf로 전송한다. 이때 rio_readn()함수는 EOF에 도달한 경우에만 short count를 반환할 수 있으며, 완전한 EOF인 경우[1]에는 0을 반환한다. rio_writen()함수는 버퍼 usrbuf에서 디스크립터 fd로 n 바이트를 전송한다. 이때 rio_writen()함수는 절대 short counts를 반환하지 않는다. 이때 두 함수는 같은 디스크립터 내에서 임의로 교차하여 사용할 수 있다. 아래는 rio_readn() 함수를 어떻게 구현하였는지 보여준다:

ssize_t rio_readn(int fd, void *usrbuf, size_t n)
{
    size_t nleft = n; // 앞으로 읽어야할 byte의 수
    ssize_t nread; //지금까지 읽은 byte의 수
    char *bufp = usrbuf;

    while (nleft > 0) {
        if ((nread = read(fd, bufp, nleft)) < 0) { /* read()는 한 번에 n 바이트를 못 읽을 수 있기 때문에 반복해서 읽음 */
            if (errno == EINTR)     /* 시그널 핸들러로 인해 인터럽트된 경우 */
                nread = 0;          /* 다시 read 호출 */
            else 
                return -1;          /* 그 외 오류일 경우 에러 리턴 */
        } else if (nread == 0) break;              /* read() 함수가 0 반환시 EOF 도달로 간주 */

        nleft -= nread;
        bufp += nread;
    }
    return (n - nleft);             /* 실제 읽은 바이트 수 리턴 (0 이상) */
}

위 코드는 파일 디스크립터 fd로부터 정확히 n 바이트를 usrbuf로 읽어오는 작업을 수행한다. 이때 read() 함수는 한번에 데이터를 원하는 만큼 읽어오지 못할 수 있으므로 반복하여 호출된다. 이때 시그널 인터럽트(EINTR)가 발생하면 읽기 작업을 재시도 하며, read() 함수가 0을 반환하면 EOF 도달로 간주하고 종료한다. 이를 통해서 rio_readn()함수는 실제로 읽은 바이트 수를 반환할 수 있다. 이때 read()함수는 다음과 같은 원인으로 인해서 데이터를 한번에 원하는 만큼 읽지 못할 수 있다.

  1. 커널 버퍼에 데이터가 부족한 경우
    • read(fd, buf, n)을 요청했는데 커널에 n보다 적은 양만 준비돼 있으면, read()는 그 준비된 양만큼만 읽고 바로 리턴한다.
  2. 시그널로 인한 인터럽트(errno == EINTR) 발생 시, read()는 실패(-1)하고 중단된다.
  3. stream device의 특성으로 인해서 부분적인 읽기가 일상적인 경우도 있다.
    • 디스크 파일은 read()함수를 사용시 보통 n바이트 다 읽힌다.
    • 하지만 네트워크 소켓, 파이프, 터미널 같은 stream 기반 장치는 read가 준비된 만큼만 읽고 종료하며, 이는 short counts의 원인이 된다.

위에서 1, 3번은 short counts의 가장 중요한 원인 중 하나이며, 다른 read()함수가 충분하지 못한 양을 읽는 이유도 대부분 short counts와 관련이 있다. 하지만 rio_readn() 함수의 구현 코드를 보면 알 수 있듯이 해당 함수는 short counts가 발생할 시 read()함수를 다시 호출하여, short_counts로부터 자유로워지도록 한다. 아래는 rio_readn()rio_writen() 함수를 어떻게 구현하였는지 보여준다:

ssize_t rio_writen(int fd, void *usrbuf, size_t n)
{
    size_t nleft = n; //버퍼에 저장해야할 바이트 수
    ssize_t nwritten; //버퍼에 저장한 바이트 수
    char *bufp = usrbuf;

    while (nleft > 0) {
        if ((nwritten = write(fd, bufp, nleft)) <= 0) { /* write() 함수도 일부만 쓸 수 있으므로 반복하여 호출한다. */
            if (errno == EINTR)     /* 시그널 핸들러로 인해 인터럽트된 경우 */
                nwritten = 0;       /* 다시 write 호출 */
            else
                return -1;          /* 그 외 오류일 경우 에러 리턴 */
        }
        nleft -= nwritten;
        bufp += nwritten;
    }
    return n;
}

위 코드는 사용자 버퍼 usrbuf의 내용을 fd에 정확히 n 바이트만큼 쓰는 작업을 수행한다. 이때 write()함수가 일부분만 쓸 수 있기 때문에 반복하여 호출된다. 이때 시그널 인터럽트(EINTR)가 발생하면 쓰기 작업을 재시도한다. write는 EOF 개념이 없기 때문에 EOF의 경우는 고려하지 않는다.
write()함수가 요청받은 바이트 수 만큼 한번에 쓰지 못하는 경우는 주로 시스템 버퍼가 다 찼을 때 발생한다. OS가 write 요청을 받으면 쓸 데이터를 커널 내부의 출력 버퍼에 저장한다. 하지만 네트워크 소켓이나 파이프의 출력 버퍼는 용량이 제한적이므로, 요청한 쓸 바이트 크기보다 현재 남아있는 출력 버퍼의 크기가 작은 경우, 해당 버퍼의 크기까지만 쓰고 리턴할 수 있다. 하지만 위 코드를 보면 알 수 있듯이, rio_writen()함수는 이러한 문제에서 자유롭다. 즉, 특별한 에러가 발생하지 않는 이상, 요청받은 n 바이트를 모두 쓰는 것을 보장한다.

Buffered RIO Input Functions

Buffered RIO 입력 함수들은 내부 메모리 버퍼에 부분적으로 캐시된 파일로부터 텍스트 라인과 binary 데이터를 효율적으로 읽는 함수들이다. 아래는 buffered RIO 입력 함수들이다:

#include "csapp.h"
void rio_readinitb(rio_t *rp, int fd); //Returns: nothing
//Returns: number of bytes read if OK, 0 on EOF, −1 on error
ssize_t rio_readlineb(rio_t *rp, void *usrbuf, size_t maxlen);
ssize_t rio_readnb(rio_t *rp, void *usrbuf, size_t n);

rio_readinitb(rio_t *rp, int fd) 함수에서 rp는 내부에 입력 버퍼와 상태 정보를 가지는 인자이다. Figure 2는 rio_t 구조체의 내부 구조를 보여준다. rio_t 구조체는 아래와 같은 3가지 필드가 존재한다:

  • rio_buf: 버퍼 전체
  • rio_bufptr: 현재 읽기 위치 (unread 시작점)
  • rio_cnt: unread 바이트 수

또한 Figure 2에서 buffer에 속하지만, alread used도, unused도 아닌 공간은 아직 read() 호출로 채워지지 않은 남은 버퍼 공간을 의미한다. 따라서, rio_readinitb() 함수를 호출할 경우에는 내부 버퍼를 초기화하고 디스크립터인 fd와 해당 버퍼를 연결시키는 역할을 한다. 이는 rio_readlinebrio_readnb 함수를 사용하기 위해서는 어떤 디스크립터로부터 읽을 지와, 버퍼를 어디에 두어야 할지를 지정해야 하기 때문에 사용된다.
rio_readlineb(rio_t *rp, void *usrbuf, size_t maxlen) 함수는 다음 한 줄을 읽고, usrbuf에 이를 복사한다. 이때 문자열의 끝에는 항상 '\0' 문자를 붙여 C 문자열 처럼 usrbuf를 다룰 수 있도록 한다. 이때, 복사하는 바이트의 수는 최대 maxlen-1 바이트로 제한되며, 그 이상의 줄은 잘리고, usrbuf의 끝 원소는 '\0'이 차지한다.
rio_readnb(rio_t *rp, void *usrbuf, size_t n) 함수는 내부 버퍼에서 n 바이트까지 읽어서 usrbuf에 복사한다. 아래는 rio_readinitb() 함수가 어떻게 구현되는지를 보여준다.

void rio_readinitb(rio_t *rp, int fd)
{
    rp->rio_fd = fd;
    rp->rio_cnt = 0; //read()로 rio_buf에 데이터를 채우면 rio_cnt는 그 바이트 수만큼 설정된다.(내부 버퍼 내의 읽을 정보량)
    rp->rio_bufptr = rp->rio_buf; //내부 버퍼인 rio_buf 안에서, 다음에 읽을 위치를 가리킨다.(내부 버퍼 내의 현재 파일 위치)
}

위 코드에서는 빈 읽기 버퍼를 설정하고, 파일 디스크립터를 해당 버퍼와 연결하는 역할을 한다. 또한 rio_readnb()rio_readnb() 함수가 어떻게 구현되는지 알기 위해서는 먼저 rio_read() 함수가 어떻게 구현되는지에 대해서 먼저 알아야 한다.

static ssize_t rio_read(rio_t *rp, char *usrbuf, size_t n)
{
    int cnt;
    while (rp->rio_cnt <= 0) { // "cnt <= 0"는 내부 버퍼에 읽을 데이터가 없다는 것을 의미
        rp->rio_cnt = read(rp->rio_fd, rp->rio_buf,sizeof(rp->rio_buf)); //read()로 디스크립터에서 rio_buf에 읽어옴
        if (rp->rio_cnt < 0) { //read() 함수가 실패한 경우
            if (errno != EINTR) //시그널로 인터럽트된 경우 (EINTR) → 무시하고 다시 시도.
                return -1;
        }
        else if (rp->rio_cnt == 0)  //read()가 0을 반환하면 → EOF, 더 이상 읽을 게 없음.
            return 0;
        else
            rp->rio_bufptr = rp->rio_buf; //성공적으로 읽었다면, 내부 포인터를 버퍼의 시작 위치로 초기화
    }

    /* min(n, rp->rio_cnt) 만큼 바이트들을 내부 버퍼에서 usrbuf로 옮긴다. */
    cnt = n;
    if (rp->rio_cnt < n)
        cnt = rp->rio_cnt;
    memcpy(usrbuf, rp->rio_bufptr, cnt);
    rp->rio_bufptr += cnt; //rp의 내부 버퍼의 현재 파일 위치를 복사한 바이트 수 만큼 이동시킨다.
    rp->rio_cnt -= cnt; //rp의 내부 버퍼의 현재 파일 위치를 복사한 바이트 수 만큼 읽을 수 있는 바이트 수는 줄어든다.
    return cnt;
}

위 함수를 보면 알 수 있듯이, rio_read() 함수는 read() 함수의 버퍼링된 버전이다. 따라서 해당 함수는 short counts를 반환할 수 있으며, 이는 오류가 아니다. 단지 해당 버퍼 내에 남아있는 바이트의 수(rio_cnt)가 요청받은 바이트 수보다 부족했음을 의미할 뿐이다. 응용 프로그램 입장에서는 rio_read() 함수는 Linux의 read() 함수와 동일한 의미를 가진다:

  • 에러 시 -1을 반환하고 errno를 설정한다.
  • EOF 시 0을 반환한다.
  • 요청한 바이트 수가 버퍼에 남은 바이트 수를 초과할 경우에는 short counts를 반환한다.

두 함수가 유사하므로, read로 대체하여 다양한 종류의 buffered 입력 함수들을 쉽게 구축할 수 있다. 아래는 rio_readnb() 함수를 구현한 코드이다:

ssize_t rio_readnb(rio_t *rp, void *usrbuf, size_t n)
{
    size_t nleft = n;
    ssize_t nread;
    char *bufp = usrbuf;

    while (nleft > 0) {
        if ((nread = rio_read(rp, bufp, nleft)) < 0) return -1; /* errno set by read() */
        else if (nread == 0) break; /* EOF */

        nleft -= nread;
        bufp += nread;
    }
    return (n - nleft);      /* Return >= 0 */
}

위 코드에서 알 수 있듯이, rio_readnb() 함수는 구조상으로 rio_readn() 함수와 동일하다. 다만 내부 버퍼에 파일의 내용을 저장한 뒤, 내부 버퍼에서 해당 내용을 끌어 쓸 뿐이다. 아래는 rio_readlineb() 함수를 구현한 코드이다:

ssize_t rio_readlineb(rio_t *rp, void *usrbuf, size_t maxlen)
{
    int n, rc;
    char c, *bufp = usrbuf;

    for (n = 1; n < maxlen; n++) { //최대 maxlen-1 바이트 까지만 읽음
        if ((rc = rio_read(rp, &c, 1)) == 1) {
            *bufp++ = c;
            if (c == '\n') { //개행 문자를 만나면 반복문 종료
                n++;
                break;
            }
        } else if (rc == 0) {         /* 예외 케이스 처리 */
            if (n == 1) return 0;     /* 완전한 EOF이므로 0을 반환함 */
            else break;               /* 몇몇 바이트를 읽었으나, 더 이상 읽을 데이터가 없음 */
        } else return -1;             /* Error */
    }

    *bufp = 0;    //usrbuf의 마지막 원소는 항상 '\0' 문자 사용
    return n - 1; //실제로 읽은 바이트의 수를 반환
}

마찬가지로 rio_readlineb() 함수 또한 거의 동일한 메커니즘을 사용하는 것을 볼 수 있다. 다만 read_nb() 함수와 마찬가지로, 내부 버퍼에 파일의 내용을 저장한 뒤, 내부 버퍼에서 해당 내용을 끌어 쓸 뿐이다.

이때 궁금증이 들 수 있다. rio_readn()rio_readnb()는 사실상 같은 역할을 하는 함수이지만, 왜 같은 일을 하는 함수가 두 개나 존재하는가? 핵심적인 차이는 내부 버퍼를 사용하는지의 여부이다. rio_read() 함수는 내부 버퍼를 사용하지 않고, 데이터를 읽고자 할 때마다 매번 read() 함수를 호출한다. 따라서 해당 함수는 stream에서 연속적으로 binary 데이터를 다룰 때 적함한 함수이다.
하지만, rio_readbn() 함수는 내부 버퍼를 따로 가지고 있으며, 사용자가 원하는 만큼 꺼내 사용하는 함수이다. 따라서 read() 함수는 오직 내부 버퍼를 채울 때만 수행된다. 따라서, 해당 함수는 텍스트 라인과 binary 데이터가 동시에 사용된 파일을 다룰 때 유리하다. 또한 해당 함수는 caching을 사용하므로 더욱 빠르다는 장점이 있다. 이때, bufferd 입력 함수는 unbufferd 함수와는 섞어서 사용할 수 없다. 그 이유는 buffered 함수가 내부 버퍼를 다룰 때 이미 fd를 이용하여 파일에 접근하므로, unbuffered 함수가 동작할 때는 현재 파일 위치가 예상치 못하게 변해 있을 수 있기 때문이다. 따라서 buffered 입력 함수를 사용하고자 할 때는 동일한 buffered 입력 함수와만 교차해서 사용하여야 한다. 아래 코드는 RIO 함수의 사용 예시를 보여준다.

int main(int argc, char **argv)
{
    int n;
    rio_t rio;
    char buf[MAXLINE];

    Rio_readinitb(&rio, STDIN_FILENO);
    while ((n = Rio_readlineb(&rio, buf, MAXLINE)) != 0)
        Rio_writen(STDOUT_FILENO, buf, n);
    exit(0);
}

위에서는 STDIN을 한줄 씩 읽어들인 다음, 이를 내부 버퍼에 저장한 후 STDOUT에 출력하는 간단한 예시이다. 이때 rio_writen() 함수가 사용되었다.

각주

  1. 읽기 작업을 시작하자마자 EOF인 경우에 해당한다.