시스템 프로그래밍 8. File IO
건국대학교 시스템 프로그래밍 진현욱 교수님의 수업을 정리한 내용입니다.
Pipe
파이프는 원자성이 제대로 보장되지 않는다. 따라서 여러 쓰레드/프로세스에서 하나의 파이프를 공유해서 사용하면, 데이터가 중간에 짤릴 수 있다. 따라서 쓰레드/프로세스마다 파이프를 하나씩 할당하여 데이터를 주고받는게 안전하다.
File
보통 File이라 함은, 스토리지 공간에 저장되어 읽고 쓸 수 있는 데이터라는 인식이 일반적이다. 하지만 File은 그보다 더 추상적인 개념이다. 데이터를 담고, 읽을 수 있는 모든 것은 File이다. 스토리지에 저장되어 읽고 쓸 수 있는 Text, Binary File은 Regular File이라고 한다. Pipe, Socket은 데이터를 잠시 담아두고, 다른곳에서 읽을 수 있다. 따라서 File이다. Device I/O도 File을 통해 관리한다. 마우스나 키보드에서 입력하면 그 값이 어딘가에 저장되고, 소프트웨어에서 그 값을 불러와야 하기 때문이다. 이런 유형들의 File을 Special File라고 한다.
File Table, File Descriptor, File Entry
모든 파일은 열고 닫을 수 있다. 파일의 데이터를 읽고 쓰기 위해선 파일을 먼저 열어야 한다. 어떤 파일을 열면 File Entry가 생성되며 File Table에 File Entry가 기록된다. 각 Process의 Context는 File descriptor Table을 하나씩 갖고있다. File descriptor table이 꽉 차지 않았으면, Table에 File Table Index를 기록하고, File Table Index에 접근할 수 있는 Index값을 최종적으로 반환한다. 이 File Descriptor Table의 Index를 File Descriptor라고 부른다.
File Entry는 여러 타입의 파일을 일관적으로 다루기 위해 생성하는 객체다. Reference Count, File Offset, readable, writeable, 실제 파일 정보가 Field값으로 담겨있다. 하나의 File은 여러개의 File Entry를 통해 참조될 수 있다. 그 이유는, File을 open하면 항상 새로운 File Entry를 생성하여 File Table에 할당하기 때문이다.
정리하면, File Descriptor란 File Descriptor Table의 Index 값이다. File Descriptor Table은 Index 값이 File Descriptor이고, Key값이 File Table Index이다. File Table은 Index마다 File Entry 객체를 저장하고 있다. 따라서, File Descriptor를 알고있으면 File Descriptor Table에 접근해서 File Table Index를 알아내고, File Table에 접근해서 Index를 통해 File Entry를 얻어내서 File을 읽고 쓸 수 있다.
File Descriptor는 보통 3부터 할당해준다. 왜냐하면, 0, 1, 2는 이미 Default 값으로 할당되어있는 File Descriptor가 존재한다. File을 Open하면 File Descriptor Table을 뒤져서 할당이 되지 않은 가장 작은 Index 위치에 File Table Index를 저장한다. 최대 1024까지 할당할 수 있지만, Config를 수정하여 1,048,576개까지 늘릴 수도 있다.
Reference Count는 File Entry에서 관리하는 값이다. 정확히는, 내 File Entry를 참조하고 있는 File Descriptor 개수를 의미한다. 한 실행 Unit에서 File을 다 사용하여 File을 Close하면 바로 File Entry가 삭제되지 않는다. File Entry의 Reference Count를 1 감소시키고, FIle Descriptor Table에서 File Table Index를 삭제한다. File Table를 관리하는 Kernel 입장에서, 어떤 File Entry의 Reference Count가 0이 된다면 그때서야 File Table에서 File Entry를 빼내어 File Entry를 삭제한다. Reference Count가 증가하는 경우는 다음과 같다. Parent Process가 File을 Open해서 File Descriptor값을 가지고 있는데, 이때 Fork하게 되면 File Descriptor Table이 복사되어 File Entry를 참조하는 File Descriptor가 하나 더생긴다. 이 때 Reference Count가 증가한다.
File Open
File의 사용법은 간단한다. File을 열고, 읽거나 쓰고, 사용이 끝나면 Close하면 된다.
int open(const char *name, int flags)
int open(const char *name, int flags, mode_t mode)
int creat(const char *name, mode_t mode)
open에 성공하면 양수 값의 File Descriptor를, 실패하면 -1를 반환한다. 레귤러 파일을 열기 위해서 name에 파일의 경로를 넣고, flags는 O_RDONLY, O_WRONLY, O_RDWR 셋중 하나를 반드시 포함해야 한다. 각각 Read Only, Write Only, Read and Write 권한이다. 만약 Flag에 O_CREAT를 사용하면 세번째 인자값인 mode_t mode
를 지정해줘야 한다.
creat 함수는 정확히 int open (file, O_WRONLY|O_CREAT|O_TRUNC, mode)
와 동일하다.
[!tip]- Flag List{title}
- O_APPEND : Wrtie 작업 시 File Offset이 파일의 맨 뒤에 가도록 한다.
- O_CREAT : 파일이 존재하지 않으면 새 파일을 생성한다.
- 해당 Flag 사용시, 반드시 세번째 인자값인 mode를 제공해야 한다. 새로 생성하면 파일의 권한을 지정해줘야 한다. 그 값은 0644와 같이 8진수로 적을 수 있다.
- 앞의 0은 8진수 사용을 의미, 순서대로 소유자, 그룹, 기타 사용자의 권한을 의미힌다. 1은 실행, 2는 쓰기, 4는 읽기 권한이다. 5라고 적으면, 1+4=실행+읽기 권한을 부여하는 것과 같다.
- 0644와 같은 표기법 대신
S_IRWXU | S_IRWXG | ...
와 같은 Flag값을 조합해서 부여할 수도 있다.- 이 권한은 File Flag로 제공하는 권한과 다르다. File의 Flag로 넘기는 권한은 File Entry 접근 권한이고, 생성시 제공하는 권한은 파일 자체에서 관리하는 권한이다.
- O_EXCL : 보통 O_CREAT와 함께 사용한다. 파일이 존재하면 오류 코드를 반환한다.
- O_SYNC : 쓰기 작업이 Buffer Cache에 담지 말고 바로 디스크에 동기화 되도록 보장한다.
- O_TRUNC : 파일을 열 때 파일 내용을 초기화하고 Open한다.
File Read
ssize_t read (int fd, void *buf, size_t len)
ssize_t pread(int fd, void *buf, size_t count, off_t pos)
file offset으로부터 len 길이만큼 읽어 buf에 저장한다. read가 성공하면 len 길이만큼 file offset이 뒤로 이동한다. 반환값은 실제로 읽은 바이트 수를 반환하며, ret < len이면 원하는만큼 다 못읽었다는 뜻이고, 0을 반환하면 현재 File Offset이 EOF인 경우, -1를 반환하면 errno를 확인해야 한다. errno=EINTR이면, Signal에 의해 read가 cancel되었다는 뜻이므로, 다시 read를 시도해야 한다.
read는 아에 읽을 데이터가 없을 경우 데이터가 들어올 때까지 기다리고, 읽을 데이터가 있지만 len 길이만큼은 아니라면 일단 읽은 후 읽은 길이만큼 반환한다. 프로그래머는 반환값이 -1, 0, >0인 케이스를 모두 체크해야 한다. 반환된 값이 ret < len이라면, 원하는 방향성에 따라 len 길이만큼 확실히 받을건지 아니면 그냥 반환된 데이터에 만족할건지 결정해야 한다.
[!example]- 모든 예외상황을 고려한 Read Code{title}
1 2 3 4 5 6 7 8 9 10 11 12 ssize_t ret; while (len != 0 && (ret = read (fd, buf, len)) != 0) { if (ret == -1) { if (errno == EINTR) continue; perror ("read"); break; } len -= ret; buf += ret; }
pread() 시스템콜을 사용하면 file offset의 영향을 주지 않고 file을 읽을 수 있다. 따라서 인자값으로 offset 값을 직접 제공해야 한다.
File Write
ssize_t write (int fd, const void *buf, size_t count)
ssize_t pwrite (int fd, const void *buf, size_t count, off_t pos)
int fsync (int fd)
buf의 count 길이만큼 file offset 위치에 write한다. write가 성공하면 count 길이만큼 file offset이 뒤로 이동한다. 반환값은 실제로 작성한 바이트 수를 반환한다. -1를 반환하면 똑같이 errno를 확인해야 한다.
File Write에는 최적화 기법이 숨어있다. File을 Write할때마다 실제 디스크에 접근해서 쓰게되면 성능이 좋지 않다. 따라서 Buffer에 Write한 내용을 미리 담아두고, 운영체제가 한가할 때 Disk에 Buffer의 내용을 Flash한다. 이때 사용하는 Buffer을 Page Cache라고 부른다. 이런 방식은 성능을 개선시켜주지만, 시스템이 중간에 Shotdown되게 되면 Write한 내용이 날아가버릴 수 있다. 강제로 Disk에 Flash하는 int fsync (int fd)
시스템콜이 있긴 하지만 정말 중요한 데이터를 확실히 보장해야 할 때만 사용하는 것이 좋다.
하나의 File은 여러개의 File Entry를 통해 접근될 수 있다. 만약 여러 실행 Unit에서 동시에 File에 접근하면 어떻게 되는가? 만약 한군데서 파일 끝에 “Hello”를 쓰고, 한군데서는 파일 끝에 “World”를 동시에 쓰려고 하면 “HWelolrold” 이런식으로 뒤섞일 수 있는거 아닌가? 싶지만, 운영체제에서 최소한 뒤섞이지 않도록 내부적으로 Lock을 사용한다. 즉, “HelloWorld” 또는 “WorldHello” 등 누가 먼저 올지는 알 수 없지만, 적어도 동시에 Write하면 내용이 뒤섞이지는 않음이 보장된다.
File Close
int close (int fd)
Close()를 사용하면 File Descriptor Table에서 Descriptor를 삭제하며, File Entry의 Reference Count를 1 감소시킨다. 위에서 설명했듯, Reference Count가 0이 될 때 File Entry가 삭제되면서 File Table의 자리가 생긴다.
File Offset
off_t lseek(int fd, off_t pos, int origin)
lssek(file, 0, SEEK_CUR)
lseek(file, 0, SEEK_END)
File Entry의 File offset을 옮길 수 있는 시스템 콜이다. origin 인자에는 SEEK_CUR
, SEEK_END
, SEEK_SET
를 넣을 수 있으며, 각각 시작점을 현재 Offset에서 옮길건지, End에서 옮길건지, Start에서 옮길건지를 정한다. 반환값은 이동 후의 Offset 값을 반환한다.
lseek
함수를 응용하여, 현재 File Offset 위치나 EOF 위치를 알아낼 수도 있다.
pos에는 음수 값을 넣어서 뒤로 이동시킬 수도 있다. 예를들어, lseek(fd, -10, SEEK_SET)
와 같이 옮길 수 없는 위치로 이동을 요청하면 어떻게 되는가? 그냥 -1
을 반환한다. lseek(fd, 10, SEEK_END
도 -1
을 반환할까? 이 경우는 좀 다르다. 실제로 File Offset이 옮겨지며, 그 사이는 NULL값으로 채워진다. 그 사이의 빈 공간을 File Hole라고 하며, 실제로 저장 공간을 채우지는 않는다.
Character Device, Block Device
Device File은 Character Device, Block Device가 있음. Character Device는 입력과 출력이 문자열 단위로 주고받는 Device를 의미함. 예를들어 키보드가 있다. Block Device는 Lower Level에서 입출력을 Block 단위로 주고받는 것을 뜻함. 블록이란, 고정된 단위 512Byte, 4KB로 주고 받는것을 뜻함. 하드디스크, SDD, USB 등이 저수준에서는 블록 단위로 읽고 쓰게 된다.