Post

Lecture 16: System Level I/O

Computer Systems: A Programmer's Perspective (CS:APP)

Unix I/O


Unix I/O Overview

Unix에서 모든 종류의 파일(File)은 바이트의 시퀀스일 뿐이다. 입출력 장치와 소켓 또한 파일로 표현되기에, 동일한 저수준 인터페이스(Unix I/O)를 사용한다.

  • 열기/닫기: open(), close()
  • 읽기/쓰기: read(), write()

터미널, 소켓 등을 제외한 대부분의 파일은 현재까지 읽은 바이트 수를 나타내는 파일 위치(File position)라는 속성을 가진다.

File Types

TypeDescription
일반 파일 (Regular file)임의의 데이터를 담고 있는 파일
디렉터리 (Directory)다른 파일의 위치 및 속성을 설명하는 특수 파일
소켓 (Socket)프로세스 간 통신(Inter-Process Communication, IPC)에 사용되는 특수 파일

이 외에도 명명된 파이프(Named pipe), 심볼링 링크(Symbolic link), 장치 파일(Device file)과 같은 다양한 특수 파일들이 존재한다.

Regular Files

커널과 달리, 응용 프로그램 수준에서는 일반 파일을 텍스트 파일과 바이너리 파일로 구분하기도 한다.

TypeDescription
텍스트 파일 (Text file)ASCII 또는 유니코드(Unicode) 문자만으로 이루어진 파일
바이너리 파일 (Binary file)인코딩된 데이터(기계 코드, 이미지, 오디오 등)를 담고 있는 파일

텍스트 파일에서는 줄 바꿈 문자가 EOL(End Of Line)을 나타낸다.

  • Linux, Mac OS: \n (Line Feed, LF)
  • Windows, Internet protocols: \r\n (Carriage Return followed by Line Feed, CRLF)

Directories

디렉터리는 파일로 저장되지만, 운영 체제의 파일 시스템에서 특별한 방식으로 해석된다.

  • 모든 디렉터리에는 현재 디렉터리(.)와 상위 디렉터리(..)를 가리키는 특수 파일이 존재한다.
  • 대부분의 파일 시스템은 계층 구조로 구성되며, 경로 이름(Pathname)을 통해 파일을 식별한다.

Opening Files

1
int open(const char *pathname, int flags);

pathname에 해당하는 파일을 flags 모드로 연다. 열려 있는 파일을 식별하기 위한 파일 디스크립터(File Descriptor, FD) 값이 반환된다. 파일 디스크립터는 순차적으로 할당되며, 대부분의 시스템에서는 프로세스가 동시에 열 수 있는 파일의 수가 제한되어 있다.

1
2
3
4
$ limit
⋮
descriptors     1048576
⋮

모든 프로세스에는 3가지의 특별한 파일 디스크립터가 연결되어 있다.

FDName<unistd.h> symbolic constant<stdio.h> file stream
0표준 입력 (Standard input)STDIN_FILENOstdin
1표준 출력 (Standard output)STDOUT_FILENOstdout
2표준 오류 (Standard error)STDERR_FILENOstderr

대부분의 시스템 콜은 반환값을 통해 오류 발생 여부를 확인할 수 있다. 반환값의 형식은 다양하므로, 각 함수의 레퍼런스를 참고하자.

1
2
3
4
5
int fd;
if ((fd = open("/etc/hosts", O_RDONLY)) < 0) {
    perror("open()");
    exit(1);
}

Closing Files

1
int close(int fd);

fd가 가리키는 파일을 닫는다. close()에도 반환값이 존재하는데, 멀티스레드(multi-thread) 프로그램에서 자료 구조와 메모리를 공유할 때 이미 닫힌 파일을 닫으려고 하면 오류가 발생할 수 있기 때문이다.

Reading Files

1
ssize_t read(int fd, void *buf, size_t count);

