개발 공부

손승열(Son Seungyeol)

[운영체제][OSTEP] 파일 시스템 구현

간단한 파일 시스템을 만드는 방법에 대해 공부합니다.

손승열(Son Seungyeol)
[운영체제][OSTEP] 파일 시스템 구현

🚪 들어가며

이번 장에서는 vsfs(Very Simple File System)라고 하는 간단한 파일 시스템 구현에 대해 알아보자.
➡️ Unix 파일 시스템을 단순화한 것
➡️ 디스크 자료 구조(on-disk structure)와 접근 방법, 다양한 파일 시스템들의 정책을 소개하기 위해 제작됨.

파일 시스템을 구현하는 다양한 방법이 존재
➡️ AFS(Andrew 파일 시스템)부터 ZFS(Sun의 Zettabyte(10^21) 파일 시스템)까지 매우 다양한 파일 시스템들이 개발됨.

모든 파일 시스템들이 서로 다른 자료 구조를 갖고 있으며 각각은 장단점이 있음.


1️⃣ 생각하는 방법

파일 시스템에 대해 학습할 때, 두 가지 측면에서 접근해보도록 하자.

[ 자료 구조 ]

이번 장에서 다룰 (vsfs를 포함한) 파일 시스템은 블럭과 다른 객체들을 배열과 같은 간단한 자료 구조로 표현

SGI의 XFS와 같은 파일 시스템들은 좀 더 복잡한 트리 기반의 자료 구조를 사용

[ 접근 방법 ]

  • 프로세스가 호출하는 open(), read(), write() 등의 명령들은 파일 시스템의 자료 구조와 어떤 관련이 있는지

  • 특정 시스템 콜을 실행할 때에 어떤 자료 구조들이 읽히고 쓰이는지

  • 이 모든 과정이 얼마나 효율적으로 동작하는지

에 대해 생각해볼 수 있다.


2️⃣ 전체 구성

vsfs 파일 시스템의 자료 구조에 대해 디스크 상의 전체적인 구성을 개발해보자.

가장 먼저 해야 할 것은 디스크를 블럭(block)들로 나누는 것

간단한 파일 시스템은 단일 블럭 크기만 사용
➡️ 일반적으로 사용되는 크기: 4KB

N개의 4KB 블럭의 크기를 갖는 파티션에서 블럭은 0부터 N-1까지의 주소를 갖고 있다.

이때 블럭이 64개 있는 작은 디스크를 가정하자.

[- - - - - - - -][- - - - - - - -][- - - - - - - -][- - - - - - - -]
 0             7  8             15 16            23 24            31 

[- - - - - - - -][- - - - - - - -][- - - - - - - -][- - - - - - - -]
 32            39 40            47 48            55 56            63

💡 파일 시스템의 대부분의 공간은 사용자 데이터로 이루어져 있음.

사용자 데이터가 있는 디스크 공간을 데이터 영역(data region)이라고 하자.

64개의 디스크 블럭 중 마지막 56개의 블럭처럼 디스크의 일정 부분을 데이터 영역으로 확보하자.

[- - - - - - - -][D D D D D D D D][D D D D D D D D][D D D D D D D D]
 0             7  8             15 16            23 24            31 

[D D D D D D D D][D D D D D D D D][D D D D D D D D][D D D D D D D D]
 32            39 40            47 48            55 56            63

파일 시스템은 각 파일에 대한 정보를 관리함.
➡️ 메타데이터(metadata)

다음과 같은 정보가 이에 해당됨.

  • 파일을 구성하는 (데이터 영역 내의) 데이터 블럭

  • 파일의 크기

  • 소유자

  • 접근 권한

  • 접근 및 변경 시간

  • 등등 ...

파일 시스템은 이 정보를 보통 아이노드(inode)라고 부르는 자료 구조에 저장

아이노드들의 저장을 위해 디스크 공간이 필요
➡️ 아이노드 테이블(inode table)
➡️ 아이노드들이 배열 형태로 저장

