Direct IO란 무엇인가요? Direct IO란 운영체제의 버퍼를 거치지 않고(bypass), 직접 IO를 수행하는 것입니다. 혹자는 파일 시스템 버퍼라도고 하는데, 저는 그냥 운영체제가 관리하는 버퍼라고 하겠습니다. Direct IO와 반대되는 용어를 굳이 정의한다면, 반대로 일반적인 IO를 Buffered IO라 할 수 있습니다. (참고로 fopen()을 이용하여 파일을 여는 경우, 응용프로그램 단계에서 IO를 버퍼링하기도 합니다. 이 이슈는 이 글에서 다루지 않습니다.)

동기화의 관점에서 IO에는 Synchronous IO (동기 IO)와 Asynchronous IO (비동기 IO)가 있습니다. 이는 쓰기에 대한 IO를 수행할때 IO가 발생한 즉시 디스크에 반영하느냐 아니냐의 정책을 결정하는 것입니다.

자 이제 프로그래머의 관점에서 생각해봅시다. 위에서 설명한 IO 정책을 내가 짠 프로그램에서 반영하려면 어떻게 해야 할까요? IO를 수행하려면 가장 먼저 open() 시스템 콜을 호출해야 합니다. 추가로 원하는 정책을 적용시키려면 적절한 플래그를 지정하면 됩니다.

Synchronous IO는 어떻게 수행할 수 있나요? 동기화에 관련된 플래그를 찾아보면 O_DSYNC,O_RSYNC, O_SYNC가 나오고, 이것들은 POSIX.1 표준입니다. 간단히 이 플래그를 open()의 플래그에 적용하시면 됩니다. (혹은 fsync() 등으로도 가능합니다.)

Direct IO는 어떻게 수행할 수 있나요? (제가 알기로) POSIX 표준은 Direct IO에 대한 플래그는 정의하지 않고 있습니다. 다행히(?) 리눅스는 (제 기억으로) 2.4 커널 일부에서 O_DIRECT라는 플래그를 제공합니다. (아마 2.6 커널에서는 모두 지원이 될 것 입니다.)

자! 그럼 이러한 플래그가 실제로 어떻게 반영되는지 눈으로 확인할 수 있는 방법은 없을까요?

만약 리눅스를 사용하신다면?

blktrace라는 툴이 있습니다.

간략하게 blktrace에 대해 설명해드리면, 리눅스의 요청 큐(request queue)의 연산을 관찰할 수 있도록 하는 툴입니다.

이제 리눅스에 ‘blktrace’를 이용하면 Direct IO를 Synchronous IO의 동작(behavior)를 관찰할 수 있습니다. (참고로 blktrace은 낮은 버전의 커널 패치가 필요한데, 제가 실험했던 2.6.22 커널에서는 별다른 패치 없이 패키지 설치만을 통해 가능했습니다. 2.6.18이상이라면 별도의 커널패치가 필요 없습니다.)

일반적으로 배포판에서 제공하는 패키지로 설치할 수도 있고, 아래의 주소의 저장소에서도 구할 수 있습니다.

git://git.kernel.org/pub/scm/linux/kernel/git/axboe/blktrace.git bt

blktrace를 이용하여 아래와 같이 완료(complete)된 요청에 대한 결과만을 출력하여 IO가 실제로 발생하는 상황을 살펴볼 수 있습니다. (실제로 더 많은 상태에 대한 정보들이 있는데, 이 실험에서는 실제로 장치에 IO가 요청되었는지만 관심이 있으므로 아래와 같은 옵션으로 필터링한 것 입니다.)

blktrace [블록장치경로] -a complete -o - | blkparse -f “%d,%S,%n\n” -i -

주의: 이 실험을 재현하는 경우 쓰기연산을 수행하는 디스크 혹은 파티션은 운영체제가 설치된 시스템과 같이 중요한 데이터를 포함하지 않아야 합니다. 이 부분을 제대로 이해하지 못하고 실험을 재현하다 생기는 문제는 책임지지 않습니다. 매우 유용한 방법이 있는데, 하드디스크에 적당한 파티션이 없는 분은 사용하지 않는 (각종 행사에서 하나씩은 얻으셨을) ‘USB 드라이브’를 이용하시면 좋습니다.

실험 순서는 아래와 같습니다.

  1. blktrace 실행
  2. IO 발생
  3. blktrace를 이용하여 실시간으로 결과 관찰

기본 옵션 (버퍼링 + 지연된 쓰기 IO 발생)

