15 minute read

Computer Systems : A Programmer’s Perspective (CSAPP)



I/O란 메인 메모리와 외부 장치들(디스크, 터미널, 네트워크 등) 간에 데이터가 복사되는 과정이다. Input은 I/O 장치에서 메인 메모리로 데이터를 복사해 오는 과정이고, output은 메인 메모리에서 I/O 장치로 데이터를 복사해 가는 과정이다.
모든 언어에서 run-time system은 입출력을 위한 high-level의 기능들을 제공한다. ANSI C에서는 표준 라이브러리가 우리에게 익숙한 printf, scanf같은 함수를 제공하고, C++에서는 <<, >> operator가 그 기능을 한다. 리눅스의 high-level 입출력 함수들은 리눅스 커널이 제공하는 Unix I/O 함수로 구현되어있다. 웬만하면 이 high-level 입출력 함수를 사용하면 문제가 없지만, 그럼에도 Unix I/O 함수를 배우는 이유는 다음과 같다.

  • Unix I/O 함수를 이해하는 것은 전반적인 시스템의 개념을 이해하는 데 도움이 된다. 입출력은 시스템의 동작에 필수적이고, 종종 우리는 입출력과 다른 system idea 간에 순환 의존 관계를 마주하게 될 때가 있다. 예를 들어, 입출력은 프로세스의 생성과 실행에 중요한 역할을 하고 반대로 프로세스 생성은 파일이 다른 프로세스들에게 공유되는 것에 중요한 역할을 한다. 따라서 입출력을 이해하기 위해서는 프로세스를 이해해야 하고, 반대로 프로세스를 이해하기 위해서는 입출력을 이해해야 한다.
  • Unix I/O 함수를 어쩔 수 없이 사용해야 될 때가 있다. 파일 metadata에 접근해야 할 때나, 네트워크 프로그래밍에서 표준 라이브러리의 입출력 함수를 쓰는 게 위험할 때가 있다.

이 챕터에서는 Unix I/O와 표준 I/O의 일반적인 개념과 안전한 사용법을 소개한다. 추후 네트워크 프로그래밍과 concurrency를 위한 기반이 되는 부분이다.

10.1 Unix I/O

리눅스에서 file 은 byte의 sequence이다.
모든 입출력 장치는 파일로 모델링되고, 모든 입력과 출력은 파일에 데이터를 쓰고 파일을 읽는 것으로 실행된다. 이 방법은 리눅스 커널이 일관된 방법으로 입출력을 수행할 수 있는 Unix I/O라는 단순한 low-level application 인터페이스를 제공할 수 있게 해 준다.

  • Opening files : 응용 프로그램은 커널에게 파일의 open 을 요청해서, 해당 입출력 장치에 접근할 것임을 알린다. 그러면 커널은 descriptor 라고 하는 음이 아닌 정수를 반환해 주는데, 이는 앞으로 모든 파일에 대한 작업에서 그 파일을 식별하는 데 사용된다. 커널은 open file의 모든 정보를 추적하고 프로그램은 오직 이 descriptor만 가지고 있게 된다.
    리눅스 쉘에 의해 생성된 모든 프로세스는 3개의 open file standard input (0번), standard output (1번), 그리고 standard error (2번)을 기본적으로 가지고 시작한다. 이들의 descriptor는 <unistd.h>에서 매크로 STDIN_FILENO, STDOUT_FILENO, STDERR_FILENO로 정의가 되어 있다.
  • Changing the current file position : 커널은 file position k의 값을 관리하는데, 처음에는 모든 open file이 0이다. 파일의 시작부터의 offset을 뜻하며, 응용 프로그램은 파일의 position k를 seek 함수를 통해 바꿀 수 있다.
  • Reading and writing files : read 함수는 파일에서 position k로부터 n>0 개 바이트를 메모리로 복사해 온다. 이때 k는 k+n이 된다. 만약 m바이트 파일에서 k>=m이 되면 end-of-file(EOF) 가 발동되고, 프로그램은 이를 감지할 수 있다. 파일의 마지막에 따로 “EOF character”가 있는 것은 아니다.
    비슷하게 write 함수는 파일의 position k부터 n>0 바이트를 수정한다. 동일하게 k의 값도 바뀌게 된다.
  • Closing files : 프로그램이 파일로의 접근을 끝마칠 때 커널에게 close 를 요청해서 이를 알린다. 커널은 파일이 열려 있는 동안 생성했던 각종 자료구조들을 free하고 그 파일의 descriptor를 사용 가능한 descriptor로 되돌려 놓는다(다른 파일에게 할당될 수 있도록). 프로세스가 어떤 이유에서든 끝나면 커널은 모든 open file을 close하고 memory resource를 free한다.

