Linked List

Linux 커널의 핵심 자료구조인 struct list_head 기반 원형 이중 연결 리스트의 설계 철학, 사용법, 다양한 변형(hlist, llist), RCU 보호 리스트, 주의사항과 디버깅까지 종합적으로 다룹니다.

전제 조건: 동기화 기법메모리 배리어 문서를 먼저 읽으세요. 커널 자료구조는 연산 복잡도뿐 아니라 동시 접근 안전성까지 함께 설계되므로, 성능과 동기화 관점을 동시에 봐야 합니다.
일상 비유: 커널의 연결 리스트는 기차 객차 연결과 비슷합니다. 각 객차(구조체)에는 앞뒤 연결고리(list_head)가 달려 있고, 어떤 위치에서든 객차를 빼거나 끼울 수 있습니다. 원형으로 연결되어 마지막 객차 다음이 첫 객차이며, 연결고리만 조작하면 되므로 객차 내용물(데이터)은 건드리지 않습니다.

핵심 요약

  • 침투적(intrusive) 설계list_head를 데이터 구조체에 임베딩하고 list_entry()/container_of()로 역참조합니다.
  • 원형 이중 연결 — 헤드와 테일 구분 없이 양방향 순회가 O(1) 삽입/삭제와 함께 가능합니다.
  • list_for_each_entry_safe — 순회 중 삭제가 안전한 _safe 변형 매크로를 반드시 사용합니다.
  • hlist/llist 변형 — 해시 버킷용 hlist(단방향), lock-free용 llist(lockless) 변형이 있습니다.
  • RCU 보호list_add_rcu()/list_del_rcu()로 읽기 경로를 lock-free로 보호합니다.

단계별 이해

  1. list_head 임베딩 패턴
    LIST_HEAD로 리스트 헤드를 선언하고, 데이터 구조체 안에 struct list_head 멤버를 넣어 list_add()/list_del()로 연결하는 기본 흐름을 익힙니다.
  2. 순회 매크로 선택
    list_for_each_entry(읽기 전용), list_for_each_entry_safe(삭제 가능), list_for_each_entry_rcu(RCU 보호) 중 상황에 맞는 매크로를 선택합니다.
  3. 동시성 보호 설계
    리스트 자체에는 잠금이 없으므로, spinlock(삽입/삭제) + RCU(읽기 순회) 조합이나 mutex 보호 전략을 설계합니다.
  4. 변형 선택
    해시 버킷에는 hlist(헤드 포인터 1개), 인터럽트 컨텍스트 큐잉에는 llist(lockless), 일반 용도에는 list_head를 사용합니다.
예제 읽기 가이드: 이 문서는 개념 설명용 의사코드를 중심으로 구성하되, 일부 섹션은 실행 절차를 바로 점검할 수 있는 실습 예제를 함께 제공합니다. 코드 주석의 개념 예시는 구조 이해 목적, 실습 예제는 검증 절차 재현 목적입니다.
관련 표준: (자료구조 구현, 외부 표준 없음) 커널 내부 자료구조 설계 패턴을 다룹니다. 종합 목록은 참고자료 — 표준 & 규격 섹션을 참고하세요.

개요 (Overview)

C 언어에는 표준 컨테이너 라이브러리가 없습니다. 사용자 공간에서는 glib, uthash 등 외부 라이브러리를 사용하지만, 커널은 자체적인 침입형(intrusive) 연결 리스트<linux/list.h>에 구현합니다. 이 구현은 1996년부터 사용되어 온 커널에서 가장 널리 쓰이는 자료구조입니다.

커널이 자체 연결 리스트를 사용하는 이유:

반대로 다음 조건에서는 list_head가 적합하지 않을 수 있습니다:

ℹ️

struct list_head는 커널 전체에서 수만 번 사용됩니다. task_struct만 해도 10개 이상의 list_head 필드를 가지며, 스케줄러 런큐, 타이머 리스트, VFS dentry 캐시 등 거의 모든 서브시스템이 이 자료구조에 의존합니다.

list_head 구조와 container_of

struct list_head의 정의는 놀라울 정도로 단순합니다:

/* 개념 예시: include/linux/types.h list_head 정의 */
/* include/linux/types.h */
struct list_head {
    struct list_head *next, *prev;
};

이 구조체는 데이터를 포함하지 않습니다. 대신, 데이터를 담은 구조체 안에 임베드됩니다:

struct my_item {
    int                 id;
    char                name[64];
    struct list_head    list;   /* 리스트 노드 임베드 */
    struct list_head    other;  /* 다른 리스트에도 참여 가능 */
};

원형 이중 연결 리스트의 구조를 다이어그램으로 살펴보겠습니다:

원형 이중 연결 리스트 구조 head 센티널과 두 개의 노드가 next와 prev 포인터로 양방향 연결되고 마지막 노드가 다시 head로 순환하는 구조를, 선이 겹치지 않도록 상하 레인으로 분리해 보여줍니다. head (sentinel) struct my_item (A) payload fields list_head struct my_item (B) payload fields list_head next next prev prev next 원형 경로 prev 원형 경로
원형 이중 연결 리스트: head ↔ node A ↔ node B ↔ head (순환)

container_of 매크로 (container_of Macro)

리스트를 순회하면 list_head 포인터를 얻게 됩니다. 이 포인터로부터 실제 데이터 구조체를 복원하는 것이 container_of 매크로입니다:

/* include/linux/container_of.h */
#define container_of(ptr, type, member) ({               \
    void *__mptr = (void *)(ptr);                        \
    static_assert(__same_type(*(ptr), ((type *)0)->member) || \
                  __same_type(*(ptr), void),              \
                  "pointer type mismatch in container_of()"); \
    ((type *)(__mptr - offsetof(type, member))); })

동작 원리는 간단합니다: list_head 멤버의 주소에서 해당 멤버의 구조체 내 오프셋을 빼면 구조체의 시작 주소가 됩니다.

container_of 메모리 레이아웃 구조체 내부의 list_head 멤버 포인터에서 offsetof를 빼서 구조체 시작 주소를 계산하는 원리를 표현합니다. container_of 메모리 레이아웃 struct my_item id (4 bytes) name[64] (64 bytes) list (list_head, 16B) other (list_head, 16B) offsetof(struct my_item, list) = 컴파일 결과값 ptr - offsetof = 구조체 시작 주소 container_of() 결과 ptr (list_head *)
container_of: list_head 포인터에서 offsetof를 빼서 원본 구조체 주소를 계산
ℹ️

offsetof(struct my_item, list) 값은 CPU 아키텍처, 정렬 규칙, 컴파일러 옵션에 따라 달라질 수 있습니다. 고정 상수로 가정하지 말고 실제 빌드 결과를 기준으로 해석하세요.

/* 사용 예: list_head 포인터에서 my_item 구조체 복원 */
struct list_head *pos;
struct my_item *item;

item = container_of(pos, struct my_item, list);
pr_info("item id=%d, name=%s\\n", item->id, item->name);

offsetof 매크로 (offsetof Macro)

container_of의 핵심은 offsetof입니다. 구조체 시작 주소로부터 특정 멤버까지의 바이트 오프셋을 컴파일 타임에 계산합니다:

/* include/linux/stddef.h */
#define offsetof(TYPE, MEMBER) __builtin_offsetof(TYPE, MEMBER)

/* GCC 내장 함수가 없을 경우의 전통적 구현 */
#define offsetof(TYPE, MEMBER) ((size_t)&((TYPE *)0)->MEMBER)
💡

container_of는 리스트뿐 아니라 커널 전체에서 광범위하게 사용됩니다. work_struct에서 원본 구조체 복원, kobject에서 디바이스 구조체 복원, rb_node에서 데이터 복원 등이 모두 같은 패턴입니다.

기본 연산 (Basic Operations)

초기화 (Initialization)

리스트 사용 전 반드시 초기화해야 합니다. 초기화된 리스트는 nextprev가 자기 자신을 가리키는 "빈 원형 리스트"입니다:

/* 컴파일 타임 초기화 (전역/정적 변수) */
static LIST_HEAD(my_list);
/* 다음과 동일:
 * static struct list_head my_list = { &my_list, &my_list };
 */

/* 런타임 초기화 (함수 내) */
struct list_head dynamic_list;
INIT_LIST_HEAD(&dynamic_list);

/* 구조체 멤버 초기화 */
struct my_item *item = kmalloc(sizeof(*item), GFP_KERNEL);
INIT_LIST_HEAD(&item->list);

추가와 삭제 (Add & Delete)

/* 리스트 head 바로 뒤에 삽입 (스택 동작: LIFO) */
list_add(&item->list, &my_list);

/* 리스트 head 바로 앞에 삽입 (큐 동작: FIFO) */
list_add_tail(&item->list, &my_list);

/* 리스트에서 삭제 (next/prev를 LIST_POISON으로 설정) */
list_del(&item->list);

/* 삭제 후 재초기화 (다시 list_add 가능) */
list_del_init(&item->list);

/* 노드 교체: old 자리에 new를 삽입 */
list_replace(&old->list, &new->list);

/* 교체 + old 재초기화 */
list_replace_init(&old->list, &new->list);
⚠️

list_del() 후의 노드에 list_del()을 다시 호출하면 정의되지 않은 동작으로 이어질 수 있습니다. next/prevLIST_POISON1/LIST_POISON2로 설정되어 이후 접근 시 OOPS나 메모리 손상이 발생할 수 있습니다. 삭제 후 재사용이 필요하면 list_del_init()을 사용하세요.

이동과 합치기 (Move & Splice)

/* 노드를 다른 리스트의 head 뒤로 이동 */
list_move(&item->list, &other_list);

/* 노드를 다른 리스트의 tail로 이동 */
list_move_tail(&item->list, &other_list);

/* 리스트 전체를 다른 리스트 head 뒤에 합치기 */
list_splice(&source_list, &dest_list);

/* 합치기 + source 리스트 재초기화 */
list_splice_init(&source_list, &dest_list);

/* 합치기 (tail 쪽에) + source 재초기화 */
list_splice_tail_init(&source_list, &dest_list);

상태 확인 (Query)

/* 리스트가 비어있는가? */
if (list_empty(&my_list))
    pr_info("list is empty\\n");

/* 노드가 하나뿐인가? */
if (list_is_singular(&my_list))
    pr_info("only one element\\n");

/* 이 노드가 리스트의 마지막인가? */
if (list_is_last(&item->list, &my_list))
    pr_info("this is the last node\\n");

/* 첫 번째 / 마지막 엔트리 가져오기 */
struct my_item *first = list_first_entry(&my_list, struct my_item, list);
struct my_item *last  = list_last_entry(&my_list, struct my_item, list);

/* 빈 리스트일 수 있을 때 (NULL 반환) */
struct my_item *f = list_first_entry_or_null(&my_list, struct my_item, list);

권장 수명주기 패턴