아래의 소스코드를 컴파일하여 수행합니다.

  1. /* default.c */ 
  2. #define _GNU_SOURCE  // O_DIRECT 
  3. #define IOUNIT 128*1024 
  4.  
  5. #include 
  6. #include 
  7. #include 
  8. #include  
  9.  
  10. int main()  
  11. {  
  12.   char *nBuf;                // not aligned buffer 
  13.   char *aBuf;                // aligned buffer 
  14.   int   fd;                  // file descriptor 
  15.  
  16.   // 1MB buffer (not aligned yet) 
  17.   nBuf = (char*) malloc(IOUNIT+getpagesize());  
  18.   // aligned buffer 'aBuf' 
  19.   aBuf = (char*)((unsigned)(nBuf+getpagesize()-1)/getpagesize()*getpagesize());  
  20.  
  21.   printf("OPEN FD: %d\n", fd=open("[블록장치경로]", O_RDWR ) );  
  22.   printf("WRITE: %d\n", write( fd, aBuf, IOUNIT ) );  
  23.   printf("READ: %d\n", read( fd, aBuf, IOUNIT ) );  
  24.   close(fd);  
  25.  
  26.   free(nBuf);  
  27. }  

위의 프로그램은 오픈한 블록 장치(block device)의 0번 주소에 대해 128KB 단위의 쓰기 연산을 한번 수행하고, 그 다음 128KB위치의 해당하는 주소에 128KB크기의 읽기를 한번 수행하는 아주 간단한 프로그램입니다.

위의 프로그램 수행 직 후 blktrace를 이용하여 관찰된 IO는 아래와 같습니다.
R,256,256

분명 쓰기 연산도 수행했는데, 반영되지 않고 있습니다. !!!

몇 초가 흐르면 아래의 IO가 발생합니다.
W,0,256

첫 번째 IO는 쓰기 요청임에도 실제로 READ 요청이 먼저 발생하였습니다. 실제로는 “W,0,256“이 발생하여야 하지만, 일단 버퍼에 요청된 쓰기를 수행하고, 디스크에는 반영하지 않았음을 의미합니다. 실제 IO는 몇 초가 지난 후에야 비로소 발생합니다.

만약 실제 쓰기에 대한 IO가 발생하기 이전에 컴퓨터가 비정상적으로 전원이 나간다거나 하는 상황이 발생하면 어떻할까요? 이러한 이유로 데이터베이스의 트랜잭션 로그의 경우, 위와 같은 코드르 사용하면 안됩니다.

추가로 프로그램을 반복하여 수행하여 봅시다. 실제로 IO가 요청되지 않습니다. 프로그램에게 버퍼에 저장되어 있는 값을 전해주기 때문입니다.

O_SYNC (쓰기 IO 즉시 발생)

처음 작성한 default.c를 아래의 osync.c와 같이 수정합니다. 실제로 다른 부분은 모두 같고,O_SYNC 플래그만이 추가된 것입니다.

  1. /* osync.c */ 
  2.  
  3. #define _GNU_SOURCE  // O_DIRECT 
  4. #define IOUNIT 128*1024 
  5.  
  6. #include 
  7. #include 
  8. #include 
  9. #include  
  10.  
  11. int main()  
  12. {  
  13.   char *nBuf;                // not aligned buffer 
  14.   char *aBuf;                // aligned buffer 
  15.   int   fd;                  // file descriptor 
  16.  
  17.   // 1MB buffer (not aligned yet) 
  18.   nBuf = (char*) malloc(IOUNIT+getpagesize());  
  19.   // aligned buffer 'aBuf' 
  20.   aBuf = (char*)((unsigned)(nBuf+getpagesize()-1)/getpagesize()*getpagesize());  
  21.  
  22.   printf("OPEN FD: %d\n", fd=open("[블록장치경로]", O_RDWR | O_SYNC ) );  
  23.   printf("WRITE: %d\n", write( fd, aBuf, IOUNIT ) );  
  24.   printf("READ: %d\n", read( fd, aBuf, IOUNIT ) );  
  25.   close(fd);  
  26.  
  27.   free(nBuf);  
  28. }  

같은 방법으로 blktrace를 시작하고, 위의 프로그램을 수행하면 아래와 같은 결과를 확인하실 수 있습니다.

W,0,256
R,256,256

위의 프로그램을 반복하면, 읽기는 발생하지 않고 쓰기만 계속 발생합니다. 쓰기 연산에 대한 동기화 플래그가 O_SYNC로 설정되어 있기 때문입니다. 참고로 O_SYNC 플래그를 이용하여 파일을 열지 않더라도 fsync()를 이용하여 쓰기 연산을 장치에 실제로 반영하도록 할 수 도 있습니다.

O_DIRECT (버퍼링 하지 않음)

