[JUNGLE] TIL_47. CSAPP 10장

모깅·2025년 10월 29일

JUNGLE

목록 보기
48/56
post-thumbnail

10장: 유닉스 입출력 (Unix I/O)

1. 입출력(I/O)이란?

  • 입출력(I/O)은 주 메모리와 디스크, 터미널, 네트워크 같은 외부 장치(external devices) 간에 데이터를 복사하는 과정입니다.
  • 입력(Input): I/O 장치 \rightarrow 주 메모리
  • 출력(Output): 주 메모리 \rightarrow I/O 장치

2. 상위 수준(Higher-Level) I/O

  • 모든 언어의 런타임 시스템은 I/O를 위한 상위 수준 기능을 제공합니다.
  • ANSI C: printf, scanf 같은 버퍼 I/O를 수행하는 표준 I/O 라이브러리
  • C++: << (put to) 및 >> (get from) 연산자
  • 리눅스에서 이러한 상위 수준 I/O 함수들은 커널이 제공하는 시스템 수준의 유닉스 I/O (Unix I/O) 함수를 사용해 구현됩니다.

3. 왜 유닉스 I/O를 배워야 하는가?

대부분 상위 수준 I/O 함수들로 충분한데, 왜 굳이 유닉스 I/O를 직접 배워야 할까요?

  • 1. 다른 시스템 개념을 이해하기 위해:
    • I/O는 시스템 동작의 핵심이며, 다른 시스템 개념들과 순환 의존성(circular dependencies)을 갖습니다.
    • 예: I/O는 프로세스 생성/실행에, 프로세스 생성은 파일 공유에 핵심 역할을 합니다.
    • I/O를 제대로 이해하려면 프로세스를, 프로세스를 이해하려면 I/O를 알아야 합니다.
    • (메모리 계층, 링킹, 로딩, 프로세스, 가상 메모리에 이어) I/O를 자세히 배움으로써 이 순환의 고리를 닫을 수 있습니다.
  • 2. 유닉스 I/O를 사용해야만 할 때가 있어서:
    • 상위 수준 I/O 함수 사용이 불가능하거나 부적절한 중요한 경우가 있습니다.
    • 예 1 (메타데이터): 표준 I/O 라이브러리는 파일 크기나 생성 시간 같은 파일 메타데이터(metadata)에 접근할 방법을 제공하지 않습니다.
    • 예 2 (네트워킹): 표준 I/O 라이브러리에는 네트워크 프로그래밍에 사용하기 위험한(risky) 문제점들이 있습니다.

4. 이 챕터의 목적

  • 유닉스 I/O와 표준 I/O의 일반적인 개념을 소개하고, C 프로그램에서 이들을 안정적으로 사용하는 방법을 배웁니다.
  • 이는 향후 네트워크 프로그래밍과 동시성(concurrency) 연구를 위한 견고한 토대를 마련할 것입니다.

10.1 유닉스 I/O (Unix I/O)

리눅스 파일mm 바이트의 시퀀스입니다.
B0,B1,...,Bk,...,Bm1B_0, B_1, ..., B_k, ..., B_{m-1}

네트워크, 디스크, 터미널과 같은 모든 I/O 장치파일로 모델링되며, 모든 입력과 출력은 해당 파일에 대한 읽기(reading)와 쓰기(writing)를 통해 수행됩니다.

이러한 장치와 파일 간의 매핑을 통해, 리눅스 커널은 유닉스 I/O라는 단순하고 낮은 수준의 애플리케이션 인터페이스를 제공하며, 모든 입출력이 균일하고 일관된 방식으로 수행될 수 있도록 합니다.


1. 파일 열기 (Opening files)

  • 애플리케이션은 커널에 해당 파일을 열도록 요청함으로써 I/O 장치에 접근하겠다는 의사를 알립니다.
  • 커널은 이후의 모든 파일 작업에서 파일을 식별하는 데 사용되는 파일 디스크립터(descriptor)라는 작은 음이 아닌 정수를 반환합니다.
  • 커널은 열린 파일에 대한 모든 정보를 추적하며, 애플리케이션은 오직 디스크립터만 추적합니다.
  • 표준 파일: 리눅스 셸에서 생성된 각 프로세스는 3개의 열린 파일로 시작합니다.
    • 표준 입력 (standard input): 디스크립터 0 (STDIN_FILENO)
    • 표준 출력 (standard output): 디스크립터 1 (STDOUT_FILENO)
    • 표준 에러 (standard error): 디스크립터 2 (STDERR_FILENO)
    • (<unistd.h> 헤더 파일에 이 상수들이 정의되어 있습니다.)

2. 현재 파일 위치 변경 (Changing the current file position)

  • 커널은 열린 파일 각각에 대해 파일의 시작 부분으로부터의 바이트 오프셋(byte offset)인 파일 위치 kk (초기값 0)를 유지합니다.
  • 애플리케이션은 seek (탐색) 작업을 수행하여 현재 파일 위치 kk를 명시적으로 설정할 수 있습니다.

3. 파일 읽기 및 쓰기 (Reading and writing files)

  • 읽기 (Read): 현재 파일 위치 kk에서 시작하여 파일로부터 메모리로 n>0n > 0 바이트를 복사한 다음, kknn만큼 증가시킵니다.
    • mm 바이트 크기의 파일에 대해 kmk \ge m일 때 읽기 작업을 수행하면 EOF (end-of-file)라는 조건이 발생하며, 이는 애플리케이션이 감지할 수 있습니다.
    • 파일 끝에 명시적인 "EOF 문자"는 없습니다.
  • 쓰기 (Write): 현재 파일 위치 kk에서 시작하여 메모리로부터 파일로 n>0n > 0 바이트를 복사한 다음, kk를 업데이트합니다.

4. 파일 닫기 (Closing files)

  • 애플리케이션이 파일 접근을 마치면, 커널에 파일을 닫도록 요청합니다.
  • 커널은 파일이 열렸을 때 생성했던 데이터 구조를 해제하고, 해당 디스크립터를 사용 가능한 디스크립터 풀(pool)로 복원함으로써 응답합니다.
  • 프로세스가 어떤 이유로든 종료되면, 커널은 모든 열려 있는 파일을 닫고 관련 메모리 리소스를 해제합니다.

10.2 파일 (Files)

각 리눅스 파일은 시스템에서의 역할을 나타내는 타입(type)을 가집니다.

  • 일반 파일 (Regular file):
    • 임의의 데이터를 포함합니다. 애플리케이션은 종종 텍스트 파일(ASCII 또는 Unicode 문자만 포함)과 바이너리 파일(그 외 모든 것)을 구분하지만, 커널에게는 이 둘의 차이가 없습니다.
    • 리눅스 텍스트 파일은 텍스트 라인(line)의 시퀀스로 구성되며, 각 라인은 \\n (newline, ASCII LF, 0x0a) 문자로 끝납니다.
  • 디렉터리 (Directory):
    • 파일 이름(filename)과 해당 파일(다른 디렉터리일 수 있음)을 매핑하는 링크(link)의 배열로 구성된 파일입니다.
    • 각 디렉터리는 최소 두 개의 엔트리를 포함합니다: . (자기 자신을 가리키는 링크)와 .. (부모 디렉터리를 가리키는 링크).
    • mkdir로 생성하고, ls로 내용을 보며, rmdir로 삭제할 수 있습니다.
  • 소켓 (Socket):
    • 네트워크를 통해 다른 프로세스와 통신하는 데 사용되는 파일입니다. (11.4절)
  • (그 외 이름 있는 파이프, 심볼릭 링크, 문자/블록 장치 등의 타입이 있습니다.)

디렉터리 계층 (Directory Hierarchy)

  • 리눅스 커널은 / (슬래시)라는 이름의 루트 디렉터리를 기준으로 모든 파일을 단일 디렉터리 계층으로 구성합니다.
  • 시스템의 모든 파일은 루트 디렉터리의 직간접적인 자손입니다. (그림 10.1)
  • 각 프로세스는 컨텍스트의 일부로, 계층 구조 내 현재 위치를 식별하는 현재 작업 디렉터리(current working directory)를 갖습니다. (cd 명령어로 변경 가능)

