Per-CPU 변수
Linux 커널의 Per-CPU 변수는 각 CPU가 독립적인 데이터 복사본을 가지도록 하여 캐시라인 바운싱과 false sharing을 줄이고 lock contention을 제거하는 고성능 기법입니다. 이 문서는 DEFINE_PER_CPU, this_cpu_* fast path, alloc_percpu 기반 동적 할당, 전역 집계 시점의 동기화 전략, 네트워크/메모리 서브시스템 사례와 NUMA 환경 튜닝 포인트까지 상세히 설명합니다.
핵심 요약
- 독립 복사본 — 각 CPU가 자신만의 변수 복사본을 가집니다.
- Lock-free — 동일 CPU 내에서는 동기화가 필요 없습니다.
- 캐시 효율 — False sharing이 없어 캐시 성능이 극대화됩니다.
- 빠른 접근 — this_cpu_* 연산은 원자적이면서도 매우 빠릅니다.
- 통계 집계 — 네트워크 패킷 수, 메모리 할당 통계 등에 최적입니다.
단계별 이해
- 핵심 요소 확인
이 문서에서 다루는 자료구조/API를 먼저 정리합니다. - 처리 흐름 추적
요청 시작부터 완료까지 실행 경로를 순서대로 확인합니다. - 문제 지점 점검
실패 경로, 경합 구간, 성능 병목을 체크합니다.
개요 (Overview)
Per-CPU 변수는 SMP(Symmetric Multi-Processing) 시스템에서 성능을 극대화하는 핵심 기법입니다:
- 캐시 친화성 — 각 CPU가 자신의 캐시 라인에만 접근하므로 캐시 미스가 최소화됩니다.
- False sharing 회피 — 다른 CPU가 같은 캐시 라인을 수정하는 일이 없습니다.
- Lock contention 제거 — spinlock 경합이 없어 대기 시간이 사라집니다.
- 확장성 — CPU 수가 증가해도 성능이 선형적으로 증가합니다.
setup_per_cpu_areas()가 .data..percpu를 각 CPU별로 복사하며, __per_cpu_offset[]에 오프셋을 저장합니다.실사용 빈도: 커널 통계 카운터의 90% 이상이 per-CPU 변수를 사용합니다. 네트워크 패킷 카운트, 메모리 할당 통계, 스케줄러 런큐, SLAB 할당자 등 모든 고성능 서브시스템에서 필수적입니다.
내부 구조 (Internals)
부팅 과정에서 커널은 각 CPU마다 .data..percpu 섹션의 복사본을 만들고, 각 복사본의 시작 주소를 __per_cpu_offset 배열에 저장합니다:
/* arch/x86/kernel/setup_percpu.c (개념 요약) */
/* 부팅 시 각 CPU에 .data..percpu 섹션 복사 */
void __init setup_per_cpu_areas(void)
{
int cpu;
for_each_possible_cpu(cpu) {
/* NUMA 로컬 노드에 섹션 복사본 할당 */
void *ptr = alloc_percpu_area(cpu);
memcpy(ptr, __per_cpu_load, __per_cpu_size);
__per_cpu_offset[cpu] = ptr - ((void *)&__per_cpu_start);
}
}
/* this_cpu_ptr() 동작 원리 */
#define this_cpu_ptr(ptr) \
((typeof(ptr))(((void *)(ptr)) + __per_cpu_offset[smp_processor_id()]))
/* 예: CPU2의 my_counter 주소 */
/* = &my_counter + __per_cpu_offset[2] */
__per_cpu_offset[NR_CPUS] 배열이 각 CPU의 per-CPU 영역 오프셋을 저장하며, this_cpu_ptr()은 단순 포인터 덧셈으로 현재 CPU의 변수 주소를 계산합니다.
기본 API (Basic API)
DEFINE_PER_CPU 매크로
/* include/linux/percpu-defs.h */
#define DEFINE_PER_CPU(type, name) \
__PCPU_ATTRS(sec) __typeof__(type) name
/* 사용 예: 정적 per-CPU 변수 선언 */
DEFINE_PER_CPU(unsigned long, my_counter);
/* 배열도 가능 */
DEFINE_PER_CPU(int[10], my_array);
/* 구조체도 가능 */
struct stats {
unsigned long count;
unsigned long bytes;
};
DEFINE_PER_CPU(struct stats, net_stats);
DEFINE_PER_CPU 변체
| 매크로 | 용도 | 특성 |
|---|---|---|
DEFINE_PER_CPU(type, name) | 일반 per-CPU 변수 | 기본 정렬, 읽기/쓰기 모두 빠름 |
DEFINE_PER_CPU_READ_MOSTLY(type, name) | 읽기 전용에 가까운 변수 | 읽기 캐시라인 최적화, 쓰기는 드묾 |
DEFINE_PER_CPU_SHARED_ALIGNED(type, name) | False sharing 명시적 방지 | 캐시라인 경계 정렬 강제 |
DEFINE_PER_CPU_ALIGNED(type, name) | 정렬 보장 필요 시 | SMP_CACHE_BYTES 단위 정렬 |
DEFINE_PER_CPU_PAGE_ALIGNED(type, name) | 페이지 정렬 필요 시 | PAGE_SIZE 단위 정렬 |
/* READ_MOSTLY: 부팅 시 설정되고 이후 읽기만 하는 값 */
DEFINE_PER_CPU_READ_MOSTLY(struct cpuinfo_x86, cpu_info);
/* SHARED_ALIGNED: 같은 캐시라인에 들어갈 수 있는 변수들을 분리 */
/* 예: 두 hot counter가 같은 캐시라인이면 False Sharing 발생 */
DEFINE_PER_CPU_SHARED_ALIGNED(struct net_device_stats, dev_stats);
/* → 각 CPU의 dev_stats는 서로 다른 캐시라인에 배치됨 */
접근 API (Access API)
/* 현재 CPU의 변수 접근 (preemption 비활성화 필요) */
unsigned long *ptr = get_cpu_var(my_counter);
(*ptr)++;
put_cpu_var(my_counter); /* preemption 재활성화 */
/* 특정 CPU의 변수 접근 */
unsigned long *cpu5_counter = per_cpu_ptr(&my_counter, 5);
/* 원자적 연산 (가장 빠름!) */
this_cpu_inc(my_counter); /* counter++ */
this_cpu_add(my_counter, 10); /* counter += 10 */
this_cpu_read(my_counter); /* 값 읽기 */
this_cpu_write(my_counter, 0); /* 값 쓰기 */
this_cpu_* 연산 (this_cpu_* Operations)
| 함수 | 기능 | 특징 |
|---|---|---|
this_cpu_read(var) |
값 읽기 | 원자적, preemption-safe |
this_cpu_write(var, val) |
값 쓰기 | 원자적 |
this_cpu_add(var, val) |
덧셈 | 원자적, lock-free |
this_cpu_inc(var) |
1 증가 | this_cpu_add(var, 1) |
this_cpu_dec(var) |
1 감소 | this_cpu_add(var, -1) |
this_cpu_and(var, val) |
비트 AND | 원자적 |
this_cpu_or(var, val) |
비트 OR | 원자적 |
this_cpu_xchg(var, val) |
교환 | 이전 값 반환 |
this_cpu_cmpxchg(var, old, new) |
조건부 교환 | CAS 연산 |
성능: this_cpu_* 연산은 x86에서 단일 명령어로 컴파일됩니다. 예를 들어 this_cpu_inc(counter)는 incl %gs:offset 하나로 끝나며, 이는 lock prefix가 붙은 lock incl보다 10배 이상 빠릅니다.
raw_cpu_* vs this_cpu_*
this_cpu_*와 raw_cpu_*는 모두 per-CPU 변수를 조작하지만, 사용 컨텍스트가 다릅니다:
- this_cpu_* — 컴파일러·아키텍처 수준에서 preemption-safe를 보장합니다. 내부적으로
preempt_disable()/preempt_enable()을 호출하지 않고, x86%gs세그먼트 레지스터 등 CPU-local 어드레싱을 이용해 현재 CPU 고정을 보장합니다. 일반 태스크 컨텍스트에서 사용합니다. - raw_cpu_* — preemption이 이미 비활성화된 컨텍스트(인터럽트 핸들러, NMI, 소프트IRQ 등)에서만 사용합니다. preemption 비활성화를 전제하므로 추가 보호 없이 직접 접근합니다.
| 함수 | 사용 컨텍스트 | Preemption 상태 | 성능 |
|---|---|---|---|
this_cpu_inc(var) |
일반 태스크, 소프트IRQ | 자동 보장 (아키텍처 레벨) | 빠름 |
raw_cpu_inc(var) |
IRQ 핸들러, NMI, preempt off 구간 | 이미 비활성화 전제 | 약간 더 빠름 |
/* 인터럽트 핸들러 — preemption 이미 비활성화 */
irqreturn_t my_irq_handler(int irq, void *dev)
{
raw_cpu_inc(irq_counter); /* IRQ 컨텍스트: raw_cpu_* 사용 */
return IRQ_HANDLED;
}
/* 일반 태스크 컨텍스트 */
void handle_packet(void)
{
this_cpu_inc(pkt_counter); /* 태스크 컨텍스트: this_cpu_* 사용 */
}
동적 할당 (Dynamic Allocation)
/* include/linux/percpu.h */
/* per-CPU 메모리 할당 */
void __percpu *alloc_percpu(type);
/* 정렬 지정 할당 */
void __percpu *__alloc_percpu(size_t size, size_t align);
/* 해제 */
void free_percpu(void __percpu *ptr);
/* 사용 예 */
struct my_data {
unsigned long count;
unsigned long bytes;
};
struct my_data __percpu *stats;
stats = alloc_percpu(struct my_data);
if (!stats)
return -ENOMEM;
/* 현재 CPU의 데이터 접근 */
struct my_data *ptr = this_cpu_ptr(stats);
ptr->count++;
/* 정리 */
free_percpu(stats);
NUMA 인식 할당
NUMA 시스템에서 per-CPU 변수는 각 CPU가 속한 로컬 NUMA 노드에 메모리를 배치하여 cross-node 접근 비용을 최소화합니다. 일반 alloc_percpu()는 기본 GFP 플래그를 사용하지만, GFP 플래그를 명시하면 메모리 부족 동작을 제어할 수 있습니다:
/* include/linux/percpu.h */
/* GFP 플래그 지정 저수준 할당 */
void __percpu *__alloc_percpu_gfp(size_t size, size_t align, gfp_t gfp);
/* 타입 기반 GFP 할당 (권장) */
#define alloc_percpu_gfp(type, gfp) \
(typeof(type) __percpu *)__alloc_percpu_gfp(sizeof(type), \
__alignof__(type), gfp)
/* NUMA 친화적 할당: GFP_KERNEL로 로컬 노드 우선 */
struct my_data __percpu *stats;
stats = alloc_percpu_gfp(struct my_data, GFP_KERNEL);
if (!stats)
return -ENOMEM;
/* 각 CPU의 per-CPU 데이터는 해당 CPU의 NUMA 노드에 배치됨 */
NUMA 팁: NUMA 시스템에서 cross-node 메모리 접근은 로컬 접근보다 2~4배 느립니다. alloc_percpu_gfp(type, GFP_KERNEL)을 사용하면 커널이 각 CPU의 로컬 NUMA 노드에 메모리를 배치하여 이 비용을 회피합니다.
실사용 사례 (Real-World Usage)
네트워크 통계
/* net/core/dev.c */
struct pcpu_lstats {
u64 packets;
u64 bytes;
struct u64_stats_sync syncp;
};
struct net_device {
struct pcpu_lstats __percpu *lstats;
/* ... */
};
/* 패킷 수신 시 */
void update_rx_stats(struct net_device *dev, int len)
{
struct pcpu_lstats *stats = this_cpu_ptr(dev->lstats);
u64_stats_update_begin(&stats->syncp);
stats->packets++;
stats->bytes += len;
u64_stats_update_end(&stats->syncp);
}
메모리 할당자
/* mm/slab.h - SLAB per-CPU 캐시 */
struct kmem_cache_cpu {
void **freelist;
unsigned long tid;
struct page *page;
};
struct kmem_cache {
struct kmem_cache_cpu __percpu *cpu_slab;
/* ... */
};
/* 빠른 할당 경로 */
void *kmem_cache_alloc(struct kmem_cache *s, gfp_t flags)
{
struct kmem_cache_cpu *c = this_cpu_ptr(s->cpu_slab);
void *object;
object = c->freelist;
if (object) {
c->freelist = get_freepointer(s, object);
return object;
}
return __slab_alloc(s, flags, c);
}
전역 집계 동기화 (Global Aggregation)
per-CPU 변수를 전체 CPU에 걸쳐 집계할 때는 일관성과 CPU 핫플러그를 고려해야 합니다.
기본 집계 패턴
단순 정수 카운터는 for_each_possible_cpu()와 per_cpu()로 합산합니다:
unsigned long total = 0;
int cpu;
for_each_possible_cpu(cpu)
total += per_cpu(my_counter, cpu);
pr_info("total = %lu\n", total);
64비트 통계 안전 집계
32비트 아키텍처에서 u64를 읽으면 중간에 갱신이 끼어들 수 있습니다. u64_stats_sync와 시퀀스 카운터를 이용해 tearing을 방지합니다:
/* 쓰기 측 (패킷 수신 등) */
struct pcpu_lstats *s = this_cpu_ptr(lstats);
u64_stats_update_begin(&s->syncp);
s->packets++;
s->bytes += len;
u64_stats_update_end(&s->syncp);
/* 읽기 측 (전역 집계) */
u64 sum_packets = 0, sum_bytes = 0;
int cpu;
for_each_possible_cpu(cpu) {
struct pcpu_lstats *s = per_cpu_ptr(lstats, cpu);
unsigned int start;
u64 p, b;
do {
start = u64_stats_fetch_begin(&s->syncp);
p = s->packets;
b = s->bytes;
} while (u64_stats_fetch_retry(&s->syncp, start));
sum_packets += p;
sum_bytes += b;
}
CPU 핫플러그 고려
집계 도중 CPU가 오프라인이 되면 for_each_possible_cpu()는 오프라인 CPU도 순회하므로 주의가 필요합니다. 온라인 CPU만 대상으로 하거나 핫플러그를 잠금으로 보호합니다:
/* 온라인 CPU만 순회 + 핫플러그 방지 */
cpus_read_lock(); /* CPU 핫플러그 비활성화 */
for_each_online_cpu(cpu) {
total += per_cpu(my_counter, cpu);
}
cpus_read_unlock();
일관성 주의: 집계 도중에도 다른 CPU가 값을 변경할 수 있으므로, 읽은 합계는 근사값입니다. 엄격한 실시간 일관성이 요구되는 경우(예: 결제 금액, 보안 카운터)에는 per-CPU 변수가 부적합하며, 대신 atomic 변수나 seqlock을 고려하십시오.
성능 최적화 (Performance)
| 방법 | 처리량 (ops/sec) | 지연시간 (ns) |
|---|---|---|
| 공유 변수 + spinlock | 5M | 200 |
| 공유 변수 + atomic_t | 15M | 67 |
| per-CPU + get/put_cpu_var | 80M | 12 |
| per-CPU + this_cpu_inc | 250M | 4 |
벤치마크 결론: per-CPU 변수는 공유 변수 대비 50배 빠르며, 특히 this_cpu_* 연산은 원자적이면서도 spinlock보다 훨씬 빠릅니다.
모범 사례 (Best Practices)
언제 사용하는가
적합한 경우:
- 통계 카운터 (패킷 수, 할당 횟수)
- 임시 버퍼 (문자열 포맷팅)
- 캐시 (SLAB per-CPU 캐시)
- 작업 큐 (워크큐)
부적합한 경우:
- CPU 간 통신이 필요한 경우
- 정확한 실시간 값이 필요한 경우
- 메모리가 극도로 제한적인 경우 (CPU당 복사본 필요)
주의사항
/* 잘못된 코드: preemption 보호 없이 접근 */
my_counter++; /* BUG: 프로세스가 다른 CPU로 이동 가능! */
/* 올바른 코드 1: this_cpu_* 사용 */
this_cpu_inc(my_counter);
/* 올바른 코드 2: get/put_cpu_var 사용 */
unsigned long *ptr = get_cpu_var(my_counter);
(*ptr)++;
put_cpu_var(my_counter);
실습 예제
/* percpu_example.c */
#include <linux/module.h>
#include <linux/percpu.h>
DEFINE_PER_CPU(unsigned long, test_counter);
static int __init percpu_init(void)
{
int cpu;
int i;
/* 패턴 1: 현재 CPU의 카운터를 증가 (실제 사용 시엔 인터럽트/태스크 컨텍스트에서) */
for (i = 0; i < 10000; i++)
this_cpu_inc(test_counter); /* 현재 CPU의 복사본만 증가 */
/* 패턴 2: 전체 CPU 합계 집계 (읽기 전용, CPU 핫플러그 고려 필요) */
unsigned long total = 0;
for_each_possible_cpu(cpu) {
total += per_cpu(test_counter, cpu); /* 각 CPU의 복사본 합산 */
}
pr_info("Total count: %lu\n", total);
return 0;
}
module_init(percpu_init);
MODULE_LICENSE("GPL");