SP - 2.2 UNIX I/O (Input & Output) (2)

hyeok's Log·2022년 4월 3일
2

SystemProgramming

목록 보기
8/29
post-thumbnail

Open File Representation

Three Basic Tables

  UNIX Kernel에서 Open File들을 어떻게 나타내는지 아래 그림을 통해 이해해보자.

~> File Descriptor 1이 Terminal File을 가리키고 있고, File Descriptor 4가 Disk File을 가리키고 있다.

  • 프로세스가 있으면, 프로세스에 대한 자료구조가 OS Kernel에 있다고 했다.

fork로 만들어진 하나의 프로세스에 대해 하나의 File Descriptor Table이 있다.

  • 0번은 stdin, 1번은 stdout, 2번은 stderr로 예약(Reserve)되어 있다.
    • 따라서, 최초 File Open은 3번 Descriptor부터 반환된다고 했다. ★
      • 이 각 Descriptor가 인덱스로 작동하여 파일에 접근한다.

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라는 변수가 있다.

    • 현재 이 파일에 reference하고 있는 프로세스의 개수를 나타낸다.
      • 후술할 것이지만, 일반적으로 fork와 dup 함수에 의해 개수가 늘어난다.
  • 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)이 열렸다.



Opening Same File

  이번엔 다른 상황을 확인해보자. 아래의 코드를 보자.

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가 생긴다. ★★★★★

      • 이는, 교재에 명확히 명시되지 않은 내용이지만, 이렇게 이해하는 것이 편리하다. 좀 더 자세한 내용은 Stack Overflow에서 확인할 수 있다. 공신력있는 포스팅은 아니지만, 이 개념에 대한 인터넷 자료 중 가장 납득할만한 설명이 자세히 게시되어 있다.
    • 이때, 하나의 프로세스에서 두 번 접근한 것이므로 refcnt는 1임에도 주목하자. ★

  • File A에 대한 Metadata는 하나의 v-Node Table에 저장되어있다. 지난 포스팅에서 언급한 stat, fstat System Call로 이를 확인할 수 있다.

    • access, size, block, atime, ctime 등의 정보가 저장되어 있다.
  • 두 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 Situation

  이번엔, 하나의 프로세스에서 fork하여 Child Process를 만든 상황을 보자. 이때는 아래와 같이 변화한다.

  • Process가 fork하면, Child Process는 Parent Process를 그대로 복사한다고 했다.

    이때, File Descriptor Table도 복사된다. ★

  • 따라서, 위와 같이 Child Process도 Parent와 똑같이 Referencing하게 됨을 주목하자.

    • 두 개의 서로 다른 프로세스가 파일을 접근하기 때문에 refcnt가 2가 되었음도 주목하자. ★

  이 상황을 다시 한 번 더 엄밀하게 설명하겠다. 시스템 내부 작업을 더 이해하기 위함이다.

  • Process A가 fork하여 Child인 Process B를 만들 예정이다.

  • 우선, Process A의 코드를 순차적으로 Top-Down으로 확인해보자.

    • Pa는 "fd1 = open("foo", ...);"부터 수행한다.
      • fd1이란 변수는 Stack 부분에 저장되고, Pa에 대한 Kernel의 File Descriptor Table에는 3번 Index가 fd1으로 반환된 상황이다.
        • 즉, 3번 인덱스는 foo에 대한 Open File Table Entry를 가리킨다.
  • Entry의 초기 상태는 다음과 같다.

    • File Pos는 0
    • refcnt는 1
  • foo라는 파일에 대해선 v-node Table이 있는데, 그곳엔 각종 Metadata가 담겨 있다.
    (Inode Table이라고도 부른다.)

  • 이 모든 작업은 Main Memory DRAM에서 일어난 것이다. 왜냐? 프로세스 A와 프로세스 B 모두 프로세스이므로 메인 메모리에 적재되어있으며, OS Kernel의 시스템 콜도 Main Memory의 Kernel Code 부분, 그리고 각 종 자료구조도 Main Memory에 담겨있기 때문이다. ★★★