fd가 가리키는 파일로부터 count 바이트를 읽어 buf가 가리키는 버퍼에 저장한다. 반환값이 0이면 파일의 끝(End Of File, EOF)에 도달했거나 네트워크 연결이 닫혔음을 의미하고, 양수이면 읽은 바이트 수를, 음수이면 오류가 발생했음을 나타낸다.

  • 표준 입력의 경우, 사용자가 문자열을 입력하고 Enter 키를 누르면 입력된 문자열을 프로그램으로 읽어 들인다.
  • 네트워크 연결의 경우, 데이터가 도착할 때까지 기다렸다가 전송된 바이트 수만큼 읽는다.

읽은 바이트 수는 1부터 버퍼 크기까지 가변적이며, count보다 작은 경우를 Short counts라 한다.

Writing Files

1
ssize_t write(int fd, const void *buf, size_t count);

fd가 가리키는 파일에 buf가 가리키는 버퍼의 count 바이트를 쓴다. 반환값이 양수이면 쓴 바이트 수를, 음수이면 오류가 발생했음을 나타낸다. read()와 마찬가지로, short counts가 발생할 수 있다.

Simple Unix I/O Example

1
2
3
4
5
6
int main() {
    char c;
    while (Read(STDIN_FILENO, &c, 1) != 0)
        Write(STDOUT_FILENO, &c, 1);
    return 0;
}

위 코드는 1바이트 단위로 읽고 쓰고 있는데, 이는 매우 비효율적인 코드이다. 시스템 콜은 커널에서 해당 동작을 처리한 뒤 다시 프로그램으로 돌아와야 하므로 높은 비용이 소요되기 때문이다.

strace를 통해 프로그램이 호출하는 모든 시스템 콜을 추적할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ strace -e trace=read,write ./a.out
read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0P\237\2\0\0\0\0\0"..., 832) = 832
read(0, echo
"e", 1)                         = 1
write(1, "e", 1e)                        = 1
read(0, "c", 1)                         = 1
write(1, "c", 1c)                        = 1
read(0, "h", 1)                         = 1
write(1, "h", 1h)                        = 1
read(0, "o", 1)                         = 1
write(1, "o", 1o)                        = 1
read(0, "\n", 1)                        = 1
write(1, "\n", 1
)                       = 1
read(0, "", 1)                          = 0
+++ exited with 0 +++

Short Counts

Short counts는 다음과 같은 이유로 발생할 수 있다.

  • 파일에서 읽다가 EOF에 도달한 경우, 더 이상 읽을 바이트가 없다.
  • 터미널에서 텍스트 줄을 읽는 경우, 터미널 핸들러가 데이터를 한 줄씩 전송한다.
  • 네트워크 소켓에서 읽거나 쓰는 경우, 데이터가 작은 덩어리(chunk) 단위로 전송된다.

따라서 이러한 저수준 I/O를 사용할 때는 항상 short counts의 가능성을 고려해야 한다.


Robust I/O


RIO(Robust I/O)는 2가지 수준의 파일 입출력 인터페이스를 제공한다.

Unbuffered RIO

1
2
ssize_t rio_readn(int fd, void *usrbuf, size_t n);
ssize_t rio_writen(int fd, void *usrbuf, size_t n);

내부 구현은 여기1 참고

Short counts 문제를 해결하기 위해, n 바이트만큼 읽거나 쓸 때까지 반환하지 않는다. (읽다가 EOF에 도달하는 경우 제외) 이로 인해 네트워크 소켓에서 읽을 때 기대한 만큼의 바이트가 없으면, 바이트를 기다리며 멈추는 문제가 발생한다.

Buffered RIO

위의 문제를 해결하기 위해, 읽기 작업 시 버퍼링(Buffering)을 수행한다.

  1. 프로그램이 바이트를 읽으려고 할 때, 먼저 버퍼에 아직 읽히지 않은 데이터가 있는지 확인한다.
  2. 있다면 해당 데이터를 반환하고, 없다면 버퍼를 채운다.

매번 운영 체제에 작은 크기의 데이터를 요청하는 대신, 한 번에 최대한 많은 데이터를 요청하여 버퍼에 저장해 놓고 프로그램이 필요할 때마다 버퍼로부터 읽어 들이는 방식이다.