위의 전체 64개의 블럭 중 5개의 블럭이 아이노드를 저장하기 위해 할당된 경우 다음과 같다.

[- - - I I I I I][D D D D D D D D][D D D D D D D D][D D D D D D D D]
 0             7  8             15 16            23 24            31 

[D D D D D D D D][D D D D D D D D][D D D D D D D D][D D D D D D D D]
 32            39 40            47 48            55 56            63

💡 아이노드는 일반적으로 128에서 256 bytes 정도로 그렇게 크지 않음.
➡️ 아이노드당 256 bytes를 가정하면 4KB 블럭에는 16개의 아이노드를 저장할 수 있음.

위의 예시의 경우 총 80개의 아이노드를 저장할 수 있음.

같은 파일 시스템이라도 더 큰 디스크 파티션에 생성한다면 아이노드 테이블을 더 크게 만들 수 있기 때문에 생성가능한 최대 파일 개수를 증가시킬 수 있음.

현재 위의 예시에는 데이터 블럭(D)과 아이노드 블럭(I)이 존재
➡️ 각 블럭이 현재 사용 중인지 아닌지를 표현할 할당 구조(allocation structure)가 필요

블럭이 사용 중인지 아닌지를 표현하는 데에는 다양한 방법이 존재
➡️ 예시로 프리 리스트(free list)를 사용하여 사용 중이 아닌 블럭들을 linked list 형태로 관리할 수 있음.

💡 아이노드는 첫 번째 프리 블럭의 위치만 기억하면 됨.
➡️ 다음 프리 블럭의 위치는 각 프리 블럭에서 정해진 위치에 기록

블럭들의 사용여부를 표현하기 위해 단순한 비트맵(bitmap)을 사용해보자.

  • 데이터 비트맵(data bitmap): 데이터 영역에 있는 블럭들의 사용여부 표현

  • 아이노드 비트맵(inode bitmap): 아이노드 테이블에 있는 아이노드들이 사용 중인지를 나타냄.

비트맵은 비트들의 배열임.
➡️ 각 비트는 해당 블럭이나 객체가 사용 중인지(1) 아닌지(0)를 나타냄.

[- i d I I I I I][D D D D D D D D][D D D D D D D D][D D D D D D D D]
 0             7  8             15 16            23 24            31 

[D D D D D D D D][D D D D D D D D][D D D D D D D D][D D D D D D D D]
 32            39 40            47 48            55 56            63

남은 한 블럭은 슈퍼블럭(superblock)을 위한 공간임.
➡️ 슈퍼블럭은 이 파일 시스템 전체에 대한 정보를 담고 있음.

슈퍼블럭은 다음과 같은 정보들을 담고 있음.

  • 아이노드와 데이터 블럭의 개수

  • 아이노드 테이블의 시작 위치(예시의 경우 블럭 3번)

  • 파일 시스템을 식별할 수 있는 매직 넘버(예시의 경우 vsfs)

  • 등등 ...

파일 시스템이 깨진다는 것은 슈퍼블럭이 저장된 디스크 블럭이 훼손되는 것
➡️ 일반적으로 대부분의 파일 시스템은 슈퍼블럭을 몇 개 복사해둠.

파일 시스템을 마운트할 때, 운영체제는 우선 슈퍼블럭을 읽어들여서 파일 시스템의 여러가지 요소들을 초기화함.

그 후 각 파티션을 파일 시스템 트리에 붙이는 작업을 진행

✅ 이를 통해 디스크 볼륨에 있는 파일들을 접근할 때, 해당 파일을 읽거나 쓰는 데 필요한 자료 구조의 위치를 파악


3️⃣ 파일 구성: 아이노드

파일 시스템의 디스크 자료 구조 중 가장 중요한 것은 아이노드(inode)
➡️ 💡 인덱스 노드(index node)의 줄임말(Unix의 창시자인 Ken Thompson이 처음 사용)

거의 모든 파일 시스템들이 비슷한 구조를 가짐.

이 노드들은 원래 배열로 되어 있었음.
➡️ 각 배열은 특정 아이노드를 접근하기 위해 탐색됨.