10.2 Files

매 리눅스 파일은 시스템에서의 역할을 알려 주는 type 을 갖는다.

  • regular 파일은 임의의 데이터를 담는 파일이다. 텍스트 파일이기도 하고, 프로그램의 소스 코드나 바이너리 파일일 수도 있다. 커널에게는 구분 없이 그냥 sequence of byte이다.
    리눅스 텍스트 파일은 text line 의 나열로 구성되는데, 각 line'\n'로 끝난다.
  • directory 파일은 link 의 배열로 구성되어 있고, 각 link 는 파일과 그 파일명을 map시킨다. 모든 directory에는 적어도 두 개의 원소가 있는데, . 은 그 directory 자신을 의미하고 ..은 부모 directory를 의미한다. mkdir 명령어로 디렉토리를 만들 수 있고 ls로 내용물을 볼 수 있으며 rmdir로 제거할 수 있다.
  • socket 은 네트워크를 통해 다른 프로세스와 통신하기 위해 사용되는 파일이다.

이 외에도 named pipes, symbolic links, character, block devices 등등이 있다.
리눅스 커널은 모든 파일을 하나의 directory hierarchy 에서 관리하는데, /라는 이름의 root 디렉토리에서 시작한다.
각 프로세스는 current working directory 를 가지는데, 이는 디렉토리 계층 구조에서 현재 위치를 나타낸다. 쉘의 current working directory는 익숙한 cd 명령어로 바꿀 수 있다.
디렉토리 계층 구조에서의 위치는 pathname 으로 특정된다. 파일명과 /의 나열로 된 문자열로 되어 있고 두 가지 형태가 있다.

  • 절대 경로(absolute pathname)은 /, 즉 root에서 시작한다.
  • 상대 경로(relative pathname)은 현재 작업 중인 디렉토리에서 시작한다. 현재 작업 중인 디렉토리가 /home/droh이면 hello.c의 상대 경로는 ./hello.c가 되고, 현재 작업 중인 디렉토리가 /home/bryant이면 상대 경로는 ../home/droh/hello.c가 된다.

10.3 Opening and Closing Files

프로세스는 아래 함수를 이용해서 새 파일을 만들거나, 기존 파일을 열 수 있다.

#include <sys/types.h>
#include <sys/stat.h>
#include <fcnt1.h>
int open(char *filename, int flags, mode_t mode);

이 함수는 filename을 file descriptor로 변환해서 이를 반환해 준다. 배정되는 값은 현재 해당 프로세스에서 열려 있지 않은 file descriptor 중 가장 작은 값이다.
flag는 프로세스가 파일을 어떻게 접근하고자 하는지를 정한다.

  • O_RDONLY 는 읽기 전용
  • O_WRONLY 는 쓰기 전용
  • O_RDWR 은 읽기 및 쓰기c

flag는 or 연산을 통해서 쓰기 관련해서 추가적인 설정을 할 수 있다.

  • O_CREAT 를 설정하면, 파일이 존재하지 않을 때 빈 파일을 생성한다.
  • O_TRUNC 를 설정하면, 파일이 존재하지 않을 때 빈 파일로 덮어씌운다.
  • O_APPEND 를 설정하면, 파일에 write 를 하기 전에 항상 file position을 파일의 끝으로 설정한다.

