리눅스시스템프로그래밍

리눅스 시스템 프로그래밍 챕터 2

ch2. 파일 입출력

Overview

유닉스에서는 모든 것을 파일로 표현하기 때문에 파일 입출력은 매우 중요한 부분이다.

int fd = open("/home/amazingguni/asdf.txt", O_RDWR);

unsigned long word;
read(fd, &word, sizeof(unsigned long));

const char *buf = "hi everyone";
write(fd, buf, strlen(buf));

파일은 읽거나 쓰기 전에 반드시 열어야 한다. 이 때 커널은 프로세스별로 파일 테이블을 관리한다.

file descriptor

프로세스에서 명시적으로 닫지 않는다면 모든 프로세서는 아래 3개의 fd를 열어두고 있다.

사용자는 이런 표준 fd를 리다이렉트하거나 파이프를 사용해서 한 프로그램의 출력을 다른 프로그램의 입력으로 리다이렉트할 수도 있다.

file descriptor

fd 는 단순히 일반 파일만 나타내지는 않는다.

기본적으로 자식 프로세스는 부모 프로세스가 소유한 파일 테이블의 복사본을 상속받는다.

2.1 파일 열기

파일을 여는 시스템콜은 open(), creat() => (오타 아님)이며, 다 쓴 다음에는 close() 시스템 콜로 파일을 닫아야 한다.

2.1.1 open()

open() 시스템 콜을 사용해서 파일을 열고 fd를 얻는다.

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

int open (const char *name, int flags);
int open (const char *name, int flags, mode_t mode);

경로 이름이 name인 파일을 fd에 맵핑하고, 성공하면 이 fd를 반환한다.

파일 오프셋은 시작 지점인 0으로 설정되며 flags로 지정된 플래그에 대응하는 접근 모드로 열린다.

open() 플래그

flags 인자는 O_RDONLY, O_WRONLY, O_RDWR 중 하나를 포함해야 한다.

아래는 읽기 전용으로 파일을 여는 예시다.

int fd = open("/home/amazingguni/asdf.txt", O_RDONLY);
if(fd == -1)
	/* error */
	
int fd = open(file, O_WRONLY | O_CREAT | O_TRUNC, 0664);

당연히 읽기 전용에서는 쓰지 못하고 쓰기 전용에서는 읽지 못한다.

flags 매개 변수에 비트 OR 연산으로 다음 값 중 하나 이상을 추가해서 열기 동작을 변경할 수 있다.

int fd = open("/home/asdf/notexist.txt", O_WRONLY | O_TRUNC);
if(fd == -1)
	/* 파일이 없을 경우 에러가 발생한다. */

2.1.2 새로운 파일의 소유자

파일 소유자의 uid는 파일을 생성한 프로세스의 euid(유효 uid)이다.

소유 그룹은 파일을 생성한 프로세스의 egid(유효 gid)로 설정한다.

2.1.3 새로운 파일의 권한

파일을 생성하는 경우에는 mode 인자에 있는 권한으로 설정되고, 아닌 경우에는 무시된다.

mode에는 아래 상수 집합이 쓰인다.

int fd = open(file, O_WRONLY | O_CREAT | O_TRUNC, 
	S_IWUSR | S_IRUSR | S_IWGRP | S_IRGRP | S_IROTH);
if(fd == -1)
	/* error */
	
int fd = open(file, O_WRONLY | O_CREAT | O_TRUNC, 0664);

2.1.4 creat() 함수

O_WRONLY | O_CREAT | O_TRUNC 조합이 자주 사용되서 이를 지원하는 함수가 따로 있다.

int creat (const char *name, mode_t mode);

왠지 create여야 할 거 같지만 정확한 이름이다. 켄 톰슨(유닉스의 아버지)는 유닉스 설계했을 때 e를 빼먹은 게 가장 후회되는 일이라고 농담했다고 한다.. 뭐라는거람

int fd = creat(filename, 0644);
if(fd == -1)
	/* error */

// 위와 동일하다
int fd = open(file, O_WRONLY | O_CREAT | O_TRUNC, 0644);

2.1.5 반환값과 에러코드

open(), creat() 는 성공하면 파일 디스크립터(fd)를 반환한다. 에러가 발생하면 둘 다 -1을 반환한다.

2.1 read()로 읽기

read() 시스템 콜은 파일을 읽을 때 사용한다.

ssize_t read(int fd, void *buf, size_t len);
  1. fd가 참조하는 파일의 현재 파일 오프셋에서 len 바이트만큼 buf로 읽어 들인다.
  2. 성공하면 buf에 쓴 바이트 숫자를 반환하며, 실패하면 -1을 반환한다.
  3. 연산 이후에 파일 오프셋은 fd에서 읽은 바이트 크기만큼 전진한다.

기본 사용법은 아래와 같다.

unsigned long word;

ssize_t nr = read(fd, &word, sizeof(unsigned long));
if(nr == -1)
	/* error */

이게 가장 단순한 구현인데 이 코드에는 두가지 문제점이 있다.

  1. len 바이트만큼 모든 데이터를 읽지 못할 경우
  2. 점검 후 처리 과정이 빠져 있는 것

2.2.1 반환값

  1. read()는 buf에 쓴 바이트 크기를 반환한다.
  2. 0을 리턴하는 경우는 read()가 파일 끝(EOF)을 만났을 때이다.
  3. 에러가 발생하면 -1을 반환한다.
  4. read()가 len보다 작은 양수값을 반환하는 경우는 아래 이유가 있을 수 있다.
    • len 바이트보다 적은 바이트만 사용 가능
    • 시그널이 시스템 콜을 중단
    • 파이프가 깨지는 경우(fd가 파이프라면)

만약 read()를 했는데도 읽을 데이터가 없을 경우 읽을 바이트가 생길 때까지 블록된다.

정리하자면 read() 호출은 다음과 같은 가능성을 가지고 있다.

  1. 호출이 len과 같은 값을 반환
    • len 바이트 전체를 읽어서 buf에 저장했다.
    • 의도한 결과
  2. 호출이 len보다 작지만 0보다는 큰 값을 반환, 읽은 바이트는 buf에 저장됨
    • 시그널이 중간에 읽기를 중단시켰거나, 에러가 발생해서 데이터를 가져오지 못했을 수 있음
    • 혹은, len바이트를 읽기 전에 EOF에 도달
    • 적절하게 buf와 len 값을 고친 다음에 다시 read()를 호출한다.
  3. 0을 반환
    • 더이상 읽을 데이터가 없음을 의미
  4. 사용가능한 데이터가 없기 때문에 호출이 블록
    • (논블록모드에서는 이런 상황이 발생하지 않는다)
  5. 호출이 -1을 반환하고 errno을 EINTR로 설정
    • EINTR은 현재 읽을 데이터가 없기 때문에 블록된 상태이며 나중에 요청하라는 뜻
    • 논블록 모드에서만 일어나는 상황
    • 다시 읽기를 요청하면 된다
  6. 호출이 -1을 반환하고 errno을 EINTR이나 EAGAIN이 아닌 다른 값으로 설정
    • 심각한 에러

2.2.2 전체 바이트 읽기

앞서 말한 상황에 대처하는 read() 코드는 아래와 같이 작성해야 한다.

ssize_t ret;

// 읽고 ret에는 읽은 길이가 저장된다
while (len != 0 && (ret = read(fd, buf, len)) != 0) {
	if (ret == -1) {
		// 에러가 발생하고 EINTR이 아닌 다른 에러가 발생했다면
		// perror를 출력하고 루프을 빠져나간다.
		if (errno == EINTR)
			continue;
		perror("read");
		break;
	}
	// 만약 len만큼 읽었다면 len이 0이 된다.
	// 아닐 경우 남은 byte만큼 len에 남음
	len -= ret;
	buf += ret;
}

2.2.3 논블록 읽기

읽을 데이터가 없을 때 read() 호출이 블록되지 않기를 바라는 경우에는 읽을 데이터가 없다는 사실을 알려주기 위해 호출이 즉시 반환되는 편이 좋다.

이를 논블록 입출력이라고 한다.

여기서 반환되는 값은 -1이고 errno는 EAGAIN 이다. 그래서 논블록인 경우 이 errno를 반드시 점검해야 한다.

char buf[BUFSIZ];
ssize_t nr;

start:
nr = read(fd, buf, BUFSIZ);
if(nr == -1) {
	if (errno == EINTR)
		goto start; /* 시그널로 인해 중단되어 에러, 다시 시도한다 */
	if (errno == EAGAIN)
		/* 읽을 데이터가 없다. 나중에 다시 시도 */
	else
		/* 에러 */
}

2.2.4 그 외 에러 값

read() 호출 시에 발생할 수 있는 다른 에러들

  1. EBADF - 파일 디스크립터가 유효하지 않거나 읽지 못하는 경우
  2. EFAULT - buf가 프로세스의 주소 공간 밖에 존재
  3. EINVAL - 파일 디스크립터가 읽기를 허용하지 않는 객체에 맵핑
  4. EIO - 저수준 입출력 에러

2.2.5 read() 크기 제약

POSIX에서는 size_tssize_t 타입을 지원한다.

두 유형은 종종 함께 사용되기 때문에 범위가 좀 더 작은 ssize_tsize_t의 범위를 제한한다.

그래서 SSIZE_MAX 보다 len이 큰 경우에는 SSIZE_MAX만큼 읽는다. 하지만 오류가 있는 시스템이 있을 수 있기 때문에 이런 처리를 하는 것이 좀 더 범용적이다.

if (len > SSIZE_MAX)
	len = SSIZE_MAX;

len이 0일 경우에는 read() 호출시 즉시 0을 반환한다.

2.3 write()로 쓰기

파일에 데이터를 기록하기 위해 사용하는 가장 기본적인 콜은 write()이다.

ssize_t write(int fd, const void *buf, size_t count);

count 바이트만큼 fd가 참조하는 파일의 현재 파일 위치에 시작 지점이 buf인 내용을 기록한다.

성공하면 쓰기에 성공한 바이트 수를 반환하며 파일 오프셋도 그만큼 전진한다.

기본 사용법은 간단하다.

const char *buf = "My ship is solid!";
ssize_t nr;

size_t len = strlen(buf);
nr = write(fd, buf, len);
if (nr == -1)
	/* error errno을 확인 */
else if(nr != count)
	/* 부분쓰기.. 에러일 가능성이 있지만, errorno는 없다. */

2.3.1 부분 쓰기

write()는 read()의 부분 읽기에 비해 부분 쓰기를 일으킬 가능성이 더 적다.

하지만 (소켓과 같은) 다른 파일 유형을 대상으로 할 경우에는 루프가 필요하다.

ssize_t ret, nr;

white (len != 0 && (ret = write(fd, buf, len)) != 0) {
	if (ret == -1) {
		if (errno == EINTR) /* 시그널로 인해 중단 다시 시도 */
			continue;
		perror("write");
		break;
	}
	len -= ret;
	buf += ret;
}

2.3.2 덧붙이기 모드

O_APPEND 옵션을 이용해 fd를 덧붙이기 모드로 열면 fd의 파일 오프셋이 파일 끝으로 지정된다.

Helloworld

# Append일 때
write("!!!")

Helloworld!!!
Helloworld

# Append가 아닐 때
write("!!!")

!!!loworld

이는 여러 프로세스가 동일한 파일에 쓰기 작업을 할 때 효과적이다.

덧붙이기 모드가 아니라면 같은 파일에 여러 프로세스가 붙었을 때 아래와 같은 일이 일어난다.

A process
  write("Helloworld")

  Helloworld
            ^ A process file offset

B process
  write("!!!!") // append mode

  Helloworld!!!

A process

  Helloworld!!!
            ^ A process file offset

  write("Why?")

  HelloworldWhy
               ^ file offset

그래서 Append를 사용한다.

2.3.3 논블록 쓰기

O_NONBLOCK 옵션을 지정해서 fd가 논블록 모드로 열린 상태에서 쓰기 작업을 하는 경우이다.

2.3.4 그 외 에러 코드

  1. EBADF - fd가 유효하지 않거나 쓰기 모드가 아님
  2. EFAULT - buf의 포인터가 호출하는 프로세스 주소 공간 안에 없음
  3. EFBIG - 너무 큰 양을 쓸 경우
  4. EINVAL - fd가 쓰기에 적합하지 않은 객체에 맵핑
  5. EIO - 저수준의 입출력 에러
  6. ENOSPC - 파일 시스템에 충분한 공간이 없음
  7. EPIPE - fd가 파이프와 소켓에 연결되어 있는데 반대쪽 읽기 단이 닫혀버린 경우

2.3.5 write() 크기 제약

딴건 없고 count가 0이면 호출 즉시 바로 0을 반환한다.

2.3.6 write() 동작 방식

write() 호출이 반환될 때, 사용자 영역에서 커널에 넘긴 버퍼에서 커널 버퍼로 데이터가 복사된다. - 즉, 반환했을 때 의도한 목적지에 데이터를 썻다는 보장이 되지 않는다.

쓴 이후에 바로 읽는 경우에 문제가 발생할 것 같지만 이런 경우에는 디스크로 가지 않고 메모리 내부의 캐시를 참조하기 때문에 문제가 되지 않는다.

앱에서는 쓰기/읽기 동작이 정상적으로 잘 수행되서 모르지만 실제로는 디스크에 저장되지 않았을 수 있다.(속도상에서 성능이 개선되면서)

지연된 연산중에 커널이 쓰기에 용이한 순서로 쓰기 요청을 바꿀 수 있기 때문에, 중간에 시스템이 비정상으로 종료되면 문제가 될 수 있다.

DB는 이런 경우에도 부적합한 상태가 되는 것을 방지해준다.

커널은 지연된 쓰기에 따른 위험을 최소화하기 위해 노력한다.

2.4 동기식 입출력

쓰기 버퍼링은 대부분의 최신 운영체제에서 제공하며, 주목할만한 성능 향상을 제공한다.

그래도 어플리케이션이 디스크에 기록되는 시점을 제어하고 싶을 때는 몇가지 옵션을 사용하면 좋다.

2.4.1 fsync()와 fdatasync()

버퍼의 데이터를 디스크에 기록되도록 할 수 있는 단순한 방법은 fsync() 를 이용하는 것이다.

#include<unistd.h>

int fsync (int fd);

fsync()를 호출하면 fd에 맵핑된 파일의 모든 변경점을 디스크에 기록된다.

만약 하드 디스크 자체에 캐시가 있다면 fsync나 fdatasync()로 보장이 되지 않지만 캐시에서 실제 디스크에 저장되기까지의 시간이 매우 짧기 때문에 큰 의미는 없다

리눅스는 또한 fdatasync()를 제공한다.

int ret;

ret = fsync(fd);

if (ret == -1)
	/* error */
int ret;

ret = fdatasync(fd);

if (ret == -1)
	/* error */

fsync(), fdatasync() 모두 변경된 파일이 포함된 디렉토리 엔트리에 대한 디스크 동기화는 보장하지 않는다.

디렉토리 엔트리란 디렉토리를 표현하는 데에 쓰이는 자료구조이다. 유닉스 계열에서는 파일이름과 inode 번호만 저장된다.

[http://maj3sty.tistory.com/928%5D%28http://maj3sty.tistory.com/928%29

** 반환값과 에러 코드 **

호출이 성공하면 0을 반환하고 실패하면 fsync(), fdatasync() 모두 -1을 반환하고 errno을 설정

몇몇 리눅스 베포판에서는 fdatasync()는 구현되었지만 fsync()는 구현되지 않은 경우가 있음

if(fsync(fd) == -1){
	// fsync()를 선호해서 fsync()를 먼저 호출해보고 실패하면 fdatasync()를 호출
	if(errno == EINVAL) {
		if(fdatasync(fd) == -1)
			perror("fdatasync");
	} else {
		perror("fsync");
	}
}

2.4.2 sync()

sync() 시스템 콜은 모든 버퍼 내용을 디스크에 동기화 한다.

void sync(void);

이 호출은 인자도 없고 반환하는 값도 없으며 항상 성공한다.

호출 이후에는 버퍼의 모든 내용(데이터와 메타데이터 모두)을 디스크에 강제로 기록한다.

실제로 sync()를 사용하는 곳은 sync(8) 유틸리티이다.

$ sync

fd를 가지고 있다면 fsync()fdatasync()를 사용하는 것이 좋다. 작업량이 많은 시스템에서는 sync()호출이 반환되기따지 수분이 걸릴 수 있기 때문이다.

2.4.3 O_SYNC 플래그

open() 호출 시 O_SYNC를 사용하면 모든 파일 입출력은 동기화된다.

int fd = open(file, O_WRONLY | O_SYNC);
if (fd == -1) {
	perror ("open");
	return -1;
}

O_SYNC 플래그는 write() 후 반환하기 직전에 fsync()를 매번 호출하는 방식이라고 보면 좋다.

가급적이면 fsync()fdatasync()를 꼭 동기화가 필요할 때 하고 이 플래그를 쓰지 않는 것이 좋다.

2.4.4 O_DSYNC와 O_RSYNC

O_DSYNCO_RSYNC라는 입출력 동기화 관련 플래그도 open()에서 쓸 수 있다.

O_DSYNC는 쓰기 작업 직후에 메타데이터를 제외한 일반 데이터만 동기화 한다.

O_RSYNC는 쓰기 뿐만 아니라 읽기까지도 동기화한다.

이 옵션은 읽기로 인해 변경되는 메타데이터도 반환되기 전에 디스크에 기록한다.

2.5 직접 입출력

리눅스 커널은 디바이스와 애플리케이션 사이에 캐시, 버퍼링, 입출력 관리 같은 복잡한 계층을 구현하고 있다.

뭐 여튼 하고 싶다면 open() 호출에 O_DIRECT 플래그를 사용하면 커널의 입출력 관리를 최소화할 수 있다.

직접 입출력을 수행할 때 요청하는 크기, 버퍼 정렬, 파일 오프셋은 모두 디바이스의 섹터 크기(512바이트)의 정수배가 되어야 한다.

2.6 파일 닫기

파일 디스크립터로 읽고 쓰는 작업을 마치고 나면 close() 시스템 콜을 이용해 파일 맵핑을 끊어야 한다.

#include <unistd.h>

int close(int fd);

close()를 호출하면 열려있는 fd에 연관된 파일과의 맵핑을 해제하며 프로세스에서 파일을 떼어낸다.

해제된 파일 디스크립터는 유효하지 않으며 커널이 다음 open()이나 creat() 호출에서 그 fd를 다시 사용할 수 있게 된다.

close() 호출은 성공하면 0을 반환하고 실패하면 -1을 반환하고 errno을 설정한다.

if (close(fd) == -1)
	perror("close");

파일을 닫더라도 파일을 디스크에 강제로 쓰지 않는다.

2.6.1 에러 값

close()의 반환값을 검사하는 것은 중요하다. 지연된 연산에 의한 에러는 한참 후에 나타나기 때문에 반환값으로 미연에 조치해야 하기 때문이다.

2.7 lseek()로 탐색하기

입출력은 파일 전체에 걸쳐 선형적으로 발생한다.

lseek() 시스템콜을 사용해 파일 디스크립터에 연결된 파일의 오프셋을 특정 값으로 지정할 수 있다.

off_t lseek(int fd, off_t pos, int origin);

lseek()origin인자에 따라 다음과 같이 동작한다.

성공하면 새로운 파일 오프셋을 반환하며 에러 발생시 -1을 반환한다.

// fd의 파일 오프셋을 1825로 설정
off_t ret = lseek(fd, (off_t) 1825, SEEK_SET);
if(ret == (off_t) - 1)
	/* 에러 */
// fd의 파일 오프셋을 현재 파일의 끝으로 설정
off_t ret = lseek(fd, 0, SEEK_END);
if (ret == (off_t) -1)
	/* 에러 */
// lseek()는 갱신된 파일 오프셋을 반환하므로 
// 아래처럼 하면 현재 파일 오프셋을 얻을 수 있다.
int pos = lseek(fd, 0, SEEK_CUR);
if(pos == (off_t) -1)
	/* 에러 */
else
	/* 'pos'는 fd의 현재 위치 */

lseek()은 파일의 시작, 혹은 끝 지점으로 오프셋을 이동시키거나, 현재 파일의 오프셋을 알아내는데 가장 많이 사용된다.

2.7.1 파일 끝을 넘어서 탐색하기

lseek()는 파일 끝을 넘어서도록 지정할 수도 있다.

int ret = lseek(fd, (off_t) 1688, SEEK_END);
if (ret = (off_t) -1)
	// error

지정할 때는 아무일도 발생하지 않지만 읽거나 쓸때 아래와 같은 일이 일어난다.

  1. 읽기 - EOF 반환
  2. 쓰기 - 마지막 오프셋과 새로운 오프셋 사이에 새로운 공간이 만들어지며 0으로 채워짐

2.7.2 에러 값

에러 == -1

  1. EBADF - 열리지 않은 fd
  2. EINVAL - origin 값이 잘못되있을 경우, 새로운 오프셋이 음수
  3. EOVERFLOW - 오프셋이 오버플로 나는 경우, 32바이트 아키텍처에서만 나타남, 반환만 못하는거지 갱신은 된다
  4. ESPIPE - fd가 잘못된 객체에 연결되어있음(파이프, FIFO, 소켓)

2.7.3 제약 사항

파일 오프셋의 최댓값은 off_t의 크기에 제한됨

blog comments powered by Disqus