1
2
3
4
5
6
7
8
9
typedef struct {
    int rio_fd;                 /* Descriptor for this internal buf */
    int rio_cnt;                /* Unread bytes in internal buf */
    char *rio_bufptr;           /* Next unread byte in internal buf */
    char rio_buf[RIO_BUFSIZE];  /* Internal buffer */
} rio_t;

ssize_t rio_readlineb(rio_t *rp, void *usrbuf, size_t maxlen);  /* Reads a text line */
ssize_t rio_readnb(rio_t *rp, void *usrbuf, size_t n);          /* Reads up to n bytes */

RIO Example

1
2
3
4
5
6
7
8
9
10
11
#define MAXLINE 8192

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);
    return 0;
}
1
2
3
4
5
6
7
8
$ strace -e trace=read,write ./a.out
read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0P\237\2\0\0\0\0\0"..., 832) = 832
read(0, echo
"echo\n", 8192)                 = 5
write(1, "echo\n", 5echo
)                   = 5
read(0, "", 8192)                       = 0
+++ exited with 0 +++

표준 입력에 대한 read(), write()가 한 번씩만 호출되는 것을 볼 수 있다.


Standard I/O


표준 입출력(Standard I/O)은 C 표준의 일부로서, RIO와 마찬가지로 버퍼링을 수행하여 저수준 작업을 최소화한다.

Buffering in Standard I/O

printf()는 다음과 같은 경우에 버퍼를 플러시(Flush)한다.

  • 줄 바꿈 문자를 만났을 때
  • fflush() 호출 시
  • 프로그램 종료 시
1
2
3
4
5
6
7
8
9
int main() {
    printf("h");
    printf("e");
    printf("l");
    printf("l");
    printf("o");
    printf("\n");
    return 0;
}
1
2
3
4
$ strace -e trace=write ./a.out
write(1, "hello\n", 6hello
)                  = 6
+++ exited with 0 +++

Unix I/O vs Standard I/O vs RIO

  • Standard I/O: 일반 파일에 적합하며, 소켓에는 적합하지 않다.2
  • Unix I/O: 비동기 시그널 안전성3을 만족해야 할 때나, 저수준 작업에 적합하다.
  • Robust I/O: 소켓 통신에 적합하다.

Aside: Working with Binary Files

텍스트 파일은 줄 바꿈 문자와 null 문자를 특수하게 취급하지만, 바이너리 파일에서는 그냥 평범한 바이트일 뿐이다. 따라서 바이너리 파일을 다룰 때, 다음과 같은 함수들은 사용하면 안 된다.

  • 텍스트 지향(Text-oriented) 입출력 함수: fgets(), scanf(), rio_readlineb()
  • 문자열 함수: strlen(), strcpy(), strcat()


Metadata, Sharing, and Redirection


File Metadata

파일 메타데이터(File metadata)는 파일에 대한 각종 정보를 담고 있는 데이터이며, stat 구조체에 저장된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
struct stat {
    dev_t     st_dev;      /* ID of device containing file */
    ino_t     st_ino;      /* Inode number */
    mode_t    st_mode;     /* File type and mode */
    nlink_t   st_nlink;    /* Number of hard links */
    uid_t     st_uid;      /* User ID of owner */
    gid_t     st_gid;      /* Group ID of owner */
    dev_t     st_rdev;     /* Device ID (if special file) */
    off_t     st_size;     /* Total size, in bytes */
    blksize_t st_blksize;  /* Block size for filesystem I/O */
    blkcnt_t  st_blocks;   /* Number of 512B blocks allocated */

    /* Since Linux 2.6, the kernel supports nanosecond
      precision for the following timestamp fields. */

    struct timespec st_atim;  /* Time of last access */
    struct timespec st_mtim;  /* Time of last modification */
    struct timespec st_ctim;  /* Time of last status change */

#define st_atime st_atim.tv_sec  /* Backward compatibility */
#define st_mtime st_mtim.tv_sec
#define st_ctime st_ctim.tv_sec
};

자세한 내용은 man 2 stat 참고

Example of Accessing File Metadata

1
int stat(const char *pathname, struct stat *statbuf);