각 아이노드는 숫자(아이넘버(inumber)라고 불림)로 표현
➡️ 앞 장에서는 파일의 저수준 이름(low-level name)이라고 불렀었음.

vsfs를 비롯한 간단한 파일 시스템에서는 아이넘버를 사용하여 해당 아이노드가 디스크상 어디에 있는지를 직접적으로 계산할 수 있음.

아이노드 블럭의 섹터 주소 iaddr은 다음과 같이 계산될 수 있음.

blk = (inumber * sizeof(inode_t)) / blockSize;
sector = ((blk * blockSize) + inodeStartAddr) / sectorSize;

아이노드에는 파일에 대한 정보가 다 들어 있음. = 메타데이터(metadata)

  • 파일의 종류(일반 파일, 디렉터리 등)

  • 크기

  • 할당된 블럭 수

  • 보호 정보(파일의 소유, 접근 권한 등)

  • 시간 정보

  • 데이터 블럭이 디스크 어디에 존재하는지(포인터의 일종)

💡 아이노드 설계 시 가장 중요한 결정 중 하나는 데이터 블럭의 위치를 표현하는 방법
➡️ 간단한 방법은 아이노드 내에 여러 개의 직접 포인터(direct pointer, 디스크 주소)를 두는 것

직접 포인터 방식에서 각 포인터는 파일의 디스크 블럭 하나를 가리킴.
➡️ ⚠️ 단 파일 크기가 (포인터의 개수)*(블럭 크기)로 제한됨.

멀티 레벨 인덱스

큰 파일을 지원하기 위해서 파일 시스템 개발자들은 아이노드 내에 다른 자료 구조를 추가해야 했음.
➡️ 일반적으로 사용되는 방법 중 하나는 간접 포인터(indirect pointer)라는 특수한 포인터를 사용하는 것

간접 포인터는 데이터 블럭을 가리키지 않음.
➡️ 간접 포인터가 가리키는 블럭에는 데이터 블럭을 가리키는 포인터들이 저장됨.

💡 직접 포인터와 간접 포인터를 결합해서 사용할 수 있음.

아이노드에는 정해진 수의 직접 포인터와 하나의 간접 포인터가 있음.
➡️ 큰 파일에 대해서는 간접 블럭이 (디스크의 데이터 블럭 영역에서) 할당됨.
➡️ 아이노드의 간접 포인터는 이 간접 블럭을 가리킴.

블럭이 4KB이고 디스크 주소가 4바이트라고 하면 1024개의 포인터들을 추가할 수 있게 됨.
➡️ 최대 파일 크기는 (12 + 1024)*4K 또는 4144KB가 됨.

💡 더 큰 파일을 저장하고 싶은 경우 아이노드에 이중 간접 포인터(double indirect pointer)를 추가함.
➡️ 이중 간접 포인터가 가리키는 블럭에는 간접 포인터들이 저장되어 있음.

이중 간접 블럭을 사용하면 파일은 4KB*1024*1024, 약 백만개의 4KByte 블럭을 가질 수 있음.

더 큰 파일을 표현해야 한다면, 삼중 간접 포인터(triple indirect pointer)를 사용하면 됨.

💡 디스크 블럭들은 일종의 트리 형태로 구성되어 하나의 파일을 이룸.
➡️ 약간 한쪽으로 치우쳐진 형태의 트리
➡️ ✅ 이러한 구성방식을 멀티 레벨 인덱스 기법이라고 함.

💡 포인터를 사용하는 대신 익스텐트(extent) 방식을 사용할 수도 있음.
➡️ 익스텐트는 단순히 디스크 포인터와 길이(블럭 단위)로 이루어짐.
➡️ 가상 메모리의 세그멘트와 유사
➡️ 포인터 기반 접근법은 가장 자유도가 높지만 각 파일당 많은 양의 메타데이터를 사용
➡️ 익스텐트 기반의 접근법은 자유도가 낮은 대신에 좀 더 집약되어 있음.(디스크에 여유 공간이 충분히 있고 파일들을 연속적으로 배치할 수 있을 때 잘 동작함.)

