O_DIRECT 정렬 요구사항, 추측하지 말고 statx로 묻기

Linux O_DIRECT의 버퍼 주소·오프셋·길이 정렬은 파일시스템과 커널마다 다릅니다. Linux 6.1의 STATX_DIOALIGN, 6.14의 STATX_DIO_READ_ALIGN으로 값을 얻고 stx_mask를 확인하는 방법을 정리합니다.

linuxdirect-iostatxsystem-calls

O_DIRECT로 연 파일에 pread()나 pwrite()를 했더니 EINVAL이 돌아온다. 같은 코드가 다른 서버에서는 잘 돈다. 이럴 때 먼저 의심할 것이 정렬(alignment)입니다. 그리고 “512바이트로 맞추면 된다”거나 “4096이면 안전하다” 같은 경험칙은 어디서나 통하지 않습니다.

Linux 6.1부터는 이 값을 커널에 직접 물어볼 수 있습니다. 이 글은 open(2)와 statx(2) 매뉴얼 페이지(Linux man-pages 6.19)를 근거로, 무엇을 맞춰야 하는지와 그 값을 어떻게 얻는지 정리합니다.

O_DIRECT가 하는 일과 하지 않는 일

open(2)는 O_DIRECT를 “이 파일에 대한 I/O의 캐시 효과를 최소화하려고 시도한다”고 설명합니다. 파일 I/O는 사용자 공간 버퍼와 직접 주고받습니다. 같은 페이지에 따르면 일반적으로는 성능이 떨어지고, 애플리케이션이 자체 캐시를 갖는 경우처럼 특수한 상황에서 쓸모가 있습니다.

O_DIRECT는 내구성 보장이 아닙니다. 매뉴얼은 O_DIRECT가 데이터를 동기적으로 전송하려고 노력하지만, 데이터와 필요한 메타데이터가 전송된다는 O_SYNC의 보장은 주지 않는다고 적습니다. 동기 I/O를 보장하려면 O_SYNC를 함께 써야 합니다. 쓰기가 언제 디스크에 남는지는 write()와 fsync() 글에서 따로 다뤘습니다.

매뉴얼의 결론도 조심스럽습니다. O_DIRECT는 잠재적으로 강력하지만 주의해서 써야 하고, 기본값은 끄고 필요할 때 켜는 성능 옵션으로 다루라고 권합니다.

무엇을 정렬해야 하나

O_DIRECT는 세 가지에 정렬 제한을 걸 수 있습니다.

  • 사용자 공간 버퍼의 메모리 주소
  • I/O 길이 (벡터 I/O라면 각 세그먼트의 길이)
  • 파일 오프셋

까다로운 점은 이 제한이 고정돼 있지 않다는 것입니다. open(2)의 NOTES에 따르면 Linux의 정렬 제한은 파일시스템과 커널 버전에 따라 다르고, 아예 없을 수도 있습니다. 정렬이 어긋난 I/O를 처리하는 방식도 다릅니다. EINVAL로 실패하기도 하고, 버퍼드 I/O로 조용히 넘어가기도 합니다.

두 번째 경우가 더 위험합니다. 에러가 나지 않으니 정렬이 틀렸다는 사실을 모른 채 페이지 캐시를 거치는 I/O를 하게 됩니다.

EINVAL이 나오는 지점도 하나 더 있습니다. 파일시스템이 O_DIRECT를 지원하지 않으면 open() 자체가 EINVAL로 실패합니다.

Linux 6.1: STATX_DIOALIGN

Linux 6.1부터 statx()의 mask에 STATX_DIOALIGN을 넣으면 두 필드를 요청할 수 있습니다.

필드 의미
stx_dio_mem_align 사용자 메모리 버퍼에 필요한 정렬(바이트)
stx_dio_offset_align 파일 오프셋과 I/O 세그먼트 길이에 필요한 정렬(바이트)

둘 다 0이면 그 파일에서 direct I/O를 지원하지 않는다는 뜻입니다. 한쪽이 0이 아니면 다른 쪽도 0이 아닙니다.

지원 범위는 넓지 않습니다. statx(2)에 따르면 블록 디바이스는 Linux 6.1부터 지원하고, 일반 파일은 파일시스템마다 다릅니다. 매뉴얼이 이름을 들어 적은 것은 Linux 6.1부터의 ext4, f2fs, xfs입니다. 그 밖의 파일시스템은 이 페이지만으로는 알 수 없으니, 지원한다고 가정하지 말고 아래처럼 런타임에 확인해야 합니다.