경로 이름 (Pathnames)

디렉터리 계층 내 위치는 경로 이름(pathname)으로 지정됩니다. 경로 이름은 선택적인 슬래시(/)로 시작하고, 슬래시로 구분된 파일 이름들의 시퀀스로 구성됩니다.

  • 절대 경로 이름 (Absolute pathname):
    • 슬래시(/)로 시작하며, 루트 노드로부터의 경로를 나타냅니다.
    • 예: /home/droh/hello.c
  • 상대 경로 이름 (Relative pathname):
    • 파일 이름으로 시작하며, 현재 작업 디렉터리로부터의 경로를 나타냅니다.
    • 예: (현재 작업 디렉터리가 /home/droh일 때) ./hello.c
    • 예: (현재 작업 디렉터리가 /home/bryant일 때) ../home/droh/hello.c

(참고) 줄 끝(EOL) 표시자

텍스트 파일 작업의 번거로운 측면 중 하나는 시스템마다 줄의 끝(End of Line)을 표시하는 문자가 다르다는 것입니다.

  • Linux 및 Mac OS X: \\n (0xa)을 사용합니다. (ASCII Line Feed, LF)
  • MS Windows 및 인터넷 프로토콜 (HTTP 등): \\r\\n (0xd 0xa) 시퀀스를 사용합니다. (ASCII Carriage Return, CR + Line Feed, LF)

문제점

  • Windows에서 생성한 파일(foo.txt)을 Linux 텍스트 편집기에서 열면, 각 줄 끝에 불필요한 ^M 문자가 보일 수 있습니다.
  • (^M은 Linux 도구가 \\r (CR) 문자를 표시하는 방식입니다.)

해결책
  • 다음 perl 명령을 실행하여 foo.txt 파일에서 \\r 문자를 제자리에서(in place) 제거할 수 있습니다.
    linux> perl -pi -e "s/\\r\\n/\\n/g" foo.txt

10.3 파일 열기 및 닫기 (Opening and Closing Files)

프로세스는 open 함수를 호출하여 기존 파일을 열거나 새 파일을 생성합니다.

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

int open(char *filename, int flags, mode_t mode);
  • 반환 값: 성공 시 새 파일 디스크립터, 오류 시 -1

open 함수는 filename파일 디스크립터(file descriptor)로 변환하고 그 디스크립터 번호를 반환합니다. 반환되는 디스크립터는 항상 해당 프로세스에서 현재 열려있지 않은 가장 작은 디스크립터입니다.


1. flags 인자

flags 인자는 프로세스가 파일에 접근하려는 방식을 나타냅니다.

  • O_RDONLY: 읽기 전용
  • O_WRONLY: 쓰기 전용
  • O_RDWR: 읽기/쓰기

(예: fd = Open("foo.txt", O_RDONLY, 0);)

flags 인자는 추가적인 쓰기 옵션을 제공하는 하나 이상의 비트 마스크와 OR(|) 연산될 수 있습니다.

  • O_CREAT: 파일이 존재하지 않으면, (비어있는) 새 파일을 생성합니다.
  • O_TRUNC: 파일이 이미 존재하면, 파일을 잘라 비웁니다(truncate).
  • O_APPEND: 매번 쓰기 작업(write operation) 전에, 파일 위치를 파일의 맨 끝으로 설정합니다.

(예: fd = Open("foo.txt", O_WRONLY|O_APPEND, 0);)


2. mode 인자

mode 인자는 O_CREAT 플래그로 새 파일을 생성할 때의 접근 권한 비트를 지정합니다. (그림 10.2의 심볼릭 이름 참조)

  • 각 프로세스는 umask 함수를 호출하여 설정하는 umask 값을 컨텍스트의 일부로 가집니다.
  • open 함수로 새 파일을 생성할 때, 파일의 최종 접근 권한 비트는 mode & ~umask (mode AND NOT umask)로 설정됩니다.
  • 예시: 기본 modeS_IRUSR|S_IWUSR|S_IRGRP|S_IWGRP|S_IROTH|S_IWOTH (0666)이고, 기본 umaskS_IWGRP|S_IWOTH (0022)라고 가정해 봅시다.
    #define DEF_MODE S_IRUSR|S_IWUSR|S_IRGRP|S_IWGRP|S_IROTH|S_IWOTH
    #define DEF_UMASK S_IWGRP|S_IWOTH
    
    umask(DEF_UMASK);
    fd = Open("foo.txt", O_CREAT|O_TRUNC|O_WRONLY, DEF_MODE);
    • 위 코드는 0666 & ~0022 = 0644 권한(rw-r--r--)을 가진 새 파일을 생성합니다.
    • 즉, 파일 소유자(owner)는 읽기/쓰기 권한을, 그 외 모든 사용자는 읽기 권한을 갖습니다.

3. close 함수

프로세스는 close 함수를 호출하여 열려있는 파일을 닫습니다.

#include <unistd.h>

int close(int fd);
  • 반환 값: 성공 시 0, 오류 시 -1

이미 닫힌 디스크립터를 닫으려고 시도하면 오류가 발생합니다.

10.4 파일 읽기 및 쓰기 (Reading and Writing Files)

애플리케이션은 각각 readwrite 함수를 호출하여 입력과 출력을 수행합니다.


1. read 함수

#include <unistd.h>

ssize_t read(int fd, void *buf, size_t n);
  • 반환 값: 성공 시 읽어들인 바이트 수, EOF(End-of-File) 시 0, 오류 시 -1

read 함수는 파일 디스크립터 fd의 현재 파일 위치로부터 buf 메모리 위치로 최대 nn 바이트를 복사합니다.

  • 1 반환 값은 오류를 나타냅니다.
  • 0 반환 값은 EOF (파일 끝)를 나타냅니다.
  • 그 외의 반환 값은 실제로 전송된 바이트 수를 나타냅니다.

2. write 함수

#include <unistd.h>

ssize_t write(int fd, const void *buf, size_t n);
  • 반환 값: 성공 시 기록한 바이트 수, 오류 시 -1

write 함수는 buf 메모리 위치로부터 파일 디스크립터 fd의 현재 파일 위치로 최대 nn 바이트를 복사합니다.