mode는 새 파일에 대한 접근 권한을 명시하는 부분이다. 교과서의 Figure 10.2에 종류가 나와 있다.
프로세스는 context 중 하나로 umask 함수로 설정할 수 있는 umask 를 가진다. 프로세스가 open 으로 파일을 열 때 mode 인자를 넣어 주면, 그 파일의 접근 권한은 mode & ~umask 로 설정된다.
마지막으로, 프로세스는 close 함수를 이용해서 open file을 닫을 수 있다.

#include <unistd.h>
int close(int fd);

이미 닫았거나 없는 file descriptor를 쓰면 에러가 발생하고 -1을 반환하고, 이상이 없으면 0을 반환한다.

10.4 Reading and Writing Files

프로그램은 read, write 함수를 이용해서 입력과 출력을 할 수 있다.

#include <unistd.h>
ssize_t read(int fd, void *buf, size_t n);
ssize_t write(int fd, const void *buf, size_t n);

read 함수는 fd에 해당하는 파일의 current file position으로부터 최대 n바이트를 buf에 복사한다. 에러가 발생하면 반환값은 -1이고, EOF이면 0이다. 정상적으로 수행된 경우는 실제로 전해진 바이트 수가 반환된다.
write 함수는 buf의 최대 n바이트를 복사해서 fd에 해당하는 파일의 current file position부터로 복사한다. lseek 함수는 프로그램이 명시적으로 file position을 수정할 수 있게 해 준다.
어떤 상황에서는 readwrite에서 프로그램이 요청한 바이트 수보다 적은 바이트가 전달될 때가 있다. 이를 short count 라고 하며, 이는 에러라고 하지 않는다. 발생할 수 있는 이유는 여러 가지가 있다.

  • Entering EOF on reads : 20바이트 짜리 파일에서 50바이트를 읽으려고 하면, 요청한 50보다 적은 20바이트만 복사되고 short count가 발생한다. read 는 0을 반환해서 EOF가 났음을 알린다.
  • Reading text lines from a terminal : open file이 터미널 같은 것이면, 매 read 함수는 '\n'으로 끊기는 text line만큼만 읽게 된다.
  • Reading and writing network sockets : open file이 네트워크 소켓이면, 내부 버퍼의 한계나 긴 딜레이로 인해 short count가 나타날 수 있다.

보통은 디스크에서 read할 때는 EOF를 만날 일이 없고, write도 동일하다. 하지만 웹 서버와 같은 네트워크 application을 robust하게 만들고자 한다면, 요청한 모든 바이트가 도착할 때까지 반복적해서 readwrite를 요쳥해 줘야 한다.

10.5 Robust Reading and Writing with the RIO Package

이 절에서는 자동으로 short count를 처리해 주는 RIO(Robust I/O) 패키지를 다룬다. RIO는 두 종류의 함수를 제공한다.

  • Unbuffered I/O function: 메모리와 파일 간에 데이터를 buffer 없이 직접 전달한다. 네트워크에서 binary data를 주고받을 때 특히 유용하다.
  • Buffered input function: Application 단계에 있는 buffer를 이용해서 text line이나 binary data를 효율적으로 읽을 수 있게 한다. 표준 라이브러리의 printf와 비슷하다.

10.5.1 RIO Unbuffered Input and Output Function

rio_readnrio_writen은 Unix의 readwrite와 비슷하다. 대신, rio_readn은 EOF일 때만 short count가 날 수 있고 다른 경우에는 나지 않는다. rio_writen은 어떤 경우에도 short count가 나지 않는다.

