Hash Table

Linux 커널의 해시 테이블 구현 전반을 다룹니다. hlist 자료구조, <linux/hashtable.h> API, 리사이즈 가능한 rhashtable, 커널 해시 함수(hash_long, jhash), RCU 보호 해시 테이블, 그리고 conntrack·inode·pid 등 실제 커널 활용 사례까지 종합적으로 설명합니다.

전제 조건: 동기화 기법메모리 배리어 문서를 먼저 읽으세요. 커널 자료구조는 연산 복잡도뿐 아니라 동시 접근 안전성까지 함께 설계되므로, 성능과 동기화 관점을 동시에 봐야 합니다.
일상 비유: 해시 테이블은 사전(dictionary)의 색인 검색과 비슷합니다. 단어의 첫 글자(해시 함수)로 해당 페이지(버킷)를 바로 펼치고, 그 안에서 정확한 항목을 찾습니다. 같은 첫 글자를 가진 단어들(해시 충돌)은 순서대로 나열되어 순차 탐색합니다.

핵심 요약

  • 해시 테이블 = O(1) 검색 — 키를 해시 함수로 변환해 버킷에 직접 접근하므로 평균 O(1)에 검색·삽입·삭제가 가능합니다.
  • hlist = 메모리 효율적 체이닝 — 버킷 헤드가 포인터 1개(8B)만 차지하여, 수만 버킷에서 list_head 대비 절반의 메모리를 사용합니다.
  • pprev 이중 포인터hlist_node의 pprev는 "이전 노드의 next 필드 주소"를 가리켜, 헤드/중간 노드 구분 없이 통일된 삭제가 가능합니다.
  • hashtable.h = 고정 크기DEFINE_HASHTABLE(name, bits)로 2^bits 버킷을 컴파일 타임에 할당하며, 간결하고 가볍습니다.
  • rhashtable = 자동 리사이즈 — 요소 수에 따라 비동기로 확장/축소하며, per-bucket spinlock + RCU로 고성능 동시성을 제공합니다.
  • 해시 함수 선택이 핵심 — 정수 키는 hash_long(Golden Ratio), 가변 데이터는 jhash, 보안이 필요하면 siphash를 씁니다.
  • 동시성 안전 — 읽기는 RCU, 쓰기는 spinlock/mutex로 보호하며, 삭제 후에는 반드시 RCU grace period를 대기합니다.
  • 실전 활용 — inode 캐시, dentry 캐시, PID 해시, conntrack, 소켓 룩업 등 커널 핵심 경로에서 광범위하게 사용됩니다.

단계별 이해

  1. 해시 테이블 기초 이해
    키 → 해시 함수 → 버킷 인덱스 → 체인 순회의 기본 흐름을 파악합니다. 해시 충돌은 같은 버킷에 여러 노드가 연결되는 것입니다.
  2. hlist 구조 학습
    hlist_head(포인터 1개)와 hlist_node(next + pprev)의 메모리 레이아웃을 이해합니다. pprev 이중 포인터가 통일된 삭제를 가능하게 하는 원리를 파악합니다.
  3. 고정 vs 동적 선택
    요소 수가 예측 가능하면 DEFINE_HASHTABLE, 변동이 크면 rhashtable을 선택합니다. 로드 팩터와 버킷 크기 관계를 이해합니다.
  4. 동시성 규칙 설계
    읽기 경로(RCU read lock), 쓰기 경로(spinlock + hash_add_rcu/hash_del_rcu), 삭제 후 해제(kfree_rcu)의 패턴을 적용합니다.
  5. 커널 실사례 분석
    conntrack(jhash + nulls 마커), inode_hashtable(superblock + ino 해시), PID hash(namespace 계층) 등 실제 코드를 읽어봅니다.
  6. 디버깅과 모니터링
    crash 유틸리티로 해시 체인 덤프, drgn으로 버킷 분포 분석, bpftrace로 실시간 해시 성능을 측정합니다.
예제 읽기 가이드: 이 문서는 개념 설명용 의사코드를 중심으로 구성하되, 주요 섹션은 실습 가능한 예제로 검증 절차를 함께 제공합니다. 코드 주석의 개념 예시는 내부 메커니즘 이해용, 실습 예제는 재현 가능한 점검 흐름용입니다.
관련 표준: (알고리즘 구현, 외부 표준 없음) 커널 자료구조 내부 구현을 다룹니다. 종합 목록은 참고자료 — 표준 & 규격 섹션을 참고하세요.

개요 (Overview)

해시 테이블(Hash Table)은 키(key)를 해시 함수로 변환하여 버킷(bucket)에 매핑하는 자료구조입니다. 평균 O(1) 시간에 검색·삽입·삭제가 가능하여, 대량의 객체를 빠르게 룩업해야 하는 커널 서브시스템 전반에서 핵심적으로 사용됩니다.

리눅스 커널에서 해시 테이블이 사용되는 대표적인 영역:

커널은 두 가지 주요 해시 테이블 구현을 제공합니다:

버킷 인덱스 계산의 실제

해시 테이블 성능을 제대로 이해하려면 “해시값을 버킷 인덱스로 줄이는 단계”를 분리해서 봐야 합니다. 핵심은 해시 혼합(mixing)인덱스 축소(index reduction)의 역할이 다르다는 점입니다.

/* 2의 거듭제곱 버킷 수: hashtable.h 전형 패턴 */
u32 h = hash_long(key, bits);      /* mixing + 상위 bits 추출 */
u32 idx = h & ((1U << bits) - 1); /* range reduction */

/* 버킷 수가 2의 거듭제곱이 아닐 수 있는 경로: conntrack 등 */
u32 hash = jhash2(tuple_words, n, seed);
u32 bucket = reciprocal_scale(hash, hsize);
💡

실무 포인트: 버킷 수를 늘리기 전에 먼저 해시 혼합 품질을 점검하세요. 나쁜 혼합 상태에서 버킷만 늘리면 메모리만 증가하고 편향은 유지될 수 있습니다.

ℹ️

커널 해시 테이블은 체이닝(chaining) 방식으로 충돌을 해결합니다. 각 버킷은 hlist(hash list)로 구현된 단일 연결 리스트이며, 이는 메모리 효율성과 구현 단순성을 모두 달성합니다.

hlist 구조 (hlist_head / hlist_node)

해시 테이블의 각 버킷은 struct hlist_head로 표현되고, 체인 내의 각 노드는 struct hlist_node입니다. 일반적인 list_head와 달리 hlist는 비원형(non-circular) 단방향 리스트로 설계되었습니다.

/* 개념 예시: include/linux/types.h hlist 기본 정의 */
/* include/linux/types.h */
struct hlist_head {
    struct hlist_node *first;
};

struct hlist_node {
    struct hlist_node *next;
    struct hlist_node **pprev;  /* 이전 노드의 next 포인터를 가리킴 */
};

왜 pprev는 이중 포인터인가?

hlist_nodepprevhlist_node *가 아닌 hlist_node **인 이유는 두 가지 핵심적인 이점 때문입니다:

/* hlist 노드 삭제 — 위치에 관계없이 동일한 코드 */
static inline void __hlist_del(struct hlist_node *n)
{
    struct hlist_node *next = n->next;
    struct hlist_node **pprev = n->pprev;

    WRITE_ONCE(*pprev, next);      /* 이전 → 다음으로 연결 */
    if (next)
        WRITE_ONCE(next->pprev, pprev);
}

hlist vs list_head 비교

특성list_headhlist_head/hlist_node
구조원형 이중 연결비원형 단방향(pprev 이중 포인터)
헤드 크기포인터 2개 (16바이트/x86_64)포인터 1개 (8바이트/x86_64)
노드 크기포인터 2개포인터 2개
tail 접근O(1)O(n)
빈 상태 판단head->next == headhead->first == NULL
주요 용도범용 리스트해시 테이블 버킷

주요 hlist 매크로/함수

/* 초기화 */
HLIST_HEAD(name);                          /* 정적 선언 + 초기화 */
HLIST_HEAD_INIT                             /* { .first = NULL } */
INIT_HLIST_HEAD(&head);                     /* 동적 초기화 */
INIT_HLIST_NODE(&node);                     /* 노드 초기화 */

/* 삽입/삭제 */
hlist_add_head(&node, &head);               /* 헤드 앞에 삽입 */
hlist_add_before(&node, &next_node);        /* 특정 노드 앞에 삽입 */
hlist_add_behind(&node, &prev_node);        /* 특정 노드 뒤에 삽입 */
hlist_del(&node);                            /* 삭제 */
hlist_del_init(&node);                      /* 삭제 후 초기화 */

/* 순회 */
hlist_for_each(pos, head)                    /* hlist_node* 순회 */
hlist_for_each_safe(pos, n, head)            /* 삭제 안전 순회 */
hlist_for_each_entry(obj, head, member)      /* 구조체 순회 */
hlist_for_each_entry_safe(obj, n, head, member) /* 삭제 안전 구조체 순회 */
hlist_entry(ptr, type, member)               /* container_of wrapper */

hlist 메모리 레이아웃 시각화

hlist_headhlist_node의 포인터 관계를 시각적으로 이해하면 pprev 이중 포인터의 필요성과 통일된 삭제 연산이 자연스럽게 납득됩니다. 아래 다이어그램은 hlist_headNode ANode BNULL 체인의 모든 포인터 관계를 보여줍니다.

hlist 메모리 레이아웃과 pprev 포인터 hlist_head의 first가 Node A로 연결되고 Node A, Node B의 next와 pprev가 각각 어디를 가리키는지 보여주며, 위치와 무관한 삭제 연산 원리를 설명합니다. hlist 메모리 레이아웃: pprev 이중 포인터 동작 원리 hlist_head first Node A (hlist_node) next pprev (**) struct 데이터 (key, value...) = &head.first Node B (hlist_node) next pprev (**) struct 데이터 (key, value...) NULL = &A.next pprev 이중 포인터의 핵심 원리 ▸ pprev는 포인터의 포인터 (hlist_node **) — "이전 노드의 next 필드 주소"를 저장 ▸ 첫 번째 노드(A): pprev = &head.first (헤드의 first 필드 주소) ▸ 두 번째 노드(B): pprev = &A.next (이전 노드 A의 next 필드 주소) 통일된 삭제 연산 (노드 위치 무관): *pprev = next; // 이전의 next(또는 head.first)를 자신의 next로 교체 if (next) next->pprev = pprev; // 다음 노드의 pprev를 자신의 pprev로 교체 → 첫 번째든 중간이든 마지막이든 동일한 코드 한 쌍으로 삭제 완료! next 포인터 (→) pprev 포인터 (--→)

이 설계의 핵심 이점은 삭제 시 분기문(if)이 불필요하다는 것입니다. list_head는 원형 이중 연결이므로 prev/next가 대칭적이지만, 헤드가 포인터 2개를 차지합니다. hlist는 비대칭(head는 1포인터, node는 2포인터)이지만, pprev 트릭으로 삭제의 통일성을 확보합니다.

hlist 삭제 동작 단계별 시각화

pprev 이중 포인터를 활용한 __hlist_del()의 동작을 3가지 케이스(첫 번째 노드, 중간 노드, 마지막 노드)로 나누어 단계별로 시각화합니다. 모든 경우에 동일한 코드 2줄로 삭제가 완료되는 것이 핵심입니다.

/* include/linux/list.h - hlist 삭제의 핵심 2줄 */
static inline void __hlist_del(struct hlist_node *n)
{
    struct hlist_node *next = n->next;
    struct hlist_node **pprev = n->pprev;

    /* 1단계: 이전의 next(또는 head.first)를 다음 노드로 연결 */
    WRITE_ONCE(*pprev, next);

    /* 2단계: 다음 노드의 pprev를 현재 노드의 pprev로 갱신 */
    if (next)
        WRITE_ONCE(next->pprev, pprev);
}
hlist 삭제: 3가지 케이스에서 동일한 코드 Case 1: 첫 번째 노드(A) 삭제 삭제 전: head A B 삭제 후: *pprev(=&head.first) = next(=B) head B B.pprev = &head.first Case 2: 중간 노드(B) 삭제 삭제 전: A B C 삭제 후: *pprev(=&A.next) = next(=C) A C C.pprev = &A.next Case 3: 마지막 노드(C) 삭제 삭제 전: B C 삭제 후: *pprev(=&B.next) = next(=NULL) B next==NULL → 2단계 스킵 핵심: 모든 케이스에서 동일한 코드 1단계: WRITE_ONCE(*pprev, next); // pprev가 가리키는 곳에 next를 씀 2단계: if (next) next->pprev = pprev; // 다음 노드의 pprev를 갱신 비교: list_head는 prev/next 대칭이므로 prev->next = next; next->prev = prev; (헤드 크기 16B) hlist는 pprev 트릭으로 비대칭 구조에서도 동일한 통일성 + 헤드 크기 8B (절반) 그림 6. hlist 삭제 동작: 3가지 케이스에서 동일한 코드 2줄로 완료