(참고: 애플리케이션은 lseek 함수를 호출하여 현재 파일 위치를 명시적으로 수정할 수 있으나, 이 책의 범위를 벗어납니다.)

  • write시 내부 동작 write() 함수를 호출하면, 데이터가 디스크에 바로 기록되는 것이 아니라, 커널(Kernel)에게 "이 데이터를 써달라"고 요청하는 시스템 콜(System Call)이 발생합니다. write() 함수의 내부는 크게 5단계로 나눌 수 있습니다.

    1. 래퍼 함수 및 시스템 콜 트랩 (Trap)

    1. 사용자가 C 코드에서 write(fd, buf, n)를 호출하면, 사실은 C 라이브러리(libc)에 있는 래퍼(wrapper) 함수를 호출한 것입니다.

    2. 이 래퍼 함수는 write에 해당하는 시스템 콜 번호(예: 1)를 RAX 레지스터에 저장하고, 인자(fd, buf, n)를 정해진 레지스터에 넣은 뒤, syscall 명령어를 실행합니다.

    3. syscall 명령어는 소프트웨어 트랩(Trap)을 발생시켜, CPU의 모드를 '유저 모드'에서 '커널 모드'로 즉시 전환시키고, 커널의 시스템 콜 핸들러로 점프합니다.


      2. 파일 디스크립터(fd) 조회

      이제 커널 모드입니다. 커널은 이 요청이 유효한지 확인해야 합니다.

    4. 커널은 fd (예: 3)를 인덱스로 사용하여, 현재 프로세스의 파일 디스크립터 테이블을 조회합니다.

    5. 이 테이블의 3번 항목은 시스템 전역의 "열린 파일 테이블(Open File Table)"에 있는 특정 항목을 가리킵니다.

    6. 이 "열린 파일" 항목에는 이 파일의 현재 위치(file position, k), 접근 권한(읽기/쓰기), 그리고 이 파일의 실제 정보가 담긴 VFS(가상 파일 시스템) 노드를 가리키는 포인터가 들어있습니다.

    7. 커널은 이 정보를 보고 write 요청이 fd의 권한(O_WRONLY 또는 O_RDWR로 열렸는지)과 일치하는지 확인합니다.


      3. 데이터 복사 (유저 \rightarrow 커널)

      이것이 write의 핵심입니다. 커널은 데이터를 디스크로 직접 보내지 않습니다.

    8. 커널은 유저의 buf(유저 공간)에 있는 데이터 n 바이트를 커널 자신의 내부 버퍼(Kernel Buffer)복사합니다. (이 내부 버퍼가 바로 "페이지 캐시(Page Cache)"입니다.)

    9. 이 작업은 메모리(RAM) \rightarrow 메모리(RAM) 복사본이므로 매우 빠릅니다.

    10. 데이터 복사가 완료되면, 커널은 "열린 파일 테이블"에 있던 파일 위치 kk + n으로 업데이트합니다.


      4. 유저 모드로 즉시 반환

      매우 중요한 지점입니다.

    11. 데이터가 커널 버퍼로 성공적으로 복사되는 즉시, write 시스템 콜은 "성공"으로 간주됩니다.

    12. 커널은 기록한 바이트 수(n)를 반환 값으로 RAX 레지스터에 설정합니다.

    13. 커널은 CPU를 다시 '유저 모드'로 전환시키고, 중단되었던 사용자 프로그램의 다음 명령어로 복귀(return)합니다.

    14. 사용자 프로그램은 write가 반환되었으므로 즉시 다음 코드를 실행합니다.

      이 시점에서 데이터는 아직 물리 디스크(HDD/SSD)에 쓰이지 않았습니다!


      5. 실제 디스크 쓰기 (비동기)

    15. 커널은 "나중에" 시간적 여유가 있을 때(예: 디스크가 유휴 상태일 때, 또는 버퍼가 꽉 찼을 때), 커널 버퍼(페이지 캐시)에 모아둔 "더러운(dirty)" 데이터들을 비동기적(asynchronously)으로 디스크 컨트롤러에 보냅니다.

    16. 이것을 "지연된 쓰기 (Delayed Write)" 또는 "Write-Back Caching"이라고 부릅니다.

    17. (만약 프로그래머가 write 직후 데이터가 반드시 디스크에 저장되어야 함을 보장받고 싶다면, fsync(fd)라는 별도의 시스템 콜을 호출하여 커널 버퍼를 강제로 디스크에 쓰도록(flush) 요청해야 합니다.)


3. Short Counts (부족한 전송)

readwrite가 애플리케이션이 요청한 nn 바이트보다 적은 수의 바이트를 전송하는 경우가 있습니다.

이러한 short count오류(error)를 의미하지 않으며, 여러 가지 이유로 발생할 수 있습니다.

  • 1. 읽기 시 EOF (End-of-File) 발생:
    • 현재 파일 위치에서 20바이트만 남아있는 파일이 있다고 가정해 봅시다.
    • 50바이트짜리 청크(chunk) 단위로 읽기를 시도하면, 다음 read20이라는 short count를 반환합니다.
    • 그다음 read0 (EOF)을 반환합니다.
  • 2. 터미널에서 텍스트 라인 읽기:
    • 열린 파일이 터미널(키보드/화면)에 연결되어 있다면, 각 read 함수는 한 번에 하나의 텍스트 라인(text line)만 전송하며, 텍스트 라인의 크기만큼 short count를 반환합니다.
  • 3. 네트워크 소켓 (Sockets) 및 파이프 읽기/쓰기:
    • 열린 파일이 네트워크 소켓(11.4절)에 해당한다면, 내부 버퍼링 제약이나 긴 네트워크 지연으로 인해 readwrite가 short count를 반환할 수 있습니다.
    • (리눅스 파이프(pipe)에서도 short count가 발생할 수 있습니다.)

4. Short Count 처리

  • 디스크 파일:
    • 디스크 파일에서 읽을 때는 EOF를 제외하고는 short count를 절대 만나지 않을 것입니다.
    • 디스크 파일에 쓸 때는 short count를 절대 만나지 않을 것입니다.
  • 네트워크 애플리케이션 (예: 웹 서버):
    • 견고한(robust) 네트워크 애플리케이션을 만들고 싶다면, 반드시 short count를 처리해야 합니다.
    • 해결책: 요청된 모든 바이트(nn)가 전송될 때까지 readwrite반복적으로 호출해야 합니다.

10.5 Rio 패키지를 이용한 견고한(Robust) 읽기 및 쓰기

이 섹션에서는 read/write 호출 시 발생하는 short count (부족한 전송)를 자동으로 처리해 주는 Rio (Robust I/O)라는 I/O 패키지를 개발합니다.

Rio 패키지는 short count가 발생하기 쉬운 네트워크 프로그램 같은 애플리케이션에서 편리하고, 견고하며, 효율적인 I/O를 제공합니다.

Rio는 두 가지 종류의 함수를 제공합니다.

  • 버퍼 없는(Unbuffered) 입출력 함수:
    • 애플리케이션 수준의 버퍼링 없이, 메모리와 파일 간에 데이터를 직접 전송합니다.
    • 네트워크로부터 바이너리 데이터를 읽고 쓰는 데 특히 유용합니다.
  • 버퍼 있는(Buffered) 입력 함수:
    • printf 같은 표준 I/O 함수와 유사하게, 애플리케이션 수준 버퍼에 캐시된 파일의 내용을 효율적으로 읽을 수 있게 합니다.
    • (다른 표준 버퍼 I/O 루틴과 달리) Rio 입력 함수는 스레드 안전(thread-safe) (12.7.1절)하며, 동일한 디스크립터(fd)에서 임의로 혼용(interleaved)할 수 있습니다.
    • 예: 한 디스크립터에서 텍스트 라인을 읽다가 \rightarrow 바이너리 데이터를 읽고 \rightarrow 다시 텍스트 라인을 읽을 수 있습니다.

Rio 패키지를 배우는 두 가지 이유

  1. 활용: 다음 두 챕터에서 개발할 네트워크 애플리케이션에서 Rio 함수들을 사용할 것입니다.
  2. 이해: Rio 루틴의 코드를 연구함으로써, 유닉스 I/O 전반에 대해 더 깊이 이해할 수 있습니다.

10.5.1 Rio 버퍼 없는(Unbuffered) 입출력 함수

애플리케이션은 rio_readnrio_writen 함수를 호출하여 메모리와 파일 간에 데이터를 직접 전송할 수 있습니다. (즉, 애플리케이션 수준의 버퍼를 사용하지 않습니다.)

#include "csapp.h"

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

반환 값

  • rio_readn: 성공 시 전송된 바이트 수, EOF 시 0, 오류 시 -1
  • rio_writen: 성공 시 전송된 바이트 수, 오류 시 -1

rio_readn 함수 (견고한 읽기)

rio_readn 함수는 디스크립터 fd의 현재 파일 위치에서 usrbuf 메모리로 최대 n 바이트를 전송합니다.

rio_readnn 바이트보다 적은 short count를 반환하는 경우는 EOF (파일 끝)을 만났을 때뿐입니다.

코드 분석 (Figure 10.4a)