default.c 파일을 아래의 odirect.c와 같이 수정합니다. (O_DIRECT 플래그 사용)

  1. /* odirect.c */ 
  2. #define _GNU_SOURCE  // O_DIRECT 
  3. #define IOUNIT 128*1024 
  4.  
  5. #include 
  6. #include 
  7. #include 
  8. #include  
  9.  
  10. int main()  
  11. {  
  12.   char *nBuf;                // not aligned buffer 
  13.   char *aBuf;                // aligned buffer 
  14.   int   fd;                  // file descriptor 
  15.  
  16.   // 1MB buffer (not aligned yet) 
  17.   nBuf = (char*) malloc(IOUNIT+getpagesize());  
  18.   // aligned buffer 'aBuf' 
  19.   aBuf = (char*)((unsigned)(nBuf+getpagesize()-1)/getpagesize()*getpagesize());  
  20.  
  21.   printf("OPEN FD: %d\n", fd=open("[블록장치경로]", O_RDWR | O_DIRECT ) );  
  22.   printf("WRITE: %d\n", write( fd, aBuf, IOUNIT ) );  
  23.   printf("READ: %d\n", read( fd, aBuf, IOUNIT ) );  
  24.   close(fd);  
  25.  
  26.   free(nBuf);  
  27. }  

blktrace를 다시 시작하고, 위의 프로그램을 실행하면 아래의 결과를 얻을 수 있습니다.

W,0,256
R,256,256

버퍼링 효과가 적용되지 않아 즉각적으로 읽기와 쓰기가 발생하였습니다. 프로그램을 반복하여 수행하여도 정직하게 반복합니다.

버퍼링과 prefetch의 관계

버퍼링은 단순히 IO가 반복하여 발생하는 경우에만 적용되는 것은 아닙니다. 위의 경우와 같이 한번 IO와 발생한 주소에서 다시 발생할 확률이 높다고 가정하는 것을 “temporal locality”라고 합니다. 하지만 데이터를 연속적으로 저장되고 처리될 확률이 높기 때문에 A라는 주소에서 IO가 발생했다면, A+1주소에서 짧은 시간안에 다시 IO가 발생할 확률 역시 높습니다. 이러한 것은 “spatial locality”라고 합니다.

Spatial locality를 이용하여 성능을 높이기 위한 대표적인 방법은 IO의 단위를 크게 하는 것입니다. 사용자가 1바이트를 읽어도 기본 IO의 단위가 8KB라면, 이후 주소에 대한 데이터까지 상위 단계의 메모리로 함께 복사해오겠죠. 이에 더불어 prefetch라는 기술을 이용할 수도 있습니다. 단순히 A주소에 대한 IO가 발생하면 A+1주소에 대한 IO까지 미리 수행하여 버퍼에 저장하는 것입니다.

Prefetch효과를 확인하기 위해, 아래의 default_prefetch.c 파일을 생성합니다. default.c파일에서 쓰기 연산을 제거하고 읽기만 수행한 것입니다.

  1. /* default_prefetch.c */ 
  2. #define _GNU_SOURCE  // O_DIRECT 
  3. #define IOUNIT 128*1024 
  4.  
  5. #include 
  6. #include 
  7. #include 
  8. #include  
  9.  
  10. int main()  
  11. {  
  12.   char *nBuf;                // not aligned buffer 
  13.   char *aBuf;                // aligned buffer 
  14.   int   fd;                  // file descriptor 
  15.  
  16.   // 1MB buffer (not aligned yet) 
  17.   nBuf = (char*) malloc(IOUNIT+getpagesize());  
  18.   // aligned buffer 'aBuf' 
  19.   aBuf = (char*)((unsigned)(nBuf+getpagesize()-1)/getpagesize()*getpagesize());  
  20.  
  21.   printf("OPEN FD: %d\n", fd=open("[블록장치경로]", O_RDWR ) );  
  22.   printf("READ: %d\n", read( fd, aBuf, IOUNIT ) );  
  23.   close(fd);  
  24.  
  25.   free(nBuf);  
  26. }  

blktrace를 시작하고 위의 소스코드를 컴파일하여 수행하면, 아래와 같은 결과를 얻을 수 있습니다.

R,0,256
R,256,256

흥미로운 것이 실제로는 128KB크기의 읽기를 한번만 요청하였지만, 연속된 128KB에 대해서도 읽기를 수행했다는 것입니다. 연속된 읽기가 발생할 수 있는 상황을 가정하여 데이터를 미리 읽어오도록 한 것입니다. 리눅스에서 어떤 상황에서 prefetch를 결정하는지는 잘 모르겠습니다. 더욱 재밌는건 처음의 쓰기 후 읽기 패턴에서는 prefetch를 수행하지 않다가 지금의 읽기만 있는 패턴에서는 prefetch를 결정했다는 것입니다.