아래의 그림을 확인해보자. 참고로, 그림에는 fork가 나와있지만, 아직 fork는 하지 않은 상태이다. Pa가 fork할 것이란 이야기이다!

  • 보조기억장치 SSD가 1TB 크기로 있다고 가정하면, 그 어딘가엔 foo라는 파일이 담겨있다.

v-node Table에 있는 특정 포인터는 SSD의 foo 블록 위치를 가리킨다.

  이때, Pa에서 "fd2 = open("bar");"라는 명령이 더 있어서 진행되었다고 해보자. 아래의 그림과 같아진다.

  • 마찬가지로 fd2도 Pa의 Stack 부분에 저장되고, Pa의 File Descriptor Table의 4번 인덱스가 fd2로 반환된다.
    • 4번 인덱스는 bar에 대한 Open File Table을 가리키고, 이 Table은 bar에 대한 v-node Table을 가리킨다.
      • v-node Table 내의 포인터가 SSD 내 bar 위치를 가리치키고 있다.

  이때, "fd3 = open("bar");"라는 명령이 하나 더 있어서, 이 명령도 수행된다고 해보자. 아래의 그림과 같아진다.

  • bar에 대해서 open을 한 번 더 했으므로 bar에 대한 Open File Table Entry가 하나 더 생겼다.
    • 이 Entry는 bar에 대한 단일의 v-node Table을 가리킨다.
      • 이때, 프로세스는 하나가 이를 가리키고 있으므로 refcnt는 여전히 1이다.

  그 다음 명령으로, Pa에서 fork가 수행된다. Pb라는 Child Process가 생성되었다. Pb는 Pa를 복제하므로, File Descriptor Table도 똑같이 복제된다. 아래 그림을 보자.

  • fork는 말그대로 복제이므로, 동일한 File Descriptor Table이 생기고, 이들이 포인팅하는 위치도 역시나 동일하므로, 동일한 Open File Table Entry들을 가리킨다.
    • 따라서, 서로 다른 프로세스 두 개가 각 Entry를 가리키고 있으므로 각 Entry의 refcnt가 모두 2로 변화함을 알 수 있다. ★★★

fork하는 경우, Parent와 Child는 동일한 Open File Table Entries를 공유한다.

  • 당연히 v-node Table도 공유한다. 이 경우엔 이유는 조금 다른데, v-node Table은 하나의 파일에 대해 1대1로만 존재하기 때문!

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는 독립적이라 했다. ★



I/O Redirection

  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로 인해 새로운 화살표가 자신을 가리킨 것처럼 인지하는 것이다. ★★★

  • fork를 한 후, 생성된 Child에 대해 Exec계열 함수로 프로그램을 입히기 이전에 dup2를 수행해 File Descriptor Table을 바꿔놓는것이다.
    • 그러면 Parent와는 다른 File Descriptor Table을 Child는 갖게 된다.
      • 따라서, 이를 STDIN_FILENO, STDOUT_FILENO, 그리고 추가적인 fd를 이용해 적절하게 조정해주면 Redirection과 Pipeline 기능을 만들 수 있는 것이다.
        • 이에 대한 자세한 매커니즘은 추후 연재할 'Bash Shell 만들기'에서 소개하겠다.

Standard I/O Function

  C언어 표준 라이브러리에는 각종 High-Level Standard I/O Function을 제공한다.

  • fopen & fclose
  • fread & fwrite
  • fgets & fputs
  • fscanf & fprintf

Standard I/O Model은 File을 Stream을 이용해 Open한다.