이 함수는 read 시스템 콜이 short count나 EINTR 시그널로 중단되는 "불안정성"을 해결합니다.

  • nleft = n: 앞으로 읽어야 할 바이트 수를 추적합니다.
  • while (nleft > 0): n 바이트를 모두 읽을 때까지 루프를 돕습니다.
  • if ((nread = read(...)) < 0): read가 오류를 반환한 경우:
    • if (errno == EINTR):
      만약 오류가 시그널 핸들러에 의한 중단(Interrupted) 때문이라면,nread = 0;으로 설정하고 루프를 다시 시도(restart) 합니다.
      → 이것이 견고성(Robustness)의 핵심입니다.
    • else return -1;:
      그 외의 실제 오류(예: 잘못된 fd)라면 -1을 반환합니다.
  • else if (nread == 0):read가 0을 반환했다면, 이는 EOF (파일 끝)을 의미하므로 break로 루프를 탈출합니다.
  • nleft -= nread;:read가 (short count로 인해) 일부만 읽어왔다면, 읽은 만큼(nread) nleft에서 빼고,bufp 포인터를 전진시켜 다음 read를 준비합니다.
  • return (n - nleft);:
    (EOF로 인해) n 바이트를 다 못 읽고 루프가 끝났을 수 있으므로,
    실제로 읽은 총 바이트 수(n - nleft)를 반환합니다.

rio_writen 함수 (견고한 쓰기)

rio_writen 함수는 usrbuf 메모리에서 fd 디스크립터로 정확히 n 바이트를 전송합니다.

이 함수는 (성공 시) 절대 short count를 반환하지 않습니다.

코드 분석 (Figure 10.4b)

이 함수는 write가 (예: 네트워크 버퍼가 꽉 차서) short count를 반환하더라도,

n 바이트를 모두 쓸 때까지 재시도합니다.

  • nleft = n: 앞으로 써야 할 바이트 수를 추적합니다.
  • while (nleft > 0): n 바이트를 모두 쓸 때까지 루프를 돕습니다.
  • if ((nwritten = write(...)) <= 0):write가 실패하거나 0바이트를 쓴 경우:
    • if (errno == EINTR):
      시그널에 의해 중단되었다면 nwritten = 0으로 설정하고 재시도합니다.
    • else return -1;:
      그 외의 실제 오류라면 -1을 반환합니다.
  • nleft -= nwritten;:write가 short count (예: n의 일부인 nwritten)를 반환했더라도,nleft에서 그만큼을 뺍니다.
  • bufp += nwritten;:bufp 포인터를 nwritten만큼 전진시켜,
    아직 쓰지 못한 나머지 nleft 바이트를 다음 루프에서 쓸 준비를 합니다.
  • return n;:while 루프가 끝났다는 것은 nleft가 0이 되었다는 뜻이므로, 항상 n을 반환합니다.
    (오류가 났다면 이미 -1이 반환되었음)

10.5.2 Rio 버퍼 있는(Buffered) 입력 함수

1. 버퍼링의 필요성

텍스트 파일의 줄 수를 세는 프로그램을 read 함수로 1바이트씩 읽는 것은 각 바이트마다 커널 트랩이 발생하여 매우 비효율적입니다. 더 나은 방법은 내부 읽기 버퍼(read buffer)를 사용하는 래퍼 함수 (rio_readlineb)를 호출하여, 버퍼가 비었을 때만 read 시스템 콜을 호출하여 버퍼를 자동으로 리필하는 것입니다.


2. RIO 버퍼 (rio_t) 구조체

RIO 버퍼 입력 함수들은 rio_t라는 구조체를 사용하여 애플리케이션 수준의 버퍼링을 구현합니다. (Fig 10.6)

#define RIO_BUFSIZE 8192

typedef struct {
    int rio_fd;                /* 이 내부 버퍼를 위한 디스크립터 */
    int rio_cnt;               /* 버퍼 안에 아직 읽지 않은 바이트 수 */
    char *rio_bufptr;          /* 버퍼 안에서 다음 읽을 바이트 위치 */
    char rio_buf[RIO_BUFSIZE]; /* 내부 버퍼 */
} rio_t;

3. RIO 버퍼 입력 함수 소개

RIO는 텍스트 라인과 바이너리 데이터를 모두 효율적으로 읽기 위해 다음과 같은 버퍼 입력 함수들을 제공합니다.

  • rio_readnb: rio_readn의 버퍼 버전으로, 원시 바이트(raw bytes)를 전송합니다.
  • rio_readlineb: 텍스트 라인을 읽는 데 특화된 함수입니다.
#include "csapp.h"// 버퍼 초기화

void rio_readinitb(rio_t *rp, int fd);
// Returns: nothing

// 한 줄 읽기 (버퍼 사용)
ssize_t rio_readlineb(rio_t *rp, void *usrbuf, size_t maxlen);
// n 바이트 읽기 (버퍼 사용)
ssize_t rio_readnb(rio_t *rp, void *usrbuf, size_t n);
// Returns: number of bytes read if OK, 0 on EOF, -1 on error

4. 함수 상세 설명

  • rio_readinitb(rp, fd) (Fig 10.6):

    void rio_readinitb(rio_t *rp, int fd)
    {
        rp->rio_fd = fd;
        rp->rio_cnt = 0;
        rp->rio_bufptr = rp->rio_buf;
    }
    • 열린 디스크립터(fd) 마다 한 번 호출됩니다.
    • fdrp 주소에 있는 rio_t 타입의 읽기 버퍼와 연결(associate)합니다.
    • 읽기 버퍼를 비어있는 상태로 초기화합니다 (rio_cnt = 0, rio_bufptr 초기화).
  • 내부 rio_read(rp, usrbuf, n) 함수 (Fig 10.7):

    static ssize_t rio_read(rio_t *rp, char *usrbuf, size_t n)
    {
        int cnt;
    
        while (rp->rio_cnt <= 0) {  /* Refill if buf is empty */
            rp->rio_cnt = read(rp->rio_fd, rp->rio_buf,
                               sizeof(rp->rio_buf));
            if (rp->rio_cnt < 0) {
                if (errno != EINTR) /* Interrupted by sig handler return */
                    return -1;
            }
            else if (rp->rio_cnt == 0)  /* EOF */
                return 0;
            else
                rp->rio_bufptr = rp->rio_buf; /* Reset buffer ptr */
        }
    
        /* Copy min(n, rp->rio_cnt) bytes from internal buf to user buf */
        cnt = n;
        if (rp->rio_cnt < n)
            cnt = rp->rio_cnt;
        memcpy(usrbuf, rp->rio_bufptr, cnt);
        rp->rio_bufptr += cnt;
        rp->rio_cnt -= cnt;
        return cnt;
    }
    • RIO 버퍼 읽기 루틴의 핵심입니다. 리눅스 read의 버퍼 버전 역할을 합니다.
    • nn 바이트 요청 시, 버퍼에 rp->rio_cnt 바이트가 남아있습니다.
    • 버퍼가 비어있으면 (rp->rio_cnt <= 0): read(rp->rio_fd, ...) 시스템 콜을 호출하여 버퍼(rp->rio_buf)를 최대 RIO_BUFSIZE만큼 채웁니다. (read가 short count를 반환해도 오류 아님) 버퍼 포인터(rio_bufptr)는 리셋합니다.
    • 버퍼가 비어있지 않으면: nnrp->rio_cnt더 작은 값(cnt) 만큼 내부 버퍼(rio_bufptr)에서 사용자 버퍼(usrbuf)로 memcpy 합니다.
    • rio_bufptrcnt만큼 전진시키고, rio_cntcnt만큼 감소시킨 후, 복사된 바이트 수(cnt)를 반환합니다.
    • 애플리케이션 입장에서는 read와 동일한 의미를 갖습니다 (오류 시 -1, EOF 시 0, 버퍼 잔량보다 많이 요청 시 short count 반환)
  • rio_readlineb(rp, usrbuf, maxlen) (Fig 10.8):

    ssize_t rio_readlineb(rio_t *rp, void *usrbuf, size_t maxlen)
    {
        int n, rc;
        char c, *bufp = usrbuf;
    
        for (n = 1; n < maxlen; n++) {
            if ((rc = rio_read(rp, &c, 1)) == 1) {
                *bufp++ = c;
                if (c == '\n') {
                    n++;
                    break;
                }
            } else if (rc == 0) {
                if (n == 1)
                    return 0; /* EOF, no data read */
                else
                    break;    /* EOF, some data was read */
            } else
                return -1;	  /* Error */
        }
        *bufp = 0;
        return n-1;
    }
    • 내부적으로 rio_read(rp, &c, 1)한 바이트씩 최대 maxlen-1번 호출합니다.
    • 각 바이트를 usrbuf에 복사하고, 개행 문자(\n)를 만나면 루프를 멈춥니다.
    • usrbuf의 끝에 NULL 문자(\0)를 추가하여 문자열로 만듭니다.
    • 읽은 총 바이트 수(NULL 제외)를 반환합니다.
    • EOF 처리: 데이터 없이 EOF 만나면 0 반환, 데이터 읽던 중 EOF 만나면 읽은 만큼 반환.
  • rio_readnb(rp, usrbuf, n) (Fig 10.8):

    ssize_t rio_readnb(rio_t *rp, void *usrbuf, size_t n)
    {
        size_t nleft = n;
        ssize_t nread;
        char *bufp = usrbuf;
    
        while (nleft > 0) {
            if ((nread = rio_read(rp, bufp, nleft)) < 0)
                return -1;          /* errno set by read() */
            else if (nread == 0)
                break;              /* EOF */
            nleft -= nread;
            bufp += nread;
        }
        return (n - nleft);         /* Return >= 0 */
    }
    • rio_readn (버퍼 없는 버전)과 구조가 거의 동일합니다.
    • read 시스템 콜 대신, 내부 rio_read 함수를 사용하여 버퍼에서 데이터를 가져옵니다.
    • rio_read가 short count를 반환하더라도, n 바이트를 모두 읽거나 EOF를 만날 때까지 rio_read반복 호출합니다.
    • 실제로 읽은 총 바이트 수를 반환합니다.

