C++ 핸들러 메모리 최적화 | 동적 할당 오버헤드 제거 [#5]

이 글의 핵심

연결 수가 많아지면 작은 핸들러 할당이 malloc 경합과 단편화로 이어져 병목이 됩니다. 할당자가 개입하는 경로를 따라가며 합성 부하로 상대 비교하는 방법과 프로파일링 도구를 소개하고, 스레드 로컬 풀을 멀티스레드 run()과 함께 쓸 때 다른 스레드에서 해제되어 메모리가 깨지는 함정도 다룹니다.

들어가며: 완료 핸들러는 어디에 저장되는가

async_read_some이나 async_write를 호출하면, 연산이 끝날 때까지 Asio는 완료 핸들러(람다, 바인드 결과 등)와 연산 상태를 어딘가에 보관해야 합니다. 이 저장소는 연산마다 새로 필요하므로, 초당 수만 번 완료가 일어나는 서버에서는 그만큼 할당과 해제가 반복됩니다. 이 비용이 프로파일에서 malloc/free나 operator new로 상위에 보이기 시작하면 할당 경로를 손볼 때입니다.

연결 수가 적거나 프로토타입 단계라면 기본 동작으로 충분합니다. 먼저 #1~#4에서 run(), strand, post 같은 기초를 익힌 뒤, 성능 튜닝 단계에서 이 글을 활용하면 됩니다.


핸들러와 동적 할당

왜 할당이 일어나는가

void Session::do_read() {
    socket_.async_read_some(boost::asio::buffer(buf_),
        [self = shared_from_this()](const boost::system::error_code& ec, std::size_t n) {
            if (!ec) self->on_read(n);
        });
}

이 람다는 async_read_some이 반환된 뒤에도, 즉 읽기가 완료될 때까지 살아 있어야 합니다. Asio는 람다와 연산 상태(버퍼, 소켓 참조 등)를 묶은 연산 객체를 만들고, 이 객체의 메모리를 핸들러에 연결된 할당자(associated allocator)로 얻습니다. 핸들러에 아무 할당자도 연결하지 않으면 기본값인 std::allocator<void>가 쓰입니다.

여기서 알아 둘 점이 있습니다. Asio는 기본 할당자를 쓰는 연산에 대해 내부적으로 스레드별 재사용 캐시(recycling allocator)를 둡니다. 방금 해제된 연산 메모리 몇 블록을 스레드 로컬로 보관했다가 다음 연산에 다시 내주기 때문에, 단순한 읽기·쓰기 루프에서는 연산마다 malloc이 호출되지 않는 경우가 많습니다. 그래서 커스텀 할당자의 이득은 핸들러 크기가 캐시 블록보다 크거나, 할당한 스레드와 해제한 스레드가 자주 달라 캐시가 잘 맞지 않거나, 동시에 진행 중인 연산이 많은 경우에 주로 나타납니다. 정확한 캐시 크기와 동작은 Asio 버전마다 다르므로, 도입 전에 실제로 할당이 일어나는지부터 측정해야 합니다.

핸들러 크기부터 줄이기

할당자를 바꾸기 전에 할 일은 핸들러를 작게 만드는 것입니다. 문자열이나 벡터를 값으로 캡처하면 연산 객체가 커지고, 캡처한 컨테이너 자체도 따로 힙 할당을 합니다. 세션 상태는 shared_ptr로 관리하는 객체에 모아 두고 핸들러는 그 포인터만 캡처하면, 연산 객체가 포인터 몇 개 크기로 줄어들어 재사용 캐시에 들어갈 가능성도 높아집니다.


Asio의 핸들러 할당 메커니즘

레거시 훅: asio_handler_allocate

과거 Asio는 핸들러 타입에 대해 ADL로 찾는 asio_handler_allocate/asio_handler_deallocate 함수를 오버로드해 할당을 가로채는 방식을 썼습니다. 이 훅은 deprecated된 뒤 최신 버전에서 제거되었으므로, 오래된 예제에서 이 함수를 보면 아래의 associated allocator 방식으로 바꿔야 합니다.

associated_allocator와 bind_allocator

현재 방식에서 할당자는 executor가 아니라 완료 핸들러에 연결됩니다. Asio는 연산 객체를 만들 때 boost::asio::get_associated_allocator(handler)로 할당자를 조회하며, 핸들러에 할당자를 연결하는 방법은 두 가지입니다.

  1. 핸들러 클래스에 allocator_type 타입과 get_allocator() 멤버를 정의합니다.
  2. Boost 1.79(Asio 1.22)부터 제공되는 boost::asio::bind_allocator(alloc, handler)로 기존 람다를 감쌉니다.

Asio는 핸들러를 호출하기 직전에 연산 객체의 메모리를 먼저 해제한다고 보장합니다. 그래서 핸들러 안에서 다음 비동기 연산을 시작하면, 방금 해제된 그 메모리를 바로 다시 쓸 수 있습니다. 아래 예제가 연결당 버퍼 하나로 충분한 이유가 이것입니다.


커스텀 할당자 연결하기

Asio 배포본의 allocation 예제와 같은 구조로, 연결마다 고정 크기 버퍼를 하나 두고 핸들러 메모리를 거기서 꺼내 쓰는 할당자입니다.

#include <boost/asio.hpp>
#include <cstddef>
#include <new>

// 한 연결에서 동시에 진행 중인 읽기가 하나뿐이라면, 그 핸들러용 저장소는 버퍼 하나로 충분하다.
class handler_memory {
public:
    void* allocate(std::size_t size) {
        if (!in_use_ && size <= sizeof(storage_)) {
            in_use_ = true;
            return storage_;
        }
        return ::operator new(size);  // 버퍼가 사용 중이거나 너무 크면 힙으로 폴백
    }
    void deallocate(void* p) noexcept {
        if (p == storage_) in_use_ = false;
        else ::operator delete(p);
    }
private:
    alignas(std::max_align_t) unsigned char storage_[1024];
    bool in_use_ = false;
};

template <typename T>
class handler_allocator {
public:
    using value_type = T;
    explicit handler_allocator(handler_memory& mem) noexcept : memory_(&mem) {}
    template <typename U>
    handler_allocator(const handler_allocator<U>& other) noexcept : memory_(other.memory_) {}

    T* allocate(std::size_t n) const { return static_cast<T*>(memory_->allocate(sizeof(T) * n)); }
    void deallocate(T* p, std::size_t) const noexcept { memory_->deallocate(p); }

    template <typename U>
    bool operator==(const handler_allocator<U>& o) const noexcept { return memory_ == o.memory_; }
    template <typename U>
    bool operator!=(const handler_allocator<U>& o) const noexcept { return memory_ != o.memory_; }
private:
    template <typename> friend class handler_allocator;
    handler_memory* memory_;
};

연결 쪽에서는 세션 객체가 handler_memory를 멤버로 갖고, 비동기 호출마다 bind_allocator로 핸들러를 감쌉니다.

void Session::do_read() {
    socket_.async_read_some(boost::asio::buffer(buf_),
        boost::asio::bind_allocator(
            handler_allocator<int>(read_memory_),
            [self = shared_from_this()](const boost::system::error_code& ec, std::size_t n) {
                if (!ec) self->on_read(n);
            }));
}

할당자의 value_type은 무엇이든 상관없습니다. Asio가 필요한 타입으로 rebind해서 쓰기 때문입니다. 읽기와 쓰기가 동시에 진행될 수 있다면 read_memory_와 write_memory_처럼 연산 종류마다 버퍼를 따로 둬야 합니다. 하나의 버퍼를 두 연산이 나눠 쓰면 두 번째 연산은 매번 힙 폴백으로 빠집니다.

handler_memory의 in_use_ 플래그는 동기화 없이 읽고 씁니다. 이것이 안전한 이유는 한 연결의 읽기가 한 번에 하나만 진행되고, 할당(다음 읽기 시작)과 해제(완료 직전)가 순서대로 일어나기 때문입니다. 여러 스레드가 run()을 돌리더라도 한 연결의 핸들러들이 같은 strand에서 실행되면 이 순서가 유지됩니다.


스레드 로컬 풀과 멀티스레드 run()의 함정

연결별 버퍼 대신 스레드마다 블록 풀을 두는 방식도 많이 시도됩니다. 락 없이 할당·해제를 할 수 있다는 장점이 있지만, 함정이 하나 있습니다. 연산 객체는 비동기 연산을 시작한 스레드에서 할당되고, 해제는 완료를 처리하는 스레드에서 일어납니다. 여러 스레드가 같은 io_context의 run()을 돌리면 이 둘이 다른 스레드일 수 있고, 그러면 스레드 A의 풀에서 나온 블록이 스레드 B의 풀로 반납되거나, 동기화 없이 다른 스레드의 풀 자료구조를 건드려 메모리가 깨집니다.

이 문제를 피하는 방법은 세 가지입니다.

  • 블록 헤더에 소유 풀을 기록해 두고, 다른 스레드가 해제한 블록은 소유 풀의 원격 해제 큐(락 또는 lock-free 스택)로 돌려보냅니다.
  • 스레드마다 io_context를 하나씩 두는 구조(io_context per thread)로 바꿔, 한 연결의 모든 연산이 한 스레드에서만 시작되고 완료되게 합니다.
  • 스레드 로컬을 포기하고 락이 있는 공유 풀이나, 앞 절처럼 연결 단위 저장소를 씁니다.

고정 블록 풀 스케치

아래는 단일 스레드에서 쓰는 고정 블록 풀의 교육용 스케치입니다. 위 규약 중 하나와 함께 써야 하며, 프로덕션에서는 통계와 디버그용 포이즈닝을 더합니다.

#include <cstddef>
#include <memory>
#include <new>
#include <vector>

inline constexpr std::size_t kBlock = 512;  // 자주 쓰이는 핸들러 크기 상한에 맞춤

class FixedBlockPool {
    struct FreeNode { FreeNode* next; };
    FreeNode* head_{nullptr};
    std::vector<std::unique_ptr<std::byte[]>> chunks_;

    // 할당과 해제가 같은 기준으로 풀/폴백을 판단해야 한다.
    static bool use_pool(std::size_t n, std::size_t align) noexcept {
        return n <= kBlock && align <= alignof(std::max_align_t);
    }
public:
    void* allocate(std::size_t n, std::size_t align) {
        if (!use_pool(n, align))
            return ::operator new(n, std::align_val_t{align});  // 폴백
        if (!head_) {
            constexpr std::size_t chunk_bytes = 1024 * kBlock;
            // new[]가 돌려주는 주소는 기본 new 정렬을 만족하고, kBlock은 그 배수이므로
            // 각 블록도 max_align_t 정렬을 만족한다(일반적인 64비트 플랫폼 기준).
            auto chunk = std::make_unique_for_overwrite<std::byte[]>(chunk_bytes);  // C++20
            for (std::size_t i = 0; i + kBlock <= chunk_bytes; i += kBlock) {
                auto* node = reinterpret_cast<FreeNode*>(chunk.get() + i);
                node->next = head_;
                head_ = node;
            }
            chunks_.push_back(std::move(chunk));
        }
        FreeNode* p = head_;
        head_ = p->next;
        return p;
    }
    void deallocate(void* p, std::size_t n, std::size_t align) noexcept {
        if (!use_pool(n, align)) {
            ::operator delete(p, std::align_val_t{align});
            return;
        }
        auto* node = static_cast<FreeNode*>(p);
        node->next = head_;
        head_ = node;
    }
};

할당 경로와 해제 경로가 같은 조건으로 풀 사용 여부를 판단하는 것이 중요합니다. 할당 때는 정렬 요구 때문에 힙으로 폴백했는데 해제 때는 크기만 보고 풀에 넣으면, 힙 블록이 풀의 자유 목록에 섞여 들어갑니다. 이 풀은 쓰는 만큼 청크를 늘리기만 하고 운영체제에 돌려주지 않으므로, 순간적으로 연결이 몰렸다 빠지면 그 최고점의 메모리를 계속 쥐고 있게 됩니다. 메모리 상한을 두거나 사용량을 모니터링해야 합니다.

코루틴 프레임

C++20 코루틴(co_spawn, use_awaitable)을 쓰면 연산마다 따로 할당되던 핸들러 대신 코루틴 프레임이 상태를 들고 있습니다. Asio는 awaitable 프레임도 스레드별 재사용 캐시로 할당하므로, 코루틴 기반 코드에서는 핸들러 할당자를 따로 붙일 일이 줄어듭니다. 다만 co_spawn의 최종 완료 토큰에는 일반 핸들러처럼 bind_allocator를 적용할 수 있습니다.


측정: 무엇을 비교할 것인가

할당자 변경의 효과는 기기, 컴파일러, Asio 버전, 핸들러 크기, 스레드 구조에 따라 크게 달라지므로 직접 재야 합니다. 비교 대상은 보통 다음 네 가지입니다.

  1. 기본 할당 + 큰 캡처 (출발점)
  2. 캡처 최소화 + shared_ptr 세션
  3. 연결별 handler_memory 또는 스레드 로컬 풀
  4. 코드는 그대로 두고 전역 할당자만 jemalloc이나 tcmalloc으로 교체

4번은 LD_PRELOAD=libjemalloc.so ./server처럼 코드 수정 없이 해 볼 수 있으므로 앱 레벨 풀을 만들기 전에 먼저 시도할 가치가 있습니다.

측정 절차는 다음과 같습니다.

  1. 워커 수, 메시지 크기, 연결 수를 고정합니다.
  2. perf record -g --call-graph dwarf ./server로 프로파일을 남기고 malloc, _int_malloc, operator new의 비중을 확인합니다.
  3. 변경 전후로 perf stat의 instructions·cycles, 처리량, p99 지연을 비교합니다.

처리량만 보면 커널, 네트워크, 락 경합에 가려 할당 쪽 개선이 보이지 않을 수 있습니다. CPU 프로파일과 지연 분포를 함께 봐야 합니다.

도구용도
perf (record/report)malloc, 커스텀 풀 함수의 CPU 비중 확인
heaptrack / Valgrind massif할당 횟수·크기·호출 스택. 할당자 적용 전후 비교에 적합
AddressSanitizer풀에 잘못 반환된 블록, 이중 해제, use-after-free. 커스텀 풀 안의 오류는 __asan_poison_memory_region으로 미사용 블록을 표시해야 잡힘
ThreadSanitizer스레드 로컬 풀을 다른 스레드에서 건드리는 데이터 경합

흔한 실수

실수해결
할당자만 바꾸고 람다 캡처 크기는 그대로연산 객체가 버퍼보다 커서 계속 폴백함. 캡처를 줄이고 세션 포인터만 캡처
읽기와 쓰기가 같은 handler_memory를 공유동시에 진행되는 연산마다 저장소를 따로 둠
스레드 로컬 풀 블록을 다른 스레드에서 해제소유 풀 기록, io_context per thread, 공유 풀 중 하나로 규약을 맞춤
측정 없이 도입기본 재사용 캐시로 이미 할당이 적을 수 있으므로 heaptrack으로 먼저 확인

같이 보면 좋은 글