파일 입출력 - write()

김신·2023년 1월 3일
0
post-thumbnail

write()로 쓰기

파일에 데이터를 기록하기 위해 사용하는 가장 기본적이며 일반적인 시스템 콜은 write()입니다. write()는 read()와 반대 개념의 시스템 콜이며 역시 POSIX.1에 정의되어 있습니다.

#include <unistd.h>

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

write() 호출은 count 바이트만큼 파일 디스크리터 fd가 참조하는 파일의 현재 파일 위치에 시작 지점이 buf인 내용을 기록합니다. fd가 표현하는 객체에 탐색 기능이 없다면 쓰기 작업은 항상 처음 위치에서 일어납니다.

성공하면 쓰기에 성공한 바이트 수를 반환하며 파일 오프셋도 같은 크기만큼 전진합니다. 에러가 발생하면 -1을 반환하며 errno를 적절한 값으로 설정합니다. write()호출은 0을 반활할 수 있지만, 이 반환값에 특별한 의미는 없습니다. 단순히 0바이트를 썼다고 알려줄 뿐입니다.

부분 쓰기

write() 시스템 콜은 read() 시스템 콜에서 발생하는 부분 읽기와 비교해서 부분 쓰기를 일으킬 가능성이 훨씬 적습니다. write()에는 EOF 조건도 없습니다. 일반 파일에 대한 write()는 에러가 발생하지 않을 경우 요청받은 전체 쓰기 작업 수행을 보장합니다.

따라서 일반 파일을 대상으로 쓰기 작업을 수행할 경우에는 루프 내에서 돌릴 필요가 없습니다. 하지만 다른 파일 유형을 대상으로 쓰기 작업을 수행할 경우에는 요청한 모든 바이트를 정말로 썼는지 보장하기 위해 루프가 필요할지도 모릅니다. 루프를 사용하면서 얻는 또 다른 장점은 두 번째 write() 호출이 숨어 있던 에러 코드를 반환할 가능성입니다. 여기서 에러코드는 첫 번째 write() 호출 결과로 부분 쓰기를 일으킨 원인을 밝혀줄 수도 있습니다.

덧붙이기 모드

O_APPEND 옵션을 이용해서 fd를 덧붙이기 모드로 열면 파일 디스크립터의 현재 파일 오프셋이 아니라 파일 끝에서부터 쓰기 연산이 일어납니다.

예를 들어 두 프로세스가 동일한 파일에 쓰기 작업을 진행 중이라고 가정합시다. 덧붙이기 모드가 아니라면 첫 번째 포르세스가 파일 끝에 쓴 다음 두 번째 프로세스가 동일한 작업을 하면 첫 번째 프로세스의 파일 오프셋은 더 이상 파일 끝을 가리키지 않습니다. 다시 말해, 파일 끝에서 두 번째 프로세스가 직전에 기록했던 데이터 크기만큼을 뺀 위치를 가리킬 것입니다. 이는 프로세스들이 경쟁 상태에 말려들기 때문에 다중 프로세스가 명시적인 동기화 과정 없이 동일 파일에 덧붙이는 작업이 불가능함을 의미합니다.

덧붙이기 모드는 이런 문제를 방지합니다. 덧붙이기 모드는 파일 오프셋이 항상 파일 끝에 위치하도록 설정하므로 심지어 쓰기 작업을 수행하는 프로세스가 여럿 존재할지라도 쓰기 과정에서 항상 덧붙이기 작업을 수행합니다. 이런 덧붙이기 작업을 매번 쓰기 요청에 앞서 파일 오프셋을 원자적으로 갱신하는 기능으로 생각해도 좋습니다. 파일 오프셋은 데이터를 새로 쓴 끝 부분을 가리키도록 생산됩니다. 파일 오프셋을 자동으로 갱신하므로 다음 번에 write()를 호출하면 문제가 없습니다. 하지만 어떤 이상한 이유 때문에 직후에 read()를 호출하면 문제를 일으킬 가능성도 있습니다.

논블록 쓰기

O_NONBLOCK 옵션을 지정해서 fd가 논블록 모드로 열린 상태에서 쓰기 작업이 블록된 경우, write() 시스템 콜은 -1을 반환하며 errno를 EAGAIN으로 설정합니다. 나중에 쓰기 요청을 다시 해야 합니다. 일반 파일에서는 대체로 일어나지 않는 일입니다.

write() 크기 제약

count를 0으로 둔 상태에서 write()를 호출하면 호출 즉시 0을 반환합니다.

write() 동작 방식

write() 호출이 반환될 때, 사용자 영역에서 커널에 넘긴 버퍼에서 커널 버퍼로 데이터가 복사된 상태이긴 하지만 의도한 목적지에 데이터를 썼다는 보장은 못합니다. 실제로 쓰기 호출에서 돌아오는 시간은 데이터를 쓰기에는 너무 촉박합니다. 프로세서와 하드 디스크 사이에 벌어지는 성능 격차는 이런 동작 방식이 뼈저리게 드러나도록 만듭니다.

대신에 사용자 영역 애플리케이션이 write() 시스템 콜을 호출하면 리눅스 커널은 몇 가지 점검을 수행한 다음에 단순히 데이터를 버퍼로 복사해 놓습니다. 나중에 커널은 모든 변경된 버퍼를 수집해서 최적 수준으로 정렬한 다음에 배경 작업으로 디스크에 씁니다. 이런 방식은 쓰기 호출을 번개처럼 빠르게 수행해서 거의 즉시 반환하도록 만듭니다. 또한, 커널은 좀 더 여유가 생길 때까지 쓰기 작업을 늦춰서 한꺼번에 여러 작업을 모아 배치로 수행합니다.

0개의 댓글