[OS] 31-1. Files and Directories (1)

Park Yeongseo·2024년 2월 16일
1

OS

목록 보기
37/54
post-thumbnail

Operating Systems : Three Easy Pieces를 보고 번역 및 정리한 내용들입니다.

여기에서는 영구 저장소(persistent storage)에 대해서 알아본다. 하드 디스크 드라이브나 SSD 같은 영구 저장 장치는 정보를 영구적으로 저장한다. 전원이 꺼지면 내용들이 사라지는 메모리와 달리 영구 저장 장치는 데이들을 손상되지 않게 유지한다.

OS어떻게 영구 저장 장치를 관리해야할까? API는 어떤 게 있을까? 구현에서 중요한 측면에는 어떤 게 있을까?

다음 몇 장에서는 성능과 신뢰성을 개선하는 방법들에 초점을 맞춰, 영구적인 데이터를 관리하는 핵심 테크닉들에 대해서 공부하게 될 것이다. 우선은 API에 대해 간략하게 알아보도록 하자. UNIX 파일 시스템과 상호작용 하는 데 쓰이는 인터페이스들이다.

1. Files And Directories

저장소의 가상화를 위해 만들어진 핵심 추상화에는 두 가지가 있다. 첫 번째는 파일(file)이다. 파일은 단순히 읽고 쓸 수 있는 선형 바이트 배열이다. 각 파일은 낮은 수준의 이름(low-level name)을 가지고 있으며, 이는 보통 숫자다. 사용자는 이 이름에 대해서 알지 못한다. 역사적으로 파일의 낮은 수준의 이름은 아이노드 번호(inode number, i-number)라 불려왔다. 이후의 장들에서 이에 대해 더 공부하게 될 것이지만, 지금은 간단히 각 파일이 그에 해당하는 아이노드 번호를 가진다는 것만 알아두자.

대부분의 시스템에서 OS는 파일의 구조에 대해 잘 알지 못한다. 그보다, 파일 시스템이 해야하는 일은 단순히 그런 데이터를 디스크에 영구적으로 저장하고, 그 데이터에 대한 요청이 다시 일어났을 때 불러올 수 있도록 하는 것이다. 하지만 이렇게 하는 일은 보기보다 쉽지 않다.

두 번째 추상화는 디렉토리(directory)다. 디렉토리는 파일과 같이 낮은 수준의 이름을 가지며, 그 내용 또한 상당히 구체적이다. 디렉토리에는 사용자가 읽을 수 있는 이름(user-readable name)과 낮은 수준 이름 쌍의 리스트가 포함되어있다. 예를 들어 "10"이라는 낮은 수준 이름과 "foo"라는 user-readable name을 가지는 파일을 가정해보자. "foo" 파일이 있는 디렉토리는 ("foo", "10")이라는 user-readable name, low-level name 쌍을 리스트에 보관한다. 디렉토리의 각 엔트리는 파일 또는 다른 디렉토리를 가리킨다. 디렉토리를 다른 디렉토리에 위치시킴으로써 사용자는 임의의 디렉토리 트리(directory tree, directory hierarchy)를 구성할 수 있다.

디렉토리의 계층 구조는 루트 디렉토리(root directory)로부터 시작해, 원하는 파일이나 디렉토리의 이름까지의 경로 상에 있는 서브 디렉토리(sub directory)를 구분하기 위해 구분자(separator)를 사용한다. 예를 들어 사용자가 foo 디렉토리를 루트 디렉토리 /에 만들고, 그 안에 bar.txt라는 파일을 만들었다고 해보자. 이 파일은 절대 경로이름(absolute pathname)으로는 /foo/bar.txt라 부를 수 있다.

디렉토리와 파일은 파일 시스템 트리 내에서 다른 위치에 있는 경우 같은 이름을 가질 수 있다.

파일의 이름이 bartxt라는 두 부분으로 나뉠 수 있다는 것도 볼 수 있을 것이다. 첫 번째 부분은 임의로 정한 이름이고, 두 번째 부분은 보통 파일의 타입을 가리키기 위해 쓰인다. 하지만 이는 단지 컨벤션을 따를 뿐이다. main.c라는 이름의 파일이 실제로 C 소스코드 파일일 필요는 없는 것이다.