5. 특징 및 주의사항

  • 혼용(Interleaving): rio_readlinebrio_readnb 호출은 동일한 rio_t 버퍼(rp)를 공유하며 임의로 혼용하여 사용할 수 있습니다.
  • ⚠️ 금지사항:버퍼 함수들(rio_readlineb, rio_readnb)의 호출과 버퍼 없는 함수(rio_readn)의 호출을 혼용해서는 안 됩니다.
  • 스레드 안전: 이 함수들은 (표준 C I/O와 달리) 스레드 안전하게 설계될 수 있습니다. (구현에 따라 다름)
  • 활용 예시: Fig 10.5는 rio_readinitb, rio_readlineb, rio_writen을 사용하여 표준 입력에서 표준 출력으로 텍스트 파일을 한 줄씩 복사하는 간단한 예제입니다.
    #include "csapp.h"
    
    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);
    }

10.6 파일 메타데이터 읽기 (Reading File Metadata)

애플리케이션은 statfstat 함수를 호출하여 파일에 대한 정보 (파일의 메타데이터)를 검색할 수 있습니다.


1. statfstat 함수

#include <unistd.h>
#include <sys/stat.h>

int stat(const char *filename, struct stat *buf);
int fstat(int fd, struct stat *buf);
  • 반환 값: 성공 시 0, 오류 시 -1
  • stat 함수: 파일 이름(filename)을 입력으로 받아 struct stat 구조체(buf)의 멤버들을 채웁니다.
  • fstat 함수: stat과 유사하지만, 파일 이름 대신 파일 디스크립터(fd)를 입력으로 받습니다.

2. struct stat 구조체 (Figure 10.9)

statfstat 함수는 파일의 메타데이터를 struct stat 구조체에 채워 반환합니다.

struct stat {
    dev_t     st_dev;     /* 장치 */
    ino_t     st_ino;     /* inode */
    mode_t    st_mode;    /* 보호(권한) 및 파일 타입 */
    nlink_t   st_nlink;   /* 하드 링크 수 */
    uid_t     st_uid;     /* 소유자 사용자 ID */
    gid_t     st_gid;     /* 소유자 그룹 ID */
    dev_t     st_rdev;    /* 장치 타입 (inode 장치일 경우) */
    off_t     st_size;    /* 총 크기 (바이트 단위) */
    blksize_t st_blksize; /* 파일 시스템 I/O 블록 크기 */
    blkcnt_t  st_blocks;  /* 할당된 블록 수 */
    time_t    st_atime;   /* 마지막 접근 시간 */
    time_t    st_mtime;   /* 마지막 수정 시간 */
    time_t    st_ctime;   /* 마지막 상태 변경 시간 */
};
  • (웹 서버를 논의할 때 st_modest_size 멤버가 필요합니다.)
  • st_size: 파일 크기를 바이트 단위로 포함합니다.
  • st_mode: 파일 권한 비트(Figure 10.2)와 파일 타입(10.2절)을 모두 인코딩합니다.

3. 파일 타입 확인 매크로

리눅스는 st_mode 멤버로부터 파일 타입을 결정하기 위한 매크로 술어(macro predicates)를 sys/stat.h에 정의하고 있습니다.

  • S_ISREG(m): 일반 파일(regular file)인가?
  • S_ISDIR(m): 디렉터리(directory file)인가?
  • S_ISSOCK(m): 네트워크 소켓(socket)인가?

4. 사용 예시 (Figure 10.10)

Figure 10.10은 이 매크로들과 stat 함수를 사용하여 파일의 st_mode 비트를 읽고 해석하는 방법을 보여줍니다.

#include "csapp.h"

int main (int argc, char **argv)
{
    struct stat stat;
    char *type, *readok;

    Stat(argv[1], &stat); // 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);
    exit(0);
}
  • 이 코드는 명령줄 인자로 받은 파일의 타입을 확인하고, 소유자(User)가 읽기 권한(S_IRUSR)을 가지고 있는지 출력합니다.

10.7 디렉터리 내용 읽기 (Reading Directory Contents)

애플리케이션은 readdir 계열 함수들을 사용하여 디렉터리의 내용을 읽을 수 있습니다.


1. opendir 함수

디렉터리를 열고 스트림 포인터를 얻습니다.

#include <sys/types.h>
#include <dirent.h>

DIR *opendir(const char *name);
  • 반환 값: 성공 시 핸들 포인터 (DIR *), 오류 시 NULL

opendir 함수는 경로 이름(name)을 받아 디렉터리 스트림(directory stream)에 대한 포인터를 반환합니다. 스트림은 항목들의 정렬된 리스트에 대한 추상화이며, 이 경우 디렉터리 항목들의 리스트입니다.


2. readdir 함수

스트림에서 다음 디렉터리 항목을 읽습니다.

#include <dirent.h>

struct dirent *readdir(DIR *dirp);
  • 반환 값: 성공 시 다음 디렉터리 항목 포인터, 더 이상 항목이 없거나 오류 시 NULL

readdir를 호출할 때마다 스트림 dirp에서 다음 디렉터리 항목에 대한 포인터를 반환합니다. 더 이상 항목이 없으면 NULL을 반환합니다.

각 디렉터리 항목은 다음과 같은 struct dirent 구조체입니다.

struct dirent {
    ino_t d_ino;       /* 아이노드 번호 */
    char d_name[256]; /* 파일 이름 */
};
  • (d_ino는 파일 위치(아이노드), d_name은 파일 이름입니다.)

오류 처리: readdir는 오류 발생 시에도 NULL을 반환하고 errno를 설정합니다. 스트림 끝(end-of-stream) 조건과 오류를 구별하는 유일한 방법은 readdir 호출 이후 errno가 변경되었는지 확인하는 것입니다.


3. closedir 함수

열린 디렉터리 스트림을 닫습니다.

#include <dirent.h>

int closedir(DIR *dirp);
  • 반환 값: 성공 시 0, 오류 시 -1

closedir 함수는 스트림을 닫고 관련 리소스를 해제합니다.


4. 사용 예시 (Figure 10.11)

Figure 10.11은 opendir, readdir, closedir를 사용하여 디렉터리의 내용(파일 이름)을 읽어 출력하는 방법을 보여줍니다. while 루프를 사용하여 readdirNULL을 반환할 때까지 반복하며, 루프 종료 후 errno를 확인하여 오류 발생 여부를 판단합니다.

