컴퓨터공학에서 I/O(Input & Output)은 다음과 같이 정의할 수 있다.
I/O : Main Memory와 External Device 사이의 소통
Input은 External에서 Main Memory로, Output은 Main Memory에서 External로
~> 이때, External Device라 함은, HDD(Hard Disk), SSD, Terminal, Network 등이 있겠다.
ANSI C : 우리에게 아주 친숙한 Standard I/O Library를 제공한다. printf나 scanf가 대표적이다.
Linux : OS Kernel에서 제공하는 UNIX I/O(이를테면 System Call)를 사용해서 구현한 여러 가지 High-Level I/O Function을 사용한다.
그런데, 우리가 본 Chapter2 연재에서 다룰 내용은 UNIX I/O부터이다. 프로그래머가 UNIX I/O를 사용할 일이 많지 않다면, 굳이 배울 필요가 있을까?라는 의문이 들 수 있다.
UNIX I/O를 배우면 System적인 많은 개념을 더 잘 이해할 수 있다. 또한, 간혹 UNIX I/O를 직접 사용해야하는 상황도 있다.
예를 들어, 파일의 Metadata를 추출하고 싶을 땐, Standard I/O Library에서는 관련 함수를 찾을 수 없다. stat이라는 System Call로만 이 일을 할 수 있다. stat은 대표적인 UNIX I/O이다. 또한, 네트워크 입출력에서도 Library는 사용할 수 없다.
~> 따라서, 우리는 UNIX I/O를 배워야한다.
File : Linux에서 File이라 함은, 여러 바이트의 시퀀스이다.
"모든 것이 파일이다."
모든 I/O Device는 파일로 표현할 수 있다.
디렉토리도 파일이다.
Kernel도 파일이다.
당연히, 프로그램도 파일이다.
각 Device와 데이터를 파일화(Mapping to Files)하는 이유는, Kernel이 이를 뽑아낼 수 있게 하기 위함이다.
즉, 모든 입출력 데이터를 파일화한다.
각 파일에는 해당 파일이 System에서 가지는 역할(Role)에 대한 Type이 있다.
Regular File, Directory, Socket, Named Pipes, Symbolic Link, Character, Block Device 등
Regular File : 그냥 '임의의 정보(Arbitrary Data)'를 가진 일반적인 파일을 의미한다. Document부터, Audio, Media, Program 파일 등 Any File!
Directory : 관련된 '파일의 집합을 위한 인덱스'를 가지는 파일을 의미한다. 즉, 폴더!
a.txt, b.txt, c.txt라는 파일들이 workspace라는 디렉토리에 들어있다고 해보자.
즉, Regular File처럼 어떠한 컨텐츠를 가진 것은 아니지만, 내부의 각 파일들을 인덱싱할 수 있는 Table을 가지고 있는 파일을 디렉토리라 하는 것이다.
Socket : 다른 Machine에 있는 프로세스와 소통하기 위한 파일이다.
Regular File contains arbitrary data.
즉, Application 파일이 모두 Regular File에 해당한다.
Binary File과 Text File로 나눌 수 있다.
Text File : 오직 ASCII나 Unicode 문자로만 이루어진 파일을 의미한다.
Text File = "Sequence of Text Lines"
Text Line : 'Newline Character(\n)'로 끝나는 문자열
EOL(End of Line)
Binary File : 이진수로 이루어진, Text File 외의 모든 파일을 의미한다.
참고로, Kernel은 Regular File에 대해, Binary와 Text를 구분하지 않는다.
Directory : 파일 이름에 대해 위치를 나타내는 Link들의 Array(Table)이다.
Directory = "Array of Links"
각 디렉토리는 디폴트로 다음의 두 Entry를 가진다.
디렉토리에 대한 명령어
디렉토리 계층구조(Directory Hierarchy)
Leaf Node가 아닌 Node는 모두 Directory이다. ★★
Kernel은 각 프로세스에게 '현재 디렉토리(Current Working Directory)'를 cwd라는 이름으로 제공한다.
계층구조에서 '파일의 위치'는 Pathname이란 것으로 표기한다. 쉽게 말해서 경로이름이다.
Pathname(경로명)
Absolute Pathname : 루트인 /부터 시작해서 쭉 나아가는 것 (절대 경로)
Relative Pathname : cwd부터 시작해서 쭉 나아가는 것 (상대 경로)
파일을 연다는 것은, Kernel에게 "나 이제 이 파일 접근할 준비됐어!"라고 알리는 것과 같다.
int fd; /* 파일 지정자(File Descriptor) */
if ((fd = open("/etc/hosts", O_RDONLY)) < 0) {
perror("File Open Error");
exit(1);
}
open함수를 사용하면, Kernel이 Return Value로 File Descriptor를 반환한다.
프로세스는 File Descriptor Table을 가진다. ★
리눅스 Shell에서 생성된 각 프로세스는 아래의 세 파일 지정자를 Default로 가진다.
이 세 파일 지정자는 예약(Reserved)되어 있다. 따라서, 프로세스에서 open함수를 처음 호출하면, 3번 인덱스부터 반환한다. ★★★
파일을 닫는다는 것은, Kernel에게 "나 이제 이 파일 접근 끝낼게!"라고 알리는 것과 같다.
int fd;
int ret; /* Return Value */
// 리턴값을 확인하는 습관은 매우 좋은 습관이다.
if ((ret = close(fd)) < 0) {
perror("File Close Error");
exit(1);
}
파일을 읽는다는 것은, 'Current File Position(somewhere in SSD or something)'에서부터 복수의 바이트를 복사해서 Main Memory에 놓고, 그 다음 Current File Position을 Update하는 것이다.
즉, "선 Copy 후 fd Update"이다.
char buf[512];
int fd;
int m; /* 읽은 바이트 개수 */
if ((fd = open("/etc/hosts", O_RDONLY)) < 0) {
perror("File Open Error");
exit(1);
}
if ((m = read(fd, buf, sizeof(buf))) < 0) {
perror("File Read Error");
exit(1);
}
"read(fd, buf, sizeof(buf))"의 의미
fd의 Current File Position부터 sizeof(buf)만큼 읽어서 buf에 넣고, 읽은 바이트 개수를 반환한다. ★
read 함수의 반환값이 0보다 작으면 에러 상황이다.
내가 요청한 사이즈보다 실제 읽은 바이트 수가 적은 것은 에러가 아니다.
파일에 쓴다는 것은, Main Memory에서 복수의 바이트를 복사해서 Current File Position에서부터 덮어쓰고, 그 다음 Current File Position을 Update하는 것이다.
즉, "선 Copy 후 fd Update"이다. (read와 동일)
char buf[512];
int fd;
int m; /* 쓰여진 바이트 개수 */
if ((fd = open("/etc/hosts", O_RDONLY)) < 0) {
perror("File Open Error");
exit(1);
}
if ((m = write(fd, buf, sizeof(buf)) < 0) {
perror("File Write Error");
exit(1);
}
"write(fd, buf, sizeof(buf))"의 의미
sizeof(buf)만큼 buf를 복사해서 fd의 Current File Position에서부터 넣고, 쓴 바이트 개수를 반환한다. ★
write 함수의 반환값이 0보다 작으면 에러 상황이다.
내가 요청한 사이즈보다 실제 읽은 바이트 수가 적은 것은 에러가 아니다. (역시나)
아래와 같은 간단한 C코드를 보자.
int main(void) {
char c;
while(Read(STDIN_FILENO, &c, 1) != 0) // Wrapper로 씌운 Read/Write
Write(STDOUT_FILENO, &c, 1);
return 0;
}
~> 터미널에서 문자열이 입력되면, 한 문자씩 읽어서 화면에 쓰는 프로그램이다.
=> 매우 비효율적인 프로그램이다.
--> read와 write는 System Call이다. 즉, Overhead가 많다. 한 번의 호출에 대략 20ms 정도의 Overhead가 난다고 한다. 이는 인간에겐 짧은 시간일지 몰라도, 컴퓨터에겐 너무나 긴 시간이다. 위 코드처럼 read/write를 잦게 호출하는 경우, 시간 비효율이 매우 높은 것이다.
read나 write 시에는, 시간 효율을 높이기 위해, 큰 바이트 단위로 읽는 것이 좋다. 일반적으로 Chunk(0.5KB, 512B)단위로 읽고 쓰곤 한다.
read나 write 시 발생할 수 있는 상황이다. 읽거나 쓴 바이트 개수가 요청한 바이트 개수보다 작을 때를 의미한다.
Short Count 발생 상황
디스크에서 읽고 쓸 때는 read 시 EOF를 만날 때를 제외하고는 Short Count가 발생하지 않는다. 매 읽기마다 읽을 수 있는 만큼 다 읽기 때문! ★
본인이 SP 연재에서 참고하는 교재 'Computer Systems: A Programmer's Perspective'에서는 RIO Package를 제공한다. 지난 포스팅에서 다룬 SIO 라이브러리와 같은 느낌이다. SIO와 RIO 모두 이후 연재에서 계속 언급될 것이기 때문에 알고 넘어가자.
RIO Package는 Unbuffered와 Buffered를 모두 제공한다.
Unbuffered : rio_readn, rio_writen
Buffered : rio_readlineb, rio_readnb
UNIX의 read & write는 Unbuffered Input & Output이다. ★★★
ssize_t rio_readn(int fd, void *usrbuf, size_t n);
ssize_t rio_writen(int fd, void *usrbuf, size_t n);
/* Return */
// num of bytes transferred if OK
// 0 on EOF (rio_readn only)
// -1 on error
rio_readn은 EOF를 만날 때만 Short Count 상황이 만들어진다.
rio_writen은 Short Count 상황이 만들어지지 않는다.
아래는 rio_readn 함수의 내부이다. 구성 원리를 알아보고 가자.
ssize_t rio_readn(int fd, void *usrbuf, size_t n) {
size_t nleft = n; // nleft는 n에서 좌로 갈 변수!
ssize_t nread;
char *bufp = usrbuf;
while (nleft > 0) {
if ((nread = read(fd, bufp, nleft)) < 0) { // nleft만큼 읽는다.
if (errno == EINTR) // Interrupt 상황 시, 다시 수행! ★
nread = 0;
else
return -1; // 에러 상황 시 -1 반환 및 종료
}
else if (nread == 0) // 파일의 끝을 만났을 때!
break;
nleft -= nread; // 읽은 만큼 nleft는 좌로 이동
bufp += nread; // 읽은 만큼 버퍼주소는 우로 이동
}
return (n - nleft); // 읽은 양을 반환
}
~> 시그널 인터럽트 시 다시 read를 수행하게 함을 주목! (rio_readn)
Buffered의 경우 내부 메모리 버퍼를 두어 조금 더 효율적인 입출력을 도모할 수 있다.
Unix I/O는 Unbuffered이다. 따라서 Buffered RIO가 더 수행속도가 빠르다.
void rio_readinitb(rio_t *rp, int fd);
ssize_t rio_readlineb(rio_t *rp, void *usrbuf, size_t maxlen);
ssize_t rio_readnb(rio_t *rp, void *usrbuf, size_t n);
/* Return */
// num of bytes read if OK
// 0 on EOF
// -1 on error
rio_readlineb : fd에서 텍스트 라인을 읽을 수 있는 최대로 읽고, 버퍼에 넣어놓는다.
네트워크 소켓에서 텍스트를 읽을 때 상당히 효율적이다.
종료 조건
rio_readnb : fd에서 최대 바이트만큼 읽을 수 있는대로 읽고, 버퍼에 넣어놓는다.
rio_readnb와 rio_readlineb는 서로 겹쳐서 써도 상관없지만, 이 둘은 rio_readn과는 함께 쓰지 않도록 한다.
Buffered : File에 버퍼를 할당하는데, 이 버퍼에는 파일로부터는 읽었지만, 아직 사용자 코드 레벨에선 읽지 않은 데이터가 들어있다. ★
typedef struct {
int rio_fd; /* 내부 버퍼를 위한 파일 지시자 */
int rio_cnt; /* 내부 버퍼엔 들어있지만 아직 사용자가 읽지 않은 양 */
char *rio_bufptr; /* 내부 버퍼엔 들어있지만 아직 사용자가 읽지 않은 구간의 시작 */
char rio_buf[RIO_BUFSIZE]; /* 내부 버퍼 */
} rio_t;
~> 이러한 구조체 자료형을 가지고, 아래와 그림과 같은 원리로 Buffered I/O를 구현한다.
아래의 예시 코드를 보자.
int main(int argc, char **argv) {
int n;
rio_t rio;
char buf[MAXLINE];
Rio_readinitb(&rio, STDIN_FILENO); // Buffered를 사용하기 위한 준비
while((n = Rio_readlineb(&rio, buf, MAXLINE)) != 0)
Rio_writen(STDOUT_FILENO, buf, n);
return 0;
}
~> Buffered 방식이기 때문에 앞서 한 문자 씩 read-write하던 코드보다 훨씬 효율적인 I/O가 가능하다.
Database System이나 Software Engineering을 공부해본 이들은 Metadata의 의미를 익히 알고 있을 것이다. 그렇다. 'Data about data'이다.
SP 맥락에서 Metadata는, 조금 더 의미를 좁혀 'Data about file'이라고 하자. ★
stat과 fstat 함수는 아래와 같은 정보들을 알려준다.
struct stat {
dev_t st_dev; /* Device */
ino_t st_ino; /* inode */
mode_t st_mode; /* Protection and file type */
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 type (if inode device) */
off_t st_size; /* Total size, in bytes */
unsigned long st_blksize; /* Blocksize for filesystem I/O */
unsigned long st_blocks; /* Number of blocks allocated */
time_t st_atime; /* Time of last access */
time_t st_mtime; /* Time of last modification */
time_t st_ctime; /* Time of last change */
};
~> 당연히, 외울 필요 없다. 아래와 같이 사용할 수 있다는 것을 음미하면 된다.
int main (int argc, char **argv) {
struct stat stat;
char *type, *readok;
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 */
readok = "yes";
else
readok = "no";
printf("Type: %s, Read: %s\n", type, readok);
return 0;
}
(쉘에서 다음과 같이 동작)
> ./example example.c
type: regular, read: yes
(자기 자신의 소스 코드 파일에 대한 stat)
> chmod 000 example.c
> ./example example.c
type: regular, read: no
(권한을 000으로 변경 후 stat)
> ./example ..
type: directory, read: yes
(상위 디렉토리에 대한 stat)
=> 위와 같이, stat 함수 호출 후, struct stat이라는 구조체 Type으로 멤버변수에 접근하여 파일에 대한 각종 정보를 확인할 수 있음을 기억하자.
금일 포스팅은 여기까지 하겠다.