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
Type | Description |
---|---|
일반 파일 (Regular file) | 임의의 데이터를 담고 있는 파일 |
디렉터리 (Directory) | 다른 파일의 위치 및 속성을 설명하는 특수 파일 |
소켓 (Socket) | 프로세스 간 통신(Inter-Process Communication, IPC)에 사용되는 특수 파일 |
이 외에도 명명된 파이프(Named pipe), 심볼링 링크(Symbolic link), 장치 파일(Device file)과 같은 다양한 특수 파일들이 존재한다.
Regular Files
커널과 달리, 응용 프로그램 수준에서는 일반 파일을 텍스트 파일과 바이너리 파일로 구분하기도 한다.
Type | Description |
---|---|
텍스트 파일 (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가지의 특별한 파일 디스크립터가 연결되어 있다.
FD | Name | <unistd.h> symbolic constant | <stdio.h> file stream |
---|---|---|---|
0 | 표준 입력 (Standard input) | STDIN_FILENO | stdin |
1 | 표준 출력 (Standard output) | STDOUT_FILENO | stdout |
2 | 표준 오류 (Standard error) | STDERR_FILENO | stderr |
대부분의 시스템 콜은 반환값을 통해 오류 발생 여부를 확인할 수 있다. 반환값의 형식은 다양하므로, 각 함수의 레퍼런스를 참고하자.
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
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)
을 호출하여 출력을 리다이렉션한 경우에 대해 살펴보자.
Before | After |
---|---|
fd 1 → File A | fd 1 → File B |
fd 3 → File B | fd 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가 된다.