10.8 파일 공유 (Sharing Files)

리눅스 파일은 여러 가지 방식으로 공유될 수 있습니다. 커널이 열린 파일을 어떻게 표현하는지 명확히 이해하지 못하면 파일 공유 개념이 혼란스러울 수 있습니다.

커널은 열린 파일을 표현하기 위해 세 가지 연관된 자료 구조를 사용합니다:

  1. 디스크립터 테이블 (Descriptor table)
    • 각 프로세스는 자신만의 별도 디스크립터 테이블을 가집니다.
    • 테이블의 항목들은 프로세스의 열린 파일 디스크립터에 의해 인덱싱됩니다.
    • 각 열린 디스크립터 항목은 파일 테이블의 한 항목을 가리킵니다.
  2. 파일 테이블 (File table)
    • 열린 파일의 집합은 모든 프로세스에 의해 공유되는 파일 테이블로 표현됩니다.
    • 각 파일 테이블 항목은 다음을 포함합니다:
      • 현재 파일 위치 (file position)
      • 이 항목을 가리키는 디스크립터 항목의 수 (참조 카운트, reference count)
      • v-node 테이블 항목을 가리키는 포인터
    • 디스크립터를 닫으면(close) 연결된 파일 테이블 항목의 참조 카운트가 감소합니다. 커널은 참조 카운트가 0이 될 때까지 파일 테이블 항목을 삭제하지 않습니다.
  3. v-node 테이블 (v-node table)
    • 파일 테이블과 마찬가지로, v-node 테이블도 모든 프로세스에 의해 공유됩니다.
    • 각 항목은 stat 구조체의 대부분의 정보 (예: st_mode, st_size 멤버)를 포함합니다.
  • Q. 테이블들은 언제 초기화되고 어떤식으로 데이터가 추가되는걸까?

    1. 각 테이블의 초기화 시점

    각 테이블의 "초기화"는 그 성격에 따라 의미가 다릅니다.
    • 디스크립터 테이블 (Process-specific)

      • 시점: 프로세스가 생성될 때입니다.
      • 설명: 이 테이블은 프로세스별로 존재합니다.
        • fork() 호출 시: 자식 프로세스는 부모 프로세스의 디스크립터 테이블을 그대로 복제하여 초기화됩니다.
        • execve() 호출 시: 디스크립터 테이블은 유지됩니다. (단, FD_CLOEXEC 플래그가 설정된 디스크립터는 닫힙니다.)
        • 최초의 프로세스 (init): 시스템 부팅 시 커널이 생성하며, 이 때 표준 입력(0), 출력(1), 에러(2)를 가리키는 기본 테이블로 초기화됩니다.
    • 파일 테이블 (System-wide)

      • 시점: 시스템 부팅 시입니다.
      • 설명: 이 테이블은 커널 전역에서 공유되는 단일 테이블입니다. 커널이 메모리에 적재되고 파일 시스템을 초기화할 때, 이 테이블을 관리하기 위한 자료 구조가 준비(초기화)됩니다. 부팅 시점에는 비어있는 상태이며, 파일이 열릴 때마다 항목이 동적으로 추가됩니다.
    • v-node 테이블 (System-wide)
      - 시점: 시스템 부팅 시입니다.
      - 설명: 파일 테이블과 마찬가지로 커널 전역에서 공유되는 테이블(정확히는 캐시)입니다. 커널이 부팅될 때 이 v-node 캐시(또는 inode 캐시) 자료 구조가 초기화됩니다. 이 역시 처음에는 비어 있으며, 파일에 접근할 때 동적으로 채워집니다.


      2. 테이블에 데이터가 추가되는 시점

      데이터 추가는 주로 파일을 열거나(open) 새로운 I/O 채널을 만들 때 발생합니다.

    • 디스크립터 테이블

      • 시점: 새로운 파일 디스크립터를 반환하는 시스템 콜이 성공할 때입니다.
      • 설명: 프로세스가 open(), pipe(), socket(), accept(), dup() 등의 함수를 호출하면, 커널은 다음을 수행합니다.
        1. (필요하다면) 파일 테이블과 v-node 테이블에 항목을 생성합니다.
        2. 프로세스의 디스크립터 테이블에서 비어있는 가장 낮은 번호의 인덱스를 찾습니다.
        3. 이 인덱스(슬롯)가 파일 테이블의 특정 항목을 가리키도록 포인터를 설정합니다. (이것이 "데이터 추가"입니다.)
        4. 커널은 이 인덱스 번호(파일 디스크립터)를 프로세스에 반환합니다.
    • 파일 테이블

      • 시점: open() 시스템 콜이 호출될 때입니다.
      • 설명: 프로세스가 open()을 호출하면, 커널은 새로운 파일 테이블 항목을 생성합니다.
        • 이 항목에는 파일의 현재 위치(offset, 처음엔 0), 열기 모드(읽기/쓰기 전용 등)가 저장됩니다.
        • 또한 이 항목은 해당 파일의 v-node 테이블 항목을 가리키는 포인터를 갖습니다.
        • 참고: fork()는 파일 테이블에 새 항목을 추가하지 않습니다. 대신 기존 항목의 참조 카운트(ref count)를 증가시킵니다. dup()도 마찬가지입니다.
    • v-node 테이블
      - 시점: 파일 시스템의 파일에 처음 접근할 때 (캐시 미스 발생 시) 입니다.
      - 설명: 이 테이블은 캐시(cache)처럼 동작합니다.
      1. 프로세스가 open() 등으로 특정 파일(예: /path/to/file.txt)에 접근을 시도합니다.
      2. 커널은 이 파일의 메타데이터(inode)가 v-node 테이블(캐시)에 이미 있는지 확인합니다.
      3. 만약 없다면 (Cache Miss): 커널은 디스크에서 해당 파일의 inode를 읽어와, 새로운 v-node 테이블 항목을 생성하여 그 정보를 채워 넣습니다.
      4. 만약 있다면 (Cache Hit): 디스크를 읽지 않고 캐시된 v-node 항목을 즉시 재사용합니다.

      🚀 요약

      사용자가 open("file.txt", ...)을 호출하면 다음과 같은 일이 발생합니다 (가장 일반적인 시나리오):

    1. v-node 테이블: 커널이 "file.txt"의 inode를 v-node 캐시에서 찾습니다. 없으면 디스크에서 읽어와 v-node 테이블에 새 항목을 추가합니다.
    2. 파일 테이블: 커널이 전역 파일 테이블에 새 항목을 추가합니다. 이 항목은 파일 위치(offset=0)를 가지며, 1번의 v-node 항목을 가리킵니다.
    3. 디스크립터 테이블: 커널이 이 프로세스의 디스크립터 테이블에서 빈 슬롯(예: 3번)을 찾아, 2번의 파일 테이블 항목을 가리키도록 포인터를 추가합니다.
    4. open() 함수는 3번 슬롯의 인덱스인 3을 반환합니다.
  • Q. 각 테이블의 역할

    1. 디스크립터 테이블 (Descriptor Table)

    • 핵심 역할: 프로세스 수준의 추상화 (Process-level Abstraction)

      이 테이블의 존재 이유는 "프로세스가 커널의 복잡한 내부 구조를 몰라도 되게 하자"입니다.

    • 프로세스가 파일을 다룰 때 사용하는 간단한 정수(파일 디스크립터, 예: 0, 1, 3...)를 제공합니다.

    • 프로세스 내부에서 사용하는 이 FD 정수(인덱스)를, 커널의 전역적인 '파일 테이블'의 특정 항목과 1:1로 연결(mapping)하는 역할을 합니다.

    • 이 테이블은 프로세스마다 독립적으로 존재하기 때문에, A 프로세스의 3번 FD와 B 프로세스의 3번 FD는 서로 다른 파일을 가리킬 수 있습니다.

      요약: 프로세스에게 간단한 정수 핸들(FD)을 제공하고, 이 핸들을 실제 '열린 파일'로 변환해주는 프로세스 전용 안내 데스크입니다.


      2. 파일 테이블 (File Table)

    • 핵심 역할: 파일 열기 '세션' 또는 '인스턴스' 관리 (Session/Instance Management)

      이 테이블은 "파일이 어떻게 열렸고, 어디까지 읽었는가"를 관리합니다.

    • 가장 중요한 역할은 '파일 위치(file position/offset)'를 저장하는 것입니다. 즉, 파일의 몇 바이트 지점을 읽거나 쓸 차례인지를 기록합니다.

    • open()이 호출될 때마다 생성되는, 파일 열기 자체의 상태(state)를 나타냅니다. (예: O_APPEND, O_RDWR 같은 열기 모드, 상태 플래그 등)

    • 이 테이블 항목이 공유되면(fork()dup() 등으로), 파일 위치(offset)도 공유됩니다.

    • 참조 카운트(reference count)를 통해, 자신을 가리키는 디스크립터가 몇 개인지 추적하여 언제 자신을 메모리에서 해제할지 결정합니다.

      요약: 특정 파일의 '열린 상태'를 관리하는 책갈피입니다. 파일의 어느 페이지를 읽고 있었는지(offset)를 기억합니다. open()을 할 때마다 새 책갈피가 생긴다고 볼 수 있습니다.


      3. v-node 테이블 (v-node Table)

    • 핵심 역할: 파일의 '메타데이터'와 '실체' 관리 (Metadata & File System Abstraction)

      이 테이블은 "파일 그 자체가 무엇인가"를 정의합니다.

    • 파일 시스템에 존재하는 파일 그 자체(디스크 상의 inode 정보)를 커널 메모리 상에 표현합니다.

    • 파일 유형, 권한, 크기, 소유자, 타임스탬프 등 stat 구조체에 담기는 모든 메타데이터의 원본을 저장합니다.

    • 파일 시스템의 종류(ext4, XFS, NTFS 등)에 관계없이 커널이 파일을 일관되게 다룰 수 있도록 하는 파일 시스템 추상화 계층의 핵심입니다.

    • 모든 프로세스가 공유하며, 디스크 I/O를 줄이기 위한 캐시(cache)로 동작합니다. (한번 읽어온 inode 정보는 메모리에 유지)

      요약: 도서관의 원장(ledger) 또는 색인 카드입니다. 파일(책)의 고유 정보(제목, 저자, 크기, 위치)를 담고 있으며, 이 정보가 필요한 모든 사람(프로세스)이 공유합니다.


      전체 흐름 요약

      이 세 테이블은 다음과 같은 계층 구조로 동작합니다.

      [프로세스]FD (디스크립터 테이블)파일 위치 (파일 테이블)파일 메타데이터 (v-node 테이블)[디스크]


