C++ I/O 성능 최적화 | io_uring·mmap·DMA·제로카피 [#51-6]
들어가며: “초당 수십 GB 처리하려는데 I/O가 따라가지 못해요”
많은 스레드가 작은 write()를 쉬지 않고 호출하는 로그 수집기에서는, 디스크 대역폭이 남아 있는데도 시스템 콜과 컨텍스트 스위칭 비용 때문에 CPU가 먼저 포화되는 일이 생깁니다. 기존 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는 호출마다 시스템 콜 한 번과 블로킹 대기가 따릅니다. io_uring은 여러 I/O를 큐에 쌓아 한 번의 시스템 콜로 제출하고, 완료도 공유 메모리의 CQ에서 모아서 확인합니다.
아래에서는 io_uring, mmap, DMA, 제로카피가 각각 어떤 비용을 줄이는지 살펴보고, 직접 측정해 볼 수 있는 벤치마크 코드를 제시합니다.
요구 환경: C++17 이상, Linux 5.1+ (io_uring), liburing
로그 쓰기·대용량 읽기·스트리밍에서 I/O가 막히는 상황
대용량 로그 파일 쓰기
로그 수집기가 여러 워커 스레드에서 동시에 파일에 씁니다. write()마다 커널 모드 전환이 발생하며, 락 경합도 있어 처리량이 제한됩니다.
먼저 사용자 공간에서 버퍼링해 write 크기를 키우고, 그래도 시스템 콜이 많다면 io_uring으로 여러 write를 SQ에 모아 한 번에 제출합니다. N개를 모아 제출하면 제출용 시스템 콜이 1/N로 줄어듭니다.
대용량 파일 순차 읽기
데이터 파이프라인에서 대용량 파일을 순차 읽습니다. read()는 커널 페이지 캐시 → 사용자 버퍼로 복사가 발생합니다.
mmap으로 파일을 가상 메모리에 매핑하면, 페이지 폴트 시 커널이 직접 디스크에서 페이지를 채우고, 사용자 공간에서는 포인터로 접근합니다. 제로카피에 가깝습니다.
네트워크 → 파일 전송 (제로카피)
스트리밍 서버에서 클라이언트 업로드를 파일로 저장합니다. recv()로 받은 데이터를 write()로 쓰면, 커널 버퍼 → 사용자 버퍼 → 커널 버퍼로 복사가 두 번 발생합니다.
소켓 → 파일 방향은 splice()로 소켓 → 파이프 → 파일을 커널 안에서 옮기면 사용자 공간 복사를 없앨 수 있습니다. sendfile()은 입력 쪽이 파일이어야 하므로 반대 방향(파일 → 소켓)에 쓰고, copy_file_range()는 파일 → 파일 복사 전용입니다.
랜덤 액세스가 많은 DB 엔진
DB 엔진이 인덱스 페이지를 랜덤하게 읽습니다. 4KB 단위로 수천 번 read()를 호출하면 시스템 콜 오버헤드가 큽니다.
io_uring으로 여러 read를 비동기 제출하며, 완료 이벤트를 배치로 처리합니다. 또는 mmap으로 인덱스 파일 전체를 매핑해 페이지 폴트로 온디맨드 로딩합니다.
실시간 스트리밍 버퍼링
실시간 인코딩 결과를 파일에 저장합니다. 동기 I/O는 블로킹되어 프레임 버퍼가 넘칩니다. io_uring 비동기 write로 I/O와 인코딩을 오버랩합니다. double buffering으로 쓰기 중 다음 프레임을 준비합니다.
시나리오별 권장 기법
| 시나리오 | 특징 | 권장 기법 |
|---|---|---|
| 대량 순차 쓰기 | 로그, 스트리밍 | io_uring, mmap |
| 대량 순차 읽기 | 파싱, 분석 | mmap |
| 파일→소켓 전송 | 제로카피 | sendfile |
| 소켓→파일 저장 | 제로카피 | splice (파이프 경유) |
| 랜덤 액세스 | DB, 인덱스 | io_uring, mmap |
| 실시간 버퍼링 | 지연 최소화 | io_uring 비동기 |
I/O 스택과 기법별 비교
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회 | 호출당 1회 (페이지 캐시↔사용자 버퍼) | 동기 | - |
| mmap | 없음(페이지 폴트로 대체) | 0회 (페이지 캐시를 직접 매핑) | 페이지 폴트 시 | - |
| io_uring | 배치 제출 1회 | read/write와 같음 (O_DIRECT면 페이지 캐시 우회) | 비동기 | 5.1+ |
| splice/sendfile | 호출당 1회 | 사용자 공간 복사 0회 | 동기 | - |
DMA (Direct Memory Access)
DMA는 CPU 개입 없이 디바이스가 메모리와 직접 데이터를 주고받는 방식입니다. 디스크 컨트롤러가 DMA로 페이지 캐시에 데이터를 쓰면, CPU는 다른 작업을 할 수 있습니다. mmap과 io_uring 모두 최종적으로 DMA를 활용합니다. “DMA를 직접 쓴다”기보다는, 복사와 시스템 콜을 줄이는 기법이 DMA 활용 효율을 높입니다.
mmap 파일 매핑
페이지 폴트와 read-ahead
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>
#include <stdexcept>
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로 쓴 내용은 페이지 캐시에 반영되므로 프로세스가 죽어도 사라지지 않고 결국 디스크에 기록됩니다. munmap은 디스크 기록을 기다려 주지 않으므로, 전원·커널 장애에도 남아야 하는 데이터라면 msync(MS_SYNC)로 기록 완료를 기다립니다.
io_uring 비동기 I/O
SQ와 CQ로 주고받는 요청
io_uring은 Linux 5.1부터 도입된 고성능 비동기 I/O 인터페이스입니다. SQ(Submission Queue)에 I/O 요청을 넣으며, CQ(Completion Queue)에서 완료 이벤트를 받습니다. 커널과 사용자가 큐를 공유 메모리로 공유하므로 완료 확인은 시스템 콜 없이 할 수 있고, 제출도 SQPOLL 모드(커널 스레드가 SQ를 폴링)를 켜면 시스템 콜 없이 가능합니다.
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 <sys/stat.h>
#include <unistd.h>
#include <cstring>
#include <iostream>
#include <stdexcept>
#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로 SQE에 read 요청을 채우고, io_uring_sqe_set_data64로 완료 시 어떤 요청인지 식별할 값을 붙입니다. io_uring_submit이 SQ의 요청을 커널에 넘기고, io_uring_wait_cqe가 CQ에서 완료를 기다립니다. read와 마찬가지로 요청한 길이보다 적게 읽히는 짧은 읽기가 가능하므로, 실제 코드에서는 cqe->res만큼 진행한 뒤 남은 부분을 다시 제출해야 합니다. 이 예제는 요청 하나만 보내므로 io_uring의 이점(여러 요청을 한 번에 제출)을 보여 주지는 않으며, 뒤의 멀티 파일 예제가 그 형태입니다.
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;
}
제로카피: 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 file_fd, int sock_fd, size_t len) {
int p[2];
if (pipe(p) < 0) return -1;
size_t total = 0;
while (total < len) {
ssize_t in = splice(file_fd, nullptr, p[1], nullptr,
len - total, SPLICE_F_MOVE | SPLICE_F_MORE);
if (in <= 0) break; // 0: EOF, <0: 에러
ssize_t left = in;
while (left > 0) { // 파이프에 들어간 만큼 소켓으로 내보냄
ssize_t out = splice(p[0], nullptr, sock_fd, nullptr,
left, SPLICE_F_MOVE | SPLICE_F_MORE);
if (out <= 0) { close(p[0]); close(p[1]); return -1; }
left -= out;
}
total += in;
}
close(p[0]);
close(p[1]);
return static_cast<ssize_t>(total);
}
소켓 → 파일 저장도 같은 방식(소켓 → 파이프 → 파일)으로 할 수 있습니다. 파일 → 소켓만 필요하다면 아래 sendfile이 더 간단합니다.
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+, glibc 2.27+). 파일 시스템에 따라 서버 측 복사나 reflink로 실제 데이터 복사 없이 끝나기도 합니다. 서로 다른 파일 시스템 사이의 복사 지원 여부는 커널 버전에 따라 달라 EXDEV로 실패할 수 있으므로, 실패하면 read/write로 대체하는 경로를 둡니다.
// 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;
size_t total = 0;
while (total < len) { // 한 번 호출로 전부 복사된다는 보장이 없음
ssize_t n = copy_file_range(fd_in, &off_in, fd_out, &off_out, len - total, 0);
if (n <= 0) return n < 0 ? -1 : static_cast<ssize_t>(total);
total += n;
}
return static_cast<ssize_t>(total);
}
mmap 라인 카운터·io_uring 병렬 읽기·제로카피 복사 예제
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;
}
io_uring 멀티 파일 병렬 읽기
// io_uring_multi_read.cpp
#include <liburing.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <unistd.h>
#include <vector>
#include <string>
#include <iostream>
struct FileReadRequest {
int fd = -1;
std::vector<char> buffer;
size_t size = 0;
};
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, -1); // 0으로 두면 실패한 항목에서 표준 입력(fd 0)을 닫게 됨
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;
if (fstat(fd, &st) < 0) continue;
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);
size_t submitted = 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);
++submitted;
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 < submitted; ++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;
}
제로카피 파일 복사
// 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;
}
ssize_t n = copy_file_zero_copy(fd_in, fd_out, st.st_size); // 위의 반복 호출 버전
close(fd_in);
close(fd_out);
return n == static_cast<ssize_t>(st.st_size);
}
munmap 순서, truncate, SQ 부족, msync 누락: 에러 해결
mmap 후 fd를 닫아도 되는가
mmap 후 close(fd)를 해도 매핑은 그대로 유효합니다. 매핑이 파일에 대한 참조를 따로 유지하기 때문입니다. 따라서 fd는 바로 닫아도 되고, 대신 매핑은 munmap으로 반드시 해제해야 합니다.
// ✅ 올바른 순서
void* addr = mmap(...);
close(fd); // mmap 후 fd는 닫아도 됨 (매핑 유지)
// ... addr 사용 ...
munmap(addr, size); // 반드시 호출
mmap 영역 접근 후 파일 truncate
다른 프로세스나 스레드가 파일을 잘라내면, 새 파일 끝을 넘어선 매핑 영역에 접근하는 순간 SIGBUS로 프로세스가 종료됩니다.
// ❌ 위험: mmap 사용 중 다른 곳에서 ftruncate(file, 0) 호출
// ✅ 해결: 매핑된 파일은 truncate하지 않거나, 매핑 해제 후 수행
munmap(addr, size);
ftruncate(fd, new_size);
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);
// ...
}
splice에서 fd 중 하나가 파이프가 아님
splice는 한쪽이 파이프여야 하므로 파일 → 소켓을 직접 이으면 EINVAL이 납니다. 파일 → 소켓은 sendfile을 쓰거나 파이프를 거칩니다.
// ❌ splice로 파일→소켓 직접 불가
splice(file_fd, nullptr, socket_fd, nullptr, len, 0); // EINVAL
// ✅ sendfile 사용
sendfile(socket_fd, file_fd, &offset, len);
mmap 크기가 0인 파일
길이 0으로 mmap을 호출하면 EINVAL로 실패합니다. 빈 파일은 미리 걸러 냅니다.
// ✅ 크기 확인 후 mmap
if (st.st_size == 0) {
return {}; // 또는 빈 결과
}
void* addr = mmap(nullptr, st.st_size, ...);
io_uring 사용 후 ring 해제 누락
io_uring_queue_exit를 호출하지 않으면 링 메모리와 링 fd가 누수됩니다.
// ✅ 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_;
};
MAP_SHARED 쓰기 후 msync 누락
MAP_SHARED로 쓴 데이터는 페이지 캐시에만 있다가 커널이 나중에 디스크로 내보냅니다. 프로세스가 죽는 것은 괜찮지만 전원·커널 장애가 나면 아직 기록되지 않은 부분을 잃을 수 있으므로, 내구성이 필요한 지점에서 msync(MS_SYNC)를 호출합니다.
// ✅ 쓰기 후 명시적 동기화
memcpy(addr, data, size);
msync(addr, size, MS_SYNC);
munmap(addr, size);
read/write·mmap·io_uring 처리량 비교
처리량은 디스크 종류, 페이지 캐시 상태(같은 파일을 두 번째 읽으면 디스크를 거의 건드리지 않음), 버퍼 크기, 커널 버전에 따라 크게 달라지므로 다른 환경의 수치를 옮겨 오기보다 직접 재는 편이 정확합니다. 측정할 때는 echo 3 | sudo tee /proc/sys/vm/drop_caches로 캐시를 비운 경우와 캐시가 찬 경우를 따로 봅니다. 일반적으로 read 루프는 4KB처럼 작은 버퍼에서 시스템 콜 비용이 두드러지고, 버퍼를 수백 KB~수 MB로 키우면 mmap과의 차이가 크게 줄어듭니다.
# 벤치마크 빌드
g++ -std=c++17 -O2 -march=native -o io_bench io_bench.cpp -luring
// 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::steady_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::steady_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::steady_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);
// read는 모든 바이트를 복사하므로, 공정하게 비교하려면 mmap도 모든 바이트를 읽어야 함
uint64_t sum = 0;
const unsigned char* p = static_cast<const unsigned char*>(addr);
for (off_t i = 0; i < st.st_size; ++i) {
sum += p[i];
}
munmap(addr, st.st_size);
auto end = std::chrono::steady_clock::now();
volatile uint64_t sink = sum; // 루프가 최적화로 제거되지 않게
(void)sink;
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";
}
기법 선택·madvise·io_uring 폴링·배치 flush
기법 선택 가이드
flowchart TD
A[I/O 요구사항] --> B{순차 vs 랜덤?}
B -->|순차 읽기| C[큰 버퍼 read, 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]
순차 읽기와 파싱 위주라면 mmap이 코드가 가장 단순하고, 쓰기 비중이 높거나 많은 요청을 동시에 띄워야 하는 경우(랜덤 읽기, 여러 파일)는 io_uring이 유리합니다. 다만 mmap은 파일 크기가 바뀌면 SIGBUS 위험이 있고, I/O 오류도 시그널로 나타나 다루기 어렵다는 점을 함께 고려합니다.
mmap + madvise 활용
// 순차 읽기: read-ahead 유도
madvise(addr, size, MADV_SEQUENTIAL);
// 랜덤 읽기: read-ahead 비활성화
madvise(addr, size, MADV_RANDOM);
// 더 이상 사용 안 함: 해당 페이지를 매핑에서 버림 (파일 매핑이면 다음 접근 시 다시 읽어 옴)
madvise(addr, size, MADV_DONTNEED);
io_uring 폴링 모드 (시스템 콜 제로)
// IORING_SETUP_SQPOLL: 커널 스레드가 SQ를 폴링 → 제출에 시스템 콜이 필요 없음
struct io_uring_params params {};
params.flags = IORING_SETUP_SQPOLL;
params.sq_thread_idle = 2000; // 2초 동안 요청이 없으면 커널 스레드가 잠듦
io_uring_queue_init_params(32, &ring, ¶ms);
// 대가: 폴링 스레드가 CPU 코어 하나를 사용. 5.11 이전 커널에서는 root 권한 필요
로그 버퍼 배치 flush
로그를 버퍼에 모아 크게 쓰는 것은 io_uring 이전에도 가장 효과가 큰 최적화입니다. io_uring으로 비동기 write를 걸 때 주의할 점은, 제출한 버퍼는 완료(CQE)를 받을 때까지 건드리면 안 된다는 것입니다. 제출 직후 버퍼를 clear()하고 다시 채우면 커널이 아직 읽고 있는 메모리를 덮어씁니다. 그래서 버퍼를 두 개 이상 두고 번갈아 씁니다.
// 버퍼 두 개를 번갈아 사용 (더블 버퍼링, 의사 코드에 가까운 예)
std::array<std::vector<char>, 2> bufs;
int active = 0;
bool in_flight[2] = {false, false};
void log_write(const char* data, size_t len) {
auto& buf = bufs[active];
buf.insert(buf.end(), data, data + len);
if (buf.size() < 64 * 1024) return;
io_uring_sqe* sqe = io_uring_get_sqe(&ring);
io_uring_prep_write(sqe, fd, buf.data(), buf.size(), -1); // offset -1: 현재 파일 위치(append)에 기록, 5.6+
io_uring_sqe_set_data64(sqe, active);
io_uring_submit(&ring);
in_flight[active] = true;
active ^= 1;
// 다음 버퍼가 아직 전송 중이면 완료를 기다린 뒤 비움
while (in_flight[active]) {
io_uring_cqe* cqe;
io_uring_wait_cqe(&ring, &cqe);
int idx = static_cast<int>(io_uring_cqe_get_data64(cqe));
in_flight[idx] = false;
bufs[idx].clear();
io_uring_cqe_seen(&ring, cqe);
}
}
참고 자료
같이 보면 좋은 글
- C++ 프로파일러 비교
- C++ 네트워크 성능 최적화 | TCP 튜닝·제로카피·커널 바이패스 [#51-7]
- C++ HTTP 클라이언트 직접 만들기
- C++ 소켓 프로그래밍
- C++ 파일 입출력: ifstream·ofstream 기초부터 에러 처리, 버퍼링, 원자적 쓰기까지
- C++ 리눅스 시스템 프로그래밍 | 시스템 콜 호출과 커널 인터페이스 이해 [#42-3]