write()의 진실을 알아보자

suntlee·2020년 9월 24일
2

42 Seoul cadet이라면 모두 write 함수에 대해 잘 알고 계실 겁니다. Piscine부터 프로그램의 출력을 위해 사용했던 함수죠.

그런데 말입니다.

write 함수는 어떻게 동작할까요? 어떻게 메모리에 있는 데이터가 write 함수를 통해 SSD나 하드디스크에 쓰여지는 것일까요?

이 글에서 write 함수가 호출되었을 때, 운영체제 내부에서 어떤 일이 일어나고 메모리에 저장된 데이터가 어떤 과정을 거쳐 디스크에 기록되는지 리눅스를 기준으로 설명하겠습니다.

0. 이 글의 대상

이 글은 대학교에서 시스템프로그래밍이나 운영체제 수업을 수강하지 않으신 분을 위한 글입니다. 따라서 운영체제 수업에서 다루는 개념을 자세하게 다루지 않고 넘어갑니다. 세부적인 내용을 알고 싶으신 분은 운영체제 이론을 학습하시기를 권장드립니다.

올바르게 설명하려는 동시에 쉽게 쓰려고 노력했습니다. 운영체제에 관한 배경지식이 없는 분이 이해하기 쉽게 설명했습니다. 글의 목적을 위해 생략하거나 엄밀하게 따지면 맞지 않은 내용이 있을 수 있습니다. 혹시 그런 부분을 발견하시더라도 양해바랍니다.

1. write()란?

먼저, write 함수의 정의를 보겠습니다.

#include <unistd.h>

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

man을 확인해보니 write 함수는 unistd.h에 선언되어 있습니다. unistd.h는 어떤 함수들이 선언된 헤더일까요? unistd.h는 POSIX 운영체제 API에 정의된 함수를 포함하는 헤더입니다. POSIX가 무엇인지는 다음 기회에 알기로 하고, 이 글에서는 unistd.h가 운영체제에서 제공하는 함수들이 정의된 헤더 파일이라고만 이해하고 넘어가겠습니다.

write 함수의 인자로는 데이터를 쓸 파일의 file descriptor, 데이터를 담고 있는 buffer 그리고 파일에 기록할 byte 수를 정하는 count가 있습니다. write 함수의 동작을 함수의 인자를 활용해 한 문장으로 설명하자면 fd에 buffer에 포함된 데이터를 count 만큼 기록한다.라고 할 수 있겠네요. 그리고 리턴 값으로 파일에 기록한 byte 수를 리턴합니다.

여기까지 man을 통해 알 수 있는 write 함수에 관한 정보를 살펴보았습니다. write 함수의 동작을 이해하니 이런 질문을 하고 싶네요.

그러면 write 함수가 호출되고 리턴되었을 때, buf에 담겨있던 데이터가 물리적인 파일(디스크에 저장된 파일)에 바로 저장되나요?

정답은 "아닐 수도 있다" 입니다.

엥? 이게 무슨 말이죠? write 함수의 호출이 끝나도 파일에 데이터가 기록이 안 될 수도 있다고요?

그렇습니다. write 함수 호출은 사실 디스크에 데이터가 저장되는 것을 보장해주지 않습니다.

그렇다면 2가지가 추가로 궁금해집니다.

  1. write 함수가 리턴된 이후에도 디스크에 데이터가 저장이 안 된다면 언제 저장이 되나요?
  2. write 함수가 리턴되었을 때 디스크에 데이터가 바로 저장되도록 보장할 수 있는 방법은 없나요?

1번 질문에 대한 답을 알아가면서 write 함수가 호출되었을 때 운영체제에서 어떤 일이 일어나는지 살펴보겠습니다. 그 다음에 2번 질문에 대한 답을 예제 코드와 함께 간단하게 설명하겠습니다.

2. write()의 동작

우리가 사용하는 write 함수는 사실 운영체제에서 제공하는 함수가 아니라 C 표준 라이브러리에서 제공하는 wrapper 함수입니다. C 프로그래머가 운영체제가 제공하는 기능을 사용하기 쉽도록 만든 함수라고 할 수 있죠. 참고로 운영체제에서 제공하는 함수를 시스템 콜(System call)이라고 부릅니다.

write 함수의 동작을 이해하기 위해서 알아야 할 운영체제 개념이 있습니다. 바로 사용자 영역(User space)과 커널 영역(Kernel space)입니다. 어려운 개념은 아니니 너무 걱정마시고, 여기서는 write 함수의 동작을 이해하기 위해 필요한 부분만 설명하겠습니다.

2.1 사용자 영역과 커널 영역

사용자 영역(User space)은 사용자 프로세스(User process)가 접근할 수 있는 메모리 영역입니다. 여기서 사용자란 운영체제의 사용자, 즉 프로그래머라고 생각하겠습니다. 여러분이 특별히 운영체제에 관심을 가지고 공부하신 게 아니라면 여태까지 여러분이 짜신 프로그램은 전부 사용자 영역만 다룬 것입니다. 우리가 코드에 write 함수를 호출한다면 그것은 사용자 영역에서 호출한 것입니다. 우리가 write에 넘긴 buf도 사용자 영역에 존재하는 데이터죠.

커널 영역(Kernel space)은 커널, 즉 운영체제가 접근할 수 있는 메모리 영역입니다. 운영체제가 하드웨어를 직접적으로 다루기 때문에 커널 영역에 존재하는 데이터는 하드웨어와 밀접한 관련이 있습니다. 파일이 저장되어 있는 디스크도 커널 영역에서만 접근이 가능하죠.

메모리에서 사용자 영역과 커널 영역을 분리한 이유는 사용자 프로세스가 하드웨어와 같은 운영체제의 중요한 자원에 맘대로 접근할 수 없도록 하기 위해서입니다. 예를 들어, 메모리 영역을 분리함으로써 사용자 영역에 있는 프로세스가 하드웨어 자원인 카메라를 맘대로 제어하는 것을 방지할 수 있습니다. 나중에 가상메모리를 다루게 된다면 사용자 영역과 커널 영역에 대해서 좀 더 자세히 설명하겠습니다.

2.2 지연된 쓰기

사용자 영역의 프로그램이 write 함수를 호출하면 사용자 영역의 버퍼에 저장된 데이터가 커널 영역의 버퍼로 복사됩니다. 커널 영역의 버퍼에 데이터를 옮겨서 데이터를 디스크에 저장하기 위해서죠.
여기서 운영체제가 write 함수의 호출이 끝나기 전에 데이터를 디스크에 저장을 하는가? 그렇지 않습니다. 왜냐하면 디스크에 데이터를 쓰는 작업은 함수 호출 시간에 비해 훨씬 오래걸리기 때문입니다.
매번 write함수를 호출할 때마다 디스크에 데이터가 기록되기를 기다리면 프로그램의 성능은 한참 떨어지겠죠. 따라서 운영체제는 디스크에 기록할 필요가 있는 버퍼에 따로 표시만 하고 바로 디스크에 기록하지 않습니다. 운영체제는 나중에 디스크에 기록할 필요가 있는 버퍼를 모아 한번에 디스크에 기록합니다. 이 작업을 지연된 쓰기(Delayed write)라고 부릅니다.

만약 write 함수를 통해 데이터를 썼지만 아직 디스크에 기록되지 않은 파일을 read 함수를 이용해 읽는다면 어떻게 될까요?
이때 운영체제는 디스크에 접근하여 데이터를 읽지 않고 버퍼에 저장된 데이터를 읽습니다. 디스크에 접근하지 않고 읽기 작업을 수행하여 읽기 성능이 향상됩니다. 어떤 프로그램이 쓰기와 읽기 작업을 번갈아 수행한다면 디스크에는 데이터가 기록되지 않은 상태에서 버퍼에 저장된 데이터만 사용됩니다. 하지만 프로그램은 프로그래머가 의도한 그대로 동작하죠.

지연된 쓰기 방식에는 문제점이 있습니다.

  1. 디스크에 기록되는 순서가 write 함수를 호출한 순서와 달라질 수 있습니다. 운영체제가 디스크에 데이터를 기록할 때 최적화를 수행하기 때문에 디스크에 데이터가 기록되는 순서가 프로그램이 write 함수를 이용해 파일에 데이터를 기록한 순서와 달라질 가능성이 있습니다. 대부분의 프로그램은 이 문제와 상관없지만, 데이터베이스와 같이 쓰기 순서가 중요한 프로그램은 쓰기 연산이 순서대로 이루어지도록 보장해야 합니다.
  2. 아직 디스크에 데이터를 기록하지 않은 상태에서 시스템이 종료되거나 디스크 오류가 발생할 수 있습니다. 이 경우 프로그램은 파일을 제대로 업데이트했다고 여기지만 실제 디스크에는 업데이트가 반영되지 않을 수도 있습니다. 이런 위험을 줄이기 위해 운영체제는 버퍼 나이라는 값을 설정해 주기적으로 버퍼 나이를 초과한 버퍼를 디스크에 기록합니다.

여기까지 1번 질문에 대합 답이었습니다.

2.3 동기화

2번 질문을 다시 보겠습니다.

write 함수가 리턴되었을 때 디스크에 데이터가 바로 저장되도록 보장할 수 있는 방법은 없나요?

정답은 "있습니다". 여러 방법이 있지만 이 글에서는 open 함수의 O_SYNC 플래그를 이용하는 방법을 설명하겠습니다.

동기화 보장 O

#include <unistd.h>
#include <fcntl.h>

int main(int argc, char *argv[])
{
	int fd;
	ssize_t ch;

	fd = open(*(argv + 1), O_WRONLY | O_CREAT | O_SYNC, 0644);
	ch = write(fd, "hello world!\n", 13);
	close(fd);
	return 0;
}
real    0m0.009s
user    0m0.001s
sys     0m0.001s

동기화 보장 X

#include <unistd.h>
#include <fcntl.h>

int main(int argc, char *argv[])
{
	int fd;
	ssize_t ch;

	fd = open(*(argv + 1), O_WRONLY | O_CREAT, 0644);
	ch = write(fd, "hello world!\n", 13);
	close(fd);
	return 0;
}
real    0m0.002s
user    0m0.002s
sys     0m0.000s

open 함수에 O_SYNC 플래그를 지정하면 파일을 동기식 입출력용으로 엽니다. 데이터를 디스크에 기록하기 전까지 쓰기 연산이 완료되지 않습니다. 다시 말해서 write 함수를 호출하면 디스크에 데이터를 기록하고 난 후에 반환됩니다. 동기화 보장 O 코드를 보시면 open 함수에 O_SYNC를 지정하고 hello world!\n를 실행 파일 인자로 주어진 파일에 쓰고 있습니다. 동기화 보장 O동기화 보장 X 의 프로그램 수행 시간을 비교하면 O_SYNC를 통해 동기화를 보장하는 경우, 그렇지 않은 경우보다 수행 시간이 더 오래걸리는 것을 확인할 수 있습니다.

O_SYNC이외에 동기화를 위해 fsync 함수 또는 fdatasync함수를 이용하는 방법도 있습니다.

3. 결론

write 함수의 동작을 살펴보며 운영체제의 개념과 내부 동작을 학습했습니다. 단순히 출력을 위해 사용하던 함수가 컴퓨터 내부에서 어떻게 돌아가는지 공부하니 컴퓨터와 운영체제라는 것이 새롭게 느껴졌습니다. 실제 동작은 위에서 설명한 것보다 훨씬 복잡합니다. 가상메모리, 페이징, 캐시, 파일시스템 등 여러 개념을 알아야 합니다. 프로젝트 하나를 해결하기 위한 공부가 아니라 꾸준히 학습해야 하는 지식입니다. 앞으로 운영체제와 리눅스를 공부하면서 정리한 내용을 계속 공유하도록 하겠습니다.

Happy coding!

profile
코딩물개

관심 있을 만한 포스트

1개의 댓글

comment-user-thumbnail
2021년 2월 19일

42서울 중인데 정말 잘 정리되었네요 :)

답글 달기