Stream : Abstraction for a file descriptor and a buffer in memory

  • 기본 스트림에는 아래와 같은 3가지가 있다.
    • stdin, stdout, stderr

  • 알다시피 Standard I/O의 입출력은 Buffered 방식이다. C언어를 처음 학습하는 사람들이 "아니 난 분명 이걸 입력받으라고 scanf를 뒀는데, 왜 의도치 않은 이상한 것을 지 혼자 받는거야??!!"라며 화를 내는 이유 중 하나가 바로 이 'Buffered I/O 원리' 때문이다. 버퍼에 들어있는 데이터를 고려치 못하기 때문이다.

  왜 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로 읽어들인다.

      • 즉, 최대한 UNIX 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;
}
  • 이 소스 코드를 gcc 컴파일러로 컴파일하여 실행파일 example을 만들었다고 해보자. 이를 Linux Shell 상에서 strace 명령과 함께 확인해보면 아래와 같은 결과가 출력된다.
    • strace는, 응용 프로그램이 사용하는 System Call과 Signal을 추적해 성능 저하 요소가 없는지, 에러는 없는지를 확인하는데 쓰이는 아주 좋은 명령어이다.
> strace ./example
...
write(1, "Hello!\n", 7) = 7
...

아니, 분명히 우리는 한 문자 씩 printf를 하라고 지시했는데, 막상 System Call은 write를 한 번만 호출해서 전체 문자열을 통으로 출력했다.

~> 이것이 바로 Standard I/O의 Buffering을 확인할 수 있는 부분이다.
=> 상황에 따라 다르긴 한데, 이 상황의 경우, 연속된 printf가 있으면, 각 printf의 출력 문자열을 하나로 합쳐서 버퍼에 기억해두었다가, printf의 연속이 끊기면(fflush에 의해), 그 순간 write를 버퍼에 대해 한 번 시행하는 것이다. ★★★


How to Choose ?

  • UNIX I/O의 장단점

    • 장점

      • 가장 범용적이고 가장 Overhead가 적다.
      • Standard I/O, RIO Package를 포함해, 모든 다른 입출력 라이브러리는 UNIX I/O를 이용해 변형한 것에 불과하다.
      • Metadata에 직접 접근할 수 있는 stat, fstat 함수가 있다.
      • Async-Signal-Safe하기 때문에 Signal Handler에서 마음껏 사용할 수 있다.
    • 단점

      • Short Count가 발생한다. ~> 에러로 발전할 수 있음.
      • Unbuffered라 읽는 양이 많을 경우 비효율적일 수 있다.
        • 의도적인 버퍼링을 하기가 어렵다.

  • Standard I/O의 장단점

    • 장점

      • Buffered I/O이기 때문에 효율성이 높고, read & write System Call을 최대한 적게 호출한다.
      • Short Count를 알아서 처리해준다.
    • 단점

      • Metadata에 접근할 함수가 없다.
      • Async-Signal-Safe 하지 않다.
        • 즉, 시그널 핸들러에서 사용하면 안됀다.
      • 네트워크 소켓에서 사용하기엔 부적절하다.
        • 소켓의 제한사항과 표준I/O의 제한사항이 충돌해서 그러하다.

  • RIO Package의 장단점 : 장점으로는, 네트워크 통신에서 사용하기 좋고, 단점으로는, 그 외 용도로는 굳이 사용할 필요가 없다는 것!

  • 적절한 I/O 함수를 고르는법

    • 우선, 기본적으로는 왠만하면 High-Level I/O Function을 사용하자는 것이다.

      • 아무래도 가독성, 유지보수 등의 측면에서 이것이 좋다.
    • Standard I/O : 디스크나 Terminal 관련 입출력 시 우수하다.

    • UNIX I/O : 시그널 핸들러 작업 시 우수하다. 또한, 좀 더 빠른 수행속도를 (반드시 이것이 필요한 상황에서) 원한다면 쓰는 것이 좋다.

    • RIO Package : 네트워크 소켓 Read & Write 시 우수하다.



  Chapter2는 여기까지 하겠다.

0개의 댓글