본문으로 건너뛰기
Embedded C++ for Real Systems · 29/41

Intrusive Containers 분석 — 동적 할당 없는 컨테이너 설계

· Hawk · 5분 읽기

#한 줄 요약

“Intrusive = 객체 안에 list pointer를 박는 방식입니다.” std::list처럼 wrapper node를 두지 않으므로 추가 할당이 0입니다.

#어떤 문제를 푸는가

std::list<T>의 내부 구조는 다음과 같습니다.

[head] → [Node{prev, next, T data}] → [Node{...}] → ...

각 Node가 heap에 할당되고, T 외에 두 pointer만큼 메모리가 더 필요합니다. 임베디드에는 부담이 됩니다.

Intrusive container는 구조가 반대입니다.

[head] → [T{prev, next, other fields}] → [T{...}] → ...

T 자체에 prev/next pointer가 들어 있어 list node를 별도로 할당하지 않습니다.

struct Task {
Task* next; // intrusive link
int priority;
void (*work)(void);
};
// List는 head pointer만
Task* g_pending_head = nullptr;
void add_task(Task* t) {
t->next = g_pending_head;
g_pending_head = t;
}

추가 할당이 0입니다. Linux 커널과 FreeRTOS가 전부 이 패턴을 씁니다.

#Linux 커널 스타일 — list_head

Linux 커널의 list.h가 표준 idiom입니다.

struct list_head {
list_head* prev;
list_head* next;
};
// 사용 — task에 list_head 임베드
struct Task {
list_head link; // 임베드
int priority;
char name[16];
};
list_head g_pending_list; // 헤드
void add_task(Task* t) {
t->link.next = g_pending_list.next;
t->link.prev = &g_pending_list;
g_pending_list.next->prev = &t->link;
g_pending_list.next = &t->link;
}
// list_head에서 Task로 변환
#define container_of(ptr, type, member) \
(type*)((char*)(ptr) - offsetof(type, member))
void iterate() {
for (list_head* p = g_pending_list.next;
p != &g_pending_list;
p = p->next) {
Task* t = container_of(p, Task, link);
// t 사용
}
}

container_of 매크로가 핵심 트릭으로, list_head*에서 enclosing struct의 pointer를 계산합니다.

C++에서는 template으로 더 깔끔하게 쓸 수 있습니다.

#C++ Template intrusive list

template<typename T, typename T::ListLink T::*LinkMember>
class IntrusiveList {
T* head_ = nullptr;
T* tail_ = nullptr;
public:
void push_front(T* node) {
if (!head_) {
head_ = tail_ = node;
(node->*LinkMember).next = nullptr;
(node->*LinkMember).prev = nullptr;
} else {
(node->*LinkMember).next = head_;
(node->*LinkMember).prev = nullptr;
(head_->*LinkMember).prev = node;
head_ = node;
}
}
void erase(T* node) {
auto& link = node->*LinkMember;
if (link.prev) (link.prev->*LinkMember).next = link.next;
else head_ = link.next;
if (link.next) (link.next->*LinkMember).prev = link.prev;
else tail_ = link.prev;
}
T* front() const { return head_; }
class Iterator {
T* p_;
public:
Iterator(T* p) : p_(p) {}
T& operator*() { return *p_; }
Iterator& operator++() {
p_ = (p_->*LinkMember).next;
return *this;
}
bool operator!=(const Iterator& o) const { return p_ != o.p_; }
};
Iterator begin() { return {head_}; }
Iterator end() { return {nullptr}; }
};

사용:

struct Task {
struct ListLink { Task *prev, *next; };
ListLink pending_link;
ListLink ready_link;
int priority;
};
IntrusiveList<Task, &Task::pending_link> pending;
IntrusiveList<Task, &Task::ready_link> ready;
Task t1, t2;
pending.push_front(&t1);
pending.push_front(&t2);
for (auto& t : pending) {
process(t);
}

같은 Task가 서로 다른 link를 두면 두 list에 동시에 들어갈 수도 있습니다. 매우 유연한 구조입니다.

#Boost.Intrusive

Boost가 제공하는 intrusive container 라이브러리이며 임베디드 친화적입니다.

#include <boost/intrusive/list.hpp>
namespace bi = boost::intrusive;
struct Task : public bi::list_base_hook<> {
int priority;
};
using TaskList = bi::list<Task>;
TaskList g_pending;
Task t1, t2;
g_pending.push_back(t1);
g_pending.push_back(t2);
for (auto& t : g_pending) {
process(t);
}

bi::list_base_hook이 prev/next를 포함하고, 상속만 하면 intrusive가 됩니다.

여러 list에 동시에 가입할 수도 있습니다.

struct PendingTag;
struct ReadyTag;
struct Task : public bi::list_base_hook<bi::tag<PendingTag>>,
public bi::list_base_hook<bi::tag<ReadyTag>> {
int priority;
};
using PendingList = bi::list<Task, bi::base_hook<bi::list_base_hook<bi::tag<PendingTag>>>>;
using ReadyList = bi::list<Task, bi::base_hook<bi::list_base_hook<bi::tag<ReadyTag>>>>;

장점은 다음과 같습니다.

  • Heap 사용이 0입니다.
  • 추가 메모리가 prev/next만큼만 늘어납니다.
  • node 자체를 알고 있으면 erase가 O(1)입니다.

#Intrusive vs std::list 비교

구조의 차이를 한눈에 보면 다음과 같습니다.

std::list와 intrusive list 구조 비교

std::list<T>Intrusive
메모리T + Node (heap)T 안에 link (free)
할당매번 heap alloc0
EraseO(n) (iterator로)O(1) (node 자체로)
다중 list 가입어려움 (T를 복사)자연스러움
Type safety강함보통 (template으로 강화)
표준C++11non-standard (Boost)

임베디드에서는 intrusive가 압도적으로 유리합니다.

#ETL의 intrusive_list

#include <etl/intrusive_list.h>
class Task : public etl::list_link<0> {
public:
int priority;
};
using TaskList = etl::intrusive_list<Task, etl::list_link<0>>;
TaskList tasks;
Task t1, t2;
tasks.push_back(t1);
tasks.push_back(t2);

ETL은 임베디드 친화적이며 Boost보다 가볍습니다.

#다른 intrusive 자료구조

#Intrusive doubly linked list (RB-tree)

Boost.Intrusive
#include <boost/intrusive/set.hpp>
struct OrderedTask : public bi::set_base_hook<> {
int priority;
bool operator<(const OrderedTask& o) const { return priority < o.priority; }
};
using TaskSet = bi::set<OrderedTask>;
TaskSet sorted_tasks;

RB-tree로 priority가 자동 정렬되고 heap도 쓰지 않습니다.

#Intrusive hash map

struct Item : public bi::unordered_set_base_hook<> {
int key;
// ...
};
using ItemMap = bi::unordered_set<Item>;

bucket array는 stack이나 static으로 별도 할당합니다.

#임베디드 — RTOS Task Queue

전형적인 사용처는 RTOS scheduler의 task queue입니다.

struct TaskTcb {
TaskTcb* ready_next; // intrusive ready queue
TaskTcb* delayed_next; // intrusive delayed queue
uint32_t stack_ptr;
uint32_t state;
uint32_t priority;
uint32_t wakeup_time;
void (*entry)(void*);
};
static TaskTcb* g_ready_head[MAX_PRIORITY]; // priority별 queue
static TaskTcb* g_delayed_head;
void task_make_ready(TaskTcb* t) {
t->ready_next = g_ready_head[t->priority];
g_ready_head[t->priority] = t;
}

heap을 쓰지 않으면서 모든 연산이 O(1)입니다. FreeRTOS와 Zephyr 모두 이 패턴을 따릅니다.

#임베디드 — Event Pool

struct Event : public bi::list_base_hook<> {
int type;
uint32_t data;
};
ObjectPool<Event, 64> event_pool;
bi::list<Event> event_queue;
void post_event(int type, uint32_t data) {
auto* e = event_pool.allocate();
if (e) {
e->type = type;
e->data = data;
event_queue.push_back(*e);
}
}
void process_events() {
while (!event_queue.empty()) {
auto& e = event_queue.front();
event_queue.pop_front();
handle_event(e);
event_pool.deallocate(&e);
}
}

Pool에서 객체를 할당하고 intrusive list로 enqueue하는 방식이 임베디드 event-driven의 표준입니다.

#자주 보는 함정과 안티패턴

#1. Container의 소유권 모호

intrusive list는 node를 소유하지 않으므로 lifetime은 외부에서 관리해야 합니다. list가 소멸해도 node는 해제되지 않습니다.

{
Task t;
list.push_back(t);
} // t 소멸 — list에는 dangling pointer

scope를 일치시키거나 명시적으로 erase합니다.

#2. Erase 이전 사용

auto* t = list.front();
list.pop_front();
t->priority; // OK (node는 살아 있음, list만 없앰)

intrusive 컨테이너는 list와 node가 분리되어 있으므로 위 코드는 안전합니다. 다만 node를 free한 후 사용하면 UB입니다.

#3. Double linking

list1.push_back(t);
list2.push_back(t); // 같은 link 사용 — 양쪽 깨짐

서로 다른 link 멤버를 씁니다.

다중 list에 가입할 때는 각자 다른 tag가 필요합니다.

#5. Const correctness

intrusive container는 node 내부를 변경하므로 const list iteration에서도 non-const node 변경이 가능합니다. 주의가 필요합니다.

#6. Concurrent 수정

intrusive list의 push/pop은 atomic하지 않습니다. multi-task 환경에서는 mutex나 lock-free intrusive list가 필요합니다.

#측정 — std::list vs intrusive

같은 1000개 객체를 enqueue/dequeue한 결과입니다 (STM32F4).

std::list<Task>:
push_back: ~150 cycles (heap alloc + node init)
pop_front: ~80 cycles (heap free)
total: ~230 ms (1000 ops × push/pop)
heap usage: ~24 KB (Task + Node)
intrusive list<Task>:
push_back: ~10 cycles (pointer update)
pop_front: ~10 cycles
total: ~20 ms
heap usage: 0 (Task pool만)

10배 이상 빠르고 heap도 쓰지 않아 임베디드에 적합한 결정적 동작을 보장합니다.

#정리

  • Intrusive 컨테이너는 객체 안에 list link를 포함하므로 node 별도 할당이 0입니다.
  • Linux 커널의 list_headcontainer_of가 전통적인 패턴입니다.
  • C++ 라이브러리로는 Boost.Intrusive와 ETL의 intrusive_list가 있습니다.
  • 서로 다른 link를 두면 다중 list 가입이 자연스럽게 가능합니다.
  • RTOS scheduler, event queue, driver chain에서 표준 도구로 쓰입니다.
  • 컨테이너는 객체를 소유하지 않으므로 lifetime은 외부에서 관리해야 합니다.

#관련 항목

#다음 글

Part 4-02: ETL 라이브러리 — 임베디드 STL 대체. heap 없는 vector, map, queue, fsm.

Embedded C++ for Real Systems · 30 of 41

  1. 1Embedded C++ for Real Systems — 임베디드 모던 C++ 시리즈 소개
  2. 2임베디드 C++ vs C — 런타임·코드 크기·ABI 관점 비교
  3. 3임베디드 C++ 컴파일러 플래그 분석 — -fno-rtti·-fno-exceptions·-Os
  4. 4임베디드 C++ 런타임 요구사항 — libstdc++·newlib·crt0 분석
  5. 5C++ 코드 크기 분석 — 가상 함수·템플릿·예외 비용 추적
  6. 6C++ ABI 호환성 — Itanium ABI·name mangling·vtable 레이아웃
  7. 7C++ 스타트업 코드 분석 — .init_array·전역 생성자 호출 순서
  8. 8임베디드 C++ 링커 스크립트 — vtable·정적 객체 배치 추적
  9. 9임베디드 C++ 표준 선택 가이드 — C++11/14/17/20/23 트레이드오프
  10. 10임베디드 RAII 기초 — 리소스 안전성과 결정적 소멸 보장
  11. 11임베디드 RAII 실전 패턴 — Lock·Pin·DMA·Power 관리
  12. 12constexpr 기초와 임베디드 적용 — 컴파일 타임 계산 활용
  13. 13constexpr 고급 활용 — 룩업 테이블·CRC·해시 컴파일 타임 생성
  14. 14consteval과 constinit 분석 — C++20 컴파일 타임 강제 메커니즘
  15. 15임베디드 Templates 기초 — 타입 안전과 코드 재사용 분석
  16. 16Template 비용 분석 — 코드 폭증·인스턴스화·디버그 정보 측정
  17. 17CRTP 패턴 분석 — vtable 없는 정적 다형성
  18. 18Type Traits 임베디드 활용 — SFINAE·is_pod·컴파일 타임 검사
  19. 19C++20 Concepts 활용 — 템플릿 제약과 가독성 개선
  20. 20동적 할당 없는 임베디드 C++ — placement new·정적 객체·풀
  21. 21Custom Allocator 기초 — std::allocator 인터페이스 분석
  22. 22Pool Allocator 구현 — Fixed-Size Block과 O(1) 보장
  23. 23std::pmr 임베디드 활용 — Polymorphic Memory Resource 분석
  24. 24No-Exception C++ 설계 — 코드 크기·결정성 트레이드오프
  25. 25임베디드 에러 처리 패턴 — Result·errno·optional 비교
  26. 26std::expected 분석 — C++23 결과 타입과 에러 전파
  27. 27No-RTTI C++ 설계 — dynamic_cast 제거와 정적 타입 분기
  28. 28임베디드 스마트 포인터 선택 — unique·shared·custom 비교
  29. 29임베디드 C++ 소유권 모델 — single·shared·borrow 패턴
  30. 30Intrusive Containers 분석 — 동적 할당 없는 컨테이너 설계
  31. 31ETL 라이브러리 분석 — Embedded Template Library의 STL 대체
  32. 32임베디드 Lock-free 기초 — atomic·memory ordering·CAS
  33. 33Lock-free Container 구현 — SPSC Queue·Ring Buffer
  34. 34Type-safe Flags 패턴 — Enum Class·Strong Typedef·Tag
  35. 35임베디드 State Machine 패턴 — Variant·Visitor·Table-driven 비교
  36. 36Compile-time FSM 구현 — 템플릿으로 상태 전이 검증
  37. 37Singleton 대안 패턴 — Service Locator·Static Init·Phantom
  38. 38MMIO Register 추상화 — 타입 안전한 비트 필드 접근
  39. 39GPIO 추상화 패턴 — Template·Concept으로 보드 독립성
  40. 40Peripheral 추상화 — UART·SPI·I2C 공통 인터페이스 설계
  41. 41임베디드 HAL 설계 패턴 — Static·Dynamic·Hybrid 비교