일반적으로 사용되는 파일 시스템인 Linux의 ext2와 ext3, NetApp의 WAFL을 포함하여 많은 파일 시스템들이 멀티 레벨 인덱스를 사용함.

SGI의 XFS와 Linux의 ext4와 같은 파일 시스템들은 간단한 포인터를 사용하는 대신 익스텐트를 사용함.

💡 포인터 기반 방법의 경우 트리의 형태가 매우 편향적임.
➡️ 파일의 시작 부분을 이루는 블럭들은 한 번의 포인터로 접근이 가능
➡️ 큰 파일의 경우, 파일의 끝 부분에 있는 블럭들은 포인터를 세 번 따라가야 실제 블럭을 읽을 수 있음.

✅ 이는 실수가 아니라 오랜 고민과 연구의 결과임.
➡️ "대부분의 파일의 크기는 작다"는 것

대부분의 파일들이 작다면, 작은 파일을 빨리 읽고 쓸 수 있도록 파일구조를 설계해야 함.
➡️ vsfs의 아이노드는 첫 12개의 블럭들은 빨리 읽을 수 있도록 직접 포인터(대체적으로 12개)를 갖고 있음.

최근의 연구 결과는 다음을 참고하자.

  • 대부분의 파일들의 크기가 작음.

    ➡️ 일반적인 파일 크기는 대략 2KB

  • 평균 파일 크기가 커지고 있음.

    ➡️ 평균 파일 크기는 거의 200KB

  • 큰 파일들이 대부분의 바이트를 사용함.

    ➡️ 몇 개의 큰 파일이 공간의 대부분을 채우고 있음.

  • 파일 시스템에는 많은 파일들이 있음.

    ➡️ 평균적으로 거의 100K가 있음.

  • 파일 시스템은 대략 반쯤 사용됨.

    ➡️ 디스크 용량이 커져도 대략 파일 시스템의 50%만 사용됨.

  • 디렉터리의 크기가 대체적으로 작음.

    ➡️ 대부분 20개 또는 그 이하의 적은 항목을 가짐.

✅ 데이터와 관련 내용들을 효율적으로 읽고 쓸 수 있다면, 위에서 다룬 방법말고도 어떤 방식도 사용 가능


4️⃣ 디렉터리 구조

디렉터리는 (항목의 이름, 아이노드 번호) 쌍의 배열로 구성되어 있음.

디렉터리의 데이터 블럭에는 문자열과 숫자가 쌍으로 존재하며 문자열 길이에 대한 정보도 있음.
➡️ 가변 길이의 이름을 가정함.

다음과 같은 dir 디렉터리를 가정해보자.

inum  │  reclen  │  strlen  │  name
  5         4          2       .
  2         4          3       ..
 12         4          4       foo
 13         4          4       bar
 24         8          7       foobar

각 항목은 다음과 같다.

  • 아이노드 번호

  • 레코드 길이(이름에 사용된 총 바이트와 남은 공간의 합)

  • 문자열 길이(실제 이름의 길이)

  • 항목의 이름

파일이 삭제되면(예, unlink() 호출) 디렉터리 중간에 빈 공간이 발생
➡️ 영역이 비었다는 것을 표시할 방법이 필요
➡️ 예를 들어, 0번 아이노드는 삭제된 파일을 나타내기로 약속한 후 아이노드 번호 0으로 쓰는 식

💡 항목의 길이를 명시하는 이유 중 하나가 중간에 빈 공간이 생기기 때문
➡️ 새로운 디렉터리 생성 시 기존 항목이 삭제되어 생긴 빈 공간에 새로이 생성된 항목을 위치시킬 수도 있기 때문

대부분의 파일 시스템에서 디렉터리는 특수한 종류의 파일로 간주
➡️ 디렉터리는 자신의 아이노드를 가지며, 이 아이노드는 아이노드 테이블에 존재
➡️ 디렉터리는 자신의 데이터 블럭을 갖고 있으며, 이들 블럭의 위치는 일반 파일과 마찬가지로 아이노드에 명시되어 있음.