파일 시스템이 제공하는 훌륭한 특징을 볼 수 있다. 바로 관심이 있는 모든 파일들에 대해 이름 붙이는 간편한 방법을 제공해준다는 것이다. 어떤 자원이든지, 접근하기 위해서는 우선 그 이름을 알아야하므로, 이름은 시스템에서 아주 중요한 부분이다. UNIX 시스템은 하나의 디렉토리 트리를 통해, 디스크, USB 스틱, CD-ROM 등 여러 장치들에 접근하기 위한 통일된 방식을 제공해준다.

2. The File System Interface

다음으로는 파일 시스템 인터페이스에 대해 더 자세하게 논의해보자. 우선은 파일 생성, 접근, 삭제부터 시작한다. 이것들이 지금은 직관적으로 보일 수 있지만, 이후에 파일 삭제를 위한 unlink() 콜을 살펴보면 그렇지만은 않다는 것을 알 수 있을 것이다. 그래도 이 장이 끝날 때면 그렇게 어렵지도 않음을 알 수 있을 것이다.

3. Creating Files

우선은 파일 생성부터 시작해보자. 이는 open 시스템 콜을 이용하면 된다. open()을 호출하고 O_CREAT 플래그를 전달함으로써, 프로그램은 새 파일을 만들 수 있게 된다. 다음의 코드는 현재의 작업 디렉토리에서 foo라는 이름의 파일을 만드는 코드다.

int fd = open("foo", O_CREAT|O_WRONLY|O_TRUNC, S_IRUSR|S_IWUSR);

open() 루틴은 두 종류의 플래그를 사용한다. 예에서, 두 번째 파라미터는 파일이 없는 경우 생성하고(O_CREAT), 파일을 쓰기 전용으로 만들며(O_WRONLY), 만약 파일이 이미 있으면 해당 파일의 사이즈를 0 바이트로 만들어 내부 내용을 모두 삭제한다(O_TRUNC). 세 번째 파라미터는 권한을 명시하는 것으로, 위 경우에는 파일을 소유자가 읽고 쓸 수 있게 만든다.

open()에서 중요한 측면 하나는, 그것이 반환하는 파일 디스크립터(file descriptor)다. 파일 디스크립터는 프로세스마다 가지는 정수값으로, UNIX 시스템에서는 파일에 접근하기 위해 쓰인다. 그러므로 파일이 한 번 열리고 나면, (그럴 권한이 있다는 가정 아래) 파일 디스크립터를 이용해 파일을 읽거나 쓸 수 있다. 파일 디스크립터는 파일 타입 객체의 포인터로 생각할 수도 있다. 이런 객체를 얻고나면 read()write()같은, 해당 파일에 접근하기 위한 다른 메서드들을 호출할 수 있다.

위에서 말했듯, 파일 디스크립터는 OS에 의해, 프로세스 별로 관리된다. UNIX 시스템의 proc 구조 안에 어떤 간단한 구조가 있다는 것이다. xv6 커널에서의 관련된 코드를 보자.

struct proc{
	...
	struct file *ofile[NOFILE]; //Open files
	...
}

파일 디스크립터로 인덱싱되는 이 간단한 배열은 각 프로세스에 어떤 파일이 열려있는지를 추적한다. 배열의 각 엔트리는 struct file의 포인터로, 읽거나 쓰이는 파일에 대한 정보를 추적하기 위해 쓰인다. 이에 대해서는 아래에서 더 논의하게 될 것이다.

4. Reading And Writing Files

이렇게 파일이 생성됐으면, 이제는 그것을 읽고 쓸 수 있어야 한다. 우선은 존재하는 파일을 읽는 것부터 시작해보자. 커맨드 라인에서 파일 내용을 스크린에 나타내고 싶을 때에는 cat 프로그램을 사용한다.

prompt> echo hello > foo
prompt> cat foo
hello
prompt> 

여기에서는 프로그램 echo의 출력을 파일 foo로 리다이렉트시켜, foo에 "hello"라는 단어를 입력한다. 이후에는 해당 파일의 내용을 보기 위해 cat을 사용할 수 있다. 그런데 어떻게 cat 프로그램이 파일 foo에 접근할 수 있는 것일까?

