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, &params);
// 대가: 폴링 스레드가 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);
    }
}

참고 자료


같이 보면 좋은 글