XFS는 디렉터리를 B-tree 형식으로 구성함.

파일 생성 시 현재 디렉터리에 동일한 이름의 파일이 있는지를 먼저 검사해야 함.
➡️ 디렉터리 항목들이 선형배열로 구성되어 있는 경우, 항목들을 모두 검색해야 함.
➡️ B-tree와 같은 검색 전용 자료구조를 사용할 경우, 검색시간이 단축됨.


5️⃣ 빈 공간의 관리

파일 시스템은 아이노드와 데이터 블럭 사용 여부를 관리해야 함.
➡️ 그래야 새로운 파일이나 디렉터리를 할당할 공간을 찾을 수 있음.

빈 공간 관리(free space management)는 모든 파일 시스템에서 중요
➡️ vsfs에서는 두 개의 비트맵을 사용

파일 생성 시 아이노드를 할당해야 함.
➡️ 파일 시스템은 아이노드 비트맵을 탐색하여 비어 있는 아이노드를 할당해야 함.
➡️ 파일 시스템은 해당 아이노드를 사용 중으로 표기하고(1로 표기) 디스크 비트맵도 적절히 갱신함.
➡️ 이와 유사한 동작이 데이터 블럭을 할당할 때에도 일어남.

데이터 블럭 할당 시 고려해야 할 사항이 또 있음.

ext2, ext3와 같은 Linux 파일 시스템의 경우 데이터 블럭 할당 시 가능하면 여러 개의 블럭들이 연속적으로 비어 있는 공간을 찾아서 할당함.
➡️ 추후에 할당요청이 발생하면 기존에 할당된 공간에 이어서 블럭을 할당하기 위함.
➡️ 연속적으로 여러 개의 블럭들이 비어있는 공간을 할당함으로써 해당 파일에 대한 입출력 성능을 개선
➡️ 이러한 선할당(pre-allocation) 정책은 데이터 블럭 할당 시 자주 사용됨.


6️⃣ 실행 흐름: 읽기와 쓰기

파일을 읽고 쓰는 실행 과정(access path)을 이해하는 것은 파일 시스템 동작을 완전히 이해하는 매우 중요한 두 번째 요인이다.

다음의 예시에서 파일 시스템은 마운트되었고 슈퍼블럭은 메모리 상에 위치한다고 가정하자.

또한 아이노드, 디렉터리 등 다른 모든 것들은 디스크에 존재한다고 하자.

디스크에서 파일 읽기

/foo/bar라는 파일을 단순히 열고, 읽고, 닫는 상황을 가정해보자.

open("/foo/bar", O_RDONLY) 시스템 콜을 하면 파일 시스템은 먼저 파일 bar에 대한 아이노드를 찾아서 파일에 대한 기본적인 정보를 획득해야 함.
➡️ 파일 시스템은 경로명을 따라가서(traverse) 원하는 아이노드를 찾아야 함.

경로명을 따라가는 것은 항상 파일 시스템의 루트에서 시작하며, 루트 디렉터리(root directory)는 /로 표기됨.
➡️ 파일 시스템이 가장 먼저 읽을 것은 루트 디렉터리의 아이노드임.

💡 아이노드를 찾기 위해서는 i-number를 알아야 함.
➡️ 일반적으로 어떤 파일이나 디렉터리의 i-number는 부모 디렉터리에서 찾을 수 있음.
➡️ 루트는 (정의상) 부모가 없음.
➡️ 루트 i-number는 "잘 알려진" 것이어야 함.
➡️ 파일 시스템이 마운트될 때 이 값이 결정됨.

✅ 대부분의 Unix 파일 시스템에서는 루트 디렉터리의 아이노드 번호는 2번임.

파일 시스템은 읽어들인 아이노드에서 데이터 블럭의 포인터를 추출함.
➡️ 파일 시스템은 이 포인터들을 사용하여 디렉터리 정보를 읽고, foo라는 항목을 찾음.