stx_mask를 반드시 확인한다

statx()는 요청한 필드를 다 채워 준다는 약속을 하지 않습니다. 매뉴얼의 설명은 이렇습니다.

  • 커널은 모르는 mask 비트를 대체로 거부하지 않습니다. 대신 이 커널과 파일시스템이 무엇을 지원하는지 stx_mask로 알려 줍니다.
  • 파일시스템이 필드를 지원하지 않으면 요청했더라도 stx_mask의 해당 비트가 꺼지고, 호환성을 위한 더미 값이 채워질 수 있습니다.
  • 요청하지 않은 필드를 채워 줄 수도 있습니다. 이때는 해당 비트가 켜집니다.

따라서 지금 커널이나 파일시스템이 STATX_DIOALIGN으로 요청한 필드를 지원하지 않으면 stx_mask에 그 비트가 없고, 필드에는 더미 값이 들어 있을 수 있습니다. stx_dio_mem_align을 바로 읽으면 의미 없는 값을 정렬 기준으로 삼을 수 있습니다. 판단 순서는 항상 stx_mask 확인이 먼저입니다.

같은 이유로 mask를 UINT_MAX로 채우지 말라는 경고도 있습니다. 나중에 일부 비트가 구조체 확장을 뜻하게 될 수 있기 때문입니다.

Linux 6.14: 읽기 전용 오프셋 정렬

Linux 6.14에는 STATX_DIO_READ_ALIGN이 추가됐고, 이것이 요청하는 필드는 stx_dio_read_offset_align입니다. direct I/O 읽기에 필요한 파일 오프셋·세그먼트 길이 정렬입니다.

  • 0이면 읽기에도 stx_dio_offset_align이 그대로 적용됩니다.
  • 0이 아니면 stx_dio_offset_align보다 작거나 같습니다. 즉 읽기 쪽 제한이 더 느슨할 수 있습니다.
  • 메모리 정렬(stx_dio_mem_align)에는 영향이 없습니다.

매뉴얼이 적은 지원 범위는 Linux 6.14부터 xfs의 일반 파일입니다. 이 값은 stx_dio_offset_align과 비교해서 의미가 생기므로 STATX_DIOALIGN과 함께 요청하는 편이 자연스럽고, 쓰기에는 여전히 stx_dio_offset_align을 써야 합니다.

C 예제

열린 파일 디스크립터에 대해 정렬 값을 얻고, 계획한 I/O가 그 값에 맞는지 검사하는 코드입니다. statx() 래퍼는 glibc 2.28부터 있습니다. STATX_DIOALIGN과 새 필드가 정의되지 않은 오래된 헤더에서는 빌드되지 않으며, STATX_DIO_READ_ALIGN 부분은 헤더에 있을 때만 컴파일되도록 #ifdef로 감쌌습니다.

#define _GNU_SOURCE
#include <fcntl.h>
#include <stdbool.h>
#include <stdint.h>
#include <sys/stat.h>

struct dio_align {
    uint32_t mem;          /* 버퍼 주소 */
    uint32_t offset;       /* 쓰기(그리고 기본적으로 읽기)의 오프셋·길이 */
    uint32_t read_offset;  /* 읽기의 오프셋·길이 */
};

/* 0: 값을 얻음, 1: 보고되지 않음(다른 방법 필요), -1: statx 실패 */
static int query_dio_align(int fd, struct dio_align *a)
{
    unsigned int want = STATX_DIOALIGN;
#ifdef STATX_DIO_READ_ALIGN
    want |= STATX_DIO_READ_ALIGN;
#endif
    struct statx stx;

    if (statx(fd, "", AT_EMPTY_PATH, want, &stx) == -1)
        return -1;
    if (!(stx.stx_mask & STATX_DIOALIGN))
        return 1;

    a->mem = stx.stx_dio_mem_align;
    a->offset = stx.stx_dio_offset_align;
    a->read_offset = a->offset;
#ifdef STATX_DIO_READ_ALIGN
    if ((stx.stx_mask & STATX_DIO_READ_ALIGN) &&
        stx.stx_dio_read_offset_align != 0)
        a->read_offset = stx.stx_dio_read_offset_align;
#endif
    return 0;
}

