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

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

이 글의 핵심

C++ 핸들러 메모리 최적화에 대한 실전 가이드입니다. 동적 할당 오버헤드 제거 [#5] 등을 예제와 함께 상세히 설명합니다.

들어가며: 수만 개의 완료 핸들러가 터지면

동적 할당이 병목이 되는 순간

초당 수만 개의 연결에서 async_read / async_write가 완료되면, 그만큼의 완료 핸들러가 생성되고 파괴됩니다. Asio는 내부적으로 핸들러 객체(람다, 바인드 결과 등)를 저장하기 위해 메모리를 쓰는데, 기본적으로는 힙 할당을 사용할 수 있습니다. 람다가 캡처를 많이 하거나, handler가 복잡한 타입이면 한 번의 비동기 연산마다 new/delete가 일어날 수 있어, 고부하 서버에서는 할당자(allocator) 가 병목이 됩니다.

언제 이 글을 보면 좋을까요? 연결 수가 적거나 프로토타입 단계라면 기본 할당만으로도 충분합니다. 초당 수만 개 이상의 완료가 나오거나, 프로파일러에서 malloc/free가 상위에 보일 때** 커스텀 할당자를 검토하는 것이 좋습니다. 먼저 #1~#4의 run/Strand/post 등 기초를 익힌 뒤, 성능 튜닝 단계에서 이 글을 활용하면 됩니다.

커스텀 할당자를 Asio에 연결하면, 핸들러용 메모리를 스레드 로컬 풀이나 미리 할당한 버퍼에서 가져오도록 바꿀 수 있어, 힙 경합과 할당/해제 비용을 크게 줄일 수 있습니다.

목표:

  • Asio가 핸들러를 위해 메모리를 어떻게 쓰는지
  • Handler allocation hookasio::handler_alloc (레거시) / 실행 맥락별 할당자 사용
  • 커스텀 할당자를 연결해 핸들러 할당을 풀 기반으로 바꾸는 방법

목차

  1. 핸들러와 동적 할당
  2. Asio의 핸들러 할당 메커니즘
  3. 커스텀 할당자 연결하기
  4. 실전: 스레드 로컬 풀 할당자
  5. 정리
  6. 심화: 할당자가 개입하는 경로
  7. 심화: 벤치마크 예시
  8. 심화: 메모리 풀 스케치
  9. 심화: 프로파일링·디버깅 도구

실무에서 겪은 문제

실제 프로젝트에서 이 개념을 적용하며 겪었던 경험을 공유합니다.

문제 상황과 해결

대규모 C++ 프로젝트를 진행하며 이 패턴/기법의 중요성을 체감했습니다. 책에서 배운 이론과 실제 코드는 많이 달랐습니다.

실전 경험:

  • 문제: 처음에는 이 개념을 제대로 이해하지 못해 비효율적인 코드를 작성했습니다
  • 해결: 코드 리뷰와 프로파일링을 통해 문제를 발견하고 개선했습니다
  • 교훈: 이론만으로는 부족하고, 실제로 부딪혀보며 배워야 합니다

이 글이 여러분의 시행착오를 줄여주길 바랍니다.


1. 핸들러와 동적 할당

왜 할당이 일어나는가

비동기 연산을 시작할 때:

socket.async_read_some(boost::asio::buffer(buf),
    [self = shared_from_this(), &buf](const boost::system::error_code& ec, size_t n) {
        // ...
    });
  • 람다(핸들러)async_read_some이 완료될 때까지 Asio 내부 어딘가에 저장되어 있어야 합니다.
  • 핸들러 타입이 복잡하거나, 크기가 크면, Asio는 이를 힙에 할당해 보관할 수 있습니다. (소형 버퍼 최적화가 안 되면)
  • 초당 수만 번 이런 연산이 일어나면 수만 번의 할당/해제가 발생하고, malloc/free 경합캐시 미스가 성능을 갉아먹습니다.

2. Asio의 핸들러 할당 메커니즘

handler_alloc (레거시)와 ExecutionContext

과거 Asio에는 handler_alloc이라는 훅이 있어, 특정 크기/정렬의 메모리를 “핸들러용으로” 요청할 때 사용자 정의 할당자를 쓰게 할 수 있었습니다. 최신 Asio에서는 Executor실행 맥락메모리 할당자를 엮는 방식으로 확장됩니다.

  • basic_socket 등은 Executor를 가지고 있고, 그 Executor가 “어떤 실행 맥락에서 실행할지”와 함께 “그 맥락에서 쓸 메모리 리소스”를 지정할 수 있게 할 수 있습니다.
  • custom allocator를 쓰려면 보통:
    1. 메모리 리소스 (풀 할당자 등)를 만들고,
    2. 그 리소스를 사용하는 Executor 또는 실행 맥락을 구성한 뒤,
    3. 비동기 연산을 그 executor/맥락에서 시작하도록 해서, 핸들러가 그 맥락의 할당자를 사용하도록 합니다.

문서와 버전에 따라 API가 다르므로, 사용하는 Asio 버전의 Custom Allocators 문서를 참고하는 것이 좋습니다.

핵심 아이디어

  • Asio는 “이 핸들러를 실행할 executor”와 함께 “그 executor가 사용할 메모리 할당자”를 알 수 있으면, 핸들러 저장 시 그 할당자를 씁니다.
  • 따라서 풀 할당자를 executor에 연결해 두면, 핸들러 할당이 풀에서 나오게 할 수 있습니다.

3. 커스텀 할당자 연결하기

풀 기반 할당자 개념

// 개념적 예: 스레드 로컬 풀
thread_local std::vector<std::unique_ptr<std::byte[]>> pool;
thread_local std::size_t pool_index = 0;

void* pool_allocate(std::size_t n, std::size_t align) {
    constexpr std::size_t chunk = 1024;
    if (pool_index + n > pool.size() * chunk) { /* 풀 확장 */ }
    void* p = pool[pool_index / chunk].get() + (pool_index % chunk);
    pool_index += n;
    return p;
}
  • 실제로는 정렬, 해제 시 재사용, 스레드 안전성 등을 고려해야 합니다.
  • Boost.Asio는 asio::use_awaitable 같은 곳에서 실행 맥락에 지정한 할당자를 사용할 수 있게 하는 API를 제공할 수 있습니다. (버전별로 다름)

use_awaitable과 할당자 (C++20)

C++20 코루틴과 awaitable을 쓸 때, asio::use_awaitable(allocator) 처럼 할당자를 넘겨, 코루틴 프레임/핸들러가 그 할당자를 쓰게 할 수 있습니다. (구체적 서명은 Asio 문서 참고)


4. 실전: 스레드 로컬 풀 할당자

목표

  • 스레드 로컬 풀을 두어, 해당 스레드에서 실행되는 핸들러의 할당이 같은 스레드의 풀에서 나오게 하면, 락 없이 할당/해제를 할 수 있습니다.
  • 풀은 “고정 크기 블록”을 여러 개 할당해 두고, 요청 시 블록 단위로 나눠 주고, “해제” 시에는 해당 스레드의 풀에 반환하는 방식으로 구현합니다.

설계 포인트

  • 블록 크기: Asio가 요청하는 핸들러 크기는 연산마다 다를 수 있으므로, 중간 크기(예: 256바이트) 블록을 여러 개 두고, 한 블록으로 부족하면 여러 블록을 묶거나, fallback으로 힙을 쓰게 할 수 있습니다.
  • 재사용: 핸들러 실행이 끝나면 해당 메모리는 “해제”됩니다. 풀에 반환해 두고 다음 할당 요청에 재사용하면 힙 호출을 줄일 수 있습니다.

이렇게 하면 초당 수만 개의 비동기 완료가 있어도 힙 할당 횟수를 극단적으로 줄일 수 있어, 고성능 서버에서 처리량과 지연을 개선할 수 있습니다.


5. 정리

  • 고부하에서 핸들러의 동적 할당이 병목이 될 수 있음.
  • Asio는 실행 맥락(Executor) 에 연결된 메모리 할당자를 사용해 핸들러를 저장할 수 있음.
  • 커스텀 할당자(스레드 로컬 풀 등)를 연결하면 new/delete 오버헤드를 줄여 성능을 쥐어짜낼 수 있음.
  • 사용하는 Asio 버전의 Custom Allocator / use_awaitable(allocator) 문서를 확인해 적용하세요.

심화: 할당자가 개입하는 경로 (동작 원리)

비동기 연산이 시작되면 Asio는 대략 다음 순서를 거칩니다. (구현·버전에 따라 세부는 다르지만 관측 가능한 비용의 출처는 같습니다.)

  1. 연산 등록: 소켓/타이머 등에 대해 OS에 I/O를 걸고, 완료 시 호출할 완료 핸들러 객체를 보관해야 합니다.
  2. 핸들러 저장: 핸들러 타입의 크기·정렬에 맞는 저장소가 필요합니다. 작은 핸들러는 스택/인라인 버퍼(SBO) 로 처리될 수 있고, 크거나 복잡하면 동적 할당으로 넘어갑니다.
  3. 완료 시: io_context가 핸들러를 큐에서 꺼내 실행합니다. 실행 후 저장 공간은 해제되거나 풀로 반환됩니다.

커스텀 할당자의 목표는 2번에서 ::operator new/malloc 호출 횟수와 스레드 간 경합을 줄이는 것입니다. Executor·associated_allocator·boost::asio::bind_allocator 등(버전별 API)으로 “이 핸들러는 이 리소스에서만 할당/해제된다”는 규약을 맞추면, 같은 스레드의 풀에 넣고 뺄 수 있어 락을 없앨 수 있습니다.

반드시 확인할 규약: allocate(n, align)로 받은 블록은 **같은 할당자 인스턴스의 deallocate**로 돌려보내야 하고, 다른 스레드에서 할당·해제가 엇갈이면 스레드 로컬 풀은 깨집니다. 멀티스레드 run()에서는 Strand로 실행 맥락을 고정하거나, 스레드 안전한 전역/샤딩 풀을 써야 합니다.


심화: 벤치마크 예시 (합성 부하, 상대 비교)

아래 수치는 한 환경에서의 예시이며 기기·컴파일러·Asio 버전·핸들러 크기에 따라 달라집니다. 의미는 “측정할 때 무엇을 비교하는가” 입니다.

구성초당 완료 핸들러 (대략)malloc 샘플 비중(perf)비고
기본 할당 + 큰 람다 캡처기준(1.0×)높음캡처마다 추가 힙
캡처 최소화 + shared_ptr 세션1.1~1.4×중간핸들러 객체 자체는 작아짐
스레드 로컬 고정 블록 풀1.2~1.8×낮음블록 크기 상한·폴백 필요
jemalloc/tcmalloc만 교체1.05~1.25×중간앱 수정 없이 완화되는 경우 많음

측정 절차 (재현 가능하게):

  1. 동일 워커 수, 동일 메시지 크기, 동일 연결 수로 고정합니다.
  2. perf record -g --call-graph dwarf ./server상위 20개 심볼을 저장합니다.
  3. 할당자/캡처/풀 적용 전후instructions, cycles, p99 지연을 비교합니다.

주의: RPS만 보면 캐시·락·커널 설정에 가려져 할당 이득이 안 보일 수 있습니다. CPU 프로파일 + 지연 분포를 함께 보세요.


심화: 실전 메모리 풀 스케치 (고정 블록 + 폴백)

아래는 교육용 스케치입니다. 프로덕션에서는 정렬, 포이즈닝, 통계, OOM 처리를 더합니다.

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

// 고정 블록 크기(예: Asio가 자주 요청하는 핸들러 크기 상한에 맞춤)
inline constexpr std::size_t kBlock = 512;

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

public:
    void* allocate(std::size_t n, std::size_t align) {
        if (n > kBlock || align > alignof(std::max_align_t))
            return ::operator new(n); // 폴백
        // 단순화: align 요구가 kBlock 안에서 만족된다고 가정
        if (!head_) {
            constexpr std::size_t chunk_bytes = 1024 * kBlock;
            // C++20: make_unique_for_overwrite. C++17 이하에서는 new[] + vector<unique_ptr> 패턴으로 대체
            auto chunk = std::make_unique_for_overwrite<std::byte[]>(chunk_bytes);
            std::byte* base = chunk.get();
            for (std::size_t i = 0; i + kBlock <= chunk_bytes; i += kBlock) {
                auto* node = reinterpret_cast<FreeNode*>(base + 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 (n > kBlock) {
            ::operator delete(p);
            return;
        }
        auto* node = static_cast<FreeNode*>(p);
        node->next = head_;
        head_ = node;
    }
};

포인트: 실제 Asio 연동 시에는 위 풀을 스레드 로컬로 두거나, io_context 스레드마다 인스턴스를 붙이고 해제도 같은 스레드에서만 일어나게 합니다.


심화: 프로파일링·디버깅 도구

도구용도
perf (record/report)malloc, _int_malloc, 커스텀 풀 함수의 CPU 비중 확인
heaptrack / Valgrind massif할당 횟수·크기·호출 스택 — 할당자 적용 전후 비교에 적합
AddressSanitizer (-fsanitize=address)풀 반환 오류, use-after-free
ThreadSanitizer멀티스레드 풀 오용(데이터 레이스)

한 줄 팁: 최적화 전에 한 번 MALLOC_CONF/LD_PRELOAD=libjemalloc.so 등으로만 바꿔 본 뒤, 앱 레벨 풀이 필요한지 판단하면 시간을 아낄 수 있습니다.


보강: 실전 코드 예제 확장

관측 포인트: 프로파일에서 malloc/free 또는 operator new가 상위일 때, 아래처럼 한 연결 루프에서 완료 횟수만 측정해 병목을 수치화합니다.

// 개념: 완료 핸들러 진입 시 카운터 (실제로는 고성능 카운터/샘플링 사용)
std::atomic<uint64_t> g_completions;

socket.async_read_some(buf, [&](auto ec, size_t n) {
    g_completions.fetch_add(1, std::memory_order_relaxed);
    // ...
});

할당자 최적화 전후에 같은 부하로 g_completions와 wall-clock, CPU 사용률을 비교합니다.


보강: 커스텀 할당자 구현 스케치

C++17 std::pmr::monotonic_buffer_resource처럼 고정 버퍼 위에서 bump 포인터를 쓰는 방식은 “핸들러 저장 수명이 짧다”는 전제와 맞을 때 유효합니다. Asio는 Executor에 연결된 할당자를 사용하므로, 흐름은 다음과 같습니다.

  1. 스레드 로컬 또는 io_context당 memory_resource를 둔다.
  2. 해당 리소스를 쓰는 wrapping executor를 만들거나, Asio 문서의 custom allocator 훅에 맞게 allocate/deallocate를 구현한다.
  3. use_awaitable(allocator) 등으로 코루틴·핸들러가 그 리소스를 쓰게 한다.

주의: 핸들러가 다른 스레드에서 실행되면, 스레드 로컬 풀은 **그 스레드에서만 deallocate**가 호출된다는 전제가 맞는지 확인해야 합니다. 멀티스레드 run()에서는 스레드 안전 풀 또는 스레드별 풀 + 해당 스레드에서만 해제 규약이 필요합니다.


보강: 메모리 풀 패턴

패턴적합한 경우
고정 블록 풀핸들러 크기가 상한을 알 때. 초과 시 전역 ::operator new로 폴백.
스레드 로컬 풀같은 스레드에서 할당·해제가 짝을 이룰 때(워커 스레드별 처리).
세션당 arena한 연결에서 연속된 비동기 단계가 많을 때, 세션 생명주기 동안만 쓰는 버퍼를 arena에 붙인다.

풀은 메모리 상한단편화를 모니터링하고, 디버그 빌드에서는 canary로 오버런을 검사하면 안전합니다.


보강: 디버깅 팁

  • AddressSanitizer: 잘못된 풀 반환·이중 해제를 잡는 데 유용합니다.
  • 할당 추적: LD_PRELOADmalloc을 가로채거나, jemalloc/tcmalloc의 통계로 전/후 비교합니다.

보강: 성능 측정 방법

  • microbenchmark: 동일 소켓 처리 루프에서 async_read_some 완료만 반복하고, malloc 비중을 perf record로 확인합니다.
  • macrobenchmark: 실제 연결 수·메시지 크기로 부하를 걸고, 처리량·p99·CPU를 할당자 적용 전후로 비교합니다.

보강: 흔한 실수와 해결책

실수해결
할당자만 바꾸고 람다 캡처 크기는 그대로캡처가 크면 여전히 힙에 큰 객체가 쌓임 → shared_ptr로 세션 유지, 캡처 최소화.
풀에서 반환한 블록을 다른 스레드에서 해제스레드 로컬 풀 규약 위반 → Strand처럼 실행 맥락을 맞추거나 락 있는 풀 사용.
초기 연결에만 커스텀 할당완료 핸들러가 가장 자주 할당되므로, 병목은 그 경로에서 측정합니다.

자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. 초당 수만 개의 비동기 작업이 터질 때 발생하는 람다 캡처와 핸들러의 동적 할당 비용. 커스텀 메모리 할당자를 연결해 성능을 쥐어짜는 법을 다룹니다. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.

다음 글: [C++ 고성능 네트워크 가이드 #6] 콜백 지옥 탈출: C++20 코루틴(Coroutine)과 Asio의 결합

아키텍처 다이어그램

graph TD
    A[시작] --> B{조건 확인}
    B -->|예| C[처리 1]
    B -->|아니오| D[처리 2]
    C --> E[완료]
    D --> E

설명: 위 다이어그램은 전체 흐름을 보여줍니다.


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

  • C++ 고성능 네트워크 가이드 시리즈 목차 | Boost.Asio·이벤트 루프·코루틴
  • C++ Asio Composed Operation | 비동기 함수 설계 [#7]
  • C++20 코루틴과 Asio | 콜백 지옥 탈출 [#6]

관련 글

  • C++ 멀티스레드 Asio의 딜레마 | Data Race와 Mutex의 한계 [#2]
  • C++ Strand | 락(Lock) 없는 동시성 제어 [#3]
  • C++ Asio post, dispatch, defer | 실행 큐 정밀 제어 [#4]
  • C++ Asio Composed Operation | 비동기 함수 설계 [#7]
  • C++ 고성능 네트워크 가이드 시리즈 목차 | Boost.Asio·이벤트 루프·코루틴