디렉터리에 많은 파일이 들어있는 경우, 모든 항목을 하나의 블럭에 저장할 수 없으므로 하나의 디렉터리를 표현하기 위해 다수의 블럭이 사용됨.
➡️ 하나 또는 그 이상의 디렉터리 데이터 블럭을 읽어서 foo에 대한 항목을 찾을 수 있음.

foo 파일의 디렉터리 항목을 찾아서 foo의 아이노드 번호를 파악함.
➡️ 파일 시스템은 foo의 아이노드가 있는 블럭과 그에 대한 디렉터리 데이터를 읽은 후에 마침내 bar에 대한 아이노드 번호를 찾아냄.

그 후 open()을 통해 bar에 대한 아이노드를 메모리로 읽어 들임.
➡️ 파일 시스템은 최종적으로 해당 파일에 대한 접근 권한을 확인하고 이 프로세스의 open file table에서 파일 디스크립터를 할당받아 사용자에 리턴함.

open() 이후 read() 시스템 콜을 통해 파일을 읽음.
➡️ 파일을 읽은 후 파일을 마지막으로 읽은 시간을 아이노드에 기록함.

read()는 open file table에서 해당 파일 디스크립터에 대한 오프셋을 갱신함.
➡️ 💡 파일 오프셋은 파일을 읽거나 쓸 때, 해당 작업을 수행할 위치를 저장하는 변수
➡️ 다음에 읽기 작업 수행 시 이전에 읽었던 다음 위치부터 읽도록 할 것임.

어느 시점이 되면 할당된 파일 디스크립터를 해제함으로써 파일을 닫음.
➡️ 디스크 I/O는 발생하지 않음.

이를 정리하면 다음과 같음.

           │  data   inode  │  root   foo    bar  │ root  foo   bar   bar   bar
           │ bitmap  bitmap │ inode  inode  inode │ data  data  data  data  data
           │                │                     │             [0]   [1]   [2]
───────────┼────────────────┼─────────────────────┼──────────────────────────────────
           │                │  read               │ 
           │                │                     │ read
 open(bar) │                │         read        │
           │                │                     │       read
           │                │                read │
───────────┼────────────────┼─────────────────────┼──────────────────────────────────
           │                │                read │
  read()   │                │                     │             read
           │                │               write │
───────────┼────────────────┼─────────────────────┼──────────────────────────────────
           │                │                read │
  read()   │                │                     │                   read
           │                │               write │
───────────┼────────────────┼─────────────────────┼──────────────────────────────────
           │                │                read │
  read()   │                │                     │                         read
           │                │               write │

⚠️ I/O 발생 횟수는 경로의 길에 비례하는 것을 유의하여 보자.

경로가 하나 추가될 때마다 아이노드와 해당하는 데이터를 읽어야 함.

디스크에 쓰기

디스크 쓰기도 비슷한 과정을 밟음.
➡️ 먼저 파일을 열고, 응용 프로그램이 write()를 호출하여 새로운 내용으로 파일을 갱신한 뒤, 최종적으로 파일을 닫음.

⚠️ 읽기와는 다르게 파일 쓰기는 블럭 할당을 필요로 할 수 있음.
➡️ 기존 블럭의 내용이 갱신되는 것이 아닌 경우

새로운 파일에 쓸 때, 각 write()는 데이터를 디스크에 기록해야 할 뿐만 아니라 파일에 어느 블럭을 할당할지를 결정해야 함.
➡️ 그에 따라 디스크에 다른 자료 구조들(데이터 비트맵과 아이노드 등)을 갱신해야 함.

✅ 파일에 대한 쓰기 요청은 논리적으로 다섯 번의 I/O를 생성함.

