C++ I/O 성능 최적화 | io_uring·mmap·DMA·제로카피 [#51-6]
이 글의 핵심
C++ 고성능 I/O: io_uring 비동기 I/O, mmap 파일 매핑, DMA, 제로카피 기법. 문제 시나리오, 완전한 예제, 흔한 에러, 성능 벤치마크, 프로덕션 패턴까지 실전 코드로 다룹니다.
들어가며: read/write가 병목이에요
”초당 수십 GB 처리하려는데 I/O가 따라가지 못해요”
고성능 로그 수집기에서 수백 개 스레드가 동시에 파일에 쓰고 있었습니다. write()를 쓰니 시스템 콜 오버헤드와 컨텍스트 스위칭이 누적되어, 디스크 대역폭은 30%만 활용되는데 CPU는 이미 포화 상태였습니다. io_uring으로 전환하니 동일 하드웨어에서 처리량이 3~4배 늘었습니다.
기존 read/write는 매 호출마다 커널 모드로 전환하고, 완료될 때까지 블로킹됩니다. io_uring은 SQ(Submission Queue)에 작업을 넣고, CQ(Completion Queue)에서 결과를 받는 비동기 방식이라, 배치로 제출·폴링하여 시스템 콜 횟수를 크게 줄입니다.
느린 코드 (동기 I/O):
// 동기 read/write - 매 호출마다 시스템 콜
ssize_t total = 0;
while (total < size) {
ssize_t n = read(fd, buf + total, size - total);
if (n <= 0) break;
total += n;
}
빠른 코드 (io_uring):
// io_uring - SQ에 제출, CQ에서 배치 폴링
struct io_uring ring;
io_uring_queue_init(32, &ring, 0);
// SQ에 read 요청 제출 → io_uring_submit()
// CQ에서 완료 폴링 → io_uring_wait_cqe()
원인: 동기 I/O는 호출당 1회 시스템 콜 + 컨텍스트 스위칭. io_uring은 여러 I/O를 한 번에 제출하고, 커널이 배치 처리합니다.
이 글을 읽으면:
- io_uring, mmap, DMA, 제로카피의 개념과 동작을 이해할 수 있습니다.
- 실전에서 활용할 수 있는 완전한 I/O 최적화 예제를 구현할 수 있습니다.
- 자주 발생하는 에러와 해결법을 알 수 있습니다.
- 성능 벤치마크로 최적화 효과를 확인할 수 있습니다.
- 프로덕션에서 적용할 패턴을 선택할 수 있습니다.
요구 환경: C++17 이상, Linux 5.1+ (io_uring), liburing
문제 시나리오
시나리오 1: 대용량 로그 파일 쓰기
"초당 100만 건 로그를 쓰는데, write() 호출만 해도 CPU 사용률이 80%를 넘어요."
"버퍼링을 해도 시스템 콜 횟수가 너무 많아요."
상황: 로그 수집기가 여러 워커 스레드에서 동시에 파일에 씁니다. write()마다 커널 모드 전환이 발생하고, 락 경합도 있어 처리량이 제한됩니다.
해결 포인트: io_uring으로 여러 write를 SQ에 모아 한 번에 제출하면, 시스템 콜 횟수가 1/N로 줄어듭니다. 또는 mmap + 메모리 직접 쓰기로 커널 경유를 최소화할 수 있습니다.
시나리오 2: 대용량 파일 순차 읽기
"10GB CSV 파일을 파싱하는데, read() 루프가 너무 느려요."
"페이지 캐시를 활용하고 싶은데, 사용자 공간 버퍼 복사가 아깝습니다."
상황: 데이터 파이프라인에서 대용량 파일을 순차 읽습니다. read()는 커널 페이지 캐시 → 사용자 버퍼로 복사가 발생합니다.
해결 포인트: mmap으로 파일을 가상 메모리에 매핑하면, 페이지 폴트 시 커널이 직접 디스크에서 페이지를 채우고, 사용자 공간에서는 포인터로 접근합니다. 제로카피에 가깝습니다.
시나리오 3: 네트워크 → 파일 전송 (제로카피)
"소켓에서 받은 데이터를 그대로 파일에 쓰고 싶어요."
"recv() → 버퍼 → write()로 하면 복사가 두 번 발생해요."
상황: 스트리밍 서버에서 클라이언트 업로드를 파일로 저장합니다. recv()로 받은 데이터를 write()로 쓰면, 커널 버퍼 → 사용자 버퍼 → 커널 버퍼로 복사가 두 번 발생합니다.
해결 포인트: splice() 또는 sendfile()로 커널 내부에서 직접 전달하면 사용자 공간 복사를 제거할 수 있습니다. Linux의 copy_file_range()도 유사한 효과가 있습니다.
시나리오 4: 랜덤 액세스가 많은 DB 엔진
"B-tree 인덱스 조회 시 작은 블록을 여러 번 읽어요."
"매번 read()하면 지연 시간이 누적됩니다."
상황: DB 엔진이 인덱스 페이지를 랜덤하게 읽습니다. 4KB 단위로 수천 번 read()를 호출하면 시스템 콜 오버헤드가 큽니다.
해결 포인트: io_uring으로 여러 read를 비동기 제출하고, 완료 이벤트를 배치로 처리합니다. 또는 mmap으로 인덱스 파일 전체를 매핑해 페이지 폴트로 온디맨드 로딩합니다.
시나리오 5: 실시간 스트리밍 버퍼링
"비디오 스트리밍에서 프레임을 디스크에 쓰는데, I/O 지연이 프레임 드랍을 유발해요."
상황: 실시간 인코딩 결과를 파일에 저장합니다. 동기 I/O는 블로킹되어 프레임 버퍼가 넘칩니다.
해결 포인트: io_uring 비동기 write로 I/O와 인코딩을 오버랩합니다. double buffering으로 쓰기 중 다음 프레임을 준비합니다.
시나리오별 권장 기법
| 시나리오 | 특징 | 권장 기법 |
|---|---|---|
| 대량 순차 쓰기 | 로그, 스트리밍 | io_uring, mmap |
| 대량 순차 읽기 | 파싱, 분석 | mmap |
| 소켓→파일 전송 | 제로카피 | splice, sendfile |
| 랜덤 액세스 | DB, 인덱스 | io_uring, mmap |
| 실시간 버퍼링 | 지연 최소화 | io_uring 비동기 |
목차
- 기본 개념
- mmap 파일 매핑
- io_uring 비동기 I/O
- 제로카피: splice·sendfile
- 완전한 I/O 최적화 예제
- 자주 발생하는 에러와 해결법
- 성능 벤치마크
- 프로덕션 패턴
- 정리
1. 기본 개념
I/O 스택과 병목
flowchart TB
subgraph user[사용자 공간]
U1[애플리케이션]
U2[버퍼]
end
subgraph kernel[커널 공간]
K1[페이지 캐시]
K2[VFS]
K3[블록 디바이스]
end
subgraph hw[하드웨어]
H1[DMA]
H2[디스크]
end
U1 --> U2
U2 -->|복사| K1
K1 --> K2 --> K3
K3 --> H1 --> H2
병목 지점:
- 시스템 콜: 사용자↔커널 전환 비용
- 데이터 복사: read/write 시 커널 버퍼 ↔ 사용자 버퍼 복사
- 컨텍스트 스위칭: 블로킹 I/O 시 스레드 대기
기법별 비교
| 기법 | 시스템 콜 | 복사 횟수 | 블로킹 | Linux 버전 |
|---|---|---|---|---|
| read/write | 호출당 1회 | 2회 (커널↔유저) | 동기 | - |
| mmap | 페이지 폴트 시 | 0~1회 (온디맨드) | 페이지 폴트 시 | - |
| io_uring | 배치 제출 | 2회 | 비동기 | 5.1+ |
| splice/sendfile | 1회 | 0회 (커널 내) | 동기/비동기 | - |
DMA (Direct Memory Access)
DMA는 CPU 개입 없이 디바이스가 메모리와 직접 데이터를 주고받는 방식입니다. 디스크 컨트롤러가 DMA로 페이지 캐시에 데이터를 쓰면, CPU는 다른 작업을 할 수 있습니다. mmap과 io_uring 모두 최종적으로 DMA를 활용합니다. “DMA를 직접 쓴다”기보다는, 복사와 시스템 콜을 줄이는 기법이 DMA 활용 효율을 높입니다.
2. mmap 파일 매핑
핵심 아이디어
mmap은 파일을 프로세스의 가상 주소 공간에 매핑합니다. 접근 시 페이지 폴트가 발생하면 커널이 해당 페이지를 디스크에서 읽어옵니다. 순차 읽기에서는 read-ahead로 미리 읽어 두어, 사용자 공간 버퍼 복사를 피할 수 있습니다.
flowchart LR
subgraph mmap_flow[mmap 동작]
A[파일] --> B[가상 메모리 매핑]
B --> C[포인터로 접근]
C --> D[페이지 폴트 시 로드]
end
mmap 기본 사용
// mmap_basic.cpp
// g++ -std=c++17 -O2 -o mmap_basic mmap_basic.cpp
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>
#include <cstring>
#include <iostream>
class MmapFile {
public:
MmapFile(const char* path) {
fd_ = open(path, O_RDONLY);
if (fd_ < 0) {
throw std::runtime_error("open failed");
}
struct stat st;
if (fstat(fd_, &st) < 0) {
close(fd_);
throw std::runtime_error("fstat failed");
}
size_ = st.st_size;
if (size_ == 0) {
data_ = nullptr;
return;
}
data_ = static_cast<const char*>(
mmap(nullptr, size_, PROT_READ, MAP_PRIVATE, fd_, 0));
if (data_ == MAP_FAILED) {
close(fd_);
throw std::runtime_error("mmap failed");
}
// 순차 읽기 힌트: 커널이 read-ahead 수행
madvise(data_, size_, MADV_SEQUENTIAL);
}
~MmapFile() {
if (data_ && data_ != MAP_FAILED) {
munmap(const_cast<char*>(data_), size_);
}
if (fd_ >= 0) close(fd_);
}
const char* data() const { return data_; }
size_t size() const { return size_; }
private:
int fd_ = -1;
const char* data_ = nullptr;
size_t size_ = 0;
};
int main(int argc, char* argv[]) {
if (argc < 2) return 1;
MmapFile file(argv[1]);
// 포인터로 직접 접근 (복사 없음)
size_t count = 0;
for (size_t i = 0; i < file.size(); ++i) {
if (file.data()[i] == '\n') ++count;
}
std::cout << "Lines: " << count << "\n";
return 0;
}
코드 설명:
MAP_PRIVATE: 쓰기 시 copy-on-write. 읽기 전용이면PROT_READ만으로 충분.MADV_SEQUENTIAL: 커널에 순차 접근을 알려 read-ahead 최적화.munmap전에data_접근 금지. 소멸자 순서 주의.
mmap 쓰기 (파일 생성)
// mmap_write.cpp
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>
#include <cstring>
void write_file_mmap(const char* path, const void* data, size_t size) {
int fd = open(path, O_RDWR | O_CREAT | O_TRUNC, 0644);
if (fd < 0) return;
if (ftruncate(fd, size) < 0) {
close(fd);
return;
}
void* addr = mmap(nullptr, size, PROT_READ | PROT_WRITE,
MAP_SHARED, fd, 0);
if (addr == MAP_FAILED) {
close(fd);
return;
}
memcpy(addr, data, size);
msync(addr, size, MS_SYNC); // 디스크에 반영
munmap(addr, size);
close(fd);
}
주의: MAP_SHARED로 쓸 때 msync()로 디스크 동기화. munmap 시 자동 flush되지만, 크래시 대비해 명시적 sync 권장.
3. io_uring 비동기 I/O
핵심 아이디어
io_uring은 Linux 5.1부터 도입된 고성능 비동기 I/O 인터페이스입니다. SQ(Submission Queue)에 I/O 요청을 넣고, CQ(Completion Queue)에서 완료 이벤트를 받습니다. 커널과 사용자가 공유 메모리로 큐를 공유해 시스템 콜 없이 제출·완료 확인이 가능합니다 (폴링 모드).
flowchart TB
subgraph app[애플리케이션]
SQ[Submission Queue]
CQ[Completion Queue]
end
subgraph kernel[커널]
K[io_uring]
end
SQ -->|제출| K
K -->|완료| CQ
liburing 설치
# Ubuntu/Debian
sudo apt install liburing-dev
# 빌드
g++ -std=c++17 -O2 -o io_uring_example io_uring_example.cpp -luring
io_uring 기본 예제 (비동기 읽기)
// io_uring_read.cpp
// g++ -std=c++17 -O2 -o io_uring_read io_uring_read.cpp -luring
#include <liburing.h>
#include <fcntl.h>
#include <cstring>
#include <iostream>
#include <vector>
class IoUringReader {
public:
explicit IoUringReader(unsigned queue_depth = 32)
: queue_depth_(queue_depth) {
if (io_uring_queue_init(queue_depth, &ring_, 0) < 0) {
throw std::runtime_error("io_uring_queue_init failed");
}
}
~IoUringReader() {
io_uring_queue_exit(&ring_);
}
std::vector<char> read_file(const char* path) {
int fd = open(path, O_RDONLY);
if (fd < 0) throw std::runtime_error("open failed");
struct stat st;
if (fstat(fd, &st) < 0) {
close(fd);
throw std::runtime_error("fstat failed");
}
size_t file_size = st.st_size;
if (file_size == 0) {
close(fd);
return {};
}
std::vector<char> buffer(file_size);
struct io_uring_sqe* sqe = io_uring_get_sqe(&ring_);
if (!sqe) {
close(fd);
throw std::runtime_error("get_sqe failed");
}
io_uring_prep_read(sqe, fd, buffer.data(), file_size, 0);
io_uring_sqe_set_data64(sqe, reinterpret_cast<uint64_t>(buffer.data()));
if (io_uring_submit(&ring_) < 0) {
close(fd);
throw std::runtime_error("submit failed");
}
struct io_uring_cqe* cqe = nullptr;
if (io_uring_wait_cqe(&ring_, &cqe) < 0) {
close(fd);
throw std::runtime_error("wait_cqe failed");
}
ssize_t result = cqe->res;
io_uring_cqe_seen(&ring_, cqe);
close(fd);
if (result < 0) {
throw std::runtime_error("read failed");
}
buffer.resize(static_cast<size_t>(result));
return buffer;
}
private:
struct io_uring ring_;
unsigned queue_depth_;
};
int main(int argc, char* argv[]) {
if (argc < 2) return 1;
IoUringReader reader;
auto data = reader.read_file(argv[1]);
std::cout << "Read " << data.size() << " bytes\n";
return 0;
}
코드 설명:
io_uring_prep_read: read 요청 준비.io_uring_sqe_set_data64: 완료 시 사용자 데이터로 식별.io_uring_submit: SQ에 있는 요청을 커널에 제출.io_uring_wait_cqe: CQ에서 완료 대기.
io_uring 배치 쓰기
// io_uring_batch_write.cpp
#include <liburing.h>
#include <fcntl.h>
#include <vector>
#include <cstring>
int batch_write_uring(const char* path,
const std::vector<std::pair<const void*, size_t>>& chunks) {
int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd < 0) return -1;
struct io_uring ring;
if (io_uring_queue_init(32, &ring, 0) < 0) {
close(fd);
return -1;
}
off_t offset = 0;
for (size_t i = 0; i < chunks.size(); ++i) {
struct io_uring_sqe* sqe = io_uring_get_sqe(&ring);
if (!sqe) break;
io_uring_prep_write(sqe, fd, chunks[i].first, chunks[i].second, offset);
io_uring_sqe_set_data64(sqe, i);
offset += chunks[i].second;
}
int submitted = io_uring_submit(&ring);
if (submitted < 0) {
io_uring_queue_exit(&ring);
close(fd);
return -1;
}
for (int i = 0; i < submitted; ++i) {
struct io_uring_cqe* cqe = nullptr;
io_uring_wait_cqe(&ring, &cqe);
if (cqe->res < 0) {
io_uring_cqe_seen(&ring, cqe);
io_uring_queue_exit(&ring);
close(fd);
return -1;
}
io_uring_cqe_seen(&ring, cqe);
}
io_uring_queue_exit(&ring);
close(fd);
return 0;
}
4. 제로카피: splice·sendfile
splice: 파이프 없이 파일↔소켓
splice는 두 파일 디스크립터 간에 데이터를 커널 내부에서 이동합니다. 사용자 공간 버퍼를 거치지 않습니다.
// splice_example.cpp
// g++ -std=c++17 -o splice_example splice_example.cpp
#include <fcntl.h>
#include <unistd.h>
#include <cerrno>
#include <cstring>
#include <iostream>
// 파일에서 소켓으로 데이터 전송 (제로카피)
ssize_t splice_file_to_socket(int fd_in, int fd_out, size_t len) {
size_t total = 0;
while (total < len) {
ssize_t n = splice(fd_in, nullptr, fd_out, nullptr,
len - total, SPLICE_F_MOVE | SPLICE_F_MORE);
if (n < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) continue;
return -1;
}
if (n == 0) break;
total += n;
}
return static_cast<ssize_t>(total);
}
주의: splice는 fd_in과 fd_out 중 하나는 파이프여야 합니다. 파일→소켓 직접은 불가. 파일→파이프→소켓으로 두 번 splice해야 합니다.
sendfile: 파일 → 소켓 (제로카피)
sendfile은 파일에서 소켓으로 직접 전송합니다. 파이프 없이 사용 가능합니다.
// sendfile_example.cpp
#include <sys/sendfile.h>
#include <fcntl.h>
#include <unistd.h>
ssize_t send_file_to_socket(int out_fd, int in_fd, off_t* offset, size_t count) {
return sendfile(out_fd, in_fd, offset, count);
}
// 사용 예: HTTP 파일 서버에서 정적 파일 전송
void serve_file(int client_fd, int file_fd, size_t file_size) {
off_t offset = 0;
size_t remaining = file_size;
while (remaining > 0) {
ssize_t sent = sendfile(client_fd, file_fd, &offset, remaining);
if (sent <= 0) break;
remaining -= sent;
}
}
copy_file_range: 파일 → 파일 (제로카피)
copy_file_range는 한 파일에서 다른 파일로 커널 내부 복사합니다. Linux 4.5+.
// copy_file_range_example.cpp
#include <unistd.h>
#include <fcntl.h>
ssize_t copy_file_zero_copy(int fd_in, int fd_out, size_t len) {
off_t off_in = 0, off_out = 0;
return copy_file_range(fd_in, &off_in, fd_out, &off_out, len, 0);
}
5. 완전한 I/O 최적화 예제
예제 1: mmap 기반 라인 카운터 (wc -l 대체)
// mmap_line_count.cpp
// g++ -std=c++17 -O2 -o mmap_line_count mmap_line_count.cpp
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>
#include <algorithm>
#include <iostream>
uint64_t count_lines_mmap(const char* path) {
int fd = open(path, O_RDONLY);
if (fd < 0) return 0;
struct stat st;
if (fstat(fd, &st) < 0) {
close(fd);
return 0;
}
if (st.st_size == 0) {
close(fd);
return 0;
}
void* addr = mmap(nullptr, st.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
close(fd);
if (addr == MAP_FAILED) return 0;
madvise(addr, st.st_size, MADV_SEQUENTIAL);
const char* p = static_cast<const char*>(addr);
const char* end = p + st.st_size;
uint64_t count = std::count(p, end, '\n');
munmap(addr, st.st_size);
return count;
}
int main(int argc, char* argv[]) {
if (argc < 2) return 1;
std::cout << count_lines_mmap(argv[1]) << "\n";
return 0;
}
예제 2: io_uring 멀티 파일 병렬 읽기
// io_uring_multi_read.cpp
#include <liburing.h>
#include <fcntl.h>
#include <vector>
#include <string>
#include <iostream>
struct FileReadRequest {
int fd;
std::vector<char> buffer;
size_t size;
};
std::vector<std::vector<char>> read_files_parallel(
const std::vector<std::string>& paths) {
const size_t n = paths.size();
std::vector<FileReadRequest> requests(n);
std::vector<int> fds(n);
for (size_t i = 0; i < n; ++i) {
int fd = open(paths[i].c_str(), O_RDONLY);
if (fd < 0) continue;
fds[i] = fd;
struct stat st;
fstat(fd, &st);
requests[i].fd = fd;
requests[i].size = st.st_size;
requests[i].buffer.resize(st.st_size);
}
struct io_uring ring;
io_uring_queue_init(static_cast<unsigned>(n), &ring, 0);
for (size_t i = 0; i < n; ++i) {
if (requests[i].size == 0) continue;
struct io_uring_sqe* sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, requests[i].fd, requests[i].buffer.data(),
requests[i].size, 0);
io_uring_sqe_set_data64(sqe, i);
}
io_uring_submit(&ring);
std::vector<std::vector<char>> results(n);
for (size_t i = 0; i < n; ++i) {
struct io_uring_cqe* cqe = nullptr;
io_uring_wait_cqe(&ring, &cqe);
uint64_t idx = io_uring_cqe_get_data64(cqe);
if (cqe->res > 0) {
requests[idx].buffer.resize(cqe->res);
results[idx] = std::move(requests[idx].buffer);
}
io_uring_cqe_seen(&ring, cqe);
}
for (int fd : fds) {
if (fd >= 0) close(fd);
}
io_uring_queue_exit(&ring);
return results;
}
예제 3: 제로카피 파일 복사
// zero_copy_copy.cpp
#include <fcntl.h>
#include <unistd.h>
#include <sys/stat.h>
bool copy_file_zero_copy(const char* src, const char* dst) {
int fd_in = open(src, O_RDONLY);
if (fd_in < 0) return false;
struct stat st;
if (fstat(fd_in, &st) < 0) {
close(fd_in);
return false;
}
int fd_out = open(dst, O_WRONLY | O_CREAT | O_TRUNC, st.st_mode);
if (fd_out < 0) {
close(fd_in);
return false;
}
off_t off_in = 0, off_out = 0;
ssize_t n = copy_file_range(fd_in, &off_in, fd_out, &off_out,
st.st_size, 0);
close(fd_in);
close(fd_out);
return n == static_cast<ssize_t>(st.st_size);
}
6. 자주 발생하는 에러와 해결법
문제 1: mmap 후 파일 close 시도 전에 munmap
증상: 크래시, 정의되지 않은 동작
원인: mmap 후 close(fd)를 해도 매핑은 유효합니다. 하지만 munmap 전에 close하면 fd가 재사용될 수 있어, 일부 환경에서 문제가 될 수 있습니다. 일반적으로 mmap 후 close는 허용되지만, munmap을 반드시 호출해야 합니다.
// ✅ 올바른 순서
void* addr = mmap(...);
close(fd); // mmap 후 fd는 닫아도 됨 (매핑 유지)
// ... addr 사용 ...
munmap(addr, size); // 반드시 호출
문제 2: mmap 영역 접근 후 파일 truncate
증상: SIGBUS, 크래시
원인: 다른 프로세스나 스레드가 파일을 잘라내면, 매핑된 페이지가 무효화됩니다.
// ❌ 위험: mmap 사용 중 다른 곳에서 ftruncate(file, 0) 호출
// ✅ 해결: 매핑된 파일은 truncate하지 않거나, 매핑 해제 후 수행
munmap(addr, size);
ftruncate(fd, new_size);
문제 3: io_uring SQ 풀 부족
증상: io_uring_get_sqe가 nullptr 반환
원인: SQ에 빈 슬롯이 없음. 제출 전에 CQ에서 완료된 항목을 회수해 슬롯을 비워야 합니다.
// ❌ 잘못된 예
for (int i = 0; i < 1000; ++i) {
struct io_uring_sqe* sqe = io_uring_get_sqe(&ring);
if (!sqe) break; // 32개 제출 후 더 이상 슬롯 없음
io_uring_prep_read(sqe, ...);
}
io_uring_submit(&ring);
// ✅ 올바른 예: 제출 후 완료 회수
while (작업 남음) {
io_uring_submit(&ring);
struct io_uring_cqe* cqe;
while (io_uring_peek_cqe(&ring, &cqe) == 0) {
io_uring_cqe_seen(&ring, cqe);
}
// 이제 새 sqe 사용 가능
struct io_uring_sqe* sqe = io_uring_get_sqe(&ring);
// ...
}
문제 4: splice에서 fd 중 하나가 파이프가 아님
증상: EINVAL
원인: splice는 한쪽이 파이프여야 합니다. 파일→소켓 직접은 sendfile 사용.
// ❌ splice로 파일→소켓 직접 불가
splice(file_fd, nullptr, socket_fd, nullptr, len, 0); // EINVAL
// ✅ sendfile 사용
sendfile(socket_fd, file_fd, &offset, len);
문제 5: mmap 크기가 0인 파일
증상: 에러 또는 예외
원인: 크기 0 파일에 mmap을 호출하면 동작이 정의되지 않았거나 실패할 수 있습니다.
// ✅ 크기 확인 후 mmap
if (st.st_size == 0) {
return {}; // 또는 빈 결과
}
void* addr = mmap(nullptr, st.st_size, ...);
문제 6: io_uring 사용 후 ring 해제 누락
증상: 리소스 누수, 파일 디스크립터 누수
원인: io_uring_queue_exit를 호출하지 않음.
// ✅ RAII로 관리
class IoUringGuard {
public:
IoUringGuard(struct io_uring* r, unsigned depth) : ring_(r) {
io_uring_queue_init(depth, ring_, 0);
}
~IoUringGuard() { io_uring_queue_exit(ring_); }
private:
struct io_uring* ring_;
};
문제 7: MAP_SHARED 쓰기 후 msync 누락
증상: 크래시 시 데이터 손실
원인: MAP_SHARED로 쓴 데이터는 msync 또는 munmap 전까지 디스크에 반영되지 않을 수 있습니다.
// ✅ 쓰기 후 명시적 동기화
memcpy(addr, data, size);
msync(addr, size, MS_SYNC);
munmap(addr, size);
7. 성능 벤치마크
테스트 환경 (예시)
- CPU: Intel Xeon 또는 AMD EPYC
- 디스크: NVMe SSD
- 파일 크기: 1GB
- 컴파일:
g++ -std=c++17 -O2 -march=native
# 벤치마크 빌드
g++ -std=c++17 -O2 -march=native -o io_bench io_bench.cpp -luring
벤치마크 결과 (예시)
| 방식 | 1GB 읽기 (ms) | 처리량 (GB/s) | 상대 속도 |
|---|---|---|---|
| read 루프 (4KB) | 850 | 1.18 | 1.0x |
| read 루프 (1MB) | 320 | 3.1 | 2.7x |
| mmap | 180 | 5.6 | 4.7x |
| io_uring (32 depth) | 150 | 6.7 | 5.7x |
벤치마크 코드 예시
// io_benchmark.cpp
#include <chrono>
#include <fcntl.h>
#include <iostream>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>
#include <vector>
void bench_read(const char* path, size_t buf_size) {
int fd = open(path, O_RDONLY);
struct stat st;
fstat(fd, &st);
std::vector<char> buf(buf_size);
auto start = std::chrono::high_resolution_clock::now();
size_t total = 0;
while (total < st.st_size) {
ssize_t n = read(fd, buf.data(), buf.size());
if (n <= 0) break;
total += n;
}
auto end = std::chrono::high_resolution_clock::now();
double ms = std::chrono::duration<double, std::milli>(end - start).count();
std::cout << "read(" << buf_size << "): " << ms << " ms, "
<< (st.st_size / 1024.0 / 1024.0 / 1024.0) / (ms / 1000.0)
<< " GB/s\n";
close(fd);
}
void bench_mmap(const char* path) {
int fd = open(path, O_RDONLY);
struct stat st;
fstat(fd, &st);
if (st.st_size == 0) return;
auto start = std::chrono::high_resolution_clock::now();
void* addr = mmap(nullptr, st.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
close(fd);
if (addr == MAP_FAILED) return;
madvise(addr, st.st_size, MADV_SEQUENTIAL);
volatile char sum = 0;
const char* p = static_cast<const char*>(addr);
for (size_t i = 0; i < st.st_size; i += 4096) {
sum += p[i];
}
munmap(addr, st.st_size);
auto end = std::chrono::high_resolution_clock::now();
(void)sum;
double ms = std::chrono::duration<double, std::milli>(end - start).count();
std::cout << "mmap: " << ms << " ms, "
<< (st.st_size / 1024.0 / 1024.0 / 1024.0) / (ms / 1000.0)
<< " GB/s\n";
}
제로카피 vs 일반 복사
| 방식 | 100MB 복사 (ms) | 상대 |
|---|---|---|
| read + write | 45 | 1.0x |
| copy_file_range | 12 | 3.75x |
8. 프로덕션 패턴
패턴 1: 기법 선택 가이드
flowchart TD
A[I/O 요구사항] --> B{순차 vs 랜덤?}
B -->|순차 읽기| C[mmap 또는 io_uring]
B -->|랜덤 읽기| D[io_uring]
A --> E{파일↔소켓?}
E -->|Yes| F[sendfile / splice]
E -->|No| G{동기 vs 비동기?}
G -->|비동기| H[io_uring]
G -->|동기| I[mmap 또는 read]
패턴 2: mmap + madvise 활용
// 순차 읽기: read-ahead 유도
madvise(addr, size, MADV_SEQUENTIAL);
// 랜덤 읽기: read-ahead 비활성화
madvise(addr, size, MADV_RANDOM);
// 더 이상 사용 안 함: 메모리 해제 힌트
madvise(addr, size, MADV_DONTNEED);
패턴 3: io_uring 폴링 모드 (시스템 콜 제로)
// IORING_SETUP_SQPOLL: 커널 스레드가 SQ를 폴링
struct io_uring_params params {};
params.flags = IORING_SETUP_SQPOLL;
io_uring_queue_init_params(32, &ring, ¶ms);
// submit 후 시스템 콜 없이 완료 대기 가능 (제한적)
패턴 4: 로그 버퍼 배치 flush
// 스레드 로컬 버퍼에 쌓고, io_uring으로 배치 write
thread_local std::vector<char> log_buffer;
constexpr size_t FLUSH_THRESHOLD = 64 * 1024;
void log_write(const char* data, size_t len) {
log_buffer.insert(log_buffer.end(), data, data + len);
if (log_buffer.size() >= FLUSH_THRESHOLD) {
io_uring_submit_write(&ring, fd, log_buffer.data(), log_buffer.size());
log_buffer.clear();
}
}
패턴 5: 구현 체크리스트
- [ ] mmap: 파일 크기 0 체크, munmap 반드시 호출
- [ ] mmap 쓰기: msync 또는 munmap 전 동기화
- [ ] io_uring: SQ 풀 부족 시 CQ 회수 후 재시도
- [ ] io_uring: io_uring_queue_exit 호출
- [ ] splice: 한쪽 fd가 파이프인지 확인
- [ ] sendfile: 파일→소켓 전용
- [ ] 프로덕션: Linux 버전 확인 (io_uring 5.1+)
9. 정리
| 기법 | 용도 | 장점 | 한계 |
|---|---|---|---|
| mmap | 순차/랜덤 파일 접근 | 제로카피에 가까움, 페이지 캐시 활용 | 큰 파일 시 메모리, truncate 주의 |
| io_uring | 비동기 I/O, 배치 처리 | 시스템 콜 감소, 높은 처리량 | Linux 5.1+, 복잡도 |
| splice | 파이프 경유 전송 | 제로카피 | 한쪽이 파이프여야 함 |
| sendfile | 파일→소켓 | 제로카피, 단순 | 방향 고정 |
| copy_file_range | 파일→파일 | 제로카피 | Linux 4.5+ |
핵심 원칙:
- 순차 읽기는 mmap 우선 검토
- 고처리량은 io_uring 배치 제출
- 파일→소켓은 sendfile
- 파일→파일 복사는 copy_file_range
- 프로파일링으로 병목 확인 후 적용
참고 자료
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. 고성능 파일 서버, 데이터베이스, 로그 수집기, 스트리밍 서비스 등 I/O 집약적 시스템 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.
Q. io_uring은 Windows에서 쓸 수 있나요?
A. io_uring은 Linux 전용입니다. Windows에서는 IOCP(IO Completion Port)를 사용합니다. Boost.Asio가 플랫폼별 I/O를 추상화합니다.
Q. mmap vs io_uring 중 뭘 써야 하나요?
A. 순차 읽기·작은 파일·파싱 위주면 mmap이 단순합니다. 대량 비동기 I/O·랜덤 액세스·쓰기 비중이 높으면 io_uring이 유리합니다.
Q. 선행으로 읽으면 좋은 글은?
A. 입출력 최적화 #32-1, 파일 I/O 기초 #11-1를 먼저 읽으면 좋습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
한 줄 요약: io_uring·mmap·제로카피를 상황에 맞게 선택하면 I/O 병목을 크게 줄일 수 있습니다.
이전 글: C++ Lock-Free 프로그래밍 #51-5
다음 글: (시리즈 계속)
관련 글
- C++ 고급 프로파일링 완벽 가이드 | perf·gprof
- C++ 네트워크 성능 최적화 | TCP 튜닝·제로카피·커널 바이패스 [#51-7]
- C++ HTTP 클라이언트 완벽 가이드 | REST API 호출·연결 풀·타임아웃·프로덕션 패턴
- C++ 소켓 프로그래밍 완벽 가이드 | TCP/UDP·소켓 옵션·논블로킹·에러 처리 [#28-1]