유닉스 계열에서 ‘모든 것은 파일’이라는 인터페이스 설계 접근을 설명하고, 파일 디스크립터와 파일시스템 이름 공간을 통한 다양한 구현 사례와 그 영향에 대해 다룬다.
URL: https://en.wikipedia.org/wiki/Everything_is_a_file
제목: 모든 것은 파일이다
위키백과, 우리 모두의 백과사전
“모든 것은 파일이다(Everything is a file)”는 유닉스 계열에서 인터페이스 설계에 적용되는 접근법이다. 이 표현 자체가 유닉스의 설계 원칙이나 철학으로 명시되어 있지는 않지만, 설계를 분석하는 흔한 방식이자, 대략 다음과 같은 우선순위를 선호하는 방식으로 새로운 인터페이스 설계에 영향을 준다.
유닉스를 분석할 때 흔히 말하는 “파일”과 “파일 디스크립터”의 경계는 자주 흐려지며, 파일의 “이름을 붙일 수 있음”은 이 원칙에서 가장 중요하지 않은 부분이다. 그래서 이를 “모든 것은 파일 디스크립터다(Everything is a file descriptor)”라고 부르기도 한다.[1][2][3]
이 접근법은 시대, 각 시스템의 철학, 적용되는 도메인에 따라 다르게 해석된다. 이 글의 나머지 부분에서는 이러한 해석의 주목할 만한 예와 그 파급효과를 설명한다.
[편집]
유닉스에서 디렉터리는 일반 파일처럼 열 수 있으며, 고정 크기의 (i-node, 파일이름) 레코드를 포함한다. 하지만 디렉터리는 직접 쓸 수 없고, 디렉터리 내에서 파일을 생성하거나 제거하는 작업의 부작용으로 커널에 의해 수정된다.[4]
일부 인터페이스는 이 지침의 부분집합만 따른다. 예를 들어 파이프는 파일시스템에 존재하지 않는다 — pipe()는 이름을 붙일 수 없는 파일 디스크립터 쌍을 만든다.[5] 이후 명명된 파이프(FIFO)가 POSIX에 의해 도입되어 이 공백을 메웠다.
이는 객체에 대한 유일한 연산이 읽기와 쓰기만을 의미하지는 않는다. ioctl() 및 유사한 인터페이스는 객체별 연산(예: TTY 특성 제어)을 허용한다. 디렉터리 파일 디스크립터는 경로 조회를 변경하는 데 사용할 수 있으며(openat()[6] 같은 점점 늘어나는 *at() 시스템 호출 변형과 함께), 파일 디스크립터가 나타내는 디렉터리로 작업 디렉터리를 변경하는 데도 사용할 수 있다.[7] 두 경우 모두 전체 경로를 조회하는 대안보다 경쟁 상태를 방지하고 더 빠르다.[8]
소켓 파일 디스크립터는 I/O에 사용되기 전에 생성 후 구성(원격 주소 설정 및 연결)이 필요하다. 서버 소켓은 아예 직접 I/O에 사용할 수 없을 수도 있다 — 연결 지향 프로토콜에서 bind()는 소켓에 로컬 주소를 할당하고, listen()은 해당 소켓을 사용해 원격 프로세스가 연결할 때까지 대기한 뒤, 그 직접적인 양방향 연결을 나타내는 “새로운” 소켓 파일 디스크립터를 반환한다.
이러한 접근은 프로그램이 사용하는 객체를 다른 파일과 마찬가지로 표준화된 방식으로 관리할 수 있게 해준다 — 주소에 바인딩한 후 권한을 내릴 수 있고, 서버 소켓을 fork()하여 여러 프로세스에 분배(접근 권한이 없어야 하는 하위 프로세스에서는 닫기)할 수 있으며, 개별 연결의 소켓을 표준 입출력으로 해당 연결의 특화된 핸들러에 넘길 수 있다. 이는 슈퍼 서버/CGI/inetd 패러다임과 같다.
초기의 유닉스에 존재했지만 파일 디스크립터를 사용하지 않던 많은 인터페이스는 이후 설계에서 중복 구현되었다. alarm()/setitimer() 시스템 호출은 지정한 시간이 경과한 뒤 시그널을 전달하도록 예약한다. 이 타이머는 자식에게 상속되고, exec() 이후에도 유지된다. POSIX의 timer_create() API는 유사한 기능을 제공하지만, 자식 프로세스에서는 파괴되고 exec() 시에도 파괴된다. 이 타이머는 불투명한 핸들로 식별된다. 두 인터페이스 모두 완료를 항상 비동기적으로 전달하며, poll()/select()의 대상이 될 수 없어 복잡한 이벤트 루프에 통합하기가 더 어렵다.
timerfd 설계(원래는 리눅스에 존재)는 각 타이머 객체를 파일 디스크립터로 바꾸어, 이를 poll() 등으로 개별 관찰할 수 있게 하고, 표준 close()/CLOEXEC/CLOFORK 제어로 자식 프로세스에 대한 상속 여부를 제어할 수 있게 한다.
POSIX API에는 timer_getoverrun()이 있어 타이머가 몇 번 경과했는지 반환하는데, timerfd에서는 이것이 read() 결과로 반환된다. 이 연산은 블록되므로, timerfd가 경과할 때까지 기다리는 것은 이를 읽는 것만큼이나 간단하다. 고전적인 유닉스나 POSIX 타이머로는 이를 원자적으로 수행할 방법이 없다. 타이머는 논블로킹 읽기(표준 I/O 연산)를 수행해 블로킹 없이 검사할 수 있다.
[편집]
장치 특수 파일은 유닉스를 정의하는 특징이다. 초기에는 i-node 번호가 40 이하인(전통적으로 /dev 아래에 저장된) 일반 파일을 여는 것이, 해당 장치에 대응하는 파일 디스크립터를 반환하도록 되어 있었고, 이는 장치 드라이버에 의해 처리되었다. 이러한 매직 i-node 번호 체계는 이후 S_IFBLK/S_IFCHR 타입의 파일로 성문화되었다.
특수 파일을 여는 작업에는 일반 파일을 여는 것과 동일한 파일 시스템 권한 검사가 적용되어 공통 접근 제어가 가능하다 — chown dmr /usr/dmr /dev/rk0; chmod o= /usr/dmr /dev/rk0는 디렉터리 /usr/dmr과 장치 /dev/rk0 둘 다의 소유자와 파일 접근 모드를 변경한다.
블록 장치(하드 디스크와 테이프 드라이브)의 경우, 그 크기 때문에 고유한 의미가 있었다. 이들은 블록 단위 주소 지정을 사용했고(참고 [9]), 프로그램은 이에 맞게 별도로 작성되어야 올바르게 동작했다. 이는 “매우 유감스러운” 것으로 묘사되었고, 이후의 인터페이스들이 이를 완화했다.[a]
많은 경우에 자기 테이프는 계속해서 고유한 의미를 가진다. 일부 테이프는 "파일"로 분할할 수 있으며, 드라이버는 파티션의 끝에 도달하면 EOF(파일 끝) 조건을 신호한다. 그래서 cp /dev/nrst0 file1; cp /dev/nrst0 file2는 테이프의 연속된 두 파티션으로 이루어진 file1과 file2를 생성한다 — 드라이버는 테이프 파일 디스크립터를 모든 것은 파일이다 패러다임에 맞추어 마치 일반 파일인 것처럼 보여주는 추상화 계층을 제공한다. 이런 테이프에서 파티션 간 이동에는 mt 같은 특수화된 프로그램이 사용된다.
명명된 파이프(FIFO)는 파일시스템에서 S_IFIFO 타입의 파일로 나타나며, 이름을 바꿀 수 있고, 일반 파일처럼 열 수 있다.
유닉스 계열에서 유닉스 도메인 소켓은 파일시스템에서 S_IFSOCK 타입의 파일로 나타나고, 이름을 바꿀 수 있지만 open()으로는 열 수 없다 — 올바른 타입의 소켓 파일 디스크립터를 생성하고 명시적으로 connect()해야 한다. Plan 9에서는 파일시스템의 소켓을 일반 파일처럼 열 수 있다.
[편집]
현대 시스템에는 고성능 I/O 이벤트 통지 기능이 있다 — kqueue(BSD 계열), epoll(리눅스), IOCP(Windows NT, Solaris), /dev/poll(Solaris) — 제어 객체는 일반적으로 전용 시스템 호출로 생성(kqueue(), epoll_create()) 및 구성(kevent(), epoll_ctl())된다. /dev/poll 인스턴스는 파일 "/dev/poll"을 직접 열고, 관찰할 객체를 구성하여 기록하고, 추가 구성을 위해 ioctl()을 사용하는 방식으로 생성된다.
메모리는 익명 메모리 매핑을 요청하여 할당할 수 있다 — 어떤 파일에도 대응하지 않는 매핑이다. 현대 시스템에서는 파일을 지정하지 않고 MAP_ANONYMOUS를 사용해 이를 수행할 수 있다. UNIX System V Release 4에서는 /dev/zero를 열고 mmap()하는 방식으로 했다.
운영체제 API는 일반 시스템 호출로 구현될 수도 있고, 합성 파일시스템으로 구현될 수도 있다. 전자의 경우 시스템 상태는 시스템과 함께 제공되는 특별히 작성된 프로그램으로만 검사할 수 있고, 사용자가 원하는 추가 처리도 그러한 프로그램의 출력을 필터링하고 파싱하거나, 원하는 상태를 쓰기 위해 프로그램을 실행하거나, 네이티브 시스템 프로그래밍 언어로 구현해야 한다.
후자의 경우 시스템 상태는 마치 일반 파일과 디렉터리인 것처럼 제시된다[12] — procfs가 있는 시스템에서는 실행 중인 프로세스에 대한 정보를 표준적으로 /proc에서 얻을 수 있다. 여기에는 시스템에서 실행 중인 PID를 이름으로 하는 디렉터리가 들어 있으며, 프로세스 메타데이터를 담은 stat(= status) 파일, 프로세스의 작업 디렉터리, 실행 이미지, 루트 디렉터리에 대한 심볼릭 링크인 cwd, exe, root, 그리고 프로세스가 열어 둔 파일에 대한 심볼릭 링크를 파일 디스크립터 번호로 이름 붙여 담고 있는 fd 같은 디렉터리가 있다.
이러한 속성들이 파일과 심볼릭 링크로 제시되기 때문에, 표준 유틸리티들이 그대로 동작한다. 예를 들어 grep Uid /proc/1392400/status로 프로세스의 신원을 확인하고, cd /proc/1392400/cwd로 해당 프로세스와 같은 디렉터리로 이동하며, ls -l /proc/1392400/fd로 그 프로세스가 열어 둔 파일을 확인한 다음, less /proc/1392400/fd/8로 그 프로세스가 열어 둔 파일을 열어 볼 수 있다. 이는 유틸리티 출력에서 이 데이터를 파싱하는 것에 비해 인체공학적으로 개선된다.[13][14]
리눅스에서는 procfs 아래의 심볼릭 링크가 “매직”하다. 즉, 이들이 가리키는 파일에 대한 실제 하드 링크처럼 교차 파일시스템 간에도 동작할 수 있다. 이러한 동작은 파일시스템에서 제거되었지만 여전히 어떤 프로세스에 의해 열려 있는 파일을 복구하고, 파일시스템에서 O_TMPFILE로 생성되어(그 자체로는 이름을 붙일 수 없는) 영구 보존되는 파일을 가능하게 한다.
4.4BSD에서 유래한 sysctl은 sysctl 프로그램으로 관리되는 키/값 매핑이다. sysctl -a로 모든 변수를 나열하고, sysctl net.inet.ip.forwarding으로 특정 변수의 값을 확인하며, sysctl -w net.inet.ip.forwarding=1로 설정한다. 리눅스에서는 동일한 메커니즘이 /proc/sys 트리 아래의 procfs로 제공된다. 각각의 작업은 find /proc/sys/grep -r ^ /proc/sys, cat /proc/sys/net/ipv4/ip_forward, echo 1 > /proc/sys/net/ipv4/ip_forward로 수행할 수 있다.
편의나 표준 준수를 위해, 전용 검사 도구(ps, sysctl 등)도 여전히 제공될 수 있으며, 이들 파일시스템을 데이터 소스/싱크로 사용한다.
sysfs[15]와 debugfs[16]는 커널을 추가로 구성하기 위한 유사한 리눅스 인터페이스다. /sys/power/state에 mem을 쓰면 RAM 절전 절차가 트리거되며,[17] /sys/module/iwlwifi/parameters/led_mode에 2를 쓰면 Wi‑Fi LED가 활동 시 깜박이기 시작한다.
이들은 “합성(synthetic)” 파일시스템이다. 각 파일의 내용이 어디에도 있는 그대로 저장되어 있지 않기 때문이다. 파일을 읽을 때는 해당 커널의 데이터 구조가 읽는 프로세스의 입력 버퍼로 직렬화되고, 파일에 쓸 때는 출력 버퍼가 파싱된다.[15] 이는 파일 메타데이터가 유효하지 않기 때문에 파일 추상화가 깨진다는 뜻이기도 하다. 파일시스템에 따라 각 파일은 크기를 0 또는 PAGE_SIZE로 보고하지만, 실제로 데이터를 읽으면 다른 바이트 수가 나오기 때문이다.
seek() 모드를 추가하여 커널에서 오프셋을 512로 곱했고,[10] 최종적으로 Version 7 유닉스에서 32비트 인자를 받는 lseek()을 제공했다.[11]./man2/pipe.2./man2/pipe.2, 그리고 Addressing on the tape files, like that on the RK and RF disks, is block-oriented. 문구가 사라졌음.usr/man/man2/lseek.2