이는 다음과 같음.

  • 데이터 비트맵을 읽기 위한 I/O

    ➡️ 이후에 블럭이 사용됨에 따라 새롭게 할당된 블럭을 사용 중으로 표시하기 위해 갱신됨.

  • 비트맵을 쓰기 위한 I/O

    ➡️ 디스크에 새로운 상태를 반영하기 위함.

  • 아이노드를 읽기 위한 I/O

  • 아이노드를 쓰기 위한 I/O

    ➡️ 새로운 블럭의 위치를 반영하기 위해 갱신됨.

  • 실제 블럭을 기록하기 위한 I/O

⚠️ 파일 생성과 같은 단순 작업에서 꽤 많은 양의 쓰기가 발생
➡️ 아이노드 할당, 새로운 파일을 위한 디렉터리 항목 할당 등

이를 구체적으로 살펴보면 다음과 같음.

  • 아이노드 비트맵을 읽기 위한 I/O

    ➡️ 프리 아이노드를 찾기 위함.

  • 아이노드 비트맵에 쓰기 위한 I/O

    ➡️ 할당되었다는 것을 표시하기 위함.

  • 새로운 아이노드 자체를 쓰기 위한 I/O

    ➡️ 초기화하기 위함.

  • 디렉터리의 데이터 블럭에 쓰기 위한 I/O

    ➡️ 아이노드 이름과 상위 수준의 이름을 연결시키기 위함.

  • 디렉터리 아이노드를 읽기 위한 I/O

  • 디렉터리 아이노드를 갱신하기 위한 (쓰기) I/O

만약 새로운 파일을 저장하기 위해서 디렉터리 크기가 증가하게 되면, I/O가 추가됨.
➡️ 데이터 비트맵과 새로운 디렉터리 블럭을 위해서

/foo/bar를 생성하고 그 안에 세 개의 블럭을 쓰는 경우 다음과 같이 동작함.

           │  data   inode  │  root   foo    bar  │ root  foo   bar   bar   bar
           │ bitmap  bitmap │ inode  inode  inode │ data  data  data  data  data
           │                │                     │             [0]   [1]   [2]
───────────┼────────────────┼─────────────────────┼──────────────────────────────────
           │                │  read               │ 
           │                │                     │ read
           │                │         read        │
           │                │                     │       read
  create   │          read  │                     │
(/foo/bar) │         write  │                     │
           │                │                     │       write
           │                │                read │
           │                │               write │
           │                │         write       │ 
───────────┼────────────────┼─────────────────────┼──────────────────────────────────
           │                │                read │
           │  read          │                     │             
  write()  │  write         │                     │
           │                │                     │             write
           │                │               write │ 
───────────┼────────────────┼─────────────────────┼──────────────────────────────────
           │                │                read │
           │  read          │                     │             
  write()  │  write         │                     │
           │                │                     │                    write
           │                │               write │ 
───────────┼────────────────┼─────────────────────┼──────────────────────────────────
           │                │                read │
           │  read          │                     │             
  write()  │  write         │                     │
           │                │                     │                           write
           │                │               write │ 

경로명을 따라가서 마침내 파일을 생성하기까지 10번의 I/O, 각 write() 마다 5번의 I/O를 발생시킴.

write()는 새로운 파일 블럭을 필요로 함.
➡️ 이를 흔히 allocating write라 함.

🤔 어떻게 이 전체 과정을 효율적으로 만들 수 있을까?


7️⃣ 캐싱과 버퍼링

파일을 읽고 쓸 때 발생하는 많은 I/O는 컴퓨터의 전체 성능에 중요한 영향을 미침.
➡️ 성능개선을 위해 대부분의 파일 시스템들은 자주 사용되는 블럭들을 메모리(DRAM)에 캐싱함.

캐싱을 하지 않는다면 파일을 여는 동작은 디렉터리 레벨마다 최소한 두 번의 읽기가 필요함.
➡️ 디렉터리 아이노드 읽기와 디렉터리 데이터 읽기

가상 메모리에서 논의했던 LRU를 비롯한 캐시 교체 정책들이 캐시에 어떤 블럭들을 남길지 결정해야 함.