static bool dio_ok(const struct dio_align *a, const void *buf,
                   uint64_t off, uint64_t len, bool is_read)
{
    uint32_t step = is_read ? a->read_offset : a->offset;

    if (a->mem == 0)   /* 이 파일은 direct I/O를 지원하지 않음 */
        return false;
    return (uintptr_t)buf % a->mem == 0 && off % step == 0 && len % step == 0;
}

statx()에 빈 경로와 AT_EMPTY_PATH를 주면 dirfd가 가리키는 파일 자체를 대상으로 합니다. 경로 이름으로 물어봐도 되지만, 이렇게 하면 I/O에 쓸 바로 그 디스크립터의 파일을 조회합니다.

query_dio_align()이 1을 돌려줬다면 정렬을 모른다는 뜻이지 제한이 없다는 뜻이 아닙니다. 이때는 다음 절의 방법으로 넘어가야 합니다.

statx로 알 수 없을 때

open(2)는 그다음 단계도 적어 둡니다.

  1. 파일시스템 고유 인터페이스. 예를 들어 xfs에는 xfsctl(3)의 XFS_IOC_DIOINFO가 있습니다. 다만 STATX_DIOALIGN을 쓸 수 있다면 그쪽을 쓰라고 합니다.
  2. 그것도 없으면 파일시스템, 개별 파일, 하부 스토리지 장치, 커널 버전의 알려진 특성에서 추정할 수밖에 없습니다.

추정의 출발점으로 매뉴얼은 역사적 기준을 소개합니다. Linux 2.4에서는 블록 디바이스 기반 파일시스템 대부분이 파일 오프셋과 모든 I/O 세그먼트의 길이·메모리 주소를 파일시스템 블록 크기(보통 4096바이트)의 배수로 요구했습니다. Linux 2.6.0에서 이것이 블록 디바이스의 논리 블록 크기(보통 512바이트)로 완화됐습니다. 논리 블록 크기는 ioctl(2)의 BLKSSZGET이나 셸의 blockdev --getss로 확인할 수 있습니다.

여기서 “보통”이라는 말을 무시하면 안 됩니다. 이 값들은 추정의 근거이지 보장이 아닙니다.

같이 알아 둘 주의사항

fork()와 동시에 돌리지 않는다

메모리 버퍼가 private 매핑(MAP_PRIVATE로 만든 매핑. 힙과 정적 버퍼도 포함)이라면 O_DIRECT I/O를 fork(2)와 동시에 실행하면 안 됩니다. 비동기 I/O 인터페이스로 제출했든 다른 스레드에서 했든, 진행 중인 I/O는 fork() 전에 끝나야 합니다. 그렇지 않으면 부모와 자식 프로세스에서 데이터 손상과 정의되지 않은 동작이 생길 수 있습니다. 버퍼를 shmat(2)이나 MAP_SHARED의 mmap(2)로 만들었거나, madvise(2)로 MADV_DONTFORK를 지정해 자식에게 넘어가지 않게 했다면 이 제한은 적용되지 않습니다.

버퍼드 I/O, mmap과 섞지 않는다

같은 파일, 특히 겹치는 바이트 범위에 O_DIRECT와 일반 I/O를 섞지 말라고 매뉴얼은 권합니다. 파일시스템이 일관성을 올바르게 처리하더라도 전체 처리량은 어느 한 방식만 쓸 때보다 느려지기 쉽다고 합니다. 같은 파일에 mmap(2)과 direct I/O를 섞는 것도 피하라고 합니다.

NFS는 다르게 동작한다

NFS 프로토콜은 이 플래그를 서버에 전달하지 못하므로, 페이지 캐시를 우회하는 것은 클라이언트뿐이고 서버는 여전히 캐시할 수 있습니다. Linux NFS 클라이언트는 O_DIRECT I/O에 정렬 제한을 두지 않습니다.

정리하면

정렬 값은 하드코딩하지 말고 파일마다 statx()로 묻습니다. 결과는 stx_mask를 본 뒤에만 믿고, 보고되지 않았다면 “제한 없음”이 아니라 “모름”으로 처리합니다. 읽기용 값은 6.14 이후 일부 파일시스템에서만 따로 나옵니다. 그리고 O_DIRECT 자체는 내구성 장치가 아니라, 기본값은 끄고 필요할 때 켜는 성능 옵션입니다.

참고 자료

블로그로