파일 공유 시나리오

  • 공유가 없는 일반적인 경우 (그림 10.12)

  • 디스크립터 1과 4가 별개의 파일 테이블 항목을 통해 두 개의 다른 파일을 참조합니다.
  • 이는 각 디스크립터가 고유한 파일에 해당하는 일반적인 상황입니다.
  • 다중 디스크립터, 동일 파일 (그림 10.13)

  • 예를 들어, open 함수를 동일한 파일 이름으로 두 번 호출하면 이와 같은 상황이 발생할 수 있습니다.
  • 핵심은 각 디스크립터가 자신만의 고유한 파일 위치를 가진다는 것입니다. 따라서 서로 다른 디스크립터를 사용하면 파일의 다른 위치에서 데이터를 읽을 수 있습니다.
  • 부모와 자식 프로세스 간 공유 (그림 10.14)

  • fork 호출 전, 부모 프로세스가 (그림 10.12)와 같이 파일을 열고 있었다고 가정합니다.
  • fork 호출 후, 자식은 부모의 디스크립터 테이블의 복사본을 얻습니다.
  • 부모와 자식은 동일한 열린 파일 테이블 집합을 공유하며, 따라서 동일한 파일 위치를 공유합니다.
  • 중요한 결과: 커널이 해당 파일 테이블 항목을 삭제하게 하려면, 부모와 자식 모두가 자신의 디스크립터를 닫아야 합니다.

10.9 입출력 리디렉션 (I/O Redirection)

리눅스 쉘은 사용자가 표준 입력(standard input)과 표준 출력(standard output)을 디스크 파일과 연결할 수 있도록 입출력 리디렉션(I/O redirection) 연산자를 제공합니다.

예를 들어, linux> ls > foo.txt를 입력하면, 쉘은 ls 프로그램을 로드하고 실행하며, 이때 표준 출력이 디스크 파일 foo.txt로 리디렉션됩니다. (11.5절에서 보겠지만) 웹 서버가 클라이언트를 대신해 CGI 프로그램을 실행할 때도 이와 유사한 리디렉션을 수행합니다.

그렇다면 입출력 리디렉션은 어떻게 작동할까요? 한 가지 방법은 dup2 함수를 사용하는 것입니다.

#include <unistd.h>

int dup2(int oldfd, int newfd);
  • 반환 값: 성공 시 0 이상의 디스크립터, 오류 시 -1

dup2 함수는 디스크립터 테이블 항목 oldfd를 디스크립터 테이블 항목 newfd복사합니다. 이 과정에서 newfd의 이전 내용은 덮어씌워집니다. 만약 newfd가 이미 열려 있었다면, dup2oldfd를 복사하기 전에 newfd를 먼저 닫습니다.


dup2 예시

dup2(4, 1)을 호출하는 상황을 가정해 보겠습니다.

  1. 호출 전 (그림 10.12)

  • 디스크립터 1 (표준 출력)은 파일 A (예: 터미널)를 가리킵니다.
  • 디스크립터 4는 파일 B (예: 디스크 파일)를 가리킵니다.
  • 파일 A와 파일 B의 파일 테이블 참조 카운트는 각각 1입니다.
  1. dup2(4, 1) 호출
    • 커널이 newfd (즉, 디스크립터 1)을 먼저 닫습니다. 이로 인해 파일 A의 파일 테이블 참조 카운트가 0이 되어, 파일 A의 파일 테이블 항목과 v-node 테이블 항목이 삭제됩니다.
    • 커널이 oldfd (즉, 디스크립터 4)의 내용을 newfd (디스크립터 1)로 복사합니다.
  2. 호출 후 (그림 10.15)

  • 이제 디스크립터 1과 4는 모두 파일 B의 파일 테이블 항목을 가리킵니다.
  • 파일 B의 파일 테이블 참조 카운트는 2로 증가합니다.
  • 이 시점부터, 표준 출력(디스크립터 1)에 쓰이는 모든 데이터는 파일 B로 리디렉션됩니다.

10.10 표준 입출력 (Standard I/O)

C 언어는 표준 입출력 라이브러리(Standard I/O library)라고 불리는 고수준 입출력 함수 집합을 정의하며, 이는 프로그래머에게 Unix I/O의 고수준 대안을 제공합니다.

이 라이브러리(libc)는 다음과 같은 함수들을 제공합니다.

  • 파일 열기/닫기: fopen, fclose
  • 바이트 읽기/쓰기: fread, fwrite
  • 문자열 읽기/쓰기: fgets, fputs
  • 정교한 서식화된 I/O: scanf, printf

스트림 (Stream)

표준 I/O 라이브러리는 열린 파일을 스트림(stream)으로 모델링합니다. 프로그래머에게 스트림은 FILE 타입의 구조체 포인터입니다.

모든 ANSI C 프로그램은 3개의 열린 스트림으로 시작하며, 이는 각각 표준 입력, 표준 출력, 표준 에러에 해당합니다:

#include <stdio.h>

extern FILE *stdin;  /* 표준 입력 (디스크립터 0) */
extern FILE *stdout; /* 표준 출력 (디스크립터 1) */
extern FILE *stderr; /* 표준 에러 (디스크립터 2) */

스트림 버퍼 (Stream Buffer)