pathname에 해당하는 파일의 정보를 statbuf가 가리키는 stat 구조체에 채운다. 매크로를 이용하여 메타데이터의 속성을 확인할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
int main(int argc, char *argv[]) {
    struct stat stat;
    char *type, *read_ok;

    if (argc != 2) {
        fprintf(stderr, "Usage: %s <filename>\n", argv[0]);
        return 2;
    }
    Stat(argv[1], &stat);
    if (S_ISREG(stat.st_mode))  /* Determine file type */
        type = "regular";
    else if (S_ISDIR(stat.st_mode))
        type = "directory";
    else
        type = "other";
    if (stat.st_mode & S_IRUSR)  /* Check read access */
        read_ok = "yes";
    else
        read_ok = "no";
    printf("type: %s, read: %s\n", type, read_ok);
    return 0;
}
1
2
3
4
5
6
7
$ ./a.out main.c
type: regular, read: yes
$ chmod 000 main.c
$ ./a.out main.c
type: regular, read: no
$ ./a.out ..
type: directory, read: yes

How the Unix Kernel Represents Open Files

---
config:
    flowchart:
        htmlLabels: false
---
graph LR
    subgraph "V-node table"
        subgraph nodeA["Info in stat struct"]
            accessA["File access"]
            sizeA["File size"]
            typeA["File type"]
            c["⋯"]
        end
        subgraph nodeB[" "]
            accessB["File access"]
            sizeB["File size"]
            typeB["File type"]
            d["⋯"]
        end
    end

    subgraph "Open file table"
        subgraph fileA["File A (terminal)"]
            posA["File position"]
            refcntA["refcnt = 1"]
            a["⋯"]
        end
        subgraph fileB["File B (disk)"]
            posB["File position"]
            refcntB["refcnt = 1"]
            b["⋯"]
        end
    end

    subgraph "FD table"
        fd0["fd 0"]
        fd1["fd 1"]
        fd2["fd 2"]
        fd3["fd 3"]
    end

    fd1 --> fileA --> nodeA
    fd3 --> fileB --> nodeB
  • 각 프로세스는 파일 디스크립터 테이블(File descriptor table)을 가지고 있으며, 여기에는 해당 프로세스가 연 파일들에 대한 파일 디스크립터가 저장된다.
  • 운영 체제는 열려 있는 파일들을 관리하기 위해, 모든 프로세스 간 공유되는 Open file table을 유지한다.
    • 각 항목에는 해당 파일의 읽기/쓰기 위치, 참조 카운트(refcnt) 등의 정보가 저장된다.
    • 참조 카운트를 통해 여러 프로세스가 동일한 파일을 공유하는 상황을 추적할 수 있으며, 참조 카운트가 0이 되면 더 이상 그 파일을 사용하는 프로세스가 없다는 뜻이므로 항목을 제거할 수 있다.
    • 파일 디스크립터는 open file table의 항목을 가리키는 포인터 역할을 한다.
  • 시스템의 모든 파일(열려 있든 닫혀 있든)에 대해, 각 파일의 메타데이터가 저장되어 있는 가상 노드(V-node)가 존재한다.

File Sharing

프로그램에서 하나의 파일에 대해 open()을 2번 호출하면, 운영 체제는 별도의 파일 디스크립터를 2개 반환한다. 이들은 동일한 파일을 가리키지만, 파일 위치는 독립적으로 관리된다.

---
config:
    flowchart:
        htmlLabels: false
---
graph LR
    subgraph "V-node table"
        subgraph nodeA[" "]
            accessA["File access"]
            sizeA["File size"]
            typeA["File type"]
            c["⋯"]
        end
        subgraph nodeB[" "]
            accessB["File access"]
            sizeB["File size"]
            typeB["File type"]
            d["⋯"]
        end
    end

    subgraph "Open file table"
        subgraph fileA["File A (disk)"]
            posA["File position"]
            refcntA["refcnt = 1"]
            a["⋯"]
        end
        subgraph fileB["File B (disk)"]
            posB["File position"]
            refcntB["refcnt = 1"]
            b["⋯"]
        end
    end

    subgraph "FD table"
        fd0["fd 0"]
        fd1["fd 1"]
        fd2["fd 2"]
        fd3["fd 3"]
        fd4["fd 4"]
    end

    fd3 --> fileA --> nodeA
    fd4 --> fileB --> nodeA