한가지 당연한 진실은 prefetch라는 것은 버퍼링이 수행된다는 가정에서만 가능하다는 것입니다. 따라서 Direct IO에서는 별 의미가 없습니다. 바보 같지만 실제로 같은 실험을 O_DIRECT플래그를 주고 실험해 보겠습니다.

  1. /* odirect_prefetch.c */ 
  2. #define _GNU_SOURCE  // O_DIRECT 
  3. #define IOUNIT 128*1024 
  4.  
  5. #include  
  6.  
  7. #include 
  8. #include 
  9. #include  
  10.  
  11. int main()  
  12. {  
  13.   char *nBuf;                // not aligned buffer 
  14.   char *aBuf;                // aligned buffer 
  15.   int   fd;                  // file descriptor 
  16.  
  17.   // 1MB buffer (not aligned yet) 
  18.   nBuf = (char*) malloc(IOUNIT+getpagesize());  
  19.   // aligned buffer 'aBuf' 
  20.   aBuf = (char*)((unsigned)(nBuf+getpagesize()-1)/getpagesize()*getpagesize());  
  21.  
  22.   printf("OPEN FD: %d\n", fd=open("[블록장치경로]", O_RDWR | O_DIRECT ) );  
  23.   printf("READ: %d\n", read( fd, aBuf, IOUNIT ) );  
  24.   close(fd);  
  25.  
  26.   free(nBuf);  
  27. }  

성실하게 위의 프로그램을 수행하면 아래의 결과를 확인할 수 있습니다.

R,0,256

버퍼링을 하지 않으므로 prefetch 효과도 발생하지 않습니다. (참고로) 프로그램을 반복 수행하여도 읽기 IO를 정직하게 실제로 계속 수행합니다.

결론

정리하면 다음과 같습니다.일반적으로 운영체제에서는 IO발생시 IO요청을 버퍼링하여 성능을 향상시키려 합니다. 이로 인해 사용자가 쓰기 연산을 수행하고, 사용자에게는 연산을 완료했다고 리턴하면서도 장치에는 즉각 반영되지 않는 일이 발생합니다.

사용자 입장에서는 당황스러울 수 있지만, 다음과 같은 IO 성능 향상의 기회를 얻을 수 있습니다.

  • 같은 번지수의 IO가 짧은 시간내에 반복될 경우, 한번만 반영할 수도 있습니다.
  • 혹은 연속된 주소의 다수의 IO가 발생하면 이를 모아서 처리할 수 있습니다.
  • 혹은 (디스크의 경우) IO요청을 블록 주소로 정렬하여 반영하면 성능이 향상될 수 있습니다.
  • 그외에도 IO를 지연시킴으로써 다른 최적화 할 수 있는 기회를 얻을 수 있습니다.

하지만 다음과 같은 이유로 버퍼링을 부분적으로 제한하거나 아예 버퍼를 무시하고자 합니다.

  • Synchronous IO: 응용프로그램의 특성에 따라 사용자는 자신의 요청을 즉각적으로 반영하길 원합니다. 이 경우 O_SYNC플래그를 이용하거나 fsync()와 같은 함수를 이용할 수 있습니다. 알티베이스와 같은 DBMS 소프트웨어의 경우에는 트랜젝션 로그를 반영하는 일들이 그에 해당될 수 있습니다.
  • Direct IO: 일부 자신만만한(?) 응용프로그램(DBMS가 대표적입니다)은 운영체제의 버퍼 자체가 없었으면 하기도 합니다. 이 경우 리눅스에서는(!) 근래의(?) 커널에서 O_DIRECT 플래그를 이용할 수 있습니다.

DBMS에서 Direct IO를 사용하는 이유는 DBMS자체가 내부적으로 버퍼를 관리하고, 이게 알고리즘이 운영체제에서 하는 것보다 우수하다고 믿기 때문입니다. 운영체제에서 IO를 버퍼링하게 되면 같은 디스크 블록에 대해 중복으로 버퍼링하므로 낭비이고, 이게 성능을 저하시킨다는 것입니다. 추가로 확인하신 prefetch의 효과 또한 DBMS에게는 낭비일 수 있습니다. DBMS는 이미 자신이 연속된 블록을 읽을지 임의의 블록을 읽을지 알 수 있어 스스로 IO의 단위를 조정할 수 있기 때문입니다.

그렇다고 항상 Direct IO가 성능이 우수한 것은 아닌 모양입니다. 최근 엑셈의 조동욱님께서 관련된 글을 찾아서 블로그에 링크시켜 놓으신게 있습니다. 글을 읽어보시면, DBMS 버퍼의 크기를 너무 작게 설정하여 빈번한 IO가 발생하는 경우, 운영체제 버퍼를 이용하는 것이 더 좋은 성능을 보인다는 어쩌면 당연한 이야기입니다.

실험 코드를 포함하다 보니 생각보다 글이 길어졌군요. (지루하지 않으셨길 바랍니다.)
모두 좋은 한주 보내세요. 다음에 뵙겠습니다.