UNIX Kernel에서 Open File들을 어떻게 나타내는지 아래 그림을 통해 이해해보자.
~> File Descriptor 1이 Terminal File을 가리키고 있고, File Descriptor 4가 Disk File을 가리키고 있다.
fork로 만들어진 하나의 프로세스에 대해 하나의 File Descriptor Table이 있다.
int fd;
fd = open("foo", ...); // First File Open Attempt!
~> 이렇게 명령을 하면, open의 반환값이 3이고, fd라는 변수는 3번 인덱스를 의미하게 된다.
각 파일을 열면 Open File Table이 열린다. 이 Table은 모든 프로세스가 접근할 수 있게 공유되어 있다.
이때, Entry는 'File'보다는, 'Open'이 기준이다. 같은 파일을 하나의 프로세스에서 여러 번 접근하거나, 또는 서로 다른 프로세스에서 접근할 때, Entry(FilePos + refcnt)는 각 Open마다 하나씩 부여된다. ★
즉, 다시 한 번 엄밀히 말하자면, Open File Table이라는 전체 Table은 Shared by all processes이고, 그 테이블 안에 있는 Entry가 각 File Open마다 할당되는 형태이다. 같은 파일을 여러 번 Open하면, 그 Open마다 Entry가 생기는 것이다.
본 포스팅에서는 이 Entry를 그냥 'Open File Table'이라고 퉁쳐서 부를 것이다. 오해하지 않도록 하자.
Open File Table 안에는 refcnt라는 변수가 있다.
Open File Table 안에는 (Current) File Position도 있다.
각 File에는 하나의 v-node Table이 할당된다.
Open File Table과 다르게, v-Node Table은 하나의 파일에 대해 오로지 하나만 존재한다. 즉, 같은 파일을 여러번 Open 시, 각 Entry가 하나의 v-Node Table을 공유하는 것이다.
위의 예시 그림을 보면, 하나의 프로세스에서 두 개의 서로 다른 파일을 접근한 상황이다.
하나의 프로세스에 대해 File Descriptor Table이 있다.
각 파일에 대해 Open File Table(Entry)이 열렸다.
이번엔 다른 상황을 확인해보자. 아래의 코드를 보자.
int fd1, fd2;
fd1 = open("File A", ...);
fd2 = open("File A", ...);
~> 하나의 프로세스에서 하나의 파일을 두 개의 Descriptor로 가리키는 상황이다.
File Descriptor Table의 3번과 4번이 모두 File A를 가리킨다.
File A에 대해, 각 Open마다 Open File Table Entry가 만들어진다.
위에서 언급한 것처럼, Open File Table이라는 큰 테이블이 하나 있고, 그 테이블은 여러 프로세스에게 공유되며, 하나의 파일을 여러 번 Open(하나의 프로세스에서 여러번 or 여러 프로세스에서 여러번)할 경우, 각 Open 마다 Open File Table Entry가 생긴다. ★★★★★
이때, 하나의 프로세스에서 두 번 접근한 것이므로 refcnt는 1임에도 주목하자. ★
File A에 대한 Metadata는 하나의 v-Node Table에 저장되어있다. 지난 포스팅에서 언급한 stat, fstat System Call로 이를 확인할 수 있다.
두 Open File Table Entry가 동일한 v-Node Table을 가리킨다.
이 상황에서, 아래와 같은 코드를 추가해보자.
#define _4KB 4096
read(fd1, ptr, _4KB),
read(fd2, ptr, _4KB),
~> 두 Open File Table Entry는 같은 파일을 가리키지만, 서로 독립적이므로, 두 Entry의 Current File Position은 둘 다 0에서 4KB 위치로 변화한다.
=> 즉, 앞에 것이 4KB, 뒤에 것이 8KB 이렇게 되는 것이 아니라, 둘 다 4KB라는 것이다. ★
이번엔, 하나의 프로세스에서 fork하여 Child Process를 만든 상황을 보자. 이때는 아래와 같이 변화한다.
Process가 fork하면, Child Process는 Parent Process를 그대로 복사한다고 했다.
이때, File Descriptor Table도 복사된다. ★
따라서, 위와 같이 Child Process도 Parent와 똑같이 Referencing하게 됨을 주목하자.
이 상황을 다시 한 번 더 엄밀하게 설명하겠다. 시스템 내부 작업을 더 이해하기 위함이다.
Process A가 fork하여 Child인 Process B를 만들 예정이다.
우선, Process A의 코드를 순차적으로 Top-Down으로 확인해보자.
Entry의 초기 상태는 다음과 같다.
foo라는 파일에 대해선 v-node Table이 있는데, 그곳엔 각종 Metadata가 담겨 있다.
(Inode Table이라고도 부른다.)
이 모든 작업은 Main Memory DRAM에서 일어난 것이다. 왜냐? 프로세스 A와 프로세스 B 모두 프로세스이므로 메인 메모리에 적재되어있으며, OS Kernel의 시스템 콜도 Main Memory의 Kernel Code 부분, 그리고 각 종 자료구조도 Main Memory에 담겨있기 때문이다. ★★★
아래의 그림을 확인해보자. 참고로, 그림에는 fork가 나와있지만, 아직 fork는 하지 않은 상태이다. Pa가 fork할 것이란 이야기이다!
v-node Table에 있는 특정 포인터는 SSD의 foo 블록 위치를 가리킨다.
이때, Pa에서 "fd2 = open("bar");"라는 명령이 더 있어서 진행되었다고 해보자. 아래의 그림과 같아진다.
이때, "fd3 = open("bar");"라는 명령이 하나 더 있어서, 이 명령도 수행된다고 해보자. 아래의 그림과 같아진다.
그 다음 명령으로, Pa에서 fork가 수행된다. Pb라는 Child Process가 생성되었다. Pb는 Pa를 복제하므로, File Descriptor Table도 똑같이 복제된다. 아래 그림을 보자.
fork하는 경우, Parent와 Child는 동일한 Open File Table Entries를 공유한다.
Q) v-node table에 파일에 대한 Metadata가 올라가는 시점은 언제인가?
A) 해당하는 File이 처음으로 Open될 때 메모리에 올라간다.
Q) refcnt는 fork에 의해서만 증가되는가?
A) 사실상 일반적으로는 그렇다고 할 수 있다.
Q) Open 시마다 Open File Table이 생긴다면, 예를 들어서 A라는 프로세스와 B라는 프로세스에서 모두 foo를 여는데(Default Offset 0), 시점으로 봤을 때 A가 먼저 foo를 4KB만큼 읽은 상태에서 B가 foo를 4KB 읽으려고 하면, B가 읽기를 시작하는 Offset은 4KB인가요? 0인가요?
A) 0이다. 같은 파일이어도, Open File Table Entry는 독립적이라 했다. ★
Shell에서 가장 중요한 기능 중 하나는 Redirection이다. 나아가 Pipeline도 매우 중요하다. Shell의 Redirection과 Pipe 기능을 가능케 하는 함수는 바로 dup2 함수이다.
dup2(oldfd, newfd) : oldfd를 newfd에 Overwrite한다.
~> dup은 duplicate의 줄임말이다. Shell의 I/O Redirection 기능은 바로 이러한 dup2을 이용해 수행한다.
dup2 수행 시에는 oldfd가 가리키는 Open File Table의 refcnt가 늘어남에 주목하라.
"refcnt는 Referencing Process의 개수라면서요! 같은 프로세스에서 접근한건데 왜죠?"
정의상으로는 그렇게 이야기했으나, Open File Table은 각 Open마다 할당된다했다. 이때 Entry 입장에서는 자신을 가리키는 것이 어떤 프로세스인지 알지 못한다. 따라서 dup2로 자신 Entry를 가리키는 화살표가 하나 더 늘어나면, Entry 입장에서는 이를 마치 fork로 인해 새로운 화살표가 자신을 가리킨 것처럼 인지하는 것이다. ★★★
C언어 표준 라이브러리에는 각종 High-Level Standard I/O Function을 제공한다.
Standard I/O Model은 File을 Stream을 이용해 Open한다.
Stream : Abstraction for a file descriptor and a buffer in memory
왜 Buffered I/O를 채택한 것일까? 그 이유는 다음과 같다.
1) getc, putc, ungetc, gets, fgets 이런 함수들은 한 번에 한 문자씩만 입력받거나, 또는 newline에서 끊기거나 한다.
2) UNIX I/O는 Unbuffered I/O이기 때문에 클록 사이클 소모량이 너무 크다. Kernel System Call을 너무 자주하게 된다.
~> 따라서, Standard I/O를 처음 개발할 적에, Buffered I/O를 채택하게된 것이다.
Standard I/O의 Buffered 수행 방식
UNIX I/O의 read를 통해 (가능한) 많은 바이트 블록을 버퍼에 읽어들인다.
User-Level Input Function는 이 버퍼에서 한 바이트씩 가져간다.
버퍼가 바닥나면 다시 read System Call로 읽어들인다.
이전에 소개한 RIO Package의 Buffered 원리와 동일하다. (물론 표준I/O가 원조임)
한 가지 재밌는 사실을 보여주겠다. 우리는 사실 명시적으로 Standard I/O의 Buffering을 확인해본적이 없다(아마 거의 없을 것이다). 아래의 C코드를 보자.
int main(void) {
printf("H");
printf("e");
printf("l");
printf("l");
printf("o");
printf("!");
printf("\n");
fflush(stdout);
return 0;
}
> strace ./example
...
write(1, "Hello!\n", 7) = 7
...
아니, 분명히 우리는 한 문자 씩 printf를 하라고 지시했는데, 막상 System Call은 write를 한 번만 호출해서 전체 문자열을 통으로 출력했다.
~> 이것이 바로 Standard I/O의 Buffering을 확인할 수 있는 부분이다.
=> 상황에 따라 다르긴 한데, 이 상황의 경우, 연속된 printf가 있으면, 각 printf의 출력 문자열을 하나로 합쳐서 버퍼에 기억해두었다가, printf의 연속이 끊기면(fflush에 의해), 그 순간 write를 버퍼에 대해 한 번 시행하는 것이다. ★★★
UNIX I/O의 장단점
장점
단점
Standard I/O의 장단점
장점
단점
적절한 I/O 함수를 고르는법
우선, 기본적으로는 왠만하면 High-Level I/O Function을 사용하자는 것이다.
Standard I/O : 디스크나 Terminal 관련 입출력 시 우수하다.
UNIX I/O : 시그널 핸들러 작업 시 우수하다. 또한, 좀 더 빠른 수행속도를 (반드시 이것이 필요한 상황에서) 원한다면 쓰는 것이 좋다.
RIO Package : 네트워크 소켓 Read & Write 시 우수하다.
Chapter2는 여기까지 하겠다.