hlist_bl (Bit-Locked Hash List)

hlist_blhlist의 변형으로, 헤드 포인터의 최하위 비트(LSB)를 spinlock으로 사용합니다. 별도의 lock 배열 없이 버킷 단위 잠금을 구현할 수 있어 메모리를 절약합니다. 리눅스 dcache(dentry_hashtable)에서 사용되며, 수백만 버킷의 해시 테이블에서 lock 배열의 오버헤드를 제거하는 핵심 기법입니다.

/* include/linux/list_bl.h - Bit-locked hlist */

struct hlist_bl_head {
    struct hlist_bl_node *first;
    /* bit 0 of 'first' = spinlock bit */
};

struct hlist_bl_node {
    struct hlist_bl_node *next;
    struct hlist_bl_node **pprev;
};

/* 잠금: first 포인터의 bit 0을 설정 */
static inline void hlist_bl_lock(struct hlist_bl_head *b)
{
    bit_spin_lock(0, (unsigned long *)&b->first);
}

/* 해제: first 포인터의 bit 0을 해제 */
static inline void hlist_bl_unlock(struct hlist_bl_head *b)
{
    __bit_spin_unlock(0, (unsigned long *)&b->first);
}

/* first 포인터 읽기: bit 0을 마스킹 */
static inline struct hlist_bl_node *hlist_bl_first(
    struct hlist_bl_head *h)
{
    return (struct hlist_bl_node *)
        ((unsigned long)h->first & ~1UL);
}
코드 설명
  • 4-5행 hlist_bl_head는 일반 hlist_head와 동일한 크기(8바이트)이지만, first 포인터의 bit 0을 lock으로 활용합니다.
  • 15행 bit_spin_lock(0, ...)는 대상 워드의 bit 0이 0이 될 때까지 spin한 후 1로 설정합니다. 원자적 test-and-set 명령을 사용합니다.
  • 29-30행 실제 첫 번째 노드를 읽을 때는 bit 0을 마스킹해야 합니다. 노드 주소는 항상 2바이트 이상 정렬이므로 bit 0은 항상 0입니다.

dcache에서의 hlist_bl 사용

/* fs/dcache.c - dentry 해시 테이블 */
static struct hlist_bl_head *dentry_hashtable __read_mostly;

/* dentry 검색 (경로명 룩업 hot path) */
struct dentry *d_lookup(const struct dentry *parent,
                       const struct qstr *name)
{
    unsigned int hash = name->hash;
    struct hlist_bl_head *b = dentry_hashtable +
                              hash_32(hash, d_hash_shift);
    struct dentry *found;

    rcu_read_lock();
    /* RCU 읽기: bit-lock 불필요 (읽기는 lock-free) */
    hlist_bl_for_each_entry_rcu(found, node, b, d_hash) {
        if (found->d_name.hash != hash)
            continue;
        if (found->d_parent != parent)
            continue;
        if (d_unhashed(found))
            continue;
        if (!dentry_cmp(found, name->name, name->len)) {
            rcu_read_unlock();
            return found;
        }
    }
    rcu_read_unlock();
    return NULL;
}

hlist 변형 비교

변형헤더헤드 크기잠금대표 사용처
hlist list.h 8B (first) 외부 lock 필요 hashtable.h, inode_hash
hlist_bl list_bl.h 8B (first + bit lock) bit-spinlock 내장 dentry_hashtable (dcache)
hlist_nulls list_nulls.h 8B (first) 외부 lock + nulls 마커 rhashtable, conntrack
hlist_bl: 포인터 bit 0을 spinlock으로 활용 hlist_bl_head.first (64비트 포인터): L 실제 노드 주소 (bit 1~63) bit 0 bit 1~63 (포인터 값, bit 0 마스킹) Unlocked: bit 0 = 0 first = 0xFFFF8800_12345670 Locked: bit 0 = 1 first = 0xFFFF8800_12345671 장점: 버킷당 추가 메모리 0바이트 — 포인터가 곧 lock dcache 수백만 버킷에서 별도 lock 배열(수 MB) 없이 per-bucket locking 구현 그림 7. hlist_bl: 포인터 최하위 비트를 활용한 zero-overhead per-bucket locking

커널 해시 함수 (<linux/hash.h>)

좋은 해시 테이블 성능의 핵심은 해시 함수의 품질입니다. 커널은 다양한 용도에 맞는 여러 해시 함수를 제공합니다.

Golden Ratio 해시 (hash_long / hash_32 / hash_64)

가장 기본적인 커널 해시 함수로, 정수 키를 해싱할 때 사용합니다. Fibonacci Hashing이라고도 불리며, 황금비(Golden Ratio)에서 유도된 상수를 곱합니다.

/* include/linux/hash.h */

/* 2^32 / phi = 2^32 * (sqrt(5)-1)/2 */
#define GOLDEN_RATIO_32  0x61C88647

/* 2^64 / phi */
#define GOLDEN_RATIO_64  0x61C8864680B583EBull

static inline u32 hash_64(u64 val, unsigned int bits)
{
    return (u32)(val * GOLDEN_RATIO_64) >> (64 - bits);
}

static inline u32 hash_32(u32 val, unsigned int bits)
{
    return (val * GOLDEN_RATIO_32) >> (32 - bits);
}

/* 아키텍처 워드 크기에 맞는 해시 */
#if BITS_PER_LONG == 64
#define hash_long(val, bits)  hash_64((u64)(val), bits)
#else
#define hash_long(val, bits)  hash_32((u32)(val), bits)
#endif
💡

bits 파라미터는 해시 테이블의 버킷 수를 2의 거듭제곱으로 표현한 지수입니다. 예를 들어 1024개 버킷이면 bits = 10입니다. 결과값은 항상 0 ~ (2^bits - 1) 범위입니다.

포인터 해싱 (hash_ptr)

static inline u32 hash_ptr(const void *ptr, unsigned int bits)
{
    return hash_long((unsigned long)ptr, bits);
}

Jenkins Hash (jhash)

가변 길이 데이터(문자열, 구조체 등)를 해싱할 때 사용합니다. Bob Jenkins가 설계한 해시 함수로, <linux/jhash.h>에 정의됩니다. 주로 네트워킹 서브시스템에서 튜플(src IP, dst IP, src port, dst port) 해싱에 사용됩니다.

/* include/linux/jhash.h */

/* 가변 길이 바이트 배열 해싱 */
u32 jhash(const void *key, u32 length, u32 initval);

/* 2개 word 해싱 (빠름) */
u32 jhash_2words(u32 a, u32 b, u32 initval);

/* 3개 word 해싱 */
u32 jhash_3words(u32 a, u32 b, u32 c, u32 initval);

/* 1개 word 해싱 */
u32 jhash_1word(u32 a, u32 initval);

네트워크 튜플 해싱 예시 (conntrack):

static u32 hash_conntrack_raw(const struct nf_conntrack_tuple *tuple,
                              unsigned int zoneid,
                              const struct net *net)
{
    unsigned int n;
    u32 seed;

    get_random_once(&nf_conntrack_hash_rnd, sizeof(nf_conntrack_hash_rnd));
    seed = nf_conntrack_hash_rnd ^ zoneid;

    n = (sizeof(tuple->src) + sizeof(tuple->dst.u3)) / sizeof(u32);
    return jhash2((u32 *)tuple, n,
                  seed ^ (u32)(unsigned long)net ^
                  ((__force __u16)tuple->dst.u.all << 16) |
                  tuple->dst.protonum);
}

기타 해시 함수

함수헤더용도
xxhash32() / xxhash64()<linux/xxhash.h>고성능 범용 해시, btrfs 체크섬 등
full_name_hash()<linux/namei.h>dcache 파일명 해싱
hashlen_string()<linux/stringhash.h>문자열 해시 + 길이 동시 반환
arch_fast_hash()아키텍처별CRC32c 등 하드웨어 가속 해시
siphash()<linux/siphash.h>HashDoS 방어용 보안 해시 (net_hash_mix)

보안 민감 경로의 해시 함수 선택 패턴

외부 입력(패킷, 사용자 문자열, ioctl 인자 등)을 키로 쓰는 경로는 단순 성능보다 충돌 공격 내성이 중요합니다. 특히 공격자가 키를 조작할 수 있는 상황에서는 siphash 기반 설계를 기본값으로 두는 것이 안전합니다.

/* 예시: 외부 입력 키를 siphash로 보호 */
#include <linux/siphash.h>

struct user_key {
    u64 a, b;
};

static siphash_key_t g_hash_key;

static u32 secure_key_hash(const struct user_key *k, u32 bits)
{
    u64 h = siphash(k, sizeof(*k), &g_hash_key);
    return (u32)(h >> (64 - bits));
}
⚠️

보안 주의: 사용자 제어 가능한 키(네트워크 패킷 등)를 해싱할 때는 jhash에 랜덤 initval을 사용하거나, siphash를 사용하여 HashDoS(해시 충돌 공격)를 방어해야 합니다. hash_long은 정수 키 전용이며 보안 해시가 아닙니다.

해시 함수 분포와 품질

해시 테이블의 성능은 해시 함수가 키를 버킷에 얼마나 균등하게 분배하느냐에 좌우됩니다. 나쁜 분포는 특정 버킷에 노드가 집중되어 O(1) → O(n) 검색 성능 저하를 초래합니다.

해시 분포 비교 같은 키 집합을 사용했을 때 hash_long 기반 혼합은 버킷 분포가 고르고, 하위 비트 중심 인덱싱은 특정 버킷에 편중될 수 있음을 막대 그래프로 비교합니다. 해시 분포 비교: 같은 키 집합(16개)에서 인덱싱 방식 차이 Golden Ratio (hash_long) — 균등 분포 2[0] 2[1] 2[2] 2[3] 2[4] 2[5] 2[6] 2[7] avg=2 하위 비트 중심 인덱싱 예시 — 편향 분포 3[0] 1[1] 0[2] 1[3] 5[4] 3[5] 2[6] 1[7] avg=2 왜 Golden Ratio 해싱이 좋은 분포를 만드는가? ▸ val × GOLDEN_RATIO_64 곱셈으로 비트가 전체 범위에 균등하게 "혼합(mix)"됨 ▸ 연속 정수(0,1,2,3...)도 서로 다른 버킷에 고르게 분배 (Fibonacci Hashing 원리) ▸ 상위 bits만 추출 (>> shift) → 곱셈의 상위 비트가 가장 좋은 분산을 가짐 핵심 정리: ▸ % 연산 자체가 문제가 아니라, 입력 키의 패턴과 해시 혼합 부족이 편향의 원인입니다. ▸ 실제 설계에서는 hash_long/jhash/siphash 등으로 키를 먼저 혼합한 뒤 버킷 인덱스를 계산해야 합니다.

해시 품질 측정 지표

지표이상값의미측정 방법
로드 팩터 (α)0.5 ~ 1.0요소 수 / 버킷 수atomic_read(&ht->nelems) / tbl->size
체인 길이 분산≈ 0 (균등)버킷별 체인 길이의 표준편차drgn/bpftrace로 버킷별 카운트 수집
최대 체인 길이≤ 4 (이상적)최악 검색 비용rhashtable: RHT_ELASTICITY(16) 초과 시 경고
빈 버킷 비율≤ 40%메모리 낭비 지표head->first == NULL인 버킷 비율

해시 함수 선택 가이드

용도추천 함수이유
정수 키 (주소, PID, inode 번호)hash_long() / hash_32()단일 곱셈+시프트, 최소 사이클
포인터 키hash_ptr()hash_long의 래퍼, 하위 비트 무시
네트워크 튜플 (고정 구조체)jhash2()word 배열 해싱에 최적화
가변 길이 바이트 데이터jhash()가변 길이 + initval 시드 지원
파일명/경로full_name_hash()dcache 특화, 대소문자 변형 지원
외부 입력 (보안 필요)siphash()HashDoS 방어, 키 비밀성 보장
대용량 데이터 체크섬xxhash64()높은 처리량, btrfs/zstd에서 활용

<linux/hashtable.h> API

커널 3.7에서 도입된 <linux/hashtable.h>는 고정 크기 해시 테이블을 위한 간결한 매크로 세트를 제공합니다. 내부적으로 hlist_head 배열과 hash_long()을 사용합니다.

선언과 초기화

#include <linux/hashtable.h>

/* 정적 해시 테이블 선언 (2^bits 버킷) */
DEFINE_HASHTABLE(my_ht, 10);  /* 1024 버킷 */

/* 또는 동적으로 */
struct hlist_head my_ht[1 << 10];
hash_init(my_ht);  /* 모든 버킷을 NULL로 초기화 */

DEFINE_HASHTABLE(name, bits)는 다음과 동일합니다:

struct hlist_head name[1 << (bits)] =
    { [0 ... ((1 << (bits)) - 1)] = HLIST_HEAD_INIT };

삽입, 삭제, 검색

struct my_object {
    int key;
    char *data;
    struct hlist_node hash_node;
};

/* 삽입: key를 해시하여 버킷에 추가 */
struct my_object *obj = kmalloc(sizeof(*obj), GFP_KERNEL);
obj->key = 42;
obj->data = "hello";
hash_add(my_ht, &obj->hash_node, obj->key);

/* 삭제 */
hash_del(&obj->hash_node);

/* 검색: 특정 key와 동일한 버킷의 모든 노드를 순회 */
struct my_object *cur;
int search_key = 42;
hash_for_each_possible(my_ht, cur, hash_node, search_key) {
    if (cur->key == search_key) {
        pr_info("found: %s\\n", cur->data);
        break;
    }
}
⚠️

hash_for_each_possible()은 동일 버킷의 모든 노드를 순회합니다. 해시 충돌이 있을 수 있으므로 반드시 키 값을 직접 비교하여 원하는 객체인지 확인해야 합니다.

전체 API 레퍼런스

매크로/함수설명
DEFINE_HASHTABLE(name, bits)정적 해시 테이블 선언 (2^bits 버킷)
hash_init(ht)모든 버킷을 빈 상태로 초기화
hash_add(ht, node, key)key를 해시하여 해당 버킷에 node 삽입
hash_del(node)노드 삭제
hash_del_init(node)노드 삭제 후 초기화 (재삽입 가능)
hash_for_each(ht, bkt, obj, member)전체 테이블 순회 (모든 버킷)
hash_for_each_safe(ht, bkt, tmp, obj, member)삭제 안전 전체 순회
hash_for_each_possible(ht, obj, member, key)특정 key의 버킷만 순회
hash_for_each_possible_safe(ht, obj, tmp, member, key)삭제 안전 버킷 순회
hash_empty(ht)테이블이 비어있는지 확인

완전한 사용 예제

/* 실습 예제: DEFINE_HASHTABLE 기반 PID 캐시 모듈 */
#include <linux/module.h>
#include <linux/hashtable.h>
#include <linux/slab.h>

/* 8-bit hash → 256 버킷 */
DEFINE_HASHTABLE(pid_cache, 8);

struct pid_entry {
    pid_t               pid;
    char                comm[TASK_COMM_LEN];
    struct hlist_node   hash_node;
};

static void add_pid(pid_t pid, const char *comm)
{
    struct pid_entry *entry;

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

    entry->pid = pid;
    strscpy(entry->comm, comm, TASK_COMM_LEN);

    hash_add(pid_cache, &entry->hash_node, pid);
}

static struct pid_entry *find_pid(pid_t pid)
{
    struct pid_entry *entry;

    hash_for_each_possible(pid_cache, entry, hash_node, pid) {
        if (entry->pid == pid)
            return entry;
    }
    return NULL;
}

static void remove_pid(pid_t pid)
{
    struct pid_entry *entry = find_pid(pid);
    if (entry) {
        hash_del(&entry->hash_node);
        kfree(entry);
    }
}

static void dump_all(void)
{
    struct pid_entry *entry;
    unsigned int bkt;

    hash_for_each(pid_cache, bkt, entry, hash_node)
        pr_info("[bucket %u] pid=%d comm=%s\\n",
                bkt, entry->pid, entry->comm);
}

static void cleanup_all(void)
{
    struct pid_entry *entry;
    struct hlist_node *tmp;
    unsigned int bkt;

    hash_for_each_safe(pid_cache, bkt, tmp, entry, hash_node) {
        hash_del(&entry->hash_node);
        kfree(entry);
    }
}

rhashtable (Resizable Hash Table)

<linux/rhashtable.h>는 커널 3.17(Thomas Graf 제안, commit 7e1e7763)에서 도입된 자동 리사이즈 해시 테이블입니다. 고정 크기 hashtable.h와 달리, 요소 수에 따라 버킷 배열을 동적으로 확장/축소하여 로드 팩터를 일정 범위로 유지합니다. 네트워킹 서브시스템의 고성능 요구사항에서 탄생했으며, 현재는 conntrack, nftables, netlabel, TIPC, IMA 등 커널 전반에서 사용됩니다.

rhashtable의 핵심 특징:

내부 구조 (Internal Architecture)

rhashtable은 세 가지 핵심 구조체로 구성됩니다:

/* include/linux/rhashtable-types.h */

/* 해시 테이블 전체를 대표하는 구조체 */
struct rhashtable {
    struct bucket_table __rcu  *tbl;       /* 현재 버킷 테이블 (RCU 보호) */
    unsigned int               key_len;   /* 키 바이트 길이 */
    unsigned int               max_elems; /* 최대 요소 수 (0=무제한) */
    struct rhashtable_params    p;         /* 파라미터 복사본 */
    bool                       rhlist;    /* rhltable 모드 여부 */
    struct work_struct         run_work;  /* 비동기 리사이즈 work */
    struct mutex               mutex;     /* 리사이즈 직렬화 */
    spinlock_t                 lock;      /* 리사이즈 상태 보호 */
    atomic_t                   nelems;    /* 현재 요소 수 */
};

/* 버킷 배열 + 메타데이터 */
struct bucket_table {
    unsigned int               size;       /* 버킷 수 (항상 2의 거듭제곱) */
    unsigned int               nest;       /* 중첩 테이블 깊이 */
    unsigned int               hash_rnd;   /* 랜덤 해시 시드 */
    struct list_head           walkers;    /* 활성 워커 리스트 */
    struct rcu_head            rcu;        /* RCU 해제용 */
    struct bucket_table __rcu  *future_tbl; /* 리사이즈 중 새 테이블 */
    lockdep_map_p              dep_map;    /* lockdep 추적 */
    struct rhash_lock_head __rcu *buckets[] __counted_by(size);
};

/* 각 요소에 내장되는 해시 노드 */
struct rhash_head {
    struct rhash_head __rcu    *next;     /* 체인 내 다음 노드 */
};
ℹ️

bucket_tablebuckets[]Flexible Array Member로 선언되어 있어, 테이블 생성 시 kvmalloc(sizeof(*tbl) + nbuckets * sizeof(tbl->buckets[0]))로 한 번에 할당됩니다. 이는 별도의 포인터 배열을 할당하는 것보다 캐시 지역성이 좋습니다.

Nulls 마커와 테이블 전환 감지

rhashtable은 일반 hlist가 아닌 hlist_nulls 기반 체인을 사용합니다. 체인의 끝(NULL)에 버킷 인덱스를 인코딩한 nulls 마커를 배치하여, 리사이즈 중 읽기 측에서 자신이 올바른 버킷을 순회하고 있는지 검증할 수 있습니다.

/*
 * Nulls 마커 원리:
 *
 * 체인 끝이 단순 NULL이 아닌 (bucket_index << 1) | 1 값.
 * 읽기 측이 체인을 순회한 후, 끝의 nulls 값이
 * 자신이 시작한 버킷과 일치하는지 확인.
 * 불일치하면 리사이즈로 인해 요소가 이동된 것이므로 재시도.
 */

/* 읽기 측 패턴 (내부 구현) */
restart:
    head = rht_bucket(tbl, hash);
    rht_for_each_rcu(he, head) {
        if (rht_key_matches(params, he, key))
            return he;
    }

    /* nulls 마커 검증 */
    if (get_nulls_value(he) != hash)
        goto restart;  /* 버킷이 바뀌었으므로 재시도 */

per-bucket 잠금 전략

rhashtable의 쓰기 연산은 버킷 단위 spinlock으로 직렬화됩니다. 각 버킷의 첫 번째 포인터에 잠금 비트를 인코딩하는 bit-spin-lock 기법을 사용합니다. 이는 별도의 lock 배열 없이 버킷 포인터의 최하위 비트(LSB)를 활용하는 방식입니다.

/*
 * Per-bucket locking 구조:
 *
 * buckets[i]는 struct rhash_lock_head* 타입.
 * 포인터의 bit 0을 spinlock으로 사용:
 *   bit 0 = 0 → 잠금 해제 (unlocked)
 *   bit 0 = 1 → 잠금 설정 (locked)
 *
 * 장점:
 *   - 별도 lock 배열 불필요 → 메모리 절약
 *   - 캐시 라인을 데이터와 공유 → 미스 감소
 *   - 서로 다른 버킷의 삽입/삭제는 완전 병렬
 */

/* 내부 잠금 헬퍼 (lib/rhashtable.c) */
static inline void rht_lock(struct bucket_table *tbl,
                            struct rhash_lock_head __rcu **bkt)
{
    local_bh_disable();
    bit_spin_lock(0, (unsigned long *)bkt);
    lock_map_acquire(&tbl->dep_map);
}

static inline void rht_unlock(struct bucket_table *tbl,
                              struct rhash_lock_head __rcu **bkt)
{
    lock_map_release(&tbl->dep_map);
    bit_spin_unlock(0, (unsigned long *)bkt);
    local_bh_enable();
}
💡

local_bh_disable()가 호출되는 이유: rhashtable은 softirq 컨텍스트(예: 네트워크 수신 경로)에서도 사용되므로, 데드락을 방지하기 위해 하단 반쪽(bottom half)을 비활성화합니다.

rhashtable_params 상세

rhashtable_params는 해시 테이블의 동작 방식을 결정하는 구성 구조체입니다. rhashtable_init()에 전달하면 내부에 복사되어 테이블 수명 동안 유지됩니다.

struct rhashtable_params {
    u16                  nelem_hint;          /* 초기 요소 수 힌트 (초기 버킷 수 계산) */
    u16                  key_len;             /* 키 바이트 길이 (obj_hashfn 사용 시 0) */
    u16                  key_offset;          /* 구조체 내 키 시작 오프셋 */
    u16                  head_offset;         /* 구조체 내 rhash_head 오프셋 */
    unsigned int         max_size;            /* 최대 버킷 수 (0=무제한) */
    u16                  min_size;            /* 최소 버킷 수 */
    bool                 automatic_shrinking; /* 자동 축소 활성화 */
    rht_hashfn_t         hashfn;              /* 키 → 해시 함수 (기본: jhash) */
    rht_obj_hashfn_t     obj_hashfn;          /* 객체 → 해시 함수 (복합 키용) */
    rht_obj_cmpfn_t      obj_cmpfn;           /* 커스텀 비교 함수 (선택) */
};
필드필수기본값설명
head_offset필수-구조체 내 rhash_head 위치. offsetof()로 지정
key_offset조건부-키 위치. obj_hashfn 미사용 시 필수
key_len조건부-키 바이트 크기. obj_hashfn 미사용 시 필수
hashfn선택jhashu32 (*)(const void *key, u32 len, u32 seed). 키 데이터를 해시
obj_hashfn선택-u32 (*)(const void *obj, u32 len, u32 seed). 복합 키 등 객체에서 직접 해시 계산
obj_cmpfn선택memcmpint (*)(struct rhashtable_compare_arg *, const void *obj). 키 일치 판단
nelem_hint선택0예상 요소 수. 초기 버킷 수를 최적화 (nelem_hint/load_factor)
min_size선택64축소 시 최소 버킷 수
max_size선택0확장 시 최대 버킷 수. 0이면 2^31까지 허용
automatic_shrinking선택false요소 삭제 시 자동 축소 여부

기본 사용 예:

#include <linux/rhashtable.h>

struct my_entry {
    u32                    key;
    char                   value[64];
    struct rhash_head      rhash;   /* rhashtable 노드 */
};

static const struct rhashtable_params my_rht_params = {
    .head_offset     = offsetof(struct my_entry, rhash),
    .key_offset      = offsetof(struct my_entry, key),
    .key_len         = sizeof(u32),
    .automatic_shrinking = true,
    .nelem_hint      = 1024,          /* 초기 요소 수 힌트 */
    .min_size        = 256,           /* 최소 버킷 수 */
    .max_size        = 0,             /* 0 = 제한 없음 */
};

복합 키 사용 예 (obj_hashfn + obj_cmpfn):

/* 복합 키: (src_ip, dst_ip, protocol) 3-tuple */
struct flow_key {
    __be32  src_ip;
    __be32  dst_ip;
    u8      protocol;
};

struct flow_entry {
    struct flow_key    fkey;
    u64                packets;
    u64                bytes;
    struct rhash_head  rhash;
};

static u32 flow_obj_hashfn(const void *data, u32 len, u32 seed)
{
    const struct flow_entry *f = data;
    return jhash(&f->fkey, sizeof(f->fkey), seed);
}

static u32 flow_key_hashfn(const void *data, u32 len, u32 seed)
{
    return jhash(data, sizeof(struct flow_key), seed);
}

static int flow_obj_cmpfn(struct rhashtable_compare_arg *arg,
                          const void *obj)
{
    const struct flow_key *key = arg->key;
    const struct flow_entry *f = obj;
    return memcmp(&f->fkey, key, sizeof(*key));
}

static const struct rhashtable_params flow_params = {
    .head_offset  = offsetof(struct flow_entry, rhash),
    .key_offset   = offsetof(struct flow_entry, fkey),
    .key_len      = sizeof(struct flow_key),
    .hashfn       = flow_key_hashfn,
    .obj_hashfn   = flow_obj_hashfn,
    .obj_cmpfn    = flow_obj_cmpfn,
};

기본 API

struct rhashtable my_rht;

/* 초기화: 파라미터 기반으로 버킷 테이블 할당 */
int ret = rhashtable_init(&my_rht, &my_rht_params);
if (ret)
    return ret;  /* -ENOMEM: 초기 버킷 할당 실패 */

/* 삽입: per-bucket lock 획득 후 체인 앞에 삽입 */
struct my_entry *entry = kzalloc(sizeof(*entry), GFP_KERNEL);
entry->key = 100;
ret = rhashtable_insert_fast(&my_rht, &entry->rhash, my_rht_params);
if (ret) {
    kfree(entry);
    return ret;  /* -ENOMEM: 리사이즈 실패, -EEXIST: 키 중복 */
}

/* 검색: RCU 보호 하에 lock-free 수행 */
u32 search_key = 100;
struct my_entry *found;
rcu_read_lock();
found = rhashtable_lookup_fast(&my_rht, &search_key, my_rht_params);
if (found)
    pr_info("found value: %s\\n", found->value);
rcu_read_unlock();

/* 삭제: per-bucket lock 획득 후 체인에서 제거 */
ret = rhashtable_remove_fast(&my_rht, &entry->rhash, my_rht_params);

/* 정리 (모든 요소 제거 후) */
rhashtable_destroy(&my_rht);

전체 API 레퍼런스

함수컨텍스트설명
rhashtable_init()process해시 테이블 초기화, 초기 버킷 할당
rhashtable_destroy()process빈 테이블 해제 (요소가 남아있으면 경고)
rhashtable_free_and_destroy()process콜백으로 모든 요소 해제 후 테이블 파괴
rhashtable_insert_fast()any요소 삽입. 중복 키 시 -EEXIST
rhashtable_lookup_fast()rcu키로 요소 검색. RCU read lock 내에서 호출
rhashtable_remove_fast()any요소 삭제. 삭제 후 RCU grace period 대기 필요
rhashtable_lookup_insert_fast()any원자적 검색+삽입. 없으면 삽입, 있으면 -EEXIST
rhashtable_lookup_get_insert_fast()any검색+삽입. 기존 엔트리 포인터 반환 또는 삽입
rhashtable_replace_fast()any기존 요소를 새 요소로 원자적 교체
rhashtable_walk_enter()process순회 반복자(iterator) 등록
rhashtable_walk_start()any순회 시작, RCU read lock 획득
rhashtable_walk_next()rcu다음 요소 반환. 리사이즈 시 -EAGAIN
rhashtable_walk_stop()any순회 일시 정지, RCU read lock 해제
rhashtable_walk_exit()process순회 반복자 해제

원자적 검색+삽입

검색과 삽입을 원자적으로 수행하는 편의 함수:

/* 키가 없으면 삽입, 이미 있으면 -EEXIST */
ret = rhashtable_lookup_insert_fast(&my_rht, &entry->rhash, my_rht_params);

/* 키가 있으면 기존 엔트리 반환, 없으면 삽입 */
struct my_entry *existing;
existing = rhashtable_lookup_get_insert_fast(&my_rht, &entry->rhash, my_rht_params);
if (IS_ERR(existing))
    return PTR_ERR(existing);  /* 에러 (-ENOMEM 등) */
if (existing)
    pr_info("already exists\\n");  /* 기존 엔트리 */
else
    pr_info("newly inserted\\n");  /* 새로 삽입됨 */

원자적 교체 (Replace)

rhashtable_replace_fast()는 동일 키의 기존 요소를 새 요소로 원자적으로 교체합니다. 읽기 측은 교체 전후의 어느 한 쪽만 관측하며, 중간 상태는 보이지 않습니다.

struct my_entry *old_entry = find_entry(&my_rht, key);
struct my_entry *new_entry = kzalloc(sizeof(*new_entry), GFP_KERNEL);
new_entry->key = old_entry->key;
strscpy(new_entry->value, "updated", sizeof(new_entry->value));

ret = rhashtable_replace_fast(&my_rht, &old_entry->rhash,
                              &new_entry->rhash, my_rht_params);
if (!ret) {
    synchronize_rcu();   /* 또는 call_rcu() */
    kfree(old_entry);   /* 이전 요소를 안전하게 해제 */
}

자동 리사이즈 메커니즘

rhashtable의 리사이즈는 다단계 과정으로 진행됩니다:

확장 (Grow) 트리거 조건

축소 (Shrink) 트리거 조건

리사이즈 과정 상세

  1. 트리거: 삽입/삭제 시 rht_grow_above_75() 또는 rht_shrink_below_30() 검사
  2. 예약: schedule_work(&ht->run_work)로 work queue에 리사이즈 작업 등록
  3. Mutex 획득: ht->mutex로 동시 리사이즈 방지
  4. 새 테이블 할당: kvmalloc()으로 2배(확장) 또는 1/2(축소) 크기의 bucket_table 할당
  5. future_tbl 설정: 현재 tbl->future_tbl = new_tbl로 연결 (RCU publish)
  6. 요소 마이그레이션: 버킷별로 lock을 잡고, 각 요소를 재해싱하여 새 테이블로 이동
  7. 테이블 교체: rcu_assign_pointer(ht->tbl, new_tbl)로 현재 테이블 전환
  8. 이전 테이블 해제: call_rcu()로 grace period 이후 이전 bucket_table 해제
rhashtable 리사이즈 과정 기존 4버킷 테이블에서 새 8버킷 테이블로 확장하면서 일부 엔트리가 재해싱되어 이동하고, 최종적으로 tbl 포인터를 전환하는 과정을 단계적으로 나타냅니다. rhashtable 리사이즈 과정 (4 → 8 버킷 확장) ① 현재 테이블 (tbl) ② 새 테이블 (future_tbl) bucket_table (size=4) [0] A E [1] B [2] C F [3] D bucket_table (size=8) [0] A [1] (빈 버킷) [2] C [3] D [4] E [5] B [6] F [7] (빈 버킷) ③ 요소 재해싱 (hash % 8): 유지: A → [0], C → [2], D → [3] (기존 버킷 번호 동일) 이동: E → [4], B → [5], F → [6] (기존 버킷 + 4 = 새 버킷) ④ 완료: rcu_assign_pointer(ht->tbl, new_tbl) → 이전 테이블은 call_rcu()로 해제 유지 (같은 버킷) 이동 (새 버킷) 마이그레이션 경로
ℹ️

리사이즈 중 삽입future_tbl(새 테이블)에만 수행됩니다. 검색은 현재 테이블에서 시작하되, future_tbl이 존재하면 새 테이블도 탐색합니다. 이 설계 덕분에 리사이즈 진행 중에도 정확성이 보장됩니다.

⚠️

중첩 테이블(Nested Table): 대규모 테이블(버킷 수 > PAGE_SIZE / sizeof(bucket))에서 kvmalloc()이 연속 메모리 할당에 실패하면, rhashtable은 중첩 페이지 테이블 방식으로 폴백합니다. bucket_table.nest 필드가 중첩 깊이를 추적하며, 각 레벨은 단일 페이지 크기의 포인터 배열입니다.

rhashtable 튜닝 플레이북

rhashtable은 기본값만으로도 동작하지만, 워크로드 특성(삽입 폭주, 삭제 편향, 키 분포)에 따라 파라미터를 명시적으로 설정하면 지연 편차와 리사이즈 진동을 크게 줄일 수 있습니다.

워크로드권장 파라미터의도
읽기 압도적(lookup >> update)nelem_hint 크게, automatic_shrinking=false리사이즈 빈도 최소화, lookup 안정화
삽입/삭제 급변automatic_shrinking=true, min_size 보수적 지정메모리 회수와 재확장 진동 균형
키 분포 불균등hashfn/obj_hashfn 커스텀편향 버킷 완화
메모리 상한 엄격max_size 지정버킷 배열 과확장 방지
/* 운영 지향 템플릿 예시 */
static const struct rhashtable_params tuned_params = {
    .head_offset = offsetof(struct my_entry, rhash),
    .key_offset  = offsetof(struct my_entry, key),
    .key_len     = sizeof(u32),
    .nelem_hint  = 1 << 16,
    .min_size    = 1 << 12,
    .max_size    = 1 << 20,
    .automatic_shrinking = true,
};
💡

초기화 원칙: nelem_hint를 실제 피크의 50~100% 수준으로 주면 초기 재해싱 비용을 줄일 수 있습니다. 과도한 nelem_hint는 초기 메모리 사용량만 늘립니다.

rhltable (중복 키 허용)

일반 rhashtable은 키 중복을 허용하지 않습니다(-EEXIST 반환). rhltable은 동일 키에 여러 엔트리를 허용하는 변형으로, 각 엔트리를 rhlist_head로 연결합니다. nftables set의 interval element 등에서 사용됩니다.

struct my_multi_entry {
    u32                     key;
    u32                     value;
    struct rhlist_head       rhlist;  /* rhltable 노드 (rhash_head 확장) */
};

struct rhltable my_rhlt;

/* 초기화 */
rhltable_init(&my_rhlt, &my_rht_params);

/* 삽입: 동일 키 허용 (중복 시에도 성공) */
rhltable_insert(&my_rhlt, &entry->rhlist, my_rht_params);

/* 검색: 동일 키의 모든 엔트리를 연결 리스트로 반환 */
struct rhlist_head *list, *tmp;
rcu_read_lock();
list = rhltable_lookup(&my_rhlt, &search_key, my_rht_params);
if (list) {
    rhl_for_each_entry_rcu(entry, tmp, list, rhlist) {
        pr_info("key=%u value=%u\\n", entry->key, entry->value);
    }
}
rcu_read_unlock();

/* 삭제: 특정 엔트리만 제거 */
rhltable_remove(&my_rhlt, &entry->rhlist, my_rht_params);
ℹ️

rhlist_headrhash_head를 내장하며, 동일 키의 엔트리들을 별도의 연결 리스트(struct rhlist_head *list)로 묶습니다. 해시 체인에는 키당 하나의 "대표 노드"만 들어가고, 나머지는 대표 노드에서 분기하는 리스트에 연결됩니다.

순회 API (Walk Iterator)

rhashtable은 리사이즈에 안전한 순회 API를 제공합니다. hash_for_each와 달리, 순회 중간에 sleep이 가능하고 리사이즈가 발생해도 정확성이 보장됩니다.

struct rhashtable_iter iter;

/* 1. 반복자 등록 (process 컨텍스트) */
rhashtable_walk_enter(&my_rht, &iter);

/* 2. 순회 시작 (RCU read lock 획득) */
rhashtable_walk_start(&iter);

/* 3. 요소 순회 */
struct my_entry *entry;
while ((entry = rhashtable_walk_next(&iter)) != NULL) {
    if (IS_ERR(entry)) {
        if (PTR_ERR(entry) == -EAGAIN)
            continue;  /* 리사이즈 발생 → 재시도 */
        break;         /* 기타 에러 */
    }
    pr_info("key=%u value=%s\\n", entry->key, entry->value);
}

/* 4. 순회 종료 (RCU read lock 해제) */
rhashtable_walk_stop(&iter);

/* 5. 반복자 해제 */
rhashtable_walk_exit(&iter);
💡

중간 sleep이 필요한 경우: rhashtable_walk_stop()으로 RCU lock을 해제한 후 sleep하고, rhashtable_walk_start()로 다시 시작하면 이전 위치에서 계속됩니다. 리사이즈가 발생했을 경우 rhashtable_walk_next()-EAGAIN을 반환하여 위치를 재조정합니다.

정리와 해제

rhashtable을 해제할 때는 반드시 모든 요소를 먼저 제거해야 합니다. rhashtable_free_and_destroy()는 콜백 함수를 통해 이를 자동화합니다.

/* 방법 1: rhashtable_free_and_destroy (권장) */
static void my_entry_free(void *ptr, void *arg)
{
    struct my_entry *entry = ptr;
    kfree(entry);
}

/* 모든 요소에 대해 my_entry_free 호출 후 테이블 해제 */
rhashtable_free_and_destroy(&my_rht, my_entry_free, NULL);

/* 방법 2: Walk API로 수동 정리 */
struct rhashtable_iter iter;
rhashtable_walk_enter(&my_rht, &iter);
rhashtable_walk_start(&iter);

struct my_entry *entry;
while ((entry = rhashtable_walk_next(&iter)) != NULL) {
    if (IS_ERR(entry))
        continue;
    rhashtable_remove_fast(&my_rht, &entry->rhash, my_rht_params);
    kfree_rcu(entry, rcu);  /* RCU grace period 후 해제 */
}

rhashtable_walk_stop(&iter);
rhashtable_walk_exit(&iter);
rhashtable_destroy(&my_rht);
⚠️

rhashtable_destroy()는 테이블에 요소가 남아있으면 WARN_ON을 출력합니다. 절대 요소가 남아있는 상태에서 호출하지 마세요. 메모리 누수가 발생합니다. 반드시 rhashtable_free_and_destroy()를 사용하거나 모든 요소를 수동으로 제거하세요.

동시성 모델 요약

연산보호 메커니즘블로킹비고
검색 (lookup_fast) RCU read lock non-blocking 리사이즈 중에도 lock-free
삽입 (insert_fast) per-bucket bit-spinlock non-blocking (spinlock) 동일 버킷만 직렬화, 다른 버킷은 병렬
삭제 (remove_fast) per-bucket bit-spinlock non-blocking 삭제 후 RCU grace period 대기 필요
리사이즈 ht->mutex + per-bucket lock blocking (mutex, kvmalloc) work queue에서 비동기 수행
순회 (walk_*) RCU read lock + walker 등록 sleepable (stop/start 사이) 리사이즈 시 -EAGAIN으로 재조정

rhashtable 전체 라이프사이클

rhashtable의 초기화부터 파괴까지 전체 생명주기를 하나의 흐름도로 정리하면, 각 API의 호출 시점과 내부 동작이 명확해집니다.

rhashtable 라이프사이클 초기화, 삽입·조회·삭제, 리사이즈 조건과 비동기 작업, walk 순회, 삭제 후 RCU 수명 정리, free_and_destroy 해제까지의 흐름을 교차선 없이 단계적으로 보여줍니다. rhashtable 전체 라이프사이클 (교차선 없는 흐름) rhashtable_init() [process ctx, GFP_KERNEL] insert_fast() [per-bucket spinlock, writer] lookup_fast() [rcu_read_lock, lock-free reader] remove_fast() [per-bucket spinlock, unlink only] nelems/size > 75% (예시) → 확장 트리거 nelems/size < 30% (예시) → 축소 트리거 schedule_work(&ht->run_work) [work queue, mutex, kvmalloc, bucket 재해싱] 리사이즈 중 동작: 삽입→future_tbl, 검색→old+new walk_enter → walk_next → walk_exit [stop/start 사이 sleep 가능, -EAGAIN 재시도] 삭제 후 수명 정리 [kfree_rcu() 또는 synchronize_rcu()] rhashtable_free_and_destroy() [process ctx, 콜백으로 요소 해제] 범례 / 주의 핵심 API 조건/비동기/중간 단계 해제 경로 * 75%/30%는 설명용 예시 * 실제 임계값은 구현/버전에 의존 * 삭제 후 반드시 RCU 수명 정리

hashtable.h vs rhashtable 비교

특성hashtable.hrhashtable
크기고정 (컴파일 시 결정)동적 (자동 확장/축소)
버킷 구조hlist_head[]rhash_lock_head[] + nulls
해시 함수hash_long()jhash() (커스텀 가능)
잠금사용자 책임per-bucket spinlock 내장
RCU 지원_rcu 접미사 매크로기본 내장
중복 키지원 (해시 충돌과 동일 취급)기본 불허, rhltable로 지원
순회hash_for_eachrhashtable_walk_* (리사이즈 안전)
메모리스택/전역에 직접 할당kvmalloc() 동적 할당
초기화DEFINE_HASHTABLE / hash_initrhashtable_init()
적합한 경우요소 수 예측 가능, 단순한 구조요소 수 변동, 고성능 동시성 필요

RCU 해시 테이블

<linux/hashtable.h>는 RCU로 보호되는 해시 테이블 연산을 별도의 _rcu 접미사 매크로로 제공합니다. 이를 사용하면 읽기 측에서 lock 없이 해시 테이블을 안전하게 순회할 수 있습니다.

RCU 해시 테이블 API

/* RCU 보호 삽입 — 쓰기 측에서 적절한 lock 보유 필요 */
hash_add_rcu(ht, &obj->hash_node, key);

/* RCU 보호 삭제 */
hash_del_rcu(&obj->hash_node);

/* RCU 읽기 측 전체 순회 */
rcu_read_lock();
hash_for_each_rcu(ht, bkt, obj, member) {
    /* obj를 읽기 전용으로 접근 */
}
rcu_read_unlock();

/* RCU 읽기 측 버킷 순회 (검색) */
rcu_read_lock();
hash_for_each_possible_rcu(ht, obj, member, key) {
    if (obj->key == search_key) {
        /* found */
        break;
    }
}
rcu_read_unlock();

읽기-쓰기 분리 패턴

static DEFINE_HASHTABLE(my_ht, 10);
static DEFINE_SPINLOCK(my_ht_lock);  /* 쓰기 측 보호 */

/* 읽기 경로: lock-free (RCU 구간에서 필요한 값만 복사) */
bool lookup_value(int key, char *buf, size_t buflen)
{
    struct my_object *obj;
    bool found = false;

    rcu_read_lock();
    hash_for_each_possible_rcu(my_ht, obj, hash_node, key) {
        if (obj->key == key) {
            strscpy(buf, obj->value, buflen);
            found = true;
            break;
        }
    }
    rcu_read_unlock();
    return found;
}

/* 쓰기 경로: spinlock으로 보호 */
void insert(struct my_object *obj)
{
    spin_lock(&my_ht_lock);
    hash_add_rcu(my_ht, &obj->hash_node, obj->key);
    spin_unlock(&my_ht_lock);
}

/* 삭제 경로 */
void delete(struct my_object *obj)
{
    spin_lock(&my_ht_lock);
    hash_del_rcu(&obj->hash_node);
    spin_unlock(&my_ht_lock);

    synchronize_rcu();   /* 또는 call_rcu()로 비동기 */
    kfree(obj);
}
💡

rhashtable는 내부적으로 RCU를 사용하여 읽기 연산을 보호합니다. rhashtable_lookup_fast()rcu_read_lock() 안에서 호출해야 합니다. 별도의 _rcu 접미사 매크로가 필요 없습니다.

충돌 해결과 성능

체이닝 (Separate Chaining)

커널 해시 테이블은 체이닝 방식으로 해시 충돌을 해결합니다. 동일 버킷에 해시되는 모든 요소는 hlist 체인으로 연결됩니다. Open addressing(선형/이차 탐사)과 비교할 때 다음과 같은 장점이 있습니다:

체인 길이 모델과 응답 시간 감각

균등 해시를 가정하면 버킷당 평균 체인 길이는 로드 팩터 α = n / m(요소 수 n, 버킷 수 m)로 근사됩니다. 즉, 평균 조회 비용은 대략 O(1 + α)이며, tail latency는 “가장 긴 체인”의 영향을 강하게 받습니다.

로드 팩터 α평균 체인 길이실무 해석
0.5약 0~1대부분 1회 비교 이내, 캐시 미스 낮음
1.0약 1일반적인 운영 구간, 균형 좋음
2.0약 2충돌 체감 시작, 긴 체인 버킷 점검 필요
4.0약 4hot bucket 발생 가능성 큼, 리사이즈/해시 개선 권장
⚠️

평균값 함정: 평균 체인 길이가 낮아도 특정 버킷에 편향이 있으면 p99 조회 지연이 급격히 증가합니다. 운영 점검 시 반드시 “최대 체인 길이”를 함께 모니터링하세요.

버킷 크기 선택 가이드

DEFINE_HASHTABLE(name, bits)bits 값 선택 기준:

bits버킷 수hlist_head 크기 (x86_64)권장 요소 수
416128 bytes~16
82562 KB~256
101,0248 KB~1,024
124,09632 KB~4,096
1665,536512 KB~65,536
201,048,5768 MB~1M
💡

일반적으로 로드 팩터(요소 수 / 버킷 수)를 0.5 ~ 1.0 사이로 유지하는 것이 좋습니다. 요소 수가 예측 불가능하면 rhashtable을 사용하세요.

캐시 라인 최적화

해시 테이블 성능에 영향을 미치는 캐시 관련 요소:

Per-CPU 해시와 NUMA 최적화

고성능 네트워킹 경로에서 해시 테이블의 경합(contention)을 줄이기 위해 커널은 Per-CPU 분할 해시 패턴을 사용합니다. 하나의 거대한 해시 테이블 대신, CPU별로 독립된 해시 테이블(또는 버킷 세그먼트)을 운영하여 캐시 바운싱과 lock 경합을 최소화합니다.

주요 Per-CPU 해시 패턴

패턴구조활용 사례장단점
Per-CPU 독립 테이블 CPU마다 별도 DEFINE_HASHTABLE 통계 수집, per-CPU 카운터 캐시 경합 0, 전체 순회 비용 높음
Striped Lock 하나의 테이블 + N개 lock (버킷 범위별) conntrack, 소켓 해시 읽기 병렬, 쓰기 부분 직렬화
RCU + Per-bucket Lock rhashtable 기본 방식 nftables, netlabel, TIPC 읽기 lock-free, 쓰기 세밀 직렬화
Per-CPU 삽입 + 전역 검색 삽입은 CPU-local, 검색은 전체 스캔 flow dissector 캐시 삽입 O(1) 무경합, 검색 O(CPU수)

Per-CPU 해시 구현 예

/* Per-CPU 해시 테이블 패턴: 통계 수집용 */
#include <linux/percpu.h>
#include <linux/hashtable.h>

struct stat_entry {
    u32                 key;
    atomic64_t          count;
    struct hlist_node   node;
};

/* CPU별 256-버킷 해시 테이블 */
struct per_cpu_ht {
    DECLARE_HASHTABLE(ht, 8);
    spinlock_t lock;
};

static DEFINE_PER_CPU(struct per_cpu_ht, cpu_stats);

/* Hot path: 현재 CPU 테이블 사용 + 로컬 lock으로 IRQ/softirq 경합 방지 */
static void count_event(u32 key)
{
    struct per_cpu_ht *pht;
    struct stat_entry *entry;
    unsigned long flags;

    preempt_disable();
    pht = this_cpu_ptr(&cpu_stats);
    spin_lock_irqsave(&pht->lock, flags);

    hash_for_each_possible(pht->ht, entry, node, key) {
        if (entry->key == key) {
            atomic64_inc(&entry->count);
            spin_unlock_irqrestore(&pht->lock, flags);
            preempt_enable();
            return;
        }
    }
    spin_unlock_irqrestore(&pht->lock, flags);
    preempt_enable();
    /* miss: 새 엔트리 할당 후 삽입 (생략) */
}

NUMA 환경 해시 테이블 팁

커널 활용 사례

conntrack (nf_conntrack)

Netfilter의 연결 추적 테이블은 커널의 가장 대표적인 rhashtable 활용 사례 중 하나입니다. 네트워크 연결의 5-tuple(프로토콜, 소스/목적지 IP, 소스/목적지 포트)을 키로 해시하여 초당 수백만 패킷의 연결 상태를 빠르게 조회합니다.

/* include/net/netfilter/nf_conntrack.h */
struct nf_conntrack_tuple_hash {
    struct hlist_nulls_node    hnnode;    /* nulls 마커가 있는 hlist */
    struct nf_conntrack_tuple  tuple;
};

/* 하나의 연결은 방향별 2개의 tuple_hash를 가짐 */
struct nf_conn {
    struct nf_conntrack_tuple_hash tuplehash[IP_CT_DIR_MAX];
    unsigned long                 status;      /* 연결 상태 비트맵 */
    possible_net_t                ct_net;
    struct hlist_node             nat_bysource;
    /* ... */
    spinlock_t                    lock;
    u32                           timeout;     /* jiffies 기반 만료 */
    u16                           mark;
    /* ... */
};

/* net/netfilter/nf_conntrack_core.c */
/* conntrack 해시 테이블 구조: hlist_nulls_head 배열 */
/* net->ct.hash: 동적 크기 해시, nf_conntrack_htable_size 버킷 */
/* 해시 함수: jhash2() + 랜덤 시드(nf_conntrack_hash_rnd) */

/* 연결 검색 경로 (패킷 수신 hot path) */
static struct nf_conntrack_tuple_hash *
____nf_conntrack_find(struct net *net,
                     const struct nf_conntrack_zone *zone,
                     const struct nf_conntrack_tuple *tuple,
                     u32 hash)
{
    struct nf_conntrack_tuple_hash *h;
    struct hlist_nulls_head *ct_hash;
    struct hlist_nulls_node *n;
    unsigned int bucket, hsize;

begin:
    nf_conntrack_get_ht(&ct_hash, &hsize);
    bucket = reciprocal_scale(hash, hsize);

    hlist_nulls_for_each_entry_rcu(h, n, &ct_hash[bucket], hnnode) {
        if (nf_ct_key_equal(h, tuple, zone, net))
            return h;
    }

    /* nulls 마커 검증: 리사이즈로 인한 버킷 이동 감지 */
    if (get_nulls_value(n) != bucket) {
        /* 테이블이 바뀌었으므로 재시도 */
        goto begin;
    }
    return NULL;
}
💡

conntrack 해시 테이블 크기는 /proc/sys/net/netfilter/nf_conntrack_buckets로 조회/설정하고, 최대 엔트리 수는 nf_conntrack_max로 제한합니다. 일반적으로 nf_conntrack_max = nf_conntrack_buckets × 4(로드 팩터 4) 관계입니다.

inode 해시 테이블

/* fs/inode.c */
static struct hlist_head *inode_hashtable;

/* inode 검색: superblock + inode number로 해시 */
struct inode *ilookup(struct super_block *sb, unsigned long ino)
{
    struct hlist_head *head = inode_hashtable +
                              hash(sb, ino);
    /* head에서 hlist 순회로 검색 */
}

PID 해시

/* kernel/pid.c */
struct pid {
    refcount_t           count;
    unsigned int         level;
    spinlock_t           lock;
    struct hlist_head    tasks[PIDTYPE_MAX];
    struct hlist_head    inodes;
    wait_queue_head_t    wait_pidfd;
    struct rcu_head      rcu;
    struct upid          numbers[];  /* pid namespace 계층 */
};

/* struct upid는 해시 테이블에 연결 */
struct upid {
    int                  nr;        /* PID 번호 */
    struct pid_namespace *ns;
    struct hlist_node    pid_chain; /* pid_hash[]에 연결 */
};

dentry 캐시 (dcache)

/* fs/dcache.c */
static struct hlist_bl_head *dentry_hashtable;

/* 해시 키: parent dentry + 파일명 해시 */
/* full_name_hash()로 파일명 해싱 */
/* hlist_bl: bit-locked hlist (비트 스핀락 내장) */

네트워크 소켓 해시

설명 요약:
  • TCP established 소켓 해시 (inet_ehash_bucket)
  • 키: local addr/port + remote addr/port
  • 빠른 수신 패킷의 소켓 매핑 핵심 경로
  • UDP 소켓 해시 (udp_table)
  • 키: local addr/port
  • SO_REUSEPORT 사용 시 추가 분산 해시 적용

모듈 심볼 해시

설명 요약:
  • kernel/module/main.c 기반 심볼 검색 경로
  • EXPORT_SYMBOL로 등록된 심볼을 해시 테이블로 검색
  • 모듈 로딩 시 심볼 해석(symbol resolution)에 사용

해시 테이블 구조 다이어그램

고정 크기 해시 테이블 구조 8개 버킷의 hlist_head 배열과 각 버킷 체인 노드, NULL 종단, key를 버킷 인덱스로 매핑하는 기본 구조를 보여줍니다. Hash Table 구조 (DEFINE_HASHTABLE, 8 buckets) hlist_head[] [0] key=256 key=512 NULL [1] NULL [2] key=42 NULL [3] key=99 NULL [4] key=1028 key=2052 NULL [5] NULL [6] NULL [7] key=7 NULL 범례 (버킷 인덱스 = key % 8) hlist_head (8B) hlist_node + data next 포인터

자료구조 선택 가이드

기준 Hash Table Red-Black Tree XArray Linked List
검색 O(1) 평균 O(log n) O(log n) O(n)
삽입/삭제 O(1) 평균 O(log n) O(log n) O(1) 위치 알 때
정렬 순회 불가 O(n) 정렬 순서 O(n) 인덱스 순서 O(n) 삽입 순서
범위 검색 불가 효율적 효율적 O(n)
키 타입 정수, 구조체, 문자열 비교 가능한 모든 키 정수 (unsigned long) N/A
메모리 버킷 배열 + 노드 노드당 3 포인터 radix tree 노드 노드당 포인터 2개
동적 크기 rhashtable만 가능 자동 자동 자동
대표 사용처 inode 캐시, conntrack, PID CFS, VMA, ext4 extent 페이지 캐시, IDR task 리스트, 타이머
💡

선택 기준 요약: 정확한 키 매칭(exact match)이 주된 연산이면 → Hash Table. 범위 검색이나 정렬 순회가 필요하면 → rbtree. 정수 키 + 희소(sparse) 데이터면 → XArray. 순서 유지가 목적이면 → Linked List.

상황별 의사결정 트리

아래 질문 순서대로 내려가면, 자료구조 후보를 빠르게 좁힐 수 있습니다.

  1. 정렬/범위 검색이 필요한가?
    필요하면 rbtree 또는 XArray를 우선 검토합니다.
  2. 키가 정수 인덱스 중심인가?
    정수 sparse 인덱스면 XArray, 일반 복합 키면 해시 계열로 갑니다.
  3. 정확 매칭 lookup이 압도적인가?
    그렇다면 해시 테이블이 1차 선택입니다.
  4. 요소 수 변동이 큰가?
    변동이 크면 rhashtable, 대체로 고정이면 hashtable.h가 단순합니다.
  5. 외부 입력 키인가?
    공격 노출 경로면 siphash 기반 해시 정책을 우선합니다.
질문아니오
범위 질의/정렬 순회 필요?rbtree / XArray해시 계열 고려
키가 정수 인덱스 중심?XArrayhashtable/rhashtable
요소 수 변동이 큼?rhashtablehashtable.h
외부 입력 키?siphash 기반hash_long/jhash 가능

디버깅 팁

crash 유틸리티로 해시 테이블 덤프

# 실습 예제: crash 셸에서 해시 테이블 구조 확인
crash> struct hlist_head inode_hashtable 10

# 특정 버킷의 체인 순회
crash> list hlist_node -H 0xffff...addr -s my_object.key,data

# DEFINE_HASHTABLE로 선언된 전역 해시 테이블
crash> p my_ht
crash> struct hlist_head my_ht 256

drgn으로 해시 테이블 분석

# 실습 예제: drgn 스크립트로 해시 테이블 통계 수집
import drgn
from drgn.helpers.linux.list import hlist_for_each_entry

prog = drgn.program_from_kernel()

# 각 버킷의 체인 길이 측정
ht = prog["my_ht"]
for i in range(len(ht)):
    count = 0
    for entry in hlist_for_each_entry("struct my_object",
                                       ht[i], "hash_node"):
        count += 1
    if count > 0:
        print(f"bucket[{i}]: {count} entries")

일반적 실수와 해결

실수증상해결
hash_for_each_possible에서 키 비교 누락 잘못된 객체 반환 (해시 충돌 시) 반드시 if (obj->key == search_key) 확인
hash_del 없이 kfree dangling pointer → 커널 패닉 hash_del()kfree() 순서 준수
RCU 해시에서 rcu_read_lock 누락 use-after-free, KASAN 경고 읽기 경로에 rcu_read_lock() 감싸기
순회 중 hash_del (safe 미사용) 무한 루프 또는 스킵 hash_for_each_safe / hash_for_each_possible_safe 사용
버킷 수가 너무 적음 긴 체인 → O(n) 검색 로드 팩터 확인, bits 값 증가 또는 rhashtable 전환
hash_add_rcu 시 쓰기 lock 미보유 동시 삽입 → 체인 손상 spinlock/mutex로 쓰기 측 직렬화
rhashtable에서 rhashtable_destroy 전 요소 미제거 메모리 누수 + WARN_ON rhashtable_free_and_destroy() 사용
rhashtable_remove_fast 후 즉시 kfree RCU 읽기 측 use-after-free kfree_rcu() 또는 synchronize_rcu()kfree()
rhashtable_walk_next-EAGAIN 미처리 순회 중 요소 누락 IS_ERR(entry) 검사 후 continue로 재시도
rhashtable_lookup_fastrcu_read_lock 밖에서 호출 KASAN/KCSAN 경고, 불안정한 결과 반드시 rcu_read_lock()/rcu_read_unlock() 내에서 호출
rhashtable_params에서 head_offset/key_offset 잘못 지정 해시 불일치, 검색 실패, 커널 패닉 offsetof() 매크로로 정확히 계산

운영 장애 트러블슈팅 플레이북

운영 중 해시 테이블 병목이 의심될 때는 아래 순서로 점검하면 원인 분리 속도가 빨라집니다.

  1. 현상 분류
    증상이 “lookup 지연 증가”인지, “삽입 실패(-ENOMEM/-EAGAIN)”인지 먼저 구분합니다.
  2. 분포 확인
    버킷별 체인 길이(평균/최대/분산)를 수집해 편향 여부를 확인합니다.
  3. 락 경합 확인
    perf lock, bpftrace로 특정 버킷/경로 lock hot spot을 찾습니다.
  4. RCU 수명 확인
    삭제 후 즉시 해제, unlock 이후 포인터 사용 같은 수명 위반 패턴을 점검합니다.
  5. 튜닝/수정 적용
    해시 함수 개선, bits/size 조정, rhashtable 파라미터 조정 후 동일 시나리오 재측정합니다.
ℹ️

권장 기록 항목: 피크 시점의 nelems, 버킷 수, 최대 체인 길이, 리사이즈 횟수, lock contention 샘플을 함께 저장하면 회귀 분석이 쉬워집니다.

⚠️

CONFIG_DEBUG_OBJECTS를 활성화하면 hlist_node의 이중 해제, 초기화 누락 등을 탐지할 수 있습니다. CONFIG_PROVE_RCU는 RCU 해시 테이블의 잘못된 사용을 경고합니다.

해시 테이블 모니터링

운영 중인 커널의 해시 테이블 성능을 실시간으로 측정하는 방법입니다. 체인 길이 불균형, 과도한 리사이즈, 캐시 미스 등의 문제를 조기에 발견할 수 있습니다.

conntrack 해시 통계

# conntrack 해시 테이블 크기와 사용량
cat /proc/sys/net/netfilter/nf_conntrack_buckets    # 버킷 수
cat /proc/sys/net/netfilter/nf_conntrack_count      # 현재 연결 수
cat /proc/sys/net/netfilter/nf_conntrack_max        # 최대 연결 수

# 로드 팩터 계산
echo "scale=2; $(cat /proc/sys/net/netfilter/nf_conntrack_count) / \
  $(cat /proc/sys/net/netfilter/nf_conntrack_buckets)" | bc

# conntrack 상세 통계
cat /proc/net/stat/nf_conntrack | head -2

# 동적 버킷 수 변경 (런타임)
echo 65536 > /proc/sys/net/netfilter/nf_conntrack_buckets

bpftrace로 해시 성능 측정

# rhashtable 삽입/삭제 빈도 추적
bpftrace -e '
kprobe:rhashtable_insert_fast { @inserts = count(); }
kprobe:rhashtable_remove_fast { @removes = count(); }
interval:s:5 { print(@inserts); print(@removes);
               clear(@inserts); clear(@removes); }
'

# rhashtable 리사이즈 이벤트 감지
bpftrace -e '
kprobe:rhashtable_rehash_one {
    @rehash_count = count();
    @rehash_cpu = hist(cpu);
}
kprobe:bucket_table_alloc {
    printf("resize: new table alloc at %lld\n", nsecs);
}'

# 해시 함수 호출 빈도 (jhash vs hash_long)
bpftrace -e '
kprobe:jhash { @jhash = count(); }
interval:s:10 { print(@jhash); clear(@jhash); }
'

perf로 캐시 미스 분석

# 해시 테이블 순회 관련 LLC 미스 (conntrack 경로)
perf stat -e cache-misses,cache-references,L1-dcache-load-misses \
    -p $(pgrep ksoftirqd/0) -- sleep 10

# 특정 함수의 캐시 미스 프로파일
perf record -e cache-misses -g -p $(pgrep ksoftirqd/0) -- sleep 5
perf report --symbol-filter=nf_conntrack

# lock 경합 프로파일
perf lock record -- sleep 5
perf lock report

디버깅 관련 커널 설정

CONFIG 옵션용도오버헤드
CONFIG_DEBUG_OBJECTShlist_node 이중 해제/초기화 누락 탐지중간
CONFIG_PROVE_RCURCU 해시 접근 시 lock 규칙 위반 경고낮음
CONFIG_PROVE_LOCKINGlockdep: 잠금 순서 위반 탐지중간
CONFIG_KASANuse-after-free, out-of-bounds 접근 탐지높음
CONFIG_KCSANdata race 탐지 (RCU 읽기 측 포함)높음
CONFIG_NF_CONNTRACK_EVENTSconntrack 이벤트 알림 (해시 모니터링)낮음

해시 테이블 운영 체크리스트

커널 해시는 성능만큼 충돌 관리와 동시성 설계가 중요합니다. 버킷 수, 해시 함수, 락 모델을 함께 설계해야 예측 가능한 성능이 나옵니다.

설계 항목질문권장 기준
버킷 크기평균 체인 길이가 긴가?로드팩터 1~4 범위 유지
동시성 모델reader 비중이 높은가?RCU + bucket lock 조합 고려
키 분포특정 키 편향이 있는가?해시 함수/시드 재검토
리사이즈트래픽 급증 시 확장 가능한가?rhashtable 자동 리해시 활용
# 충돌/리사이즈 경로 추적
git grep -n "hash_add\\|hash_for_each\\|rhashtable" -- "*.c"
perf stat -e cache-misses,cache-references -- sleep 5

jhash/xxhash 비교 분석

리눅스 커널은 용도별로 다양한 해시 함수를 제공합니다. 이 섹션에서는 jhash(Jenkins hash), xxhash, siphash의 내부 동작 원리를 비교하고, 각 함수가 어떤 상황에서 최적인지 분석합니다.

해시 함수 내부 비교 jhash(3-word mixing), xxhash(stripe 병렬 처리), siphash(SipRound 보안 해싱)의 내부 처리 흐름을 3열로 비교합니다. jhash (Jenkins) xxhash64 siphash 입력: key[], length, initval a=b=c=initval+length 3-word mixing loop a-=c; a^=rot(c,4); c+=b; final mixing (14단계) 결과: u32 해시값 (c) 빠르고 분산 양호 보안 목적 부적합 입력: data[], len, seed 4 accumulators 초기화 32-byte stripe 병렬 acc += data * PRIME2; rot(acc,31) merge + avalanche 결과: u64 해시값 대용량 데이터 최고 처리량 btrfs/zstd 체크섬 활용 입력: data[], len, secret key v0..v3 = key XOR const SipRound × 2 per block v0+=v1; v1=rot(v1,13); v1^=v0 finalization: SipRound × 4 결과: u64 해시값 비밀 키 기반 — HashDoS 방어 네트워크 해시 필수 속도: xxhash > jhash > siphash | 보안: siphash >> jhash ≈ xxhash | 범용 커널 해시: jhash가 가장 보편적

jhash 구현 분석

jhash(Jenkins hash)는 Bob Jenkins가 설계한 해시 함수로, 커널에서 <linux/jhash.h>로 제공됩니다. 가변 길이 바이트 배열과 고정 크기 word 배열 모두를 처리할 수 있습니다.

/* include/linux/jhash.h — 핵심 mixing 매크로 */
#define __jhash_mix(a, b, c) \
({ \
    a -= c;  a ^= rol32(c,  4);  c += b; \
    b -= a;  b ^= rol32(a,  6);  a += c; \
    c -= b;  c ^= rol32(b,  8);  b += a; \
    a -= c;  a ^= rol32(c, 16);  c += b; \
    b -= a;  b ^= rol32(a, 19);  a += c; \
    c -= b;  c ^= rol32(b,  4);  b += a; \
})

/* jhash2() — u32 배열을 해싱 (conntrack 5-tuple 등) */
static inline u32 jhash2(const u32 *k, u32 length, u32 initval)
{
    u32 a, b, c;
    a = b = c = JHASH_INITVAL + (length << 2) + initval;

    /* 3 words씩 처리 — mixing loop */
    while (length > 3) {
        a += k[0]; b += k[1]; c += k[2];
        __jhash_mix(a, b, c);
        length -= 3; k += 3;
    }
    /* 나머지 처리 + final mixing */
    switch (length) {
    case 3: c += k[2];  /* fallthrough */
    case 2: b += k[1];  /* fallthrough */
    case 1: a += k[0];
        __jhash_final(a, b, c);
    case 0: break;
    }
    return c;
}
jhash 핵심 특성:
  • 3개의 u32 변수(a, b, c)를 혼합 — 입력 비트가 모든 출력 비트에 영향
  • initval 시드로 서로 다른 해시 계열을 생성 (복수 해시 테이블 공존)
  • conntrack의 nf_conntrack_hash()는 5-tuple을 jhash2()로 해싱

xxhash 구현 분석

xxhash는 Yann Collet이 설계한 고속 해시 함수로, 커널 4.14에서 lib/xxhash.c로 도입되었습니다. 대용량 데이터 처리에서 jhash보다 월등한 처리량을 제공합니다.

/* lib/xxhash.c — 핵심 accumulator 처리 */
static u64 xxh64_round(u64 acc, u64 input)
{
    acc += input * XXH_PRIME64_2;
    acc  = rol64(acc, 31);
    acc *= XXH_PRIME64_1;
    return acc;
}

/* 32바이트 stripe를 4개 accumulator로 병렬 처리 */
while (p + 32 <= b_end) {
    v1 = xxh64_round(v1, get_unaligned_le64(p +  0));
    v2 = xxh64_round(v2, get_unaligned_le64(p +  8));
    v3 = xxh64_round(v3, get_unaligned_le64(p + 16));
    v4 = xxh64_round(v4, get_unaligned_le64(p + 24));
    p  += 32;
}

/* 커널 사용 사례: btrfs 체크섬 */
/* fs/btrfs/hash.c */
u64 btrfs_name_hash(const char *name, int len)
{
    return xxh64(name, len, 0);
}

해시 함수 종합 비교

항목jhashxxhash64siphashhash_long
출력 크기32-bit64-bit64-bitBITS_PER_LONG
입력 타입가변 바이트/word 배열가변 바이트가변 바이트/u64정수 (unsigned long)
처리량 (큰 입력)~3 GB/s~10 GB/s~1.5 GB/s단일 곱셈
짧은 키 성능양호초기화 오버헤드양호최고
보안성없음없음강함 (비밀 키)없음
분산 품질양호우수우수양호 (정수)
커널 헤더linux/jhash.hlinux/xxhash.hlinux/siphash.hlinux/hash.h
대표 사용처conntrack, 네트워크btrfs, zstd 압축소켓 해시, 난수inode/PID 캐시
함수 선택 원칙: 정수 키 → hash_long(), 구조체/네트워크 튜플 → jhash2(), 대용량 데이터 → xxhash64(), 외부 입력(보안) → siphash(). 보안이 필요한 네트워크 해시에서 jhash를 사용하면 HashDoS 공격에 취약합니다.

rhashtable 리사이즈 메커니즘

rhashtable은 로드 팩터에 따라 자동으로 확장(grow)과 축소(shrink)를 수행합니다. 리사이즈 과정에서 두 개의 테이블이 동시에 존재하며, RCU를 활용해 읽기 측은 락 없이 접근할 수 있습니다. 이 섹션에서는 리사이즈의 내부 메커니즘을 단계별로 분석합니다.

rhashtable 리사이즈 메커니즘 구테이블(4 buckets)에서 신테이블(8 buckets)로 요소가 마이그레이션되는 과정. 두 테이블이 동시에 존재하며 RCU 읽기가 양쪽 모두에서 가능한 구조를 보여줍니다. rhashtable Grow: 4 buckets → 8 buckets 구 테이블 (old_tbl) 신 테이블 (new_tbl) bucket[0] → future_tbl ──→ bucket[1] bucket[2] bucket[3] A E B C F D bucket[0] bucket[1] bucket[2] bucket[3] bucket[4] bucket[5] bucket[6] bucket[7] A C E F 리사이즈 3단계 동작 ① 할당: 새 bucket_table을 kvmalloc()으로 할당 (2배 크기) ② 연결: old_tbl→future_tbl 포인터로 신테이블 연결 ③ 마이그레이션: 워커 스레드가 버킷 단위로 요소 이동 (bucket_lock 보호) ④ 전환: 모든 버킷 이동 완료 후 rcu_assign_pointer(ht→tbl, new_tbl) ⑤ 해제: RCU grace period 후 구테이블 kfree_rcu() 핵심: 읽기 측은 리사이즈 중에도 RCU로 old/new 양쪽 테이블에서 검색 가능

rhashtable_init 파라미터

/* lib/rhashtable.c — 초기화 핵심 파라미터 */
struct rhashtable_params params = {
    .nelem_hint         = 1024,      /* 예상 초기 요소 수 → 버킷 수 결정 */
    .key_len            = 16,        /* 고정 키 크기 (바이트) */
    .key_offset         = offsetof(struct my_obj, key),
    .head_offset        = offsetof(struct my_obj, node),
    .max_size           = 1 << 20,   /* 최대 버킷 수 (1M) */
    .min_size           = 64,        /* 최소 버킷 수 */
    .automatic_shrinking = true,     /* 로드 팩터 30% 미만 시 자동 축소 */
    .hashfn             = jhash,
    .obj_hashfn         = my_obj_hash,
};

int ret = rhashtable_init(&my_ht, &params);
if (ret)
    return ret;

리사이즈 트리거 조건

트리거조건동작
자동 확장 (grow)삽입 시 nelems / size > 1 (로드팩터 > 100%)워커 큐에 확장 작업 예약
자동 축소 (shrink)nelems / size < 0.3 (로드팩터 < 30%)워커 큐에 축소 작업 예약 (automatic_shrinking 필요)
체인 길이 초과한 버킷의 체인 길이 > RHT_ELASTICITY(16)즉시 확장 트리거, 삽입 실패(-EBUSY) 가능
수동 호출rhashtable_expand() / rhashtable_shrink()직접 리사이즈 수행

리사이즈 중 동시 접근

/* 리사이즈 중 룩업 — 두 테이블 모두 검색 */
/* lib/rhashtable.c: rhashtable_lookup_fast() 내부 */
rcu_read_lock();
tbl = rht_dereference_rcu(ht->tbl, ht);
restart:
hash = rht_key_hashfn(ht, tbl, key, params);
head = rht_bucket(tbl, hash);

rht_for_each_rcu(he, head) {
    if (params.obj_cmpfn ?
        params.obj_cmpfn(&arg, rht_obj(ht, he)) == 0 :
        rhashtable_compare(&arg, rht_obj(ht, he)) == 0)
        return rht_obj(ht, he);
}

/* 구 테이블에서 못 찾으면 → 신 테이블에서 재시도 */
new_tbl = rht_dereference_rcu(tbl->future_tbl, ht);
if (unlikely(new_tbl)) {
    tbl = new_tbl;
    goto restart;  /* 신 테이블에서 다시 검색 */
}
rcu_read_unlock();
주의: 리사이즈 워커는 system_unbound_wq에서 실행됩니다. rhashtable_insert_fast()-EBUSY를 반환하면 리사이즈가 진행 중이므로, 잠시 대기 후 재시도하거나 rhashtable_insert_slow()를 사용해야 합니다.

ftrace/perf 디버깅 실습

해시 테이블의 성능 문제는 충돌 편중, 리사이즈 빈도, 락 경합으로 나타납니다. 커널의 ftrace, bpftrace, perf 도구로 이러한 문제를 실시간 진단할 수 있습니다.

해시 테이블 디버깅 도구 흐름 ftrace, bpftrace, perf 세 도구가 해시 테이블의 서로 다른 계층(함수 호출, 버킷 분포, 캐시 성능)을 진단하는 흐름을 보여줍니다. 해시 테이블 디버깅 도구 매핑 ftrace 함수 호출 추적 bpftrace 분포 분석/히스토그램 perf 지연/캐시 측정 rhashtable_insert rhashtable_lookup rhashtable_rehash bucket chain 길이 해시값 분포 리사이즈 횟수 cache-misses lookup 지연시간 lock contention 호출 빈도/스택 리사이즈 경로 추적 편향 버킷 식별 최적 해시 함수 선택 병목 지점 특정 NUMA 영향 분석 조합 예: ftrace로 rehash 트리거 확인 → bpftrace로 분포 분석 → perf로 캐시 영향 측정

ftrace로 해시 연산 추적

# rhashtable 리사이즈 추적
echo 'rhashtable_rehash_one' > /sys/kernel/tracing/set_ftrace_filter
echo function > /sys/kernel/tracing/current_tracer
echo 1 > /sys/kernel/tracing/tracing_on

# 10초 추적 후 결과 확인
sleep 10
echo 0 > /sys/kernel/tracing/tracing_on
cat /sys/kernel/tracing/trace

# function_graph로 리사이즈 호출 흐름 확인
echo 'rhashtable_rehash_table' > /sys/kernel/tracing/set_graph_function
echo function_graph > /sys/kernel/tracing/current_tracer

bpftrace로 해시 분포 분석

# conntrack 해시값 분포 히스토그램
bpftrace -e '
kprobe:nf_conntrack_hash {
    @dist = lhist(arg1 % 1024, 0, 1024, 32);
}
interval:s:10 { exit(); }
'

# rhashtable 체인 길이 측정 (kretprobe 활용)
bpftrace -e '
kprobe:rhashtable_lookup_fast {
    @start[tid] = nsecs;
}
kretprobe:rhashtable_lookup_fast /@start[tid]/ {
    @lookup_ns = hist(nsecs - @start[tid]);
    delete(@start[tid]);
}
interval:s:30 { exit(); }
'

perf로 해시 룩업 지연 측정

# 해시 룩업 관련 캐시 미스 측정
perf stat -e cache-misses,cache-references,instructions \
    -a -G "rhashtable" -- sleep 5

# 해시 테이블 룩업 함수에 동적 프로브 설정
perf probe --add 'rhashtable_lookup_fast'
perf record -e probe:rhashtable_lookup_fast -a -- sleep 10
perf report --sort=comm,dso

# lock contention 분석
perf lock record -a -- sleep 5
perf lock report
실전 팁: 해시 테이블 성능 문제는 대부분 편향된 버킷 분포리사이즈 빈도에서 발생합니다. bpftrace의 히스토그램으로 분포를 먼저 확인하고, 편향이 심하면 해시 함수를 교체하거나 시드를 변경하세요.

성능 벤치마크 비교

자료구조 선택은 워크로드 특성에 따라 성능이 크게 달라집니다. 이 섹션에서는 hashtable, rhashtable, XArray, rbtree의 데이터 크기별 성능 특성과 캐시/NUMA 환경에서의 차이를 분석합니다.

자료구조 성능 특성 비교 요소 수 증가에 따른 hashtable(고정), rhashtable(동적), XArray, rbtree의 룩업 성능 변화를 개념적 그래프로 비교합니다. 데이터 크기별 룩업 성능 특성 (개념적 비교) 요소 수 (N) 룩업 시간 (ns) 100 1K 10K 100K 1M hashtable (고정) rhashtable XArray rbtree O(log N) 해시 기반: N 증가에도 거의 일정 | rbtree: log₂(1M) ≈ 20단계 트리 탐색

자료구조별 성능 특성 비교

특성hashtable (고정)rhashtableXArrayrbtree
룩업 복잡도O(1) 평균O(1) 평균O(log N) 래딕스O(log N)
삽입 복잡도O(1)O(1) 분할상환O(log N)O(log N)
메모리 효율빈 버킷 낭비 가능동적 조정희소 데이터 효율적노드당 3포인터
캐시 친화성좋음 (배열 기반)좋음 + 리사이즈 비용매우 좋음 (노드 병합)나쁨 (포인터 체이싱)
범위 쿼리불가불가가능 (xa_for_each)가능 (in-order)
정렬 순서없음없음키 순서키 순서
NUMA 영향버킷 배열 배치 의존리사이즈 시 원격 할당 위험노드 분산 양호포인터 체이싱으로 원격 접근 빈번

캐시 동작 분석

해시 테이블의 실제 성능은 알고리즘 복잡도보다 캐시 미스 비율에 더 크게 좌우됩니다.

연산캐시라인 접근 수설명
버킷 접근1hlist_head 배열에서 해시 인덱스로 직접 접근
체인 순회 (노드당)1~2노드 구조체 + 키 비교용 데이터 접근
rhashtable 리사이즈 중+1future_tbl 포인터 확인 추가 비용
RCU 보호 접근+0읽기 측 오버헤드 없음 (배리어만 추가)
NUMA 최적화 지침:
  • 해시 테이블 버킷 배열은 kvmalloc_node()로 로컬 노드에 할당
  • Per-CPU 해시는 NUMA 원격 접근을 원천 차단하므로 최선의 선택
  • rhashtable 리사이즈 시 새 테이블이 다른 NUMA 노드에 할당되면 성능 저하 발생
  • 대규모 시스템에서는 alloc_pages_node()로 NUMA 인접 할당을 강제

해시 테이블 보안 고려사항

해시 테이블은 외부 입력으로 키가 결정되는 경우 HashDoS(Hash collision Denial of Service) 공격에 취약합니다. 공격자가 동일 버킷에 매핑되는 키를 의도적으로 생성하면 O(1) 성능이 O(n)으로 퇴화하여 서비스 거부가 발생합니다. 이 섹션에서는 커널의 보안 대응 전략과 siphash 도입 과정을 분석합니다.

HashDoS 공격과 siphash 방어 jhash 기반 해시에서 공격자가 동일 버킷으로 매핑되는 키를 생성하는 HashDoS 공격과, siphash의 비밀 키 기반 방어 구조를 비교합니다. HashDoS 공격 vs siphash 방어 취약: jhash (공개 알고리즘) 공격자 패킷 jhash(key) bucket[3] k1 k2 k3 ... kN O(n) 퇴화 → 서비스 거부! 방어: siphash (비밀 키) 공격자 패킷 siphash(key, secret) [0] [3] [7] k1 k2 k3 균등 분산 유지 → 공격 무력화 siphash 방어 원리 ① 부팅 시 128-bit 비밀 키 생성 (net_secret, get_random_bytes) ② 해시 함수 입력에 비밀 키를 혼합 → 공격자가 해시 충돌 패턴 예측 불가 ③ SipRound 암호학적 혼합 → 키 없이는 출력 역추산 불가능 ④ 커널 4.1+: 소켓 해시, conntrack, 라우팅 캐시 등에 siphash 적용 완료 핵심: 공개 알고리즘(jhash)은 분산이 좋아도, 공격자가 알고리즘을 알면 충돌 키를 생성할 수 있음

siphash 도입 과정

커널은 2017년(v4.13 전후)부터 네트워크 스택의 해시 함수를 jhash에서 siphash로 점진적으로 교체했습니다. 이는 실제 HashDoS 공격 사례에 대한 대응이었습니다.

/* net/core/utils.c — 부팅 시 비밀 시드 생성 */
static siphash_key_t net_secret __read_mostly;
static __always_inline void net_secret_init(void)
{
    net_get_random_once(&net_secret, sizeof(net_secret));
}

/* net/ipv4/inet_hashtables.c — 소켓 해시에서 siphash 사용 */
static u32 inet_ehashfn(const struct net *net,
    const __be32 laddr, const __u16 lport,
    const __be32 faddr, const __be16 fport)
{
    /* siphash로 4-tuple 해싱 — 비밀 키 포함 */
    static siphash_key_t inet_ehash_secret;
    net_get_random_once(&inet_ehash_secret,
                        sizeof(inet_ehash_secret));
    return __inet_ehashfn(laddr, lport, faddr, fport,
                          &inet_ehash_secret);
}

Per-Boot Random Seed

/* include/linux/net.h — net_hash_mix(): NUMA/네임스페이스 혼합 */
static inline u32 net_hash_mix(const struct net *net)
{
    /* 네트워크 네임스페이스별로 서로 다른 해시 결과 */
    return net_hash_31(net_hash(net));
}

/* 사용 예: conntrack 해시에 네임스페이스 시드 혼합 */
static u32 hash_conntrack_raw(
    const struct nf_conntrack_tuple *tuple,
    const struct net *net)
{
    return jhash2((const u32 *)tuple,
        sizeof(*tuple) / sizeof(u32),
        nf_conntrack_hash_rnd ^ net_hash_mix(net));
        /* nf_conntrack_hash_rnd: 부팅 시 랜덤 생성 */
}

보안 체크리스트

점검 항목안전취약대응
해시 함수siphash + 비밀 키jhash (공개 시드)네트워크 해시를 siphash로 교체
시드 관리부팅 시 랜덤 생성고정값/컴파일 상수get_random_bytes() 사용
네임스페이스net_hash_mix() 적용전역 단일 시드네임스페이스별 시드 혼합
체인 길이 제한rhashtable RHT_ELASTICITY무제한 체이닝삽입 시 체인 길이 검사
리사이즈 정책자동 확장 (로드팩터 기반)고정 크기 + 과부하rhashtable 사용 또는 수동 모니터링
보안 원칙: 외부 네트워크 입력이 해시 키에 영향을 주는 모든 경로에서는 반드시 siphash를 사용하세요. jhash는 내부 전용(프로세스 관리, 파일시스템 캐시 등 공격자가 키를 제어할 수 없는 경우)에만 안전합니다. 커널 소스에서 jhash를 사용하는 네트워크 코드를 발견하면 보안 리뷰 대상입니다.

Hash Table과 관련된 다른 주제를 더 깊이 이해하고 싶다면 다음 문서를 참고하세요.

학습 목적에 따라 시작점을 다르게 잡으면 이해 속도가 빨라집니다.

  1. 자료구조 기초 보강
    Linked Listhlist 섹션hashtable API
  2. 동시성/RCU 중심
    RCU동기화 기법RCU 해시 섹션rhashtable
  3. 네트워킹 실전 경로
    네트워크 스택conntrack 헬퍼conntrack 사례
  4. 대안 자료구조 비교
    Red-Black TreeXArray선택 가이드