이를 알아보기 위해서 시스템 콜을 추적하기 위해 쓰일 수 있는 유용한 도구를 하나 사용해보도록 하자. 리눅스에서는 strace, 맥에서는 dtruss, 보다 오래된 UNIX 변형 OS에서는 truss로 불리는 툴을 쓸 수 있다. 이 strace가 하는 일은 프로그램이 실행될 때 호출하는 모든 시스템 콜을 추적해 스크린에 띄워 보이는 것이다. cat이 어떻게 쓰이는지를 strace로 따라가보자.

prompt> strace cat foo
...
open("foo", O_RDONLY|O_LARGEFILE) = 3
read(3, "hello\n", 4096) = 6
write(1, "hello\n", 6) = 6
hello
read(3, "", 4096) = 0
close(3) = 0
...
prompt>

cat은 우선 읽을 파일을 연다. 여기에서는 알아야 할 것이 몇 가지 있다. 첫 번째는 O_RDONLY 플래그가 가리키듯, 파일은 쓰기 전용으로 열린다는 것이고, 두 번째는 64-비트 오프셋이 사용되고 있다는 것(O_LARGEFILE), 마지막으로는 open() 호출이 성공해 3의 값을 가지는 파일 디스크립터를 반환한다는 것이다.

그런데 왜 open()에 대한 첫 번째 호출이 0이나 1이 아닌 3을 반환하는 걸까? 그 이유는 각 실행 중인 프로세스가 이미 세 파일들을 열어 놓고 있기 때문이다. 표준 입력, 표준 출력, 표준 에러가 바로 그것들이다. 이들은 각각 파일 디스크립터 0, 1, 2에 해당하고, 따라서 다른 파일이 처음 열리면 파일 디스크립터 3을 얻게 되는 것이다.

열기가 성공하면 catread() 시스템 콜을 사용해 파일의 몇 바이트들을 계속해서 읽는다. read()의 첫 번째 인자는 파일 디스크립터로, 시스템에 어떤 파일을 읽을지를 알리기 위해 쓰인다. 프로세스는 여러 개의 파일을 동시에 열 수 있고, OS는 그 중 어떤 파일을 읽을지를 알아야 하기 때문이다. 두 번째 인장는 read()의 결과가 위치할 버퍼를 가리킨다. 위의 시스템 콜 추적에서, straceread()의 결과를 보여준다. 세 번쨰 인자는 버퍼의 크기로, 위에서는 4KB다. read() 호출도 반환을 잘 하고 있는데, 여기에서는 읽은 바이트의 수를 반환하고 있다.

이 지점에서, starce의 다른 흥미로운 결고를 볼 수 있다. 바로 파일 디스크립터 1로 write() 시스템 콜을 호출한 것이다. 위에서 언급헸듯 이 디스크립터는 표준 출력을 위한 것으로, "hello"라는 단어를 스크린에 쓰기 위해 사용된 것이다. 그런데 과연 catwrite()를 직접 호출한 것일까? 만약 그렇지 않다면, cat은 아마 라이브러리 함수인 printf()를 호출했을 것이다. 내부적으로 printf()는 입력을 형식화해 표준 출력에 씀으로써 결과를 스크린에 나타내도록 한다.

cat 프로그램은 이후로 파일을 더 읽으려 하는데, 파일에 남은 데이터가 없으므로 read()는 0을 리턴하고, 이로써 프로그램은 전체 파일을 읽었음을 알게 된다. 따라서 프로그램은 close()에 "foo" 파일의 디스크립트를 전달해 해당 파일에 대한 작업을 마쳤음을 알린다.

파일에 쓰는 것 또한 비슷한 단계로 이루어진다. 우선 파일은 읽기 위해 열리고, write() 시스템 콜이 (더 큰 파일의 경우에는 아마 반복적으로) 호출되고, 마지막으로는 close()가 호출된다. strace를 이용해 직접 만든 파일 작성 프로그램이 어떤 시스템 콜을 이용하는지를 따라가보도록 하자.

5. Reading And Writing, But Not Sequentially

지금까지 어떻게 파일을 읽고 쓰는지를 논의해왔는데, 이 모든 접근들은 순차적(sequential)이었다. 즉, 파일을 맨 처음부터 끝까지 읽거나 써왔다.

하지만 때로는 파일의 특정 위치에서부터 읽거나 쓸 수 있는 것이 유용할 수도 있다. 예를 들어 만약 텍스트 문서에 목차를 만들거나, 특정 단어를 찾거나 하고 싶은 경우에는 해당 문서의 임의의 위치에서 읽는 것이 좋을 것이다. 그렇게 하기 위해서는 lseek() 시스템 콜을 사용한다. 다음은 해당 함수의 프로토타입이다.

off_t lseek(int fildes, off_t offset, int whence);

첫 번째 인자는 파일 디스크립터고, 두 번째 인자는 offset으로 해당 파일의 특정 위치를 가리킨다. 세 번째는 whence라 불리는 것으로, 이떻게 탐색이 수행될지를 지정하는 데 쓰인다. man 페이지를 보면 다음의 설명을 볼 수 있다.

If whence is SEEK_SET, the offset is set to offset bytes.
If whence is SEEK_CUR, the offset is set to its current location plus offset bytes.
If whence is SEEK_END, the offset is set to the size of the file plus offset bytes.

OS는 프로세스가 여는 각 파일의 현재 오프셋을 찾는다. 이는 다음의 읽기나 쓰기가 파일 내의 어디에서 시작할지를 가리킨다. 이 현재 오프셋은 다음의 두 방식으로 업데이트될 수 있다. 첫 번째는 NN바이트 크기의 읽기나 쓰기가 일어날 때로, NN이 현재 오프셋에 더해진다. 두 번째는 lseek을 이용해 명시되는 것으로, 위에 명시된 것과 같이 오프셋을 바꾸는 것이다.

오프셋은 struct proc내의 struct file에 저장된다. 다음은 xv6에서 사용하는 해당 구조체의 정의다.

struct file {
	int ref;
	char readable;
	char writable;
	struct inode *ip;
	uint off;
};

OS는 이 구조체를 이용해 열린 파일이 읽을 수 있는지, 쓸 수 있는지(혹은 둘 다인지), 해당 파일이 참조하는 파일은 어떤 것인지, 그리고 현재 오프셋은 무엇인지를 결정하는 데 사용한다. 여기에는 참조 카운트도 있는데, 이에 대해서는 아래에서 논의할 것이다.

이 파일 구조체들은 시스템 내의 모든 현재 열린 파일들을 나타내며, open file table로 불린다. xv6 커널은 이것들을 배열처럼 관리하며, 전체 테이블을 위한 하나의 락을 둔다.

struct {
	struct spinlock lock;
	struct file file[NFILE];
} ftable;

몇 가지 예들을 보자. 우선 300 바이트 크기의 파일을 열고 한 번에 100 바이트 씩 읽는 read()를 반복적으로 호출하는 프로세스를 따라가보자. 아래는 관련 시스템 콜들과, 각 시스템 콜의 반환값, 그리고 이 파일접근에 대한 open file table의 현재 오프셋 값이 나타나있다.

여기서는 파일이 열렸을 때 현재 오프셋이 0으로 초기화되어 있음을 볼 수 있다. read()를 통해 어떻게 오프셋이 증가되는지를 볼 수 있고, 마지막으로는 끝에서 read()를 호출하고 0을 반환받음으로써 해당 프로세스가 파일을 전부 읽었음을 알 수 있게 됨도 볼 수 있다.

그런데 다른 프로세스가 같은 파일을 열고, 두 프로세스가 각각 파일을 읽는 경우에는 어떨까?

이 예에서는 두 파일 디스크립터가 할당됨을 볼 수 있다. 각각이 open file table의 서로 다른 엔트리를 가리키고 있는 것이다. 위 표를 잘 따라가보면, 어떻게 현재 오프셋이 각각 독립적으로 업데이트되고 있는지를 볼 수 있을 것이다.

아래의 마지막 예에서 프로세스는 lseeK()을 이용해 읽기 전에 현재 오프셋을 재위치시키고 있다. 이 경우에는 오직 하나의 open file table 엔트리만이 필요하다.

여기서 lseek() 콜은 우선 현재 오프셋을 200으로 설정한다. 이후에 read()는 다음 50 바이트를 읽고, 그에 따라 현재 오프셋을 갱신한다.

2개의 댓글

comment-user-thumbnail
2024년 2월 16일

[OS] 31-2.로 말머리 바꿔주세요🙏

1개의 답글