자세한 구현은 아래 코드와 같다. Unix I/O 함수 readwrite 를 nleft가 0이 될 때까지 반복적으로 수행한다. rio_readn`은 중간에 EOF가 나오면 그때까지 읽은 바이트 수를 반환하고, interrupted 되었을 경우 루프를 다시 반복한다.

10.5.2 RIO Buffered Input Functions

텍스트 파일의 줄 수를 세는 프로그램을 read만을 이용해서 짜려면 1바이트씩 read해서 '\n'인지 일일히 확인해 줘야 한다. 매우 비효율적이고, 바이트마다 trap을 작동시켜야 하는 좋지 않은 방법이다. 이런 방법 대신 rio_readlineb는 내부의 버퍼를 이용해서 text line을 복사하고 버퍼 비면 read를 불러서 자동으로 버퍼를 채운다. Binary data와 텍스트를 모두 읽어야 할 때는 rio_readnb를 사용하는데, rio_readlineb와 동일하게 내부의 버퍼를 이용한다.

void rio_readinitb(rio_t *rp, int fd);

void rio_readlineb(rio_t *rp, void *usrbuf, size_t maxlen);
void rio_readnb(rio_t *rp, void *userbuf, size_t n);

rio_readinitb는 open descriptor마다 한 번씩 불리는 함수이다. Descriptor fd를 주소 rp에 있는 rio_t 자료형의 읽기 버퍼와 연동시켜 준다.

rio_t는 csapp.h에 정의되어 있는 구조체로, 다음과 같이 생겼다.

// csapp.h
#define RIO_BUFSIZE 8192
typedef struct {
    int rio_fd;                 /* Descriptor for this internal buf */
    int rio_cnt;                /* Unread bytes in internal buf */
    char *rio_buffer;           /* Next unread byte in internal buf */
    char rio_buf[RIO_BUFSIZE];  /* Internal buffer */
} rio_t;

rio_readlineb 함수는 rp에서 텍스트 한 줄을 usrbuf로 복사하고 마지막을 NULL (zero) character로 끝낸다. 즉 마지막 한 글자를 빼고 최대 (maxlen-1)글자를 읽어 온다. 만약 텍스트의 한 줄이 그 이상이라면 그냥 자르고 끝에 NULL character를 붙인다. rio_readnb는 rp로부터 userbuf에 최대 n바이트를 복사한다.
Buffered function 두 개는 동시에 수행되도 되어도 괜찮지만 unbuffered rio_readn과 동시에 수행되면 안 된다.


가장 먼저 rio_readinitb 함수는 다음과 같이 되어 있다.

void rio_readinitb(rio_t *rp, int fd)
{
    rio->rio_fd = fd;
    rio->rio_cnt = 0;
    rio->rio_bufptr = rp->rio_buf;
}

우선 file descriptor의 값을 rio 구조체 안에 넣어 준다.그리고 읽지 않은 바이트 수인 rio_cnt는 0으로 초기화하고 다음 읽을 바이트의 포인터 rio_bufptr는 버퍼의 맨 처음, 즉 char 배열의 머리로 초기화한다.


RIO 의 핵심은 rio_read 함수이다. 리눅스의 read 함수에 버퍼를 추가한 형식인데, 만약 내부 버퍼가 비어 있으면 read를 불러서 버퍼를 채우고 그 다음 할 일을 한다. read가 short count가 생겨도 버퍼가 꽉 차지 않을 뿐 동작에는 지장이 없다. 내부 버퍼가 비어 있지 않다면 요청받은 n바이트와 남은 읽지 않은 바이트 수 rp->rio_cnt 중에 작은 값 만큼을 rp로부터 usrbuf로 복사한다.

rp->rio_cnt가 0 이하, 즉 버퍼가 비어 있으면 file descriptor을 이용해서 파일로부터 버퍼의 최대 크기만큼 buf로 read해온다. read의 반환값이 -1이면 에러인데, 만약 errnoEINTR이 아니면, 즉 sig handler의 interrupt가 아닌 다른 이유의 에러라면 즉시 -1을 반환한다. Signal handler의 interrupt 때문이면 반복문을 다시 돈다. read의 반환값이 0이면 EOF이므로, 0을 반환한다. 반환값이 양수, 즉 정상적으로 read가 됐으면 rp->rio_cnt에는 읽어온 바이트 수가 기록되고, 다음 읽을 바이트를 가리키는 포인터 rp->rio_bufptr은 버퍼의 시작 부분을 가리키도록 초기화한다.
파일에서 내부 버퍼에 read를 넉넉하게 잔뜩 해왔으니, 이제 요청받은 바이트 수만큼 내부 버퍼에서 usrbuf로 옮겨야 한다. 요청받은 바이트수와 버퍼에 남은 바이트 수 rp->rio_cnt 중 작은 값을 cnt에 저장하고 memcpy 함수를 이용해서 cnt 바이트만큼 내부 버퍼에서 usrbuf로 옮긴다. 옮긴 후 rp의 정보들을 업데이트 해준 뒤 읽은 바이트 수 cnt를 반환한다.


이제 텍스트를 한 줄씩 읽는 rio_readlineb의 구현을 이해할 수 있다.

내부 버퍼로부터 최대 maxlen 길이의 text line을 읽는 함수이다. 단순하게 1바이트씩 rio_read를 하는 방식으로 구현되어 있다. rio_read로 1바이트 읽기를 요청하면 내부 버퍼에서 char c로 1바이트를 복사할텐데, 정상적으로 돼서 1이 반환됐으면 읽은 cuserbuf의 끝에 추가하고 포인터를 한 칸 뒤로 옮긴다. 읽어서 쓴 1바이트 c가 줄바꿈 문자라면 text line을 다 읽은 것이니 반복문을 나간다. rio_read로 1바이트가 정상적으로 읽히지 않을 경우는 EOF와 에러가 있는데, 먼저 에러인 경우는 즉시 -1을 반환한다. EOF인 경우 중에 하나도 읽지 않고 EOF가 난 경우에는 즉시 0을 반환한다. EOF이지만 조금은 읽었을 때와 정상적으로 줄바꿈이 나올 때까지 읽은 경우는 반복문을 나가고, 맨 끝에 0을 붙인 후 읽은 바이트 수인 (n-1)을 반환한다.

rio_readnb는 앞에서 다룬 unbuffered 함수인 rio_readnreadrio_read로 바꾼 게 사실상 끝이다. rio_readn에서는 sig handler에 의한 interrupt가 있을 때도 따로 처리를 했지만, rio_read가 이걸 해 주고 있기 때문에 rio_readn에서는 이 부분이 빠졌다. 지금 다루고 있는 함수들이 전부 같은 구조(같은 입력, 같은 출력)을 가지고 있기 때문에 이렇게 이해하기 쉽고 편리하게 만들어 질 수 있다.

10.6 Reading File Metadata

Application은 stat 함수와 fstat 함수로 파일의 metadata 를 가져올 수 있다.

#include <unistd.h>
#include <sys/stat.h>
int stat(const char *filename, struct stat *buf);
int fstat(int fd, struct stat *buf);

stat 함수는 파일 이름을 받아서, stat 구조체인 buf에 해당 파일의 정보를 저장한다. fstat은 비슷하지만 파일 이름이 아닌 descriptor를 받는다.
stat 구조체에는 다양한 정보들이 있는데, 대부분은 지금 배울 건 아니고 st_sizest_mode 정도만 알아본다. st_size는 파일이 몇 바이트인지에 대한 정보를 담고 있다. st_mode는 파일의 permission bit와 file type의 정보를 담고 있다. st_mode m으로부터 타입을 알아내기 위한 매크로가 정의되어 있다.

  • S_ISREG(m) : 파일이 regular file인가?
  • S_ISDIR(m) : 파일이 directory file인가?
  • S_ISSOCK(m) : 파일이 network socket인가?

10.7 Reading Directory Contents

Application은 directory의 내용을 readdir 류의 함수들로 읽을 수 있다.

#include <sys/types.h>
#include <dirent.h>

DIR *opendir(const char *name);
struct dirent *readdir(DIR *dirp);
int closedir(DIR *dirp);

opendir 함수는 경로를 받아서 directory stream 을 가리키는 포인터를 반환한다. Stream은 ordered item list의 abstraction으로, 이 경우에는 directory entry의 list가 된다.
readdir을 부를 때마다 directry stream인 dirp의 다음 directory entry를 가리키는 포인터를 반환한다. Entry가 더 없으면 NULL을 반환한다. Directory entry는 다음과 같은 구조체로 되어 있다.

struct dirent {
    ino_t d_ino;    /* inode number */
    char  d_name[256];   /* Filename */

리눅스 버전에 따라 다른 멤버가 구조체에 속할 수도 있지만 모든 시스템 공통으로 있는 것은 이 두 가지다. d_ino는 파일의 위치, d_name은 파일명을 뜻한다.
readdir에서 에러가 나면 NULL을 반환하고 errno의 값을 설정한다. end-of-stream에도 NULL이 반환되기 때문에, 둘을 구별하기 위해서는 readdir을 부르기 전과 후의 errno를 비교하는 방법밖에 없다.
closedir은 stream을 닫고 resource를 모두 free한다.

10.8 Sharing Files

리눅스의 파일은 여러 가지 방법을 통해 공유될 수 있는데, 파일을 공유하는 것은 파일을 open하는 것에 비해 헷갈리는 개념일 수 있다.
커널은 세 가지의 자료구조를 통해 open file들을 표현한다.

  1. Descriptor table : 각 프로세스마다 가지고 있다. 이 table의 entry는 그 프로세스의 open file descriptor로 index되어 있고, 각 entry에는 file table 의 한 entry를 가리키는 포인터가 들어 있다.
  2. File table : 모든 프로세스들이 공유하는 table로, open file들의 집합을 표현하는 자료구조이다. 각 entry에는 우리가 쓰기 위한 정보들(파일 위치, ref cnt 등)이 들어 있고 v-node table 을 가리키는 포인터도 들어 있다. 프로세스가 close로 descriptor를 닫으면 그 entry가 가리키고 있던 open file table의 entry의 ref_cnt 가 1 감소하게 되고, 0이 되면 커널은 file table을 지운다.
  3. v-node table : File table처럼 모든 프로세스가 공유한다.이 table의 각 entry에는 st_modest_size와 같은 stat 구조체에 있는 정보들의 대부분이 들어 있다.

image

위 그림은 한 프로세스에서 descriptor 1은 파일 A를 열었고, descriptor 4로는 파일 B를 연 상황을 나타낸다. 각 open file table에서 ref_cnt 가 1이고, 각 파일의 v-node table을 가리키는 포인터가 들어 있는 것을 볼 수 있다.
image

위 그림은 상황이 조금 다른데, 한 프로세스에서 descriptor 1과 4로 같은 파일을 연 상황이다. 두 file table은 같은 v-node table을 가리키는 포인터를 가지고 있지만 차이점은 파일 위치의 정보가 들어 있는 entry이다. 두 descriptor에서 파일을 읽은 진행 상황이 다를 수 있기 때문이다.

image

위 그림은 fork가 만들어낸 상황이다. 앞에서 fork를 통해 생성된 child process는 부모의 모든 것, 그 중에서도 특히 descriptor table도 똑같이 복사해서 가지게 된다고 했다. Open file table이 두 배로 늘어나지 않고, parent와 child의 descriptor table의 같은 index의 entry는 같은 open file table을 가리키게 된다. 그리고 해당 open file table의 entry 중 ref_cnt 는 2가 된다.

10.9 I/O Redirection

리눅스 쉘에서는 사용자가 표준 입/출력을 디스크의 파일로 할 수 있는 I/O redirection operator를 제공한다.

linux> ls > foo.txt

이렇게 쉘에 치면, 지금부터 표준 출력 대신 foo.txt로 redirect된다. 웹 서버도 비슷한 redirection을 수행한다. 이러한 I/O redirection을 하는 방법 중 하나는 dup2 함수이다.

#include <unistd.h>
int dup2(int oldfd, int newfd);

이 함수는 프로세스의 descriptor table의 oldfd번째 entry를 newfd번째 entry에 덮어 씌운다. newfd번째 descriptor가 open된 상태이면 먼저 close한 후 덮어 씌운다.
위의 세 그림 중 첫 번째 상황에서 dup2(4, 1)을 수행한다면 파일 A의 open file table은 ref_cnt 가 0이 되어 커널에 의해 삭제될 것이고(파일 A의 v-node table도 마찬가지), descriptor table의 1번 entry는 이제 파일 B의 open file table을 가리키게 되어서 ref_cnt 는 2가 된다. 1번 entry는 표준 출력이므로, 이제부터 표준 출력으로 들어오는 모든 데이터는 파일 B로 redirect될 것이다.

10.10 Standard I/O

C에는 higher-level의 입출력 함수들의 모음인 표준 I/O 라이브러리가 정의되어 있다. Unix I/O 대신 사용자에게 higher-level의 대체재를 제공한다. libc에는 파일을 열고 닫을 수 있는 fopen, fclose와 바이트를 읽고 쓸 수 있는 fread, fwrite, 그리고 string을 읽고 쓸 수 있는 fgetsfputs가 있고 formatted I/O를 제공하는 scanfprintf가 있다.
표준 I/O 라이브러리에서는 파일을 stream 으로 모델링한다. Stream buffer는 RIO의 버퍼와 같은 용도로, 비용이 큰 Linux I/O 시스템 콜의 횟수를 줄이기 위함이다.

10.11 Putting It Together:Which I/O Functions Should I Use?

Unix I/O는 운영 체제의 커널 위에 구축되어 있다. Application들은 open, close, lseek와 같은 함수들을 이용해서 이 기능에 접근할 수 있다.
higher-level의 RIO와 표준 I/O 라이브러리는 Unix I/O를 기반으로 깔고 만들어졌다. RIO는 short count나 interrupt에 robust하게 대응하고 버퍼를 사용해서 text line을 빠르게 읽어올 수 있는 wrapper function이고, 표준 I/O 라이브러리는 좀 더 복잡하고 편리한 기능들을 제공한다.
그렇다면 우리는 언제 어떤 걸 써야 할까? 기본적인 가이드라인은 다음과 같다.

G1 : 가능하면 표준 I/O 라이브러리를 써라.

표준 라이브러리는 디스크와 터미널 장비들과의 입출력을 위한 최선의 선택이다. 대다수의 C 프로그래머들은 stat같은 예외 기능 외에는 항상 표준 라이브러리를 쓰지, Unix I/O 함수들을 굳이 힘들게 쓰지 않는다.

G2: Binary file을 읽을 때는 scanfrio_readlineb 를 쓰면 안 된다.

이 두 함수는 텍스트 파일을 읽는 데 특화되어 있어서 예상치 못하고 이상한 에러들이 발생할 수 있다. 예를 들면, 텍스트에서 줄바꿈 문자에 해당하는 character는 binary file에는 잔뜩 널려 있을 수 있다.

G3: 네트워크 소켓에 입출력할때는 RIO를 사용하라.

아쉽지만 표준 입출력 라이브러리는 네트워크에서 입출력을 수행할 때 귀찮은 문제들이 좀 발생한다. 11장에서 다룬다고 한다.
표준 I/O의 stream 은 프로그램이 입력과 출력을 같은 stream에서 할 수 있다는 점에서 full duplex, 즉 완전한 복층 구조이다. 하지만 소켓에서 생기는 제한 사항으로 인해 stream이 가지는 제한 사항이 존재한다.

  • Restriction 1: 입력 함수가 출력 함수 직후에 이루어 지는 경우

입력 함수는 사이에 fflush, fseek, fsetposrewind 없이 출력 함수 다음에 나오면 안 된다. fflush는 해당 스트림의 버퍼를 모두 비우는 함수이고, 나머지 세 개의 함수는 Unix I/O의 lseek을 사용해서 current file position을 원위치시킨다.

  • Restriction 2: 출력 함수가 입력 함수 직후에 이루어지는 경우

출력 함수는 사이에 fseek, fsetposrewind 없이 입력 함수 다음에 나오면 안 된다(입력 함수가 EOF를 만나는 경우 제외).

이런 제한 사항들은 network application에서 문제가 되는데, 소켓에서는 lseek를 쓸 수가 없기 때문이다. 매번 입력 함수 전마다 flush를 하면 첫 번째 제한 사항은 처리할 수 있지만, 두 번째 제한 사항을 해결하려면 동일한 socket descriptor에 stream을 두 개(하나는 읽기용, 하나는 쓰기용) 열어야 한다.

FILE *fpin, *fpout;

fpin = fdopen(sockfd, "r");
fpout = fdopen(socked, "w");

하지만 이렇게 하면 또 문제가 되는 게, 두 stream의 메모리 리소스를 free해 줘서 메모리 누수를 막아야 하기 때문에 각각 fclose를 해야 하는데 12장에서 배울 쓰레드에서는 이미 닫힌 descriptor를 또 닫는 것은 재앙적인 문제가 될 수 있다.
따라서 네트워크 소켓에 입출력을 할 때는 표준 I/O가 아닌 RIO를 쓰기를 권장한다. Formatted I/O가 필요하다면 sprintf로 format화해서 메모리에 저장한 다음에 rio_written으로 소켓에 쓰고, rio_readlineb로 소켓을 통째로 읽은 다음 ssanf로 포맷화 해야한다.

10.12 Summary

리눅스는 Unix I/O 모델을 기반으로 소수의 system-level 함수들을 제공한다. 이들은 파일을 열고 닫고, 읽고 쓰고, metadata에 접근하거나 I/O redirection을 수행할 수 있다. 하지만 리눅스의 readwrite는 short count가 발생할 수 있기 때문에 응용 프로그램들은 이를 적절히 처리해 줘야 한다.
Unix I/O를 직접 사용하는 대신, RIO 패키지에는 short count를 알아서 처리해 주는 함수들이 마련되어 있다. Short count가 발생했더라도 요청한 만큼 입출력이 완료될 때까지 반복해서 readwrite를 요청하는 방식으로 해결한다.
리눅스 커널은 open file을 나타내기 위해 세 가지 자료구조를 사용한다. Descriptor table은 file descriptor로 인덱싱 되어 있고, entry에는 open file table을 기리키는 포인터가 들어 있다. Open file table에는 파일의 ref_cnt 같은 정보 및 v-node table을 가리키는 포인터가 들어 있는 entry들이 있다. Descriptor table은 각 프로세스마다 개별적으로 가지고, open file table과 v-node table은 모든 프로세스가 공유한다. 이러한 구조를 잘 이해하는 것은 file sharing과 I/O redirection을 명확히 이해하는 데 큰 도움이 된다.
표준 I/O 라이브러리는 Unix I/O를 기반으로 만들어졌고, 강력한 higher-level의 입출력 함수를 제공한다. 대부분의 경우 응용 프로그램에서 표준 라이브러리는 Unix I/O 보다 간단하고 선호되지만, network application의 경우에는 호환이 잘 되지 않는 점이 있기 때문에 Unix I/O를 사용해야 하는 부분이 있다.

Tags:

Categories:

Updated: