4. 동기식 입출력 - fsync(), fdatasync()
System/System Programming

4. 동기식 입출력 - fsync(), fdatasync()



개요


쓰기 작업이 지연되고 있어! 어떡하지???


  이는 큰 문제는 아니다. 쓰기 버퍼링은 오히려 에러가 아니라 성능을 향상시키는 과정이라고 할 수 있다. 흔히 '최신' 운영체제라고 하면 버퍼를 통해 지연된 쓰기 작업을 구현하고 있기 때문에 안심해도 된다. 하지만 프로그램에서 데이터가 기록되는 시점을 직접 컨트롤할 수 있다면? 이럴 때를 대비해 리눅스 커널에서는 입출력을 동기화하는 시스템 콜을 제공하고 있다. 물론 성능은 ... 비교적 희생해야 하는 면이 없지 않아 있다.

fsync()와 fdatasync()


  POSIX에 정의된 데이터가 디스크에 기록되도록 확인할 수 있는 가장 단순한 방법은 fsync() 시스템 콜을 사용하는 것이다.

1
2
3
#include <unistd.h>
 
int fsync(int fd);
cs


  인자로는 파일 디스크립터 하나만 주면 된다. fsync()를 호출하면 파일 디스크립터(fd)에 매핑된 파일의 모든 순간을 디스크에 기록한다. 정확히는 파일이 변경되는 순간 (변경점)들을 기록한다.


  유의해야 할 점은 파일 디스크립터가 반드시 쓰기 모드로 열려야 한다는 것이다. 파일에 담긴 데이터, 파일 생성 시간 등... inode에 포함된 메타데이터를 모두 디스크에 기록해야 하기 때문이다. fsync() 시스템 콜은 하드 디스크에 데이터와 메타데이터가 성공적으로 기록을 완료할 때까지 반환하지 않고 기다린다.


  fdatasync()데이터만 기록하는 점을 제외하면 fsync()와 동일한 기능을 하는, 리눅스 운영체제에서 제공하는 동기식 입출력 시스템 콜이다.


1
2
3
#include <unistd.h>
 
int fdatasync(int fd);
cs


  fdatasync()는 메타데이터까지 실제 디스크에 기록된다는 것을 보장하지는 않기 때문에 이론상으로는 fsync()보다 반환 속도가 빠르다. 어차피 동기식 입출력을 한다면 성능을 버리는 편이기는 하지만, 굳이 둘 중 하나를 써보고 싶다면 성능상 fdatasync()를 사용하는 것이 더 이득일 것이다.


  fsync()와 fdatasync()의 사용법은 동일하다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
 
int main() {
    int fd = open("TEXT", O_RDWR | O_CREAT, 0666);
    if(fd == -1) {
        fprintf(stderr, "Failed to open file.\n");
        exit(0);
    }
 
    // fsync()
    int ret = fsync(fd);
    if(ret == -1) {
        fprintf(stderr, "Failed fsync().\n");
        exit(0);
    }
    else printf("Successed fsync()!\n");
 
    // fdatasync()
    int ret = fdatasync(fd);
    if(ret == -1) {
        fprintf(stderr, "Failed fdatasync().\n");
        exit(0);
    }
    else printf("Successed fdatasync()!\n");
 
    return 0;
}
cs


  두 함수 모두 변경된 파일이 포함된 디렉토리 엔트리(Directory Entry)[각주:1][각주:2] 에 대한 디스크 동기화는 보장하지 않는다. 파일 링크가 최근에 갱신되었고, 디스크에도 이 사실이 제대로 기록되었다고 가정해 보자. 이 경우 디렉터리 엔트리가 디스크에 기록되지 않았다면 파일에 대한 접근이 불가능하다(!) 디렉토리 엔트리 역시 디스크에 강제로 기록하려면 디렉토리 자체를 대상으로 open한 파일 디스크립터를 fsync() 사용 시 인자로 넘겨야 한다.


반환값과 에러 코드

  fsync()와 fdatasync() 둘 다 동일하다. 성공하면 0, 실패하면 -1을 반환한다. 실패 시 errno를 EBADF, EINVAL, EIO 셋 중 하나로 설정한다.

에러 코드

설명

EBADF

 주어진 파일 디스크립터가 유효하지 않거나 쓰기 모드가 아니다.

EINVAL

 주어진 파일 디스크립터가 동기화를 지원하지 않는 객체에 매핑되어 있다.

EIO

 동기화 과정 중 저수준 입출력 에러 발생. (쓰기 과정 도중 실제 입출력 에러 발생)


  어떤 리눅스 배포판에서는 파일 시스템에 fdatasync()는 구현되어 있는데 fsync()는 구현되지 않는 경우가 있어서(!) 호출이 실패하는 경우도 있다!




  조금 신경 써서 만든 응용 프로그램이라면 fsync()가 EINVAL을 반환하면 fdatasync()를 대신 호출시키는 예외 처리를 넣어주었을 것이다. 아래는 이의 예시이다.


1
2
3
4
5
6
if(fsync(fd) == -1) {
    if(errno == EINVAL) {
        if(fdatasync(fd) == -1) perror("Failed fsync(). Failed fdatasync().\n");
    }
    else perror("Failed fsync(). Successed fdatasync()!\n");
}
cs


  POSIX에서 fsync()를 필수적으로 구현하도록 요구하기 때문에 일반적으로 이러한 상황은 특수한 경우에만 나타난다. 반면 fdatasync()는 필수가 아니다.


sync() 시스템 콜

  모든 버퍼 내용을 디스크에 동기화하는 sync() 시스템 콜에 대해 알아보자. 앞에서 다루었던 fsync()와 fdatasync()에 비해 최적화 성능은 조금 부족하지만, 동기화하는 정보가 많은 만큼 활용 범위가 넓다. 

1
2
3
#include <unistd.h>
 
void sync(void);
cs


  보다시피 반환값도 void, 인자도 void. 인자도 없고 반환하는 값도 없다. 호출은 항상 성공하며(아무것도 없으니까...) 데이터와 메타데이터를 전부 포함한 버퍼의 모든 내용을 디스크에 강제로 기록한다. 표준에서는 sync()의 반환을 강제하지 않고, 그냥 기록만 할 뿐이다.


  실제로 sync()를 사용하는 곳은 sync(8) 유틸리티이다. 꼭 필요한 디스크립터의 데이터를 디스크에 강제로 기록하기 위해서는 fsync()와 fdatasync()가 필요하다. 작업량이 많은 시스템에서는 sync() 호출이 반환하기까지 시간이 꽤 많이 걸릴 수도 있다. (몇 분 정도...?)


O_SYNC 플래그

  이 플래그는 open()으로 파일을 열 때 사용되는데, 사용 시 모든 파일 입출력이 동기화된다. 굳이 fsync()니, fdatasync()니... 이런 것들을 안 써도 알아서 동기화해 준다!

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
 
int main() {
    if(open("TEXT", O_WRONLY | O_CREAT | O_SYNC, 0666== -1) {
        perror("Failed to open file.\n");
        return -1;
    }
    else printf("Successfully opened file with O_SYNC!\n");
    return 0;
}
cs


  읽기 요청은 언제나 동기화되는데, 그렇지 않으면 읽기 요청의 결과로 저장된 데이터가 유효한지 확인할 수 있는 방버이 없기 때문이다. 하지만, 이전 포스팅에서 언급한 적은 있는데 write() 시스템 콜 호출은 보통 동기화되지 않는다. 호출이 반환되는 것과 데이터가 디스크에 기록되는 것 사이에는 아무런 관련성이 없다. O_SYNC 플래그는 write() 시스템 콜의 파일 기록 작업이 동기화되도록 해준다.


  이 플래그는 쓰기 작업 후 반환되기 직전, fsync()를 여러 번 출력한다고 생각하면 그 동작 방식을 쉽게 이해할 수 있다. (실제로는 좀 더 효율적인 어떤...방식으로 운영된다.)


  하지만 모든 것에는 단점이 존재한다. O_SYNC는 사용자 영역과 커널 영역에서 소모되는 시간을 조금씩 늘린다. 게다가 디스크에 쓴 파일 크기에 따라 전체 소요 시간이 무지막지하게 늘어날 수도 있다. 이렇게 되면 동기화 한다고 성능을 다 죽여버리는 결과가 나온다. 따라서 입출력 동기화는 신중히 골라야 할 선택지라고 할 수 있다.


O_DSYNC, O_RSYNC


  O_SYNC와는 동일한 기능을 하는 플래그이다. POSIX에서는 open() 시스템 콜에서 사용할 수 있는 입출력 동기화 관련 플래그로 정의되어 있다.


  O_DSYNC는 쓰기 작업 직후에 메타데이터를 제외한 일반 데이터만 동기화한다. 쓰기 요청 직후 fdatasync()를 호출한 것과 같은 결과를 가져온다. O_SYNC가 좀 더 확실한 동기화를 보장하는데, 이 때문에 O_DSYNC를 O_SYNC의 별칭으로 두면 기능적으로 누락되는 것은 없다. 하지만 앞에서 언급했듯이 O_SYNC의 요구사항으로 인해 성능 저해를 감수해야 할 것이다.


  O_RSYNC는 읽기와 쓰기 모두를 동기화시킨다. 이건 혼자 사용할 수 없으며, O_SYNC나 O_DSYNC와 함께 사용해야만 한다. 읽기 작업은 원래 동기화되므로 최종적으로 사용자에게 넘겨줄 데이터가 생길 때까지 반환되지 않는다.


  O_RSYNC가 조금 다른 것은, 읽기 과정 중에 발생하는 부작용까지도 동기화한다는 것이다. 읽기 작업으로 변경된 메타데이터도 반환하기 전에 디스크에 기록한다는 뜻과 같다. 실제 동작 과정을 살펴보면 read() 호출 반환 전에 inode의 디스크 복사본에 파일 접근 시간을 갱신하는 것을 알 수 있다. 그다지 효율적인 방법은 아니다... 현재는 리눅스에서 이 플래그의 동작 방식을 구현하기가 쉽지도 않고, 거의 필요하지도 않다.

  1. 디렉토리를 표현하는 데에 쓰이는 자료구조. [본문으로]
  2. 일반적으로는 파일이름, 파일속성 등 파일에 대한 여러가지 정보가 저장되는데, 유닉스 계열에서는 파일이름과 inode 번호만 저장된다. [본문으로]

'System > System Programming' 카테고리의 다른 글

6. 파일 닫기 - close()  (0) 2019.04.24
5. 직접 입출력  (0) 2019.04.24
3. 파일 쓰기 - write()  (0) 2019.04.16
2. 파일 읽기 - read()  (0) 2019.04.15
1. 파일 열기 - open(), creat()  (0) 2019.04.11