본문으로 건너뛰기
Previous
Next
C++ 핸들러 메모리 최적화 | 동적 할당 오버헤드 제거 [#5]

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. 핸들러와 동적 할당

왜 할당이 일어나는가

비동기 연산을 시작할 때:

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 스레드마다 인스턴스를 붙이고 해제도 같은 스레드에서만 일어나게 합니다.

일상 비유로 이해하기: 메모리를 아파트 건물로 생각해보세요. 스택은 엘리베이터 같아서 빠르지만 공간이 제한적입니다. 힙은 창고처럼 넓지만 물건을 찾는 데 시간이 걸립니다. 포인터는 “3층 302호”처럼 주소를 가리키는 메모지라고 보면 됩니다.

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

도구용도
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++ 핸들러 메모리 최적화 | 동적 할당 오버헤드 제거 [#5]」)를 구현·런타임·운영 관점에서 다시 압축합니다. 도메인별 세부 구현은 글마다 다르지만, 입력 검증 → 핵심 연산 → 부작용(I/O·네트워크·동시성) → 관측의 흐름으로 장애를 나누면 원인 추적이 빨라집니다.

내부 동작과 핵심 메커니즘

flowchart TD
  A[입력·요청·이벤트] --> B[파싱·검증·디코딩]
  B --> C[핵심 연산·상태 전이]
  C --> D[부작용: I/O·네트워크·동시성]
  D --> E[결과·관측·저장]
sequenceDiagram
  participant C as 클라이언트/호출자
  participant B as 경계(런타임·게이트웨이·프로세스)
  participant D as 의존성(API·DB·큐·파일)
  C->>B: 요청/이벤트
  B->>D: 조회·쓰기·RPC
  D-->>B: 지연·부분 실패·재시도 가능
  B-->>C: 응답 또는 오류(코드·상관 ID)
  • 불변 조건(Invariant): 버퍼 경계, 프로토콜 상태, 트랜잭션 격리, FD 상한 등 단계별로 문장으로 적어 두면 디버깅 비용이 줄어듭니다.
  • 결정성: 순수 층과 시간·네트워크·스케줄에 의존하는 층을 분리해야 테스트와 장애 분석이 쉬워집니다.
  • 경계 비용: 직렬화, 인코딩, syscall 횟수, 락 경합, 할당·GC, 캐시 미스를 의심 목록에 둡니다.
  • 백프레셔: 생산자가 소비자보다 빠를 때 버퍼·큐·스트림에서 속도를 줄이는 신호를 어디에 둘지 정의합니다.

프로덕션 운영 패턴

영역운영 관점 질문
관측성요청 단위 상관 ID, 에러율·지연 p95/p99, 의존성 타임아웃·재시도가 대시보드에 보이는가
안전성입력 검증·권한·비밀·감사 로그가 코드 경로마다 일관적인가
신뢰성재시도는 멱등 연산에만 적용되는가, 서킷 브레이커·백오프·DLQ가 있는가
성능캐시·배치 크기·커넥션 풀·인덱스·백프레셔가 데이터 규모에 맞는가
배포롤백 룬북, 카나리/블루그린, 마이그레이션·피처 플래그가 문서화되어 있는가
용량피크 트래픽·디스크·FD·스레드 풀 상한을 주기적으로 검증하는가

스테이징은 데이터 양·네트워크 RTT·동시성을 프로덕션에 가깝게 맞출수록 재현율이 올라갑니다.

확장 예시: 엔드투엔드 미니 시나리오

앞선 본문 주제(「C++ 핸들러 메모리 최적화 | 동적 할당 오버헤드 제거 [#5]」)를 배포·운영 흐름에 맞춰 옮긴 체크리스트입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.

  1. 입력 계약 고정: 스키마·버전·최대 페이로드·타임아웃·에러 코드를 경계에 둔다.
  2. 핵심 경로 계측: 요청 ID, 단계별 지연, 외부 호출 결과 코드를 로그·메트릭·트레이스에서 한 흐름으로 본다.
  3. 실패 주입: 의존성 타임아웃·5xx·부분 데이터·락 대기를 스테이징에서 재현한다.
  4. 호환·롤백: 설정/마이그레이션/클라이언트 버전을 되돌릴 수 있는지 확인한다.
  5. 부하 후 검증: 피크 대비 p95/p99, 에러율, 리소스 상한, 알림 임계값을 점검한다.
handle(request):
  ctx = newCorrelationId()
  validated = validateSchema(request)
  authorize(validated, ctx)
  result = domainCore(validated)
  persistOrEmit(result, idempotentKey)
  recordMetrics(ctx, latency, outcome)
  return result

문제 해결(Troubleshooting)

증상가능 원인조치
간헐적 실패레이스, 타임아웃, 외부 의존성, DNS최소 재현 스크립트, 분산 트레이스·로그 상관관계, 재시도·서킷 설정 점검
성능 저하N+1, 동기 I/O, 락 경합, 과도한 직렬화, 캐시 미스프로파일러·APM으로 핫스팟 확인 후 한 가지씩 제거
메모리 증가캐시 무제한, 구독/리스너 누수, 대용량 버퍼, 커넥션 미반납상한·TTL·힙/FD 스냅샷 비교
빌드·배포만 실패환경 변수, 권한, 플랫폼 차이, lockfileCI 로그와 로컬 diff, 런타임·이미지 버전 핀
설정 불일치프로필·시크릿·기본값, 리전스키마 검증된 설정 단일 소스와 배포 매트릭스 표준화
데이터 불일치비멱등 재시도, 부분 쓰기, 캐시 무효화 누락멱등 키·아웃박스·트랜잭션 경계 재검토

권장 순서: (1) 최소 재현 (2) 최근 변경 범위 축소 (3) 환경·의존성 차이 (4) 관측으로 가설 검증 (5) 수정 후 회귀·부하 테스트.

배포 전에는 git addgit commitgit pushnpm run deploy 순서를 권장합니다.


이 글에서 다루는 키워드 (관련 검색어)

C++, Boost.Asio, 핸들러, 커스텀할당자, 메모리최적화, 고성능네트워크 등으로 검색하시면 이 글이 도움이 됩니다.