13.5.3.1 메모리 누수 방지를 위한 C/C++ 소스 노드의 데이터 핀다운(Pinning) 기법 적용

13.5.3.1 메모리 누수 방지를 위한 C/C++ 소스 노드의 데이터 핀다운(Pinning) 기법 적용

Zenoh-Flow 파이프라인에서 공유 메모리 포인터 교환(Zero-Copy)의 강력한 축복을 받아 이기종 C++ 오퍼레이터 노드들이 10MB의 화상 데이터를 눈 깜짝할 새 복사 없이 퍼 나르고 있을 때, C/C++ 매뉴얼 메모리 관리 시스템 내부에는 시한폭탄 하나가 도사리고 조용히 카운트다운을 시작한다.
바로 원본 버퍼가 어디에선가 소멸(Free)되어 버리고, 후속 노드가 텅 빈 허공(Dangling Pointer)을 참조하여 메모리 대폭발(Segmentation Fault)을 일으키는 끔찍한 오작동이다.

파이썬의 친절한 가비지 컬렉터(GC) 따위를 믿을 수 없는 거친 시스템 프로그래밍의 층위에서, 파워에 취한 엔지니어는 데이터의 소멸 주기를 강고하게 묶어버리는 철창을 구축해야만 한다. 본 절에서는 C/C++ 소스 노드가 데이터를 생산해 던질 때, 다음 놈들이 모두 다 씹고 뜯고 맛볼 때까지 결코 RAM에서 삭제되지 못하도록 못을 박아버리는 메모리 핀다운(Pinning) 기법과 레퍼런스 카운트 연동 런북을 서술한다.

1. Zero-Copy 파이프라인의 Дан글링 포인터(Dangling Pointer) 지옥

센서 소스(Source) 노드를 C++로 짜는 흔하고도 파괴적인 오답 코드를 들여다보자.

// [재앙을 부르는 생명주기(Lifetime) 붕괴 소스 코드]
void produce_sensor_data(OutputPort& out_port) {
    uint8_t local_buffer[1024]; // 1. 스택(Stack)이나 로컬 영역에 변수 할당 
    fill_lidar_data(local_buffer);
    
    // 2. 우와 제로카피다! 포인터만 떼어서 젠(Data::borrow)으로 감쌌음
    auto capsule = zf::Data::borrow(local_buffer, 1024);
    
    out_port.send(std::move(capsule)); // 3. 파이프라인으로 던졌다! 와아!
    
} // 4. [파멸] 함수 종료. { } 괄호를 벗어나며 local_buffer 메모리는 리눅스에 의해 증발(Free)!

위의 함수를 던지고 소스 노드의 콜스택이 끝나는 그 1밀리초 찰나, local_buffer 영역은 잿더미가 되어 소멸한다.
하지만 파이프라인의 큐를 타고 그다음 추론 딥러닝 노드에 도달한 capsule 은 여전히 그 잿더미 주소표 번지수를 진짜라고 굳게 믿고 가리키고 있다. 딥러닝 노드의 CUDA 메모리 엔진이 그 번지수의 숫자를 읽으려고 tensor_copy() 를 호출하는 순간, 운영체제의 메모리 보호 시스템(MMU)은 비정상적 허공 찌르기 접근을 감지하고 시스템 전체를 잔인하게 무력화(SIGSEGV Core Dump) 치고 죽여버린다.

2. Shared_Pointer 락과 핀다운(Pinning) 메커니즘의 강제

이 불쌍한 생명주기 단절 추락을 막기 위해선, 내가 만든 데이터(버퍼)가 파이프라인의 끄트머리인 Sink 노드에서 완전히 똥으로 배출되어 소비될(Consumed) 때까지, 절대로 소멸할 수 없도록 레퍼런스 카운트 핀다운(Reference Count Pinning) 족쇄를 채워야 한다.

단순한 malloc 이나 stack array의 폭주를 막고 힙(Heap) 객체의 스마트 체인(std::shared_ptr)을 프레임워크 꼬리표(zf::Data의 커스텀 할당자 파괴 로직)에 강제로 동기화(Sync)시키는 런북을 거행하라.

// [Zenoh-Flow 생명주기 잠금(Pinning) 생존 런북]

void produce_immortal_data(OutputPort& out_port) {
    
    // 1. 단순 스택/배열 객체가 아닌 스마트 족쇄(shared_ptr) 힙 할당
    auto pinned_buffer = std::make_shared<std::vector<uint8_t>>(1024);
    fill_data(*pinned_buffer); // 데이터 적재
    
    // 2. 젠노우(Zenoh) 캡슐을 만들 때, 포인터만 던지는 것이 아니라 
    // "이 데이터가 다 쓰이고 캡슐이 폭파(Drop)될 때, 
    // 나한테서 가져간 복제된 스마트 포인터도 같이 죽여서 해제(Destruct)해라!" 
    // 라는 커스텀 삭제기(Custom Deleter / Cleanup Callback) 람다 함수 밧줄을 묶어 건넨다.
    
    // 이 람다 구문 안에 복사된 `pinned_buffer`(Ref Count 2)가 함께 갇혀서 
    // 파이프라인 끝단으로 흘러간다. 절대 도중 증발하지 못한다! 
    auto capsule = zf::Data::allocate(
        pinned_buffer->data(), 
        pinned_buffer->size(), 
        [pinned_buffer]() { /* 캡슐 해제 시점까지 참조 카운트가 잡혀있다가 비로소 여기서 카운트 -1 되어 안전 소멸 */ }
    );
    
    out_port.send(std::move(capsule));
}

3. GC 개입 배제로 얻는 무결점의 동시성

이 핀다운(Pinning) 처리를 파이프라인 소켓 끝단에 묶어놓으면, 놀랍게도 개발자는 자바나 파이썬 같은 고급 인터프리터 런타임이 무거운 백그라운드 스레드를 띄워 안 쓰는 메모리를 소록소록 찾아 지우는(Garbage Collection) 스톱 더 월드(Stop-The-World) 발작을 견디지 않아도 된다.

데이터 캡슐은 파이프라인의 여러 노드(Fork)로 잘게 다중 분기되어 뿌려지더라고, 각각 분기될 때마다 내부 캡슐의 레퍼런스 카운트 룰렛이 +1씩 누적 증가한다.
그리고 마지막 수신단인 다중 싱크(Sink) 노드들에서 연산이 각각 종료되고 변수 생명 주기가 끝날 때마다 정확히 -1 씩 상쇄된다. 카운터가 정확히 0의 숫자를 가리키는 그 1나노초 단위의 순간! 그 어떤 지체도 마비 현상도 없이, pinned_buffer 의 람다 해제기가 칼륨 주사처럼 터지며 메모리 덩어리는 OS에게 무결점으로 즉시 환원된다.

분산 시스템의 제로 카피(Zero-Copy) 지능은 “데이터를 복사하지 않는 게으름“이 아니라, “복사하지 않고도 언제 그 데이터의 목숨을 즉각적으로 끊어야 할지를 칼같이 아는 거대한 참조(Reference) 생존 스케줄링의 폭압“에서 비롯됨을 잊지 마라.