같은 파일을 가리키는 여러 개의 파일 디스크립터를 열어 두고 동시에 쓰기 작업을 수행하면, 파일 내용이 손상되거나 예측할 수 없는 결과가 발생할 수 있으므로 주의가 필요하다.

How Processes Share Files: fork()

fork()를 통해 생성된 자식 프로세스는 부모 프로세스의 파일 디스크립터 테이블을 상속받는다. 이들은 open file table의 동일한 항목을 가리키므로, 파일 위치 또한 공유하게 된다.

---
config:
    flowchart:
        htmlLabels: false
---
graph LR
    subgraph "V-node table"
        subgraph nodeA[" "]
            accessA["File access"]
            sizeA["File size"]
            typeA["File type"]
            c["⋯"]
        end
        subgraph nodeB[" "]
            accessB["File access"]
            sizeB["File size"]
            typeB["File type"]
            d["⋯"]
        end
    end

    subgraph "Open file table"
        subgraph fileA["File A (terminal)"]
            posA["File position"]
            refcntA["refcnt = 2"]
            a["⋯"]
        end
        subgraph fileB["File B (disk)"]
            posB["File position"]
            refcntB["refcnt = 2"]
            b["⋯"]
        end
    end

    subgraph "FD table"
        subgraph parent["Parent"]
            fd0P["fd 0"]
            fd1P["fd 1"]
            fd2P["fd 2"]
            fd3P["fd 3"]
        end
        subgraph child["Child"]
            fd0C["fd 0"]
            fd1C["fd 1"]
            fd2C["fd 2"]
            fd3C["fd 3"]
        end
    end

    fd1P & fd1C --> fileA --> nodeA
    fd3P & fd3C --> fileB --> nodeB

I/O Redirection

1
int dup2(int oldfd, int newfd);

dup2()는 파일 디스크립터를 복제하는 시스템 콜이며, 주로 입출력 리다이렉션(I/O redirection)에 사용된다. 셸에서 표준 입력 대신 파일에서 읽기 위해 < 기호를, 표준 출력 대신 파일에 쓰기 위해 > 기호를 사용하면, 셸은 dup2()를 호출하여 입출력 리다이렉션을 수행한다.

dup2(3, 1)을 호출하여 출력을 리다이렉션한 경우에 대해 살펴보자.

BeforeAfter
fd 1 → File Afd 1 → File B
fd 3 → File Bfd 3 → File B
---
config:
    flowchart:
        htmlLabels: false
---
graph LR
    subgraph "V-node table"
        subgraph nodeA[" "]
            accessA["File access"]
            sizeA["File size"]
            typeA["File type"]
            c["⋯"]
        end
        subgraph nodeB[" "]
            accessB["File access"]
            sizeB["File size"]
            typeB["File type"]
            d["⋯"]
        end
    end

    subgraph "Open file table"
        subgraph fileA["File A (terminal)"]
            posA["File position"]
            refcntA["refcnt = 0"]
            a["⋯"]
        end
        subgraph fileB["File B (disk)"]
            posB["File position"]
            refcntB["refcnt = 2"]
            b["⋯"]
        end
    end

    subgraph "FD table"
        fd0["fd 0"]
        fd1["fd 1"]
        fd2["fd 2"]
        fd3["fd 3"]
    end

    fd1 & fd3 --> fileB --> nodeB

dup2() 호출 시 newfd가 열려 있는 파일 디스크립터였다면, 기존의 newfd를 먼저 닫은 뒤 oldfd를 복제한다. 따라서 별도로 close()를 호출하지 않아도 File A의 참조 카운트가 0이 되고, 파일 디스크립터가 복제된 File B의 참조 카운트는 2가 된다.


References


Footnote

이 글은 저작자의 CC BY-SA 4.0 라이선스를 따릅니다.