실무에서 리스트 버그의 대부분은 API 자체보다 수명주기 순서에서 발생합니다. 아래 순서를 습관화하면 use-after-free와 이중 삭제를 크게 줄일 수 있습니다.

  1. 할당/초기화: kmalloc() 후 payload를 채우고 INIT_LIST_HEAD(&obj->list) 실행
  2. 공개(publish): lock 보호 하에 list_add() 또는 list_add_tail() 호출
  3. 사용: 순회 시 컨텍스트 규칙(락 또는 RCU)을 엄격히 지킴
  4. 제거(unlink): list_del_init() 또는 list_del_rcu()로 연결 해제
  5. 해제(free): 일반 리스트는 즉시 kfree(), RCU 리스트는 grace period 뒤 해제
/* 실무 패턴: 수명주기와 에러 경로를 한 함수에서 정리 */
int my_item_publish(struct list_head *head, int id)
{
    struct my_item *obj;

    obj = kmalloc(sizeof(*obj), GFP_KERNEL);
    if (!obj)
        return -ENOMEM;

    obj->id = id;
    INIT_LIST_HEAD(&obj->list);

    spin_lock(&list_lock);
    list_add_tail(&obj->list, head);
    spin_unlock(&list_lock);
    return 0;
}

순회 매크로 (Traversal Macros)

커널의 리스트 순회 매크로는 for 루프를 감싸는 편의 매크로입니다. 가장 자주 사용되는 것은 list_for_each_entry입니다:

/* list_head 포인터 순회 (거의 사용 안 함) */
struct list_head *pos;
list_for_each(pos, &my_list) {
    struct my_item *item = container_of(pos, struct my_item, list);
    pr_info("id=%d\\n", item->id);
}

/* 엔트리(구조체) 직접 순회 (가장 권장) */
struct my_item *item;
list_for_each_entry(item, &my_list, list) {
    pr_info("id=%d, name=%s\\n", item->id, item->name);
}

/* 역순 순회 */
list_for_each_entry_reverse(item, &my_list, list) {
    pr_info("reverse: id=%d\\n", item->id);
}

엔트리 접근 매크로들:

/* 현재 엔트리로부터 리스트 포인터를 추출 */
struct my_item *entry = list_entry(pos, struct my_item, list);
/* list_entry는 container_of와 동일 */

/* 다음/이전 엔트리 */
struct my_item *next_item = list_next_entry(item, list);
struct my_item *prev_item = list_prev_entry(item, list);
💡

list_for_each_entry 매크로의 확장을 이해하면 디버깅이 쉬워집니다:

#define list_for_each_entry(pos, head, member)                  \
    for (pos = list_first_entry(head, typeof(*pos), member); \
         &pos->member != (head);                                \
         pos = list_next_entry(pos, member))

종료 조건은 &pos->member != head입니다. 원형 리스트이므로 head로 돌아오면 순회가 끝납니다.

안전한 순회 (Safe Traversal)

순회 중에 현재 노드를 삭제하면 next 포인터가 파괴되어 순회를 계속할 수 없습니다. 이 문제를 해결하기 위해 _safe 버전의 매크로가 제공됩니다:

/* 순회 중 안전하게 삭제 가능 (tmp에 next를 미리 저장) */
struct my_item *item, *tmp;
list_for_each_entry_safe(item, tmp, &my_list, list) {
    if (item->id == target_id) {
        list_del(&item->list);
        kfree(item);
        /* tmp 덕분에 순회가 안전하게 계속됨 */
    }
}

/* 역순 안전 순회 */
list_for_each_entry_safe_reverse(item, tmp, &my_list, list) {
    list_del(&item->list);
    kfree(item);
}

순회 패턴 선택 기준

상황 권장 매크로 핵심 주의점
읽기 전용 순회 list_for_each_entry 동시 수정 가능성이 있으면 락 또는 RCU 필요
순회 중 현재 노드 삭제 list_for_each_entry_safe safe는 동시성 안전이 아니라 iterator 안전
RCU 읽기 순회 list_for_each_entry_rcu rcu_read_lock() 구간 안에서만 사용
역순 정리 list_for_each_entry_safe_reverse tail 근처 삭제 정책에 유리
⚠️

list_for_each_entry_safe"safe"는 동시성 안전이 아닙니다. 다른 CPU나 인터럽트 핸들러가 동시에 리스트를 수정하면 여전히 위험합니다. 동시성 보호에는 별도의 lock(spinlock, mutex)이나 RCU가 필요합니다. "safe"는 오직 "현재 순회 컨텍스트에서 삭제해도 순회가 깨지지 않는다"는 의미입니다.

RCU 보호 리스트 (RCU-Protected Lists)

RCU(Read-Copy-Update)를 사용하면 읽기 측에서 lock 없이 리스트를 순회할 수 있습니다. 쓰기 측만 lock을 잡으면 되므로, 읽기가 압도적으로 많은 경우 최적의 성능을 제공합니다.

/* RCU 보호 리스트 추가 (publish-subscribe 패턴) */
list_add_rcu(&item->list, &my_list);
/* 내부적으로 rcu_assign_pointer()를 사용하여 메모리 배리어 보장 */

/* RCU 보호 리스트 삭제 */
list_del_rcu(&item->list);
/* next 포인터를 유지하여 진행 중인 reader가 계속 순회 가능 */
synchronize_rcu();  /* 모든 reader가 빠져나올 때까지 대기 */
kfree(item);         /* grace period 후에 안전하게 해제 */

/* 또는 콜백 기반 비동기 해제 */
list_del_rcu(&item->list);
kfree_rcu(item, rcu);  /* struct에 rcu_head 멤버 필요 */
/* RCU 읽기 측: lock 없이 순회 */
rcu_read_lock();
list_for_each_entry_rcu(item, &my_list, list) {
    pr_info("id=%d\\n", item->id);
    /* 이 구간에서는 preemption 비활성화 (PREEMPT_RCU 제외) */
}
rcu_read_unlock();

/* lockless 순회 (KCSAN 경고 억제) */
list_for_each_entry_lockless(item, &my_list, list) {
    /* lock도 rcu_read_lock도 없이 순회할 때 사용 */
}
ℹ️

RCU 리스트의 핵심 규칙:

  • 읽기 측: rcu_read_lock() / rcu_read_unlock() 사이에서 list_for_each_entry_rcu()로 순회합니다.
  • 쓰기 측: spinlock 등으로 writer간 동기화 후, list_add_rcu() / list_del_rcu()를 사용합니다.
  • 해제: list_del_rcu() 후 즉시 kfree()하면 안 됩니다. synchronize_rcu() 또는 kfree_rcu()를 거쳐야 합니다.

RCU 쓰기 측 권장 패턴

RCU는 "읽기 lock-free"가 핵심이지만, 쓰기 측은 보통 spinlock으로 직렬화합니다. 아래 패턴은 커널 서브시스템에서 가장 흔히 쓰이는 형태입니다.

/* writer: 갱신은 락으로 직렬화, 읽기는 RCU로 병렬화 */
void my_table_insert(struct my_item *new)
{
    spin_lock(&table_lock);
    list_add_rcu(&new->list, &my_list);
    spin_unlock(&table_lock);
}

void my_table_remove(struct my_item *obj)
{
    spin_lock(&table_lock);
    list_del_rcu(&obj->list);
    spin_unlock(&table_lock);

    /* reader 종료 이후 해제 */
    kfree_rcu(obj, rcu);
}
⚠️

RCU 리스트에서도 쓰기 측 락을 생략하면 writer-writer 경합으로 리스트 연결이 깨질 수 있습니다. RCU는 reader-writer 충돌 비용을 줄이는 기법이지, writer 동기화를 없애는 기법이 아닙니다.

hlist (해시 리스트)

struct hlist_head는 해시 테이블의 버킷으로 최적화된 단방향 리스트입니다. list_headnextprev 두 포인터(16바이트)를 사용하지만, hlist_headfirst 하나(8바이트)만 사용하여 해시 테이블의 메모리를 절반으로 줄입니다.

struct hlist_head {
    struct hlist_node *first;
};

struct hlist_node {
    struct hlist_node *next;
    struct hlist_node **pprev;  /* prev 노드의 next 포인터의 주소 */
};
hlist와 list_head 비교 위쪽은 list_head 원형 양방향 연결, 아래쪽은 hlist 단방향 체인과 pprev 역참조를 보여주며 두 구조의 버킷 헤드 메모리 차이를 함께 표시합니다. hlist vs list_head 비교 list_head (원형, 양방향) head (16B) next + prev node next + prev node next + prev next 순환 prev 순환 hlist (비원형, 단방향 + pprev) head (8B) first node next + pprev node next + pprev → NULL pprev → &head.first pprev → &prev.next 1024 buckets list_head: 16KB 1024 buckets hlist: 8KB 헤드 메모리 50% 절감
hlist은 head 크기를 8B로 줄여 대규모 해시 테이블에서 메모리를 절반으로 절약

pprev는 이전 노드의 next 포인터(또는 head의 first 포인터)를 가리키는 이중 포인터입니다. 이를 통해 O(1) 삭제를 구현합니다:

/* hlist 기본 사용 */
DEFINE_HASHTABLE(my_htable, 10);  /* 2^10 = 1024 buckets */

struct my_entry {
    int                  key;
    char                 value[32];
    struct hlist_node    node;
};

/* 삽입 */
struct my_entry *entry = kmalloc(sizeof(*entry), GFP_KERNEL);
entry->key = 42;
hash_add(my_htable, &entry->node, entry->key);

/* 검색 */
struct my_entry *cur;
hash_for_each_possible(my_htable, cur, node, 42) {
    if (cur->key == 42) {
        pr_info("found: %s\\n", cur->value);
        break;
    }
}

/* 삭제 */
hash_del(&entry->node);
kfree(entry);

llist (Lock-less 리스트)

llist (Lock-less Linked List)는 cmpxchg(Compare-And-Swap) 기반의 lock-free 스택입니다. 여러 CPU에서 동시에 lock 없이 노드를 추가할 수 있으며, 하나의 소비자가 전체 리스트를 원자적으로 꺼내갑니다.

llist MPSC 처리 흐름 여러 CPU producer가 lock-free로 노드를 push하고 단일 consumer 스레드가 llist_del_all로 원자적으로 회수하는 흐름입니다. llist MPSC 처리 흐름 CPU0 producer CPU1 producer CPU2 producer llist_head.first llist_add(node, &head) CAS 기반 lock-free push LIFO 체인 누적 consumer thread llist_del_all() 원자적 일괄 회수
여러 producer는 lock-free로 push, single consumer가 llist_del_all()로 배치 처리
struct llist_head {
    struct llist_node *first;
};

struct llist_node {
    struct llist_node *next;
};
/* 초기화 */
static LLIST_HEAD(my_llist);

/* Lock-free 추가 (여러 CPU에서 동시 호출 가능) */
struct my_work {
    struct llist_node node;
    int data;
};

struct my_work *w = kmalloc(sizeof(*w), GFP_ATOMIC);
w->data = 42;
llist_add(&w->node, &my_llist);

/* 전체 리스트를 원자적으로 꺼내기 (단일 소비자) */
struct llist_node *batch = llist_del_all(&my_llist);

/* 꺼낸 리스트 순회 */
struct llist_node *pos;
llist_for_each(pos, batch) {
    struct my_work *w = container_of(pos, struct my_work, node);
    process_work(w);
    kfree(w);
}

llist의 대표적인 사용 시나리오:

⚠️

llist는 MPSC(Multiple Producer, Single Consumer) 패턴만 지원합니다. 여러 소비자가 llist_del_all()을 동시에 호출하면 경합(race) 조건이 발생할 수 있습니다. 소비자가 여러 개 필요하면 spinlock과 일반 list_head를 사용하세요.

설계 체크리스트 (Design Checklist)

아래 체크리스트를 먼저 채우고 구현에 들어가면 자료구조 재작업 비용을 크게 줄일 수 있습니다.

질문 판단 기준 권장 선택
검색이 잦은가? 요청당 선형 탐색이 허용되는지 아니오: list_head, 예: hlist/rbtree/xarray
정렬 순서가 필요한가? 삽입 후 즉시 정렬 상태를 유지해야 하는지 필요: rbtree
읽기 비중이 압도적인가? reader 수가 writer보다 훨씬 많은지 그렇다면 RCU 리스트 고려
컨텍스트가 IRQ인가? sleep 불가 컨텍스트 여부 spinlock/RCU/llist, GFP_ATOMIC 고려
엔트리 수 상한이 작은가? 최대 수가 수십~수백 수준인지 작다면 단순한 list_head 유지
💡

구조를 빠르게 고르는 실전 규칙: 삭제가 많고 검색이 적다list_head, 키 검색이 많다hlist/hashtable, 정렬/범위 질의가 필요하면 rbtree를 기본 선택으로 두고 시작하세요.

커널 내 실제 사용 사례 (Real-World Usage in Kernel)

커널의 핵심 자료구조에서 list_head가 어떻게 사용되는지 살펴봅니다:

/* include/linux/sched.h - task_struct의 리스트들 */
struct task_struct {
    /* ... */
    struct list_head    tasks;       /* 모든 프로세스 리스트 (init_task.tasks가 head) */
    struct list_head    children;    /* 자식 프로세스 리스트 head */
    struct list_head    sibling;     /* 형제 프로세스 리스트 (parent->children에 연결) */
    struct list_head    thread_group;/* 같은 스레드 그룹 */
    /* ... */
};
서브시스템 자료구조 list_head 필드 용도
스케줄러 sched_entity group_node CFS 그룹 스케줄링 엔티티 리스트
VFS dentry d_lru, d_child, d_subdirs LRU 캐시, 디렉터리 계층 구조
메모리 page lru active/inactive LRU 리스트, buddy free list
네트워크 sock sk_node (hlist) 소켓 해시 테이블 (established, listen)
블록 I/O request queuelist I/O 스케줄러 요청 큐
모듈 module list 로드된 모듈 목록 (/proc/modules)
타이머 timer_list entry (hlist) 타이머 wheel 버킷
/* 모든 프로세스 순회 예: init_task에서 시작 */
struct task_struct *task;
for_each_process(task) {
    pr_info("PID %d: %s\\n", task->pid, task->comm);
}
/* for_each_process는 list_for_each_entry의 래퍼:
 * #define for_each_process(p) \
 *     list_for_each_entry(p, &init_task.tasks, tasks)
 */

주의사항과 함정 (Pitfalls & Common Mistakes)

1. 초기화 누락

/* 잘못된 코드: 초기화 없이 사용 */
struct list_head my_list;
list_add(&item->list, &my_list);  /* BUG! next/prev가 쓰레기 값 */

/* 올바른 코드 */
struct list_head my_list;
INIT_LIST_HEAD(&my_list);
list_add(&item->list, &my_list);  /* OK */

2. 이중 삭제

/* BUG: list_del 후 다시 list_del */
list_del(&item->list);
/* ... 나중에 ... */
list_del(&item->list);  /* LIST_POISON 접근 → OOPS */

/* 해결: list_del_init 사용 */
list_del_init(&item->list);
/* 이제 list_empty(&item->list)로 삭제 여부 확인 가능 */

3. 동시성 보호 누락

/* 위험: lock 없이 공유 리스트 접근 */
list_add(&item->list, &shared_list);  /* 경합! */

/* 올바른 패턴 */
spin_lock(&list_lock);
list_add(&item->list, &shared_list);
spin_unlock(&list_lock);

/* 또는 RCU 패턴 */
spin_lock(&list_lock);
list_add_rcu(&item->list, &shared_list);
spin_unlock(&list_lock);

4. list_for_each_entry_safe의 한계

/* list_for_each_entry_safe는 다음 노드(tmp)를 미리 저장하지만,
 * 만약 다른 CPU가 tmp 노드를 삭제하면 여전히 위험합니다.
 *
 * safe 버전은 "나 자신을 삭제해도 순회가 안전하다"는 뜻이지,
 * "다른 CPU의 수정으로부터 안전하다"는 뜻이 아닙니다!
 */

/* 동시 수정이 있을 때의 올바른 패턴 */
spin_lock(&list_lock);
list_for_each_entry_safe(item, tmp, &my_list, list) {
    list_del(&item->list);
    kfree(item);
}
spin_unlock(&list_lock);

5. Iterator 변수 스코프 (커널 6.x 변경)

/* 커널 6.x부터: 순회 매크로의 iterator 변수가
 * 루프 종료 후 유효한 엔트리를 가리키지 않을 수 있습니다.
 *
 * 기존 잘못된 패턴: */
struct my_item *item;
list_for_each_entry(item, &my_list, list) {
    if (item->id == target)
        break;
}
/* item을 루프 밖에서 사용 → 6.x에서 위험! */

/* 올바른 패턴: 별도 변수에 저장 */
struct my_item *item, *found = NULL;
list_for_each_entry(item, &my_list, list) {
    if (item->id == target) {
        found = item;
        break;
    }
}
if (found)
    pr_info("found id=%d\\n", found->id);

성능 고려사항 (Performance Considerations)

list_head는 만능 자료구조가 아닙니다. 사용 패턴에 따라 더 적합한 자료구조를 선택해야 합니다:

자료구조 삽입 삭제 검색 적합한 경우
list_head O(1) O(1) O(n) 순차 접근, FIFO/LIFO, 작은 리스트
hlist O(1) O(1) O(1) 평균 해시 테이블 버킷
rbtree O(log n) O(log n) O(log n) 정렬된 대규모 데이터, 범위 검색
xarray O(log n) O(log n) O(log n) 정수 키 기반 매핑, 페이지 캐시
llist O(1) lock-free 전체 꺼내기 N/A IRQ→스레드 전달, MPSC

캐시 성능 관련 고려사항:

💡

SLAB_HWCACHE_ALIGN 플래그로 slab 캐시를 생성하면 각 오브젝트가 캐시 라인 경계에 정렬되어, 리스트 노드 접근 시 false sharing을 방지할 수 있습니다.

성능 수치 예시

아래 표는 자료구조 선택 감각을 잡기 위한 예시 수치입니다. 절대값은 하드웨어, 락 경합, 키 분포, 버킷 수, NUMA 배치에 따라 크게 달라집니다.

작업 list_head
(순차)
hlist
(1024 버킷)
rbtree xarray
10만 개 삽입 2.1 ms 3.8 ms 12.5 ms 15.3 ms
특정 키 검색 (1회) 850 μs 1.2 μs 0.8 μs 0.6 μs
전체 순회 420 μs 680 μs 520 μs 480 μs
범위 검색 (1000개) 8.5 ms N/A 95 μs 110 μs
무작위 삭제 (1만 개) 850 ms 12 ms 1.8 ms 2.1 ms
ℹ️

측정 조건(예시): Intel Xeon E5-2680 v4 @ 2.4GHz, 64GB RAM, Linux 6.1, CONFIG_PREEMPT_NONE, 단일 소켓, 캐시 워밍 후 30회 반복 평균

핵심 인사이트:

  • list_head는 순차 삽입이 가장 빠르지만, 검색과 무작위 삭제는 O(n) 특성으로 느립니다.
  • hlist는 적절한 버킷 수(엔트리 수의 1~2%)를 사용하면 검색과 삭제가 O(1)에 근접합니다.
  • rbtree는 균형잡힌 성능을 제공하며, 범위 검색이 필요하면 최선의 선택입니다.
  • xarray는 정수 키 기반 점 조회에 최적화되어 있습니다.

캐시 미스율 비교 (perf stat 측정):

자료구조 L1 캐시 미스율 LLC 캐시 미스율 비고
list_head (순회) 3.2% 0.8% 순차 접근으로 prefetcher 효과
list_head (무작위) 28.5% 12.3% 노드 분산으로 캐시 비효율
hlist 15.7% 6.2% 버킷 head 접근 비용
rbtree 18.9% 7.1% 트리 탐색 경로 분산
배열 (참고) 1.1% 0.2% 연속 메모리, 최고 캐시 효율
⚠️

실무 선택 가이드: 벤치마크 수치보다 실제 접근 패턴이 중요합니다. 리스트 길이가 100개 미만이고 검색이 드물면 list_head의 단순함이 오히려 유리할 수 있습니다. 측정 없이 최적화하지 마세요.

디버깅 (Debugging)

리스트 corruption은 커널에서 가장 흔한 버그 중 하나입니다. 다행히 커널은 강력한 디버깅 도구를 제공합니다.

CONFIG_DEBUG_LIST

이 옵션을 활성화하면 모든 리스트 연산에 무결성 검사가 추가됩니다:

/* CONFIG_DEBUG_LIST 활성화 시 list_add()가 다음을 검증: */
/* 1. next->prev == head (리스트 연결 무결성) */
/* 2. prev->next == head (리스트 연결 무결성) */
/* 3. new != LIST_POISON1 (이미 삭제된 노드 재삽입 방지) */

/* 위반 시 커널 경고 메시지: */
/* list_add corruption. next->prev should be <addr1>, but was <addr2>. */
/* list_del corruption. prev->next should be <addr1>, but was <addr2>. */
# .config에서 활성화
CONFIG_DEBUG_LIST=y

# 또는 menuconfig에서
# Kernel hacking → Memory Debugging → Debug linked list manipulation

LIST_POISON 탐지

/* include/linux/poison.h */
#define LIST_POISON1  ((void *) 0x100 + POISON_POINTER_DELTA)
#define LIST_POISON2  ((void *) 0x122 + POISON_POINTER_DELTA)

/* list_del()은 next를 LIST_POISON1로, prev를 LIST_POISON2로 설정합니다.
 * 이 값에 접근하면 즉시 page fault가 발생하여 버그를 빨리 발견할 수 있습니다. */

KASAN과의 연계

# KASAN (Kernel Address Sanitizer) 활성화
CONFIG_KASAN=y

# Use-after-free 탐지: kfree 후 list 접근 시
# BUG: KASAN: use-after-free in list_del+0x20/0x50
# Read of size 8 at addr ffff888012345678
# Freed by task xyz at: kfree+0x...

실전 디버깅 체크리스트:

ℹ️

crash 도구로 코어 덤프를 분석할 때 list 명령으로 연결 리스트를 순회하면서 corruption 지점을 찾을 수 있습니다:

crash> list task_struct.tasks -s task_struct.comm,pid -H init_task.tasks

실습 예제 (Hands-On Example)

이론을 넘어 실제 동작하는 커널 모듈을 통해 list_head 사용법을 익혀봅니다. 아래 예제는 간단한 학생 정보 관리 시스템으로, 삽입/순회/삭제/검색을 모두 다룹니다.

완전한 커널 모듈 예제

/* student_list.c - 실습용 list_head 모듈 */
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
#include <linux/list.h>
#include <linux/slab.h>

/* 학생 정보 구조체 */
struct student {
    unsigned int        id;
    char                name[32];
    unsigned int        score;
    struct list_head    list;    /* 리스트 노드 */
};

/* 학생 리스트 head (전역) */
static LIST_HEAD(student_list);

/* 학생 추가 함수 */
static struct student *add_student(unsigned int id, const char *name, unsigned int score)
{
    struct student *s;

    s = kmalloc(sizeof(*s), GFP_KERNEL);
    if (!s)
        return NULL;

    s->id = id;
    strncpy(s->name, name, sizeof(s->name) - 1);
    s->name[sizeof(s->name) - 1] = '\0';
    s->score = score;

    /* 리스트 끝에 추가 (FIFO) */
    list_add_tail(&s->list, &student_list);

    pr_info("Added: ID=%u, Name=%s, Score=%u\n", id, name, score);
    return s;
}

/* ID로 학생 검색 */
static struct student *find_student(unsigned int id)
{
    struct student *s;

    list_for_each_entry(s, &student_list, list) {
        if (s->id == id)
            return s;
    }
    return NULL;
}

/* 학생 삭제 (ID 기준) */
static bool remove_student(unsigned int id)
{
    struct student *s;

    s = find_student(id);
    if (!s) {
        pr_warn("Student ID=%u not found\n", id);
        return false;
    }

    pr_info("Removing: ID=%u, Name=%s\n", s->id, s->name);
    list_del(&s->list);
    kfree(s);
    return true;
}

/* 전체 학생 목록 출력 */
static void print_all_students(void)
{
    struct student *s;
    int count = 0;

    pr_info("=== Student List ===\n");
    list_for_each_entry(s, &student_list, list) {
        pr_info("  [%d] ID=%u, Name=%-20s, Score=%u\n",
                ++count, s->id, s->name, s->score);
    }
    pr_info("Total: %d students\n", count);
}

/* 조건부 삭제: 점수가 60 미만인 학생 제거 */
static void remove_failing_students(void)
{
    struct student *s, *tmp;
    int removed = 0;

    /* 순회 중 삭제: _safe 버전 필수 */
    list_for_each_entry_safe(s, tmp, &student_list, list) {
        if (s->score < 60) {
            pr_info("Removing failing student: %s (score=%u)\n",
                    s->name, s->score);
            list_del(&s->list);
            kfree(s);
            removed++;
        }
    }
    pr_info("Removed %d failing students\n", removed);
}

/* 모듈 초기화 */
static int __init student_list_init(void)
{
    pr_info("Student List Module: Initializing\n");

    /* 테스트 데이터 추가 */
    add_student(1001, "Alice", 95);
    add_student(1002, "Bob", 58);
    add_student(1003, "Charlie", 72);
    add_student(1004, "Diana", 88);
    add_student(1005, "Eve", 45);

    /* 초기 목록 출력 */
    print_all_students();

    /* 특정 학생 검색 */
    struct student *found = find_student(1003);
    if (found)
        pr_info("Found student: %s (score=%u)\n", found->name, found->score);

    /* 낙제생 제거 */
    remove_failing_students();

    /* 최종 목록 출력 */
    print_all_students();

    return 0;
}

/* 모듈 종료 - 메모리 정리 */
static void __exit student_list_exit(void)
{
    struct student *s, *tmp;

    pr_info("Student List Module: Cleaning up\n");

    /* 남은 모든 학생 제거 */
    list_for_each_entry_safe(s, tmp, &student_list, list) {
        pr_info("Freeing: %s\n", s->name);
        list_del(&s->list);
        kfree(s);
    }

    pr_info("All students freed\n");
}

module_init(student_list_init);
module_exit(student_list_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("MINZKN");
MODULE_DESCRIPTION("list_head practice module");

Makefile

# Makefile for student_list module
obj-m += student_list.o

KDIR := /lib/modules/$(shell uname -r)/build
PWD := $(shell pwd)

all:
	$(MAKE) -C $(KDIR) M=$(PWD) modules

clean:
	$(MAKE) -C $(KDIR) M=$(PWD) clean

빌드 및 실행

# 모듈 빌드
make

# 모듈 로드
sudo insmod student_list.ko

# 커널 로그 확인
dmesg | tail -30

# 예상 출력:
# [ 1234.567890] Student List Module: Initializing
# [ 1234.567891] Added: ID=1001, Name=Alice, Score=95
# [ 1234.567892] Added: ID=1002, Name=Bob, Score=58
# [ 1234.567893] Added: ID=1003, Name=Charlie, Score=72
# [ 1234.567894] Added: ID=1004, Name=Diana, Score=88
# [ 1234.567895] Added: ID=1005, Name=Eve, Score=45
# [ 1234.567896] === Student List ===
# [ 1234.567897]   [1] ID=1001, Name=Alice               , Score=95
# [ 1234.567898]   [2] ID=1002, Name=Bob                 , Score=58
# [ 1234.567899]   [3] ID=1003, Name=Charlie            , Score=72
# [ 1234.567900]   [4] ID=1004, Name=Diana              , Score=88
# [ 1234.567901]   [5] ID=1005, Name=Eve                , Score=45
# [ 1234.567902] Total: 5 students
# [ 1234.567903] Found student: Charlie (score=72)
# [ 1234.567904] Removing failing student: Bob (score=58)
# [ 1234.567905] Removing failing student: Eve (score=45)
# [ 1234.567906] Removed 2 failing students
# [ 1234.567907] === Student List ===
# [ 1234.567908]   [1] ID=1001, Name=Alice               , Score=95
# [ 1234.567909]   [2] ID=1003, Name=Charlie            , Score=72
# [ 1234.567910]   [3] ID=1004, Name=Diana              , Score=88
# [ 1234.567911] Total: 3 students

# 모듈 언로드
sudo rmmod student_list

# 정리 확인
dmesg | tail -5
# [ 1245.678901] Student List Module: Cleaning up
# [ 1245.678902] Freeing: Alice
# [ 1245.678903] Freeing: Charlie
# [ 1245.678904] Freeing: Diana
# [ 1245.678905] All students freed

연습 과제

💡

직접 시도해보기: 위 모듈을 기반으로 다음 기능을 추가해보세요.

  1. 정렬 삽입: ID 순서를 유지하며 삽입하는 add_student_sorted() 구현 (힌트: list_for_each_entry로 삽입 위치 찾기 + list_add)
  2. 평균 계산: 전체 학생의 평균 점수를 계산하는 get_average_score() 구현
  3. RCU 버전: 읽기 측을 rcu_read_lock() + list_for_each_entry_rcu()로 변경하고, 쓰기 측에 spinlock 추가
  4. proc 파일 인터페이스: /proc/students를 통해 학생 목록을 cat으로 읽을 수 있도록 구현 (힌트: proc_create(), seq_file)
⚠️

메모리 누수 방지: 모듈 종료 시(__exit) 반드시 list_for_each_entry_safe로 모든 엔트리를 순회하며 kfree()해야 합니다. CONFIG_KMEMLEAK을 활성화하면 메모리 누수를 자동 탐지할 수 있습니다.

부록: 인접 자료구조 선택 가이드

Linked List 문서의 핵심은 list_head, hlist, llist, RCU 리스트의 동작입니다. 아래 내용은 본문 흐름을 보조하는 빠른 선택 가이드이며, 상세 구현은 각 전용 문서에서 다룹니다.

ℹ️

읽기 순서 권장: 이 문서의 Linked List 핵심 섹션을 먼저 완료한 뒤, 필요 시 아래 자료구조를 확장 학습하세요.

자료구조 선택 요약

자료구조검색삽입/삭제강점상세 문서
list_headO(n)O(1)빈번한 삽입/삭제, 단순 연결현재 문서
hlistO(n/k)O(1)해시 버킷 체인에 최적화현재 문서의 hlist 섹션
rbtreeO(log n)O(log n)정렬 유지, 범위 탐색Red-Black Tree
Hash Table평균 O(1)평균 O(1)키 기반 점 조회Hash Table
XArrayO(log n)O(log n)정수 키 기반 인덱싱XArray
💡

실무 기준: 순서 보존+빈번한 삭제는 list_head, 해시 버킷 체인은 hlist, 정렬/범위 질의는 rbtree, 정수 인덱스 매핑은 XArray가 일반적으로 유리합니다.

실무 운영 체크리스트

연결 리스트 코드는 단순해 보여도 수명주기와 동시성에서 문제가 자주 발생합니다. 아래 항목을 릴리스 전 점검하면 장애 확률을 크게 줄일 수 있습니다.

점검 항목확인 질문권장 조치
초기화모든 head/노드가 초기화됐는가?INIT_LIST_HEAD 누락 여부 코드 검색
삭제 경로순회 중 삭제가 안전한가?list_for_each_entry_safe 사용
동시성공유 리스트에 락/RCU 규칙이 있는가?writer 락 + reader 규칙 문서화
해제 타이밍unlink 후 참조가 남아있지 않은가?일반 free 또는 kfree_rcu 구분 적용

hlist 레이아웃 심화

앞서 hlist 섹션에서 기본 구조를 살펴보았습니다. 이 섹션에서는 hlist_nodepprev가 왜 이중 포인터(**pprev)인지, 그리고 이 설계가 해시 테이블 버킷에서 어떤 이점을 제공하는지 깊이 분석합니다.

hlist 메모리 레이아웃 상세

hlist 메모리 레이아웃 심화 hlist_head의 first 포인터와 hlist_node의 next, pprev 이중 포인터가 어떻게 연결되는지, pprev가 이전 노드의 next 포인터 주소를 가리키는 구조를 상세히 보여줍니다. hlist_head + hlist_node 메모리 레이아웃 hlist_head first 8 bytes sizeof = 8B hlist_node A next 8 bytes pprev 8 bytes sizeof = 16B hlist_node B next → NULL pprev 8 bytes first next pprev → &head.first pprev → &A.next pprev 이중 포인터로 O(1) 삭제가 가능한 이유 1단계: *node->pprev = node->next → pprev가 가리키는 위치(이전 노드의 next 또는 head의 first)를 현재 노드의 next로 덮어씁니다. 2단계: if (node->next) node->next->pprev = node->pprev → 다음 노드의 pprev를 현재 노드의 pprev로 업데이트합니다. 핵심: head 포인터를 알 필요 없이 노드 자신만으로 삭제 가능 → 일반 단방향 리스트는 이전 노드를 찾으려면 head부터 순회해야 하지만, pprev 이중 포인터 덕분에 O(1) 삭제 달성
hlist의 pprev 이중 포인터는 head의 first와 노드의 next를 통일된 방식으로 가리켜 O(1) 삭제를 가능하게 합니다

pprev가 이중 포인터인 이유를 코드로 확인해보겠습니다:

/* include/linux/list.h — hlist_del() 핵심 로직 */
static inline void __hlist_del(struct hlist_node *n)
{
    struct hlist_node *next = n->next;
    struct hlist_node **pprev = n->pprev;

    /* pprev가 가리키는 곳(이전 노드의 next 또는 head의 first)을
     * 현재 노드의 next로 덮어쓴다 */
    WRITE_ONCE(*pprev, next);

    /* 다음 노드가 있으면 그 pprev를 현재 노드의 pprev로 업데이트 */
    if (next)
        WRITE_ONCE(next->pprev, pprev);
}

/* 만약 pprev 대신 단순 prev 포인터였다면:
 * - 첫 번째 노드 삭제 시 head->first를 수정해야 함
 * - head 포인터를 별도로 전달받아야 함
 * - pprev 이중 포인터 덕분에 head와 node의 구분 없이 통일된 삭제 가능 */

해시 테이블 버킷에서의 hlist 활용

커널의 DEFINE_HASHTABLE 매크로는 내부적으로 hlist_head 배열을 생성합니다. 1024개 버킷이면 list_head 사용 시 16KB이지만, hlist_head는 8KB만 사용합니다.

/* include/linux/hashtable.h */
#define DEFINE_HASHTABLE(name, bits) \
    struct hlist_head name[1 << (bits)] = \
        { [0 ... ((1 << (bits)) - 1)] = HLIST_HEAD_INIT }

/* 실제 사용 예: PID 해시 테이블 */
static struct hlist_head *pid_hash;
static unsigned int pidhash_shift;

/* kernel/pid.c — PID 검색 */
struct pid *find_pid_ns(int nr, struct pid_namespace *ns)
{
    struct hlist_node *node;
    struct upid *pnr;

    hlist_for_each_entry_rcu(pnr, &pid_hash[pid_hashfn(nr, ns)], pid_chain) {
        if (pnr->nr == nr && pnr->ns == ns)
            return container_of(pnr, struct pid,
                                numbers[ns->level]);
    }
    return NULL;
}
/* hlist RCU 버전 — 해시 테이블에서 RCU 보호 검색 */
rcu_read_lock();
hlist_for_each_entry_rcu(entry, &my_htable[bucket], node) {
    if (entry->key == search_key) {
        result = entry;
        break;
    }
}
rcu_read_unlock();

/* hlist RCU 삽입 */
spin_lock(&htable_lock);
hlist_add_head_rcu(&new_entry->node, &my_htable[bucket]);
spin_unlock(&htable_lock);

/* hlist RCU 삭제 */
spin_lock(&htable_lock);
hlist_del_rcu(&entry->node);
spin_unlock(&htable_lock);
kfree_rcu(entry, rcu);
💡

hlist vs list_head 선택 기준: 해시 테이블처럼 수백~수천 개의 빈 버킷이 존재하는 구조에서는 hlist가 메모리 절약 면에서 압도적입니다. 반면, 양방향 순회가 필요하거나 원형 리스트의 특성이 필요하면 list_head를 사용하세요.

RCU 리스트 순회 상태 머신

RCU 보호 리스트 섹션에서 기본 API를 살펴보았습니다. 이 섹션에서는 RCU 리스트의 읽기/쓰기 경로가 어떤 상태를 거치며 동작하는지 상태 머신 관점에서 분석합니다.

RCU 리스트 순회 상태 머신 RCU 리스트 읽기 측의 상태 전이와 쓰기 측의 삭제-grace period-해제 흐름을 동시에 보여주는 다이어그램입니다. RCU 리스트 Reader/Writer 상태 머신 Reader 경로 일반 컨텍스트 rcu_read_lock() preempt 비활성화 list_for_each_entry_rcu rcu_dereference() 사용 rcu_read_unlock() quiescent state 다음 노드 Writer 경로 spin_lock() writer 직렬화 list_del_rcu() next 유지 (reader 보호) spin_unlock() 쓰기 완료 Grace Period 모든 reader의 quiescent state 대기 kfree() / kfree_rcu() 안전한 메모리 해제 synchronize_rcu() 또는 call_rcu() 완료 위험: grace period 이전에 kfree() → reader가 해제된 메모리 접근 → use-after-free 크래시 발생 Reader와 Writer는 동시에 진행 가능 — Writer의 삭제가 진행 중인 Reader에게 투명(transparent) list_del_rcu()는 next 포인터를 유지하여 진행 중인 순회가 끊기지 않음
RCU 리스트의 Reader는 lock-free로 순회하고, Writer는 grace period 이후에만 메모리를 해제합니다

list_for_each_entry_rcu vs list_for_each_entry 비교

/* 일반 리스트 순회 — 락 보호 필요 */
spin_lock(&my_lock);
list_for_each_entry(item, &my_list, list) {
    /* next 포인터를 직접 역참조 */
    process(item);
}
spin_unlock(&my_lock);

/* RCU 리스트 순회 — lock-free */
rcu_read_lock();
list_for_each_entry_rcu(item, &my_list, list) {
    /* rcu_dereference()로 next를 읽음
     * → 컴파일러 최적화 방지 + 메모리 배리어 */
    process(item);
}
rcu_read_unlock();

/* 핵심 차이: list_for_each_entry_rcu 매크로 내부 */
#define list_for_each_entry_rcu(pos, head, member)         \
    for (pos = list_entry_rcu((head)->next,               \
                    typeof(*pos), member);                \
         &pos->member != (head);                              \
         pos = list_entry_rcu(pos->member.next,              \
                    typeof(*pos), member))

/* list_entry_rcu는 rcu_dereference_raw()를 사용하여
 * DATA_RACE 어노테이션과 READ_ONCE 시맨틱을 보장 */

list_add_rcu/list_del_rcu의 메모리 순서

/* list_add_rcu — rcu_assign_pointer()로 publish */
static inline void list_add_rcu(struct list_head *new,
                                  struct list_head *head)
{
    __list_add_rcu(new, head, head->next);
}

static inline void __list_add_rcu(struct list_head *new,
                                     struct list_head *prev,
                                     struct list_head *next)
{
    /* 신규 노드의 포인터를 먼저 설정 */
    new->next = next;
    new->prev = prev;

    /* rcu_assign_pointer: store-release 시맨틱
     * → 신규 노드의 내용이 reader에게 보이기 전에
     *   포인터 연결이 완료되도록 보장 */
    rcu_assign_pointer(list_next_rcu(prev), new);
    next->prev = new;
}

/* list_del_rcu — next 포인터 유지 */
static inline void list_del_rcu(struct list_head *entry)
{
    __list_del_entry(entry);
    /* 일반 list_del()은 next를 LIST_POISON1로 설정하지만,
     * list_del_rcu()는 next를 그대로 유지!
     * → 진행 중인 reader가 계속 다음 노드로 순회 가능 */
    entry->prev = LIST_POISON2;
}

올바른 Reader/Writer 패턴

/* 올바른 RCU 리스트 사용 패턴 (전체 구조) */
struct my_data {
    int                 value;
    struct list_head    list;
    struct rcu_head     rcu;   /* kfree_rcu 사용 시 필요 */
};

static LIST_HEAD(data_list);
static DEFINE_SPINLOCK(data_lock);

/* Reader: lock 없이 순회 */
int find_value(int target)
{
    struct my_data *d;
    int ret = -ENOENT;

    rcu_read_lock();
    list_for_each_entry_rcu(d, &data_list, list) {
        if (d->value == target) {
            ret = 0;
            break;
        }
    }
    rcu_read_unlock();
    return ret;
}

/* Writer: 삽입 */
int add_data(int value)
{
    struct my_data *d = kmalloc(sizeof(*d), GFP_KERNEL);
    if (!d)
        return -ENOMEM;
    d->value = value;

    spin_lock(&data_lock);
    list_add_rcu(&d->list, &data_list);
    spin_unlock(&data_lock);
    return 0;
}

/* Writer: 삭제 */
void remove_data(struct my_data *d)
{
    spin_lock(&data_lock);
    list_del_rcu(&d->list);
    spin_unlock(&data_lock);

    /* grace period 이후 자동 해제 */
    kfree_rcu(d, rcu);
}

/* Writer: 요소 갱신 (copy-and-replace 패턴) */
void update_data(struct my_data *old, int new_value)
{
    struct my_data *new = kmalloc(sizeof(*new), GFP_KERNEL);
    if (!new)
        return;
    new->value = new_value;

    spin_lock(&data_lock);
    list_replace_rcu(&old->list, &new->list);
    spin_unlock(&data_lock);

    kfree_rcu(old, rcu);
}
⚠️

흔한 실수: list_del_rcu()list_add_rcu()를 즉시 호출하는 것은 안전하지 않습니다. list_del_rcu()prevLIST_POISON2로 설정하므로, 재삽입하려면 먼저 INIT_LIST_HEAD()로 초기화하거나 list_del_init_rcu()(최신 커널)를 사용해야 합니다.

list_lru 프레임워크

list_lru는 커널의 메모리 회수(reclaim) 경로에서 사용되는 LRU 리스트 프레임워크입니다. dentry 캐시와 inode 캐시의 축소(shrinking)를 효율적으로 처리하기 위해 설계되었으며, NUMA 노드별/memcg별로 분리된 리스트를 관리합니다.

list_lru 프레임워크 구조 list_lru가 NUMA 노드별, memcg별로 분리된 LRU 리스트를 관리하고 shrinker를 통해 메모리 회수에 연동되는 구조를 보여줍니다. list_lru 프레임워크 구조 struct list_lru node[] — NUMA 노드별 분리 list_lru_node[0] NUMA node 0 list_lru_node[1] NUMA node 1 list_lru_node[N] NUMA node N memcg A LRU nr_items: 156 memcg B LRU nr_items: 89 struct shrinker count_objects / scan_objects 메모리 압박 (memory pressure) → shrink_slab() 호출 list_lru_walk() dentry dentry inode 메모리 압박 → shrink_slab() → shrinker.scan_objects() → list_lru_walk() → 콜백으로 개별 항목 처리 (유지/삭제/중단) list_lru는 NUMA-aware + memcg-aware로 메모리 회수의 지역성과 공정성을 보장
list_lru는 NUMA 노드별/memcg별로 분리된 LRU 리스트를 관리하며 shrinker와 연동하여 메모리를 회수합니다

list_lru 핵심 API

/* include/linux/list_lru.h */

/* LRU 리스트 초기화 */
int list_lru_init(struct list_lru *lru);
int list_lru_init_memcg(struct list_lru *lru, struct shrinker *shrinker);

/* 항목 추가: LRU tail에 추가 (가장 오래된 위치) */
bool list_lru_add(struct list_lru *lru, struct list_head *item);

/* 항목 삭제 */
bool list_lru_del(struct list_lru *lru, struct list_head *item);

/* 항목 수 조회 (NUMA 노드별) */
unsigned long list_lru_count_node(struct list_lru *lru, int nid);

/* LRU 순회 + 콜백으로 항목 처리 */
unsigned long list_lru_walk_node(
    struct list_lru *lru, int nid,
    list_lru_walk_cb isolate,   /* 콜백 함수 */
    void *cb_arg,
    unsigned long *nr_to_walk);

/* 콜백 반환값 */
enum lru_status {
    LRU_REMOVED,      /* 항목을 리스트에서 제거함 */
    LRU_REMOVED_RETRY,/* 제거 + 락 재획득 후 재시도 */
    LRU_ROTATE,       /* 항목을 tail로 이동 (유지) */
    LRU_SKIP,         /* 건너뜀 */
    LRU_RETRY,        /* 락 재획득 후 재시도 */
};

dentry 캐시에서의 list_lru 사용

/* fs/dcache.c — dentry LRU 리스트 사용 */
static struct list_lru dentry_lru;

/* dentry가 참조 카운트 0이 되면 LRU에 추가 */
static void d_lru_add(struct dentry *dentry)
{
    list_lru_add(&dentry->d_sb->s_dentry_lru, &dentry->d_lru);
    dentry->d_flags |= DCACHE_LRU_LIST;
}

/* shrinker 콜백: 메모리 압박 시 호출 */
static enum lru_status dentry_lru_isolate(
    struct list_head *item,
    struct list_lru_one *lru,
    spinlock_t *lru_lock,
    void *arg)
{
    struct dentry *dentry = container_of(item, struct dentry, d_lru);

    /* 참조 카운트 확인 */
    if (dentry->d_lockref.count) {
        /* 사용 중 → LRU에서 제거만 */
        d_lru_shrink_move(lru, dentry);
        return LRU_REMOVED;
    }

    /* 최근 사용됨 → 한 번 더 기회를 줌 (rotate) */
    if (dentry->d_flags & DCACHE_REFERENCED) {
        dentry->d_flags &= ~DCACHE_REFERENCED;
        return LRU_ROTATE;
    }

    /* 회수 대상 → 격리 */
    return LRU_REMOVED;
}
ℹ️

list_lru는 Linux 3.12부터 도입되었으며, 이전에는 각 서브시스템이 자체 LRU 리스트를 관리했습니다. 통합 프레임워크 덕분에 NUMA 인식과 memcg 인식이 일관되게 적용되어 메모리 회수의 공정성이 크게 개선되었습니다.

리스트 정렬 (list_sort)

커널은 lib/list_sort.c에 연결 리스트 전용 정렬 함수 list_sort()를 제공합니다. 이 구현은 bottom-up 합병 정렬(merge sort)을 사용하며, 안정 정렬(stable sort)이고 O(n log n) 시간 복잡도를 보장합니다.

리스트 합병 정렬 알고리즘 연결 리스트에서의 bottom-up 합병 정렬 과정을 단계별로 보여주며, 크기 1부터 시작하여 점점 큰 블록을 합병하는 구조를 표현합니다. list_sort() — Bottom-up 합병 정렬 원본 리스트: 5 3 8 1 7 2 6 4 1단계: 2개씩 합병 3 5 1 8 2 7 4 6 2단계: 4개씩 합병 1 3 5 8 2 4 6 7 3단계: 최종 합병 1 2 3 4 5 6 7 8 list_sort() 구현 특성 - 안정 정렬 (stable): 동일 키의 상대적 순서 보존 - 시간 복잡도: O(n log n) 최악/평균 - 공간 복잡도: O(1) 추가 메모리 (in-place, 포인터 재배치만 수행) - 비교 함수(cmp)를 인자로 받아 임의 기준으로 정렬 가능
list_sort()는 bottom-up 합병 정렬을 사용하여 O(n log n)으로 연결 리스트를 정렬합니다

list_sort API와 사용법

/* include/linux/list_sort.h */
typedef int (*list_cmp_func_t)(void *priv,
    const struct list_head *a,
    const struct list_head *b);

void list_sort(void *priv, struct list_head *head,
              list_cmp_func_t cmp);

/* 사용 예: 학생 점수순 정렬 */
static int student_cmp(void *priv,
    const struct list_head *a,
    const struct list_head *b)
{
    struct student *sa = list_entry(a, struct student, list);
    struct student *sb = list_entry(b, struct student, list);

    /* 오름차순: sa < sb이면 음수 반환 */
    if (sa->score < sb->score)
        return -1;
    if (sa->score > sb->score)
        return 1;
    return 0;
}

/* 정렬 실행 */
list_sort(NULL, &student_list, student_cmp);

list_sort 내부 구현 핵심

/* lib/list_sort.c — 핵심 알고리즘 (단순화) */

/* merge: 두 정렬된 리스트를 하나로 합병 */
static struct list_head *merge(
    void *priv, list_cmp_func_t cmp,
    struct list_head *a, struct list_head *b)
{
    struct list_head *head, **tail = &head;

    for (;;) {
        /* cmp <= 0 이면 a를 선택 (안정 정렬 보장) */
        if (cmp(priv, a, b) <= 0) {
            *tail = a;
            tail = &a->next;
            a = a->next;
            if (!a) { *tail = b; break; }
        } else {
            *tail = b;
            tail = &b->next;
            b = b->next;
            if (!b) { *tail = a; break; }
        }
    }
    return head;
}

/* list_sort 본체:
 * 1. 원형 리스트를 단방향으로 변환
 * 2. bottom-up으로 점점 큰 블록을 합병
 *    - pending 리스트에 2의 거듭제곱 크기로 누적
 *    - 같은 크기의 블록이 2개 모이면 합병
 * 3. 최종 합병 후 다시 원형 이중 연결 리스트로 복원 */
💡

커널 사용 사례: list_sort()ext4의 extent 정렬, btrfs의 chunk 정렬, 네트워크 필터 규칙 정렬 등 다양한 서브시스템에서 활용됩니다. 배열과 달리 연결 리스트는 합병 정렬이 가장 효율적인데, 노드 이동이 포인터 재배치만으로 O(1)이기 때문입니다.

Lockless 리스트 패턴

앞서 llist 섹션에서 기본 개념을 다루었습니다. 이 섹션에서는 llist_add()cmpxchg 기반 구현과, 실제 커널에서의 활용 패턴을 심층적으로 분석합니다.

llist cmpxchg 기반 lock-free 연산 llist_add가 cmpxchg를 사용하여 lock 없이 노드를 추가하는 과정을 단계별로 보여주며, CAS 실패 시 재시도 루프를 표현합니다. llist_add() — cmpxchg 기반 Lock-free Push 1단계: 현재 상태 읽기 old = READ_ONCE(head->first) new->next = old 신규 노드가 기존 체인을 가리키도록 설정 2단계: CAS (Compare-And-Swap) cmpxchg(&head->first, old, new) head->first가 여전히 old이면: → head->first = new 으로 원자적 교체 CAS 성공 노드 추가 완료, 반환 CAS 실패 (경합 발생) 다른 CPU가 먼저 수정함 old 다시 읽고 재시도 (spin-retry) 삽입 전 head.first node B node A → NULL 삽입 후 head.first new C B A llist 핵심 특성 - LIFO 순서 (스택): 가장 마지막에 추가된 노드가 first - 다중 Producer 안전: cmpxchg 재시도로 경합 해결 - 단일 Consumer: llist_del_all()로 전체 체인을 원자적 회수 - IRQ 컨텍스트에서 안전: lock 불필요, GFP_ATOMIC 할당만 주의
llist_add()는 cmpxchg로 head->first를 원자적으로 교체하여 lock 없이 노드를 추가합니다

llist_add 구현 분석

/* include/linux/llist.h */
static inline bool llist_add(struct llist_node *new,
                               struct llist_head *head)
{
    return llist_add_batch(new, new, head);
}

static inline bool llist_add_batch(
    struct llist_node *new_first,
    struct llist_node *new_last,
    struct llist_head *head)
{
    struct llist_node *first;

    do {
        new_last->next = first = READ_ONCE(head->first);
    } while (cmpxchg(&head->first, first, new_first) != first);
    /* cmpxchg 실패 = 다른 CPU가 head->first를 변경함
     * → first를 다시 읽고 new_last->next를 갱신 후 재시도 */

    return !first; /* 이전에 비어있었으면 true */
}

/* 전체 리스트 원자적 회수 */
static inline struct llist_node *llist_del_all(
    struct llist_head *head)
{
    return xchg(&head->first, NULL);
    /* xchg: head->first를 NULL로 설정하고 이전 값 반환
     * → 전체 체인을 한 번에 분리 */
}

커널 내 llist 활용 패턴

/* 패턴 1: IRQ → 스레드 워크 전달 */
struct irq_work {
    struct llist_node    llnode;
    void                (*func)(struct irq_work *);
};

/* IRQ 핸들러 내부 (lock 불가 컨텍스트) */
void irq_work_queue(struct irq_work *work)
{
    /* lock 없이 per-CPU llist에 추가 */
    if (llist_add(&work->llnode, this_cpu_ptr(&raised_list)))
        arch_irq_work_raise();  /* IPI로 워커 깨우기 */
}

/* 패턴 2: per-CPU 객체 해제 일괄 처리 */
static DEFINE_PER_CPU(struct llist_head, dead_objects);

void defer_free(struct my_object *obj)
{
    /* 인터럽트 컨텍스트에서도 안전 */
    llist_add(&obj->lnode, this_cpu_ptr(&dead_objects));
}

void flush_dead_objects(void)
{
    struct llist_node *batch, *pos, *next;

    batch = llist_del_all(this_cpu_ptr(&dead_objects));
    llist_for_each_safe(pos, next, batch) {
        struct my_object *obj = llist_entry(pos,
                                    struct my_object, lnode);
        kfree(obj);
    }
}

/* 패턴 3: RCU 콜백 큐잉 (kernel/rcu/tree.c) */
/* call_rcu() 내부에서 llist를 사용하여
 * grace period 이후 실행할 콜백을 lock-free로 큐잉 */
⚠️

llist 사용 시 주의점:

  • llist_del_all()은 LIFO 순서로 반환합니다. FIFO가 필요하면 회수 후 llist_reverse_order()로 뒤집어야 합니다.
  • 단일 노드 삭제(llist_del_first())는 단일 소비자만 호출해야 합니다. 다중 소비자가 호출하면 ABA 문제가 발생할 수 있습니다.
  • llist_empty()는 정확한 스냅샷이 아닐 수 있습니다(다른 CPU가 동시에 추가 중). 비어있음을 확인하는 용도로만 사용하세요.

리스트 접합 연산

리스트 접합(splice)은 두 개의 리스트를 효율적으로 합치는 O(1) 연산입니다. 개별 노드를 하나씩 옮기는 대신, 포인터 4개만 수정하여 전체 리스트를 즉시 연결합니다. 배치 처리, 큐 드레인(drain), 작업 분배 등에서 핵심적으로 사용됩니다.

list_splice 연산 과정 source 리스트의 모든 노드를 dest 리스트의 head 뒤에 삽입하는 list_splice 연산의 포인터 재배치를 단계별로 보여줍니다. list_splice() — 리스트 접합 연산 접합 전 dest: head D1 D2 source: head S1 S2 list_splice(&source, &dest) 후 dest: head S1 S2 D1 D2 source의 S1, S2가 dest head 바로 뒤에 삽입됨 (O(1) 연산) splice 변형 비교 list_splice(src, dst) src 노드를 dst head 뒤에 삽입. src head는 미정리 상태 (재사용 불가) list_splice_init(src, dst) 삽입 후 src를 빈 리스트로 재초기화 (가장 안전, 가장 권장) list_splice_tail(src, dst) src 노드를 dst tail 앞에 삽입 (FIFO 순서 유지에 유리) list_splice_tail_init(src, dst) tail 삽입 + src 재초기화 (배치 큐잉에 최적) list_splice_init_rcu(src, dst, sync) RCU 안전 접합: sync 콜백(synchronize_rcu) 호출 후 합침
list_splice는 포인터 4개만 수정하여 O(1)으로 두 리스트를 합칩니다

접합 연산 구현과 사용 패턴

/* include/linux/list.h — list_splice 핵심 구현 */
static inline void __list_splice(
    const struct list_head *list,
    struct list_head *prev,
    struct list_head *next)
{
    struct list_head *first = list->next;
    struct list_head *last = list->prev;

    /* 포인터 4개만 수정 → O(1) */
    first->prev = prev;
    prev->next = first;
    last->next = next;
    next->prev = last;
}

/* 안전한 버전: 빈 리스트 체크 + source 재초기화 */
static inline void list_splice_init(
    struct list_head *list,
    struct list_head *head)
{
    if (!list_empty(list)) {
        __list_splice(list, head, head->next);
        INIT_LIST_HEAD(list);
    }
}
/* 배치 처리 패턴: 락 구간 최소화 */
void process_pending_work(void)
{
    LIST_HEAD(local_list);

    /* 락을 짧게 잡고 전체 리스트를 로컬로 이동 */
    spin_lock(&work_lock);
    list_splice_init(&pending_work, &local_list);
    spin_unlock(&work_lock);

    /* 락 없이 로컬에서 처리 */
    struct work_item *item, *tmp;
    list_for_each_entry_safe(item, tmp, &local_list, list) {
        do_work(item);
        list_del(&item->list);
        kfree(item);
    }
}

/* RCU 안전 접합 패턴 */
void rcu_safe_splice(struct list_head *src,
                      struct list_head *dst)
{
    /* list_splice_init_rcu는 synchronize_rcu 콜백을 받아
     * reader가 src를 순회 중이 아님을 보장한 후 합침 */
    list_splice_init_rcu(src, dst, synchronize_rcu);
}
ℹ️

배치 처리 패턴이 중요한 이유: list_splice_init()으로 공유 리스트를 로컬로 이동하면, 이후 처리는 락 없이 수행할 수 있습니다. 이 패턴은 네트워크 스택의 패킷 배치 처리, 블록 I/O의 요청 배치 처리, workqueue의 작업 배치 처리 등에서 광범위하게 사용됩니다.

리스트 디버깅

기본 디버깅 섹션에서 CONFIG_DEBUG_LISTKASAN을 소개했습니다. 이 섹션에서는 list_del() vs list_del_init()의 안전성 차이, POISON 값의 작동 원리, 그리고 ftrace/bpftrace를 활용한 실시간 리스트 연산 추적 방법을 다룹니다.

list_del() vs list_del_init() 안전성 비교

/* list_del(): next/prev를 POISON 값으로 설정 */
static inline void list_del(struct list_head *entry)
{
    __list_del_entry(entry);
    entry->next = LIST_POISON1;  /* 0x100 + delta */
    entry->prev = LIST_POISON2;  /* 0x122 + delta */
}
/* → 이후 접근 시 page fault 발생 → 즉시 버그 감지
 * → 하지만 list_empty() 체크 불가 (자기 자신을 가리키지 않으므로)
 * → 재삽입 시도 시 CONFIG_DEBUG_LIST가 경고 */

/* list_del_init(): next/prev를 자기 자신으로 재초기화 */
static inline void list_del_init(struct list_head *entry)
{
    __list_del_entry(entry);
    INIT_LIST_HEAD(entry);
}
/* → list_empty() = true (안전한 상태 확인 가능)
 * → 즉시 list_add()로 다른 리스트에 재삽입 가능
 * → 이중 삭제에도 안전 (빈 리스트에서 삭제는 무해) */
특성 list_del() list_del_init()
삭제 후 next/prev LIST_POISON1/2 자기 자신 (빈 리스트)
list_empty() 체크 불가 (정의되지 않은 동작) 가능 (true 반환)
재삽입 안전성 위험 (INIT 필요) 즉시 재삽입 가능
이중 삭제 크래시 (POISON 접근) 무해 (빈 리스트에서 삭제)
버그 발견 용이성 높음 (즉시 크래시) 낮음 (조용히 무시)
권장 용도 최종 삭제 + 즉시 kfree 임시 제거, 상태 추적 필요

LIST_POISON 값의 동작 원리

/* include/linux/poison.h */
#ifdef CONFIG_ILLEGAL_POINTER_VALUE
#define POISON_POINTER_DELTA _AC(CONFIG_ILLEGAL_POINTER_VALUE, UL)
#else
#define POISON_POINTER_DELTA 0
#endif

#define LIST_POISON1  ((void *) 0x100 + POISON_POINTER_DELTA)
#define LIST_POISON2  ((void *) 0x122 + POISON_POINTER_DELTA)

/* x86_64에서 POISON_POINTER_DELTA = 0
 * → LIST_POISON1 = 0x100, LIST_POISON2 = 0x122
 * → 사용자 공간 주소 범위의 유효하지 않은 주소
 * → 접근 시 즉시 page fault (OOPS/BUG) 발생
 *
 * 디버깅 메시지 예:
 * BUG: unable to handle page fault for address: 0x0000000000000100
 * → 주소가 0x100이면 list_del() 후 next 접근 → LIST_POISON1
 * → 주소가 0x122이면 list_del() 후 prev 접근 → LIST_POISON2 */

ftrace/bpftrace로 리스트 연산 추적

# ftrace: list 관련 함수 추적
# CONFIG_DEBUG_LIST=y 시 list 연산이 outline 함수로 노출됨

# 1. list corruption 이벤트 모니터링
echo 1 > /sys/kernel/tracing/events/list/list_add_corruption/enable
echo 1 > /sys/kernel/tracing/events/list/list_del_corruption/enable
cat /sys/kernel/tracing/trace_pipe

# 2. kprobe로 특정 리스트 연산 추적
echo 'p:my_list_add __list_add new=%di prev=%si next=%dx' > \
    /sys/kernel/tracing/kprobe_events
echo 1 > /sys/kernel/tracing/events/kprobes/my_list_add/enable
# bpftrace: list_del 호출 추적 (호출 스택 포함)
bpftrace -e '
kprobe:__list_del_entry {
    printf("list_del from %s (pid=%d)\n",
           comm, pid);
    print(kstack(5));
}'

# bpftrace: list corruption 감지 시 상세 정보
bpftrace -e '
kprobe:__list_add_valid {
    $next = (struct list_head *)arg1;
    $prev = (struct list_head *)arg2;

    if ($next->prev != $prev) {
        printf("CORRUPTION: next->prev mismatch!\n");
        printf("  next=%p, prev=%p, next->prev=%p\n",
               $next, $prev, $next->prev);
        print(kstack);
    }
}'
💡

실전 디버깅 전략:

  1. 개발 초기: CONFIG_DEBUG_LIST=y + CONFIG_KASAN=y로 빌드하여 corruption을 조기에 발견
  2. 재현이 어려운 버그: bpftrace로 특정 리스트의 add/del 패턴을 실시간 추적
  3. crash dump 분석: crash 도구의 list 명령으로 corruption 지점 탐색
  4. 운영 환경: list_del_init()을 기본으로 사용하되, 최종 삭제에는 list_del() + 즉시 kfree() 패턴 적용

서브시스템별 활용 분석

커널 내 실제 사용 사례에서 주요 서브시스템의 list_head 사용을 개괄적으로 살펴보았습니다. 이 섹션에서는 wait_queue, workqueue, 타이머 등의 핵심 서브시스템에서 리스트가 어떤 역할을 하는지 구조적으로 분석합니다.

서브시스템별 리스트 활용 커널의 주요 서브시스템(프로세스, VFS, 메모리, 네트워크, 블록 I/O)에서 list_head, hlist, llist가 각각 어떻게 사용되는지 보여줍니다. 커널 서브시스템별 리스트 활용 맵 리스트 유형 list_head hlist llist 프로세스 관리 task_struct.tasks task_struct.children wait_queue_head.head pid_hash (hlist) 주로 list_head + hlist VFS / 파일시스템 dentry.d_lru (list_lru) dentry.d_child inode.i_lru (list_lru) super_block.s_list 주로 list_head + list_lru 메모리 관리 page.lru (active/inactive) vm_area_struct.list slab free 리스트 memcg per-node lists 주로 list_head 네트워크 sock.sk_node (hlist, 소켓 해시) net_device.dev_list napi.poll_list softnet_data (llist, 패킷 큐잉) hlist + list_head + llist 혼합 Workqueue / 타이머 work_struct → workqueue_struct.worklist (list_head) timer_list → timer_base.vectors (hlist, 타이밍 휠) irq_work → per-CPU llist (lock-free 큐잉) 블록 I/O request.queuelist (list_head, I/O 스케줄러) bio_list (list_head, BIO 체인) blk_mq_hw_ctx (per-CPU llist, 요청 배치) list_head: 범용 순차 접근 | hlist: 해시 버킷 최적화 | llist: IRQ/lock-free 큐잉 | list_lru: 메모리 회수 LRU
각 서브시스템은 접근 패턴에 따라 list_head, hlist, llist, list_lru를 선택적으로 사용합니다

wait_queue의 리스트 사용

/* include/linux/wait.h */
struct wait_queue_head {
    spinlock_t          lock;
    struct list_head    head;  /* 대기 중인 태스크 리스트 */
};

struct wait_queue_entry {
    unsigned int        flags;
    void                *private;   /* task_struct */
    wait_queue_func_t   func;      /* 깨우기 콜백 */
    struct list_head    entry;     /* wait_queue_head.head에 연결 */
};

/* 대기 → 깨우기 흐름:
 * 1. prepare_to_wait(): entry를 head에 list_add_tail()
 * 2. schedule(): 현재 태스크 슬립
 * 3. wake_up(): head의 리스트를 순회하며 콜백 호출
 * 4. finish_wait(): entry를 list_del_init()으로 제거 */

workqueue의 리스트 사용

/* kernel/workqueue.c — 워크큐 내부 리스트 구조 */
struct pool_workqueue {
    struct list_head    pwqs_node;     /* workqueue_struct에 연결 */
    struct list_head    inactive_works;/* 비활성 작업 리스트 */
    /* ... */
};

struct worker_pool {
    struct list_head    worklist;  /* 대기 중인 work 리스트 */
    struct list_head    idle_list; /* 유휴 worker 리스트 */
    struct list_head    workers;   /* 모든 worker 리스트 */
    /* ... */
};

/* work 제출 흐름:
 * queue_work() → list_add_tail(&work->entry, &pool->worklist)
 * worker_thread() → list_first_entry(&pool->worklist, ...)로 꺼내기 */

서브시스템별 리스트 유형 비교표

서브시스템 list_head hlist llist list_lru 핵심 이유
프로세스 관리 tasks, children pid_hash - - 순회 + 해시 검색 혼합
VFS d_child, d_subdirs d_hash - d_lru, i_lru 계층 순회 + LRU 회수
메모리 page.lru - - per-memcg active/inactive LRU 분류
네트워크 dev_list sk_node softnet - 소켓 해시 + IRQ 큐잉
블록 I/O queuelist - blk_mq batch - 요청 정렬 + 배치 제출
타이머 - timer wheel - - 타이밍 휠 버킷 체인
Workqueue worklist, idle - irq_work - 작업 큐 + IRQ 전달
RCU - - 콜백 큐잉 - grace period 콜백 MPSC
ℹ️

패턴 인식: 서브시스템의 리스트 선택을 관찰하면 일관된 패턴이 보입니다. (1) 순차 접근이 주이면 list_head, (2) 키 검색이 필요하면 hlist, (3) IRQ에서 lock 없이 큐잉하면 llist, (4) 메모리 회수 대상이면 list_lru를 선택합니다. 새로운 서브시스템을 설계할 때도 이 패턴을 따르면 검증된 선택을 할 수 있습니다.

성능 분석

성능 고려사항 섹션에서 기본적인 복잡도와 벤치마크를 다루었습니다. 이 섹션에서는 캐시 동작, 메모리 오버헤드, 실제 접근 패턴에 따른 자료구조 선택 가이드를 보다 깊이 분석합니다.

캐시 동작 분석

연결 리스트의 가장 큰 성능 약점은 캐시 비친화성입니다. 노드가 메모리에 흩어져 있으면 순회 시 캐시 미스가 빈번하게 발생합니다.

/* 캐시 친화적 배치 전략 */

/* 전략 1: slab 할당으로 노드 밀집 배치 */
static struct kmem_cache *item_cache;

/* 모듈 초기화 시 */
item_cache = kmem_cache_create("my_item_cache",
    sizeof(struct my_item),
    0,                         /* align: 기본 */
    SLAB_HWCACHE_ALIGN,        /* 캐시 라인 정렬 */
    NULL);

/* 할당: 같은 slab 페이지에 밀집 → 캐시 친화적 */
struct my_item *item = kmem_cache_alloc(item_cache, GFP_KERNEL);

/* 전략 2: list_head를 구조체 앞에 배치 */
struct my_item_optimized {
    struct list_head    list;   /* 오프셋 0: 순회 시 첫 캐시 라인 */
    int                 key;    /* 자주 비교하는 필드도 앞에 */
    /* ... 나머지 필드 ... */
    char                data[256]; /* 드물게 접근하는 필드는 뒤로 */
};

메모리 오버헤드 분석

자료구조 노드당 오버헤드 헤드/루트 크기 10만 노드 총 오버헤드
list_head 16B (next + prev) 16B 약 1.6 MB
hlist_node 16B (next + pprev) 8B (first only) 약 1.6 MB + 8B/bucket
llist_node 8B (next only) 8B 약 0.8 MB
rb_node 24B (left + right + parent_color) 8B 약 2.4 MB
배열 (참고) 0B (연속 메모리) 포인터 + 길이 0 MB (데이터만)

자료구조 전환 시점

아래 가이드라인은 절대적인 기준이 아니라 경험적 판단 기준입니다. 실제 성능은 반드시 측정으로 확인하세요.

현재 자료구조 전환 시점 대안 이유
list_head 검색이 빈번하고 노드 수 > 100 hlist + 해시 O(n) → O(1) 검색
list_head 정렬 상태 유지가 필요 rbtree 삽입 후 재정렬 비용 제거
hlist 버킷당 노드 수 > 10 버킷 수 증가 또는 rbtree 해시 충돌 감소
list_head IRQ 컨텍스트에서 큐잉 llist lock 제거로 IRQ 지연 감소
모든 리스트 대량 순차 접근 + 크기 고정 배열 캐시 효율 극대화
⚠️

성능 최적화 원칙: "측정하지 않은 최적화는 최적화가 아닙니다." 커널에서 자료구조를 변경하기 전에 반드시 (1) perf stat으로 캐시 미스율 측정, (2) perf record로 핫스팟 확인, (3) 실제 워크로드에서 벤치마크를 수행하세요. 이론적으로 더 나은 자료구조가 실제 워크로드에서는 오히려 느릴 수 있습니다.

소스 코드 워크스루

이 섹션에서는 include/linux/list.h의 핵심 인라인 함수들이 어떻게 구현되어 있고, 서로 어떤 호출 관계를 갖는지 분석합니다. container_of 매크로의 깊은 동작 원리도 함께 다룹니다.

list.h 함수 호출 관계 list_add, list_del, list_for_each_entry 등 공개 API가 내부 헬퍼 함수(__list_add, __list_del 등)를 어떻게 호출하는지 계층적으로 보여줍니다. list.h 핵심 함수 호출 관계 공개 API list_add() list_add_tail() list_del() list_del_init() list_replace() list_splice_init() 내부 헬퍼 __list_add() new, prev, next __list_del_entry() → __list_del(prev, next) __list_splice() list, prev, next 순회 매크로 list_for_each_entry() list_for_each_entry_safe() list_for_each_entry_rcu() list_entry() 기반 매크로/함수 container_of() ptr - offsetof(type, member) offsetof() __builtin_offsetof rcu_dereference() READ_ONCE + 배리어 WRITE_ONCE() volatile 시맨틱 의존 모든 공개 API는 2~3개의 내부 헬퍼로 구현되며, 순회 매크로는 container_of를 기반으로 동작합니다
list.h의 함수 계층: 공개 API → 내부 헬퍼 → 순회 매크로 → 기반 매크로(container_of, offsetof)

__list_add() 인라인 구현

/* include/linux/list.h — 모든 삽입의 기반 */
static inline void __list_add(
    struct list_head *new,
    struct list_head *prev,
    struct list_head *next)
{
    /* CONFIG_DEBUG_LIST 시: __list_add_valid() 검증 추가 */
    if (!__list_add_valid(new, prev, next))
        return;

    next->prev = new;    /* 1. next의 prev를 new로 */
    new->next = next;     /* 2. new의 next를 next로 */
    new->prev = prev;     /* 3. new의 prev를 prev로 */
    WRITE_ONCE(prev->next, new); /* 4. prev의 next를 new로
                                  * WRITE_ONCE: 컴파일러가 이 쓰기를
                                  * 분할하거나 재배치하지 못하게 */
}

/* list_add(new, head) = __list_add(new, head, head->next)
 * → head 뒤에 삽입 (LIFO / 스택 동작)
 *
 * list_add_tail(new, head) = __list_add(new, head->prev, head)
 * → head 앞에 삽입 (FIFO / 큐 동작)
 *
 * 같은 헬퍼, 다른 인자 → 코드 중복 제거 */

__list_del() 인라인 구현

/* 삭제의 기반: 이전과 다음을 직접 연결 */
static inline void __list_del(
    struct list_head *prev,
    struct list_head *next)
{
    next->prev = prev;
    WRITE_ONCE(prev->next, next);
}

/* __list_del_entry: 노드에서 prev/next를 추출 후 __list_del 호출 */
static inline void __list_del_entry(struct list_head *entry)
{
    if (!__list_del_entry_valid(entry))
        return;
    __list_del(entry->prev, entry->next);
}

/* __list_del_entry_valid (CONFIG_DEBUG_LIST 시):
 * - entry->prev->next == entry 확인
 * - entry->next->prev == entry 확인
 * - entry != LIST_POISON1 확인
 * → 위반 시 WARN + false 반환 → 삭제 중단 → 손상 확대 방지 */

container_of 매크로 심층 분석

/* include/linux/container_of.h — 전체 구현 */
#define container_of(ptr, type, member) ({                \
    void *__mptr = (void *)(ptr);                          \
    static_assert(                                         \
        __same_type(*(ptr), ((type *)0)->member) ||        \
        __same_type(*(ptr), void),                          \
        "pointer type mismatch in container_of()");         \
    ((type *)(__mptr - offsetof(type, member)));            \
})

/* 분해:
 *
 * 1. void *__mptr = (void *)(ptr)
 *    → const/volatile 제거, 임시 변수에 저장
 *    → ptr을 여러 번 평가하지 않도록 방지 (매크로 안전성)
 *
 * 2. static_assert(__same_type(...))
 *    → ptr의 타입이 type.member의 타입과 일치하는지 컴파일 타임 검증
 *    → 잘못된 member 이름 사용 시 컴파일 에러
 *    → void 타입은 허용 (범용 포인터)
 *
 * 3. (type *)(__mptr - offsetof(type, member))
 *    → member의 주소에서 구조체 내 오프셋을 빼면 구조체 시작 주소
 *    → 결과를 type*로 캐스팅
 *
 * 컴파일러 최적화:
 *    → offsetof는 컴파일 타임 상수
 *    → 뺄셈은 단일 SUB 명령어로 컴파일
 *    → inline 확장 시 오버헤드 제로 */

/* container_of_safe: NULL 안전 버전 (일부 코드에서 사용) */
#define container_of_safe(ptr, type, member) ({            \
    void *__mptr = (void *)(ptr);                          \
    static_assert(                                         \
        __same_type(*(ptr), ((type *)0)->member) ||        \
        __same_type(*(ptr), void),                          \
        "pointer type mismatch");                           \
    IS_ERR_OR_NULL(__mptr) ? NULL :                        \
        ((type *)(__mptr - offsetof(type, member)));        \
})
💡

소스 코드 읽기 팁: list.h를 읽을 때 핵심은 세 가지입니다. (1) 공개 API는 모두 1~2줄의 래퍼이고, 실제 로직은 __ 접두사 헬퍼에 있습니다. (2) 순회 매크로는 for 루프의 syntactic sugar이며, 종료 조건은 항상 &pos->member != head입니다. (3) WRITE_ONCE는 컴파일러 최적화 방지이지 메모리 배리어가 아닙니다(RCU 버전은 rcu_assign_pointer를 사용).

Linked List와 관련된 다른 주제를 더 깊이 이해하고 싶다면 다음 문서를 참고하세요.