FILE 타입의 스트림은 파일 디스크립터(file descriptor)스트림 버퍼(stream buffer)에 대한 추상화입니다.

스트림 버퍼의 목적은 비용이 많이 드는 리눅스 I/O 시스템 콜의 횟수를 최소화하는 것입니다.

  • 예시: 표준 I/O 함수인 getc를 반복 호출하는 프로그램이 있다고 가정해 봅시다.
  • getc처음 호출되면, 표준 라이브러리는 read 시스템 콜을 한 번 호출하여 스트림 버퍼를 가득 채웁니다. 그리고 버퍼의 첫 번째 바이트를 애플리케이션에 반환합니다.
  • 버퍼에 아직 읽지 않은 바이트가 남아있는 한, 이후의 getc 호출은 시스템 콜을 다시 호출하지 않고, 메모리에 있는 스트림 버퍼에서 직접 데이터를 가져와 처리됩니다.

10.11 종합: 어떤 I/O 함수를 사용해야 하는가?

이 장에서 논의한 다양한 I/O 패키지들이 [그림 10.16]에 요약되어 있습니다.

  • Unix I/O: 운영체제 커널에서 구현됩니다. open, close, lseek, read, write, stat 같은 함수를 통해 애플리케이션에 제공됩니다.
  • 고수준 I/O (Rio, Standard I/O): Unix I/O 함수들을 기반으로(사용하여) 구현됩니다.
    • Rio 함수: 이 교재를 위해 특별히 개발된 read/write견고한(robust) 래퍼(wrapper)입니다. 'Short count'(부분 읽기/쓰기)를 자동으로 처리하며, 텍스트 라인을 읽기 위한 효율적인 버퍼링 접근법을 제공합니다.
    • 표준 I/O (Standard I/O): Unix I/O에 대한 더 완전한 버퍼링 대안을 제공하며, printf, scanf와 같은 서식화된 I/O 루틴을 포함합니다.

💡 I/O 함수 사용 가이드라인

그렇다면 프로그램에서 어떤 함수를 사용해야 할까요? 기본적인 가이드라인은 다음과 같습니다.

G1: 가능한 한 표준 입출력(Standard I/O) 함수를 사용하라.

  • 표준 I/O 함수는 디스크 및 터미널 장치의 I/O를 위한 최고의 선택입니다.
  • 대부분의 C 프로그래머는 경력 내내 (표준 I/O에 대응하는 함수가 없는 stat 정도를 제외하고는) 저수준 Unix I/O 함수를 신경 쓰지 않고 표준 I/O만 독점적으로 사용합니다.
  • 가능한 한 이 방식을 따르기를 권장합니다.

G2: 바이너리 파일을 읽기 위해 scanfrio_readlineb를 사용하지 말라.

  • scanfrio_readlineb 같은 함수들은 특별히 텍스트 파일을 읽도록 설계되었습니다.
  • 이 함수들을 바이너리 데이터에 사용하면 프로그램이 이상하고 예측 불가능한 방식으로 실패하게 만드는 흔한 실수를 저지릅니다.
  • (예시: 바이너리 파일에는 텍스트 줄 종료와 아무 관련 없는 0xa (개행) 바이트가 마구 흩어져 있을 수 있습니다.)

G3: 네트워크 소켓 I/O에는 Rio 함수를 사용하라.

  • 불행히도, 표준 I/O는 네트워크 입출력에 사용하려고 할 때 몇 가지 골치 아픈 문제를 일으킵니다.
  • 리눅스에서 네트워크 추상화는 소켓(socket)이라는 파일 유형이며, 이는 소켓 디스크립터로 참조됩니다. 애플리케이션은 이 소켓 디스크립터에 읽고 씀으로써 다른 컴퓨터의 프로세스와 통신합니다.
  • 표준 I/O의 문제점:
    • 표준 I/O 스트림은 전이중(full duplex)이지만, 소켓의 제약 사항과 충돌하는 잘 알려지지 않은 제약 사항이 있습니다.
    • 제약 1 (출력 후 입력): 출력 함수 다음에 fflush, fseek, fsetpos, rewind 중 하나를 중간에 호출하지 않고는 입력 함수를 호출할 수 없습니다.
    • 제약 2 (입력 후 출력): 입력 함수가 EOF(end-of-file)를 만나지 않는 한, 입력 함수 다음에 fseek, fsetpos, rewind 중 하나를 호출하지 않고는 출력 함수를 호출할 수 없습니다.
  • 이것이 소켓에 치명적인 이유:
    • 이 제약사항들의 유일한 해결책(예: fseek)은 Unix I/O의 lseek 함수를 사용합니다. 하지만 소켓에 lseek를 사용하는 것은 불법(illegal)입니다.
    • (제약 1은 모든 입력 전 fflush를 호출하는 습관으로 해결할 수 있습니다.)
    • (제약 2를 피하기 위해) 하나의 소켓 디스크립터에 읽기용/쓰기용 스트림 2개를 여는 꼼수(fdopen)가 있습니다.
      FILE *fpin, *fpout;
      fpin = fdopen(sockfd, "r");
      fpout = fdopen(sockfd, "w");
    • 하지만 이 접근법도 fclose(fpin)fclose(fpout)를 둘 다 호출해야 한다는 문제가 있습니다. 각 fclose동일한 소켓 디스크립터를 닫으려고 시도하므로, 두 번째 close 연산은 실패할 것입니다. 이것은 스레드 프로그램에서 재앙을 초래할 수 있습니다.
  • 결론:
    • 네트워크 소켓 입출력에는 표준 I/O 함수를 사용하지 마십시오.
    • 대신 견고한 Rio 함수를 사용하십시오.
    • 서식화된 출력(Formatted Output)이 필요하면: sprintf를 사용해 메모리에 문자열을 포맷팅한 다음, rio_writen으로 소켓에 전송하십시오.
    • 서식화된 입력(Formatted Input)이 필요하면: rio_readlineb로 텍스트 한 줄을 통째로 읽어들인 다음, sscanf를 사용해 그 줄에서 여러 필드를 추출하십시오.

10.12 요약 (Summary)

리눅스는 Unix I/O 모델을 기반으로 하는 소수의 시스템 수준 함수를 제공하며, 이는 애플리케이션이 파일을 열고, 닫고, 읽고, 쓰며, 파일 메타데이터를 가져오고, I/O 리디렉션을 수행할 수 있게 합니다.

  • 리눅스의 readwrite 연산은 'short count'(부분 읽기/쓰기)가 발생할 수 있으며, 애플리케이션은 이를 예상하고 올바르게 처리해야 합니다.
  • Unix I/O 함수를 직접 호출하는 대신, 애플리케이션은 Rio 패키지를 사용해야 합니다. Rio는 요청된 모든 데이터가 전송될 때까지 read/write 연산을 반복 수행함으로써 'short count'를 자동으로 처리해 줍니다.

리눅스 커널은 열린 파일을 표현하기 위해 세 가지 연관된 자료 구조(디스크립터 테이블, 파일 테이블, v-node 테이블)를 사용합니다.

  • 각 프로세스는 자신만의 별도 디스크립터 테이블을 가지며, 모든 프로세스는 동일한 파일 테이블과 v-node 테이블을 공유합니다.
  • 이 구조들의 구성을 이해하면 파일 공유와 I/O 리디렉션 모두를 명확히 파악할 수 있습니다.
  • 표준 I/O 라이브러리(Standard I/O library)는 Unix I/O를 기반으로 구현되며, 강력한 고수준 I/O 루틴 집합을 제공합니다.
  • 대부분의 애플리케이션에 있어, 표준 I/O는 Unix I/O보다 더 간단하고 선호되는 대안입니다.
  • 하지만, 표준 I/O와 네트워크 파일(소켓) 간의 상호 호환되지 않는 일부 제약 사항 때문에, 네트워크 애플리케이션에는 표준 I/O 대신 Unix I/O (또는 Rio)를 사용해야 합니다.
profile
멈추지 않기

0개의 댓글