초기 파일 시스템에서 자주 사용되는 블럭들을 저장하기 위해 도입하였던 고정 크기의 캐시는 일반적으로 부팅 시에 할당되며 전체 메모리의 약 10%를 차지함.
➡️ 하지만, 이런 메모리의 정적 기법은 낭비가 많음.
➡️ 파일 캐시에서 사용되지 않은 페이지들은 다른 목적을 갖도록 용도 변경을 할 수 없으므로 낭비됨.

💡 현대의 시스템은 동적 파티션 방식을 사용함.

현대의 많은 운영체제는 가상 메모리 페이지들과 파일 시스템 페이지들을 통합하여 일원화된 페이지 캐시(unified page cache)를 만들었음.
➡️ 어느 한 시점에 메모리 수요에 따라 파일 시스템과 가상 메모리에 좀 더 융통성 있게 메모리를 할당할 수 있음.

캐싱을 하는 경우 파일 열기 시의 상황은 다음과 같다.

  • 첫 번째 열기: 디렉터리 아이노드와 데이터로 인해서 많은 읽기 I/O가 발생됨.

  • 그 뒤의 같은 파일 또는 같은 디렉터리의 파일들 열기: 캐시에서 히트되므로 추가 I/O가 필요 없음.

다음으로 쓰기 캐싱에 대한 영향력을 살펴보자.

캐시가 충분히 크면 대부분의 읽기 I/O를 제거할 수 있음.

쓰기는 영속성을 유지하기 위해서 해당 블럭들을 디스크로 내려야 함.

캐시는 쓰기 시점을 연기하는 역할을 함.
➡️ 이를 쓰기 버퍼링(write buffering)이라 함.

쓰기 버퍼링을 통해 얻을 수 있는 성능 이득은 다음과 같음.

  • 쓰기 요청을 지연시켜 다수의 쓰기 작업들을 적은 수의 I/O로 일괄처리(batch)할 수 있음.

  • 여러 개의 쓰기 요청들을 모아둠으로써 다수의 I/O들을 스케줄하여 성능을 개선할 수 있음.

  • 지연시키는 것을 통해 쓰기 자체를 피할 수도 있음.

    ➡️ 컴파일러가 컴파일 과정에서 수많은 임시파일을 생성하고 삭제하는 경우 등

위와 같은 이유로 대부분의 파일 시스템들은 쓰기 요청을 메모리에 약 5초에서 30초 정도동안 버퍼링함.
➡️ 🚫 쓰기 요청들이 디스크에 기록되기 전에 시스템이 크래시되면 내용 손실 발생

💡 데이터베이스 등 버퍼링으로 인해 발생하는 문제점을 용납하지 않는 응용 프로그램들도 존재
➡️ 쓰기 버퍼링으로 인한 예기치 않은 데이터 유실을 피하기 위해 fsync()를 사용
➡️ 캐시를 사용하지 않도록 direct I/O 인터페이스를 사용하거나 디스크(raw disk) 인터페이스를 사용하여 파일 시스템을 건너뛰고 직접 디스크에 기록하는 경우도 있음.


📚 참고 문헌

Operating Systems: Three Easy Pieces ― 40: File System Implementation

운영체제 아주 쉬운 세 가지 이야기 ― 43: 파일 시스템 구현

관련있는 게시물

[운영체제][OSTEP] 파일과 디렉터리
개발 공부

[운영체제][OSTEP] 파일과 디렉터리

운영체제가 영속 장치를 관리하는 방법에 대해 공부해봅니다.

손승열(Son Seungyeol)
손승열(Son Seungyeol)
[운영체제][OSTEP] 지역성과 Fast File System
개발 공부

[운영체제][OSTEP] 지역성과 Fast File System

성능 개선을 위해 디스크 특성을 파일 시스템에 반영한 FFS에 대해 공부해봅니다.

손승열(Son Seungyeol)
손승열(Son Seungyeol)

Made with React, Gatsby and DatoCMS by @smastrom

Contribute or star on GitHub

© 2022-2023 손승열 for 🅒🅞🅝🅣🅔🅝🅣🅢

모두 좋은 하루 보내세요! 😊