본문으로 건너뛰기
Practical RTOS Internals · 39/53

SMP RTOS 설계 — Ready List·Affinity·IPI·Load Balancing

· Hawk · 10분 읽기

#한 줄 요약

“SMP RTOS는 여러 core가 하나의 OS 인스턴스를 공유합니다.” — single-core RTOS의 모든 자료구조동시 접근 가능성이 추가됩니다.

#어떤 문제를 푸는가

single-core RTOS에서는 한 시점에 한 task만 실행된다는 가정이 모든 설계의 바탕이었습니다. ready list에 동시 접근하는 주체는 없고, critical section은 __disable_irq() 한 줄이면 충분했습니다. 멀티코어가 들어오면 이 가정이 모두 깨집니다. core 0과 core 1이 같은 ready list를 동시에 만지고, 한 core가 IRQ를 막아도 다른 core는 멀쩡히 진행합니다.

해결 방향은 두 갈래입니다. AMP(Asymmetric Multi-Processing)는 각 core에 별도의 OS 인스턴스를 두고 IPC로 통신합니다. 자동차의 Cortex-A(Linux) + Cortex-R(RTOS) 조합이 전형입니다. SMP(Symmetric Multi-Processing)는 하나의 OS가 모든 core를 통합 관리합니다. Linux, FreeRTOS 11 SMP, Zephyr SMP가 여기 속합니다.

이번 편은 SMP에 집중합니다. AMP는 4-12편에서 OpenAMP를 중심으로 다룹니다.

#Ready List를 어떻게 둘 것인가

SMP scheduler 설계의 첫 결정은 ready list를 하나로 둘 것인가, core마다 둘 것인가입니다. 두 선택은 lock 비용과 cache locality 사이의 trade-off가 다릅니다.

#Global Ready List

struct ready_list global_ready;
spinlock_t global_lock;
void schedule(int core) {
spin_lock(&global_lock);
Task *t = pick_highest(&global_ready);
spin_unlock(&global_lock);
run_on(core, t);
}

장점은 단순함입니다. 어느 core에서 보든 같은 list이므로 부하 균형이 자연스럽게 맞춰집니다. 한 core가 idle이면 곧바로 ready list 머리에서 task를 집어가면 됩니다.

단점은 lock contention입니다. core 수가 늘어날수록 global_lock 위에서 충돌이 폭증합니다. ready list 자료구조 자체도 cache line이 core 사이를 핑퐁합니다. 4 core 정도까지는 단순함의 이득이 크지만, 8 core 이상에서는 확장성 한계가 명확합니다.

#Per-Core Ready List

struct ready_list per_core_ready[NUM_CORES];
spinlock_t per_core_lock[NUM_CORES];
void schedule(int core) {
spin_lock(&per_core_lock[core]);
Task *t = pick_highest(&per_core_ready[core]);
spin_unlock(&per_core_lock[core]);
run_on(core, t);
}

각 core가 자기 ready list만 만집니다. lock contention이 거의 없고, list 자료구조도 core local cache에 머뭅니다. Linux CFS가 이 구조를 씁니다.

단점은 부하가 자동으로 맞춰지지 않는다는 것입니다. core 0의 list에 task 10개가 쌓이고 core 1이 idle이어도, 명시적 load balancing이 없으면 core 1은 계속 놀게 됩니다. periodic balance나 work stealing 같은 추가 메커니즘이 필요합니다.

두 구조를 나란히 놓고 보면 trade-off가 분명해집니다. Global은 하나의 lock 위에 코어가 모이고, Per-Core는 코어마다 자기 list를 만지지만 migration이 별도로 필요합니다.

SMP global vs per-core ready list

#어느 쪽을 고를까

항목GlobalPer-Core
Lock contention높음낮음
Cache locality나쁨좋음
Load balance자동별도 구현 필요
확장성~4 core수십 core
결정성 분석쉬움어려움

소규모 embedded SMP(2~4 core)는 global이 합리적이고, 8 core 이상이거나 일반 컴퓨팅 워크로드는 per-core가 표준입니다.

#FreeRTOS 11 SMP

2022년 발표된 FreeRTOS 11은 공식 SMP를 도입했습니다. 핵심 config 세 줄이 전부입니다.

#define configNUMBER_OF_CORES 4
#define configUSE_CORE_AFFINITY 1
#define configUSE_TASK_PREEMPTION_DISABLE 1

내부 구현은 single ready list + 두 단계 spinlock입니다. task lock과 ISR lock을 분리해 nested ISR 시나리오에서도 deadlock이 생기지 않게 합니다. 자세한 spinlock 구조는 다음 편 4-08 SMP Spinlock에서 다룹니다.

TaskHandle_t h;
xTaskCreate(task_fn, "ctrl", 2048, NULL, 5, &h);
/* CPU 0, 1만 사용 */
UBaseType_t mask = (1U << 0) | (1U << 1);
vTaskCoreAffinitySet(h, mask);
/* 현재 어느 core에서 실행 중인가 */
BaseType_t core = portGET_CORE_ID();

ESP-IDF의 portMUX_TYPE은 FreeRTOS SMP variant의 또 다른 예입니다. ESP32 dual core에서 portENTER_CRITICAL(&mux)spinlock + IRQ disable을 한 번에 처리합니다.

#Zephyr SMP

Zephyr는 per-CPU runqueueglobal priority scheduler를 얹은 구조입니다. 각 CPU는 자기 runqueue를 가지지만, scheduler 자체는 전체 시스템에서 가장 우선순위 높은 task가장 한가한 core에 가도록 push/pull balancing을 수행합니다.

K_THREAD_STACK_DEFINE(stack, 2048);
struct k_thread thread;
k_thread_create(&thread, stack, K_THREAD_STACK_SIZEOF(stack),
entry_fn, NULL, NULL, NULL,
K_PRIO_PREEMPT(5), 0, K_NO_WAIT);
/* Affinity 설정 — CPU 0에만 고정 */
k_thread_cpu_mask_disable_all(&thread);
k_thread_cpu_mask_enable(&thread, 0);

balancing 정책은 두 가지로 나뉩니다. push는 한 core가 ready로 만든 새 task를 다른 idle core로 IPI를 보내 즉시 깨우는 방식입니다. pull은 idle이 된 core가 다른 core의 runqueue에서 task를 가져오는 방식입니다. Zephyr는 두 가지를 모두 씁니다.

#IPI — Cross-Core 동기화의 기본

core 0이 다른 core에 즉시 알려야 할 때 IPI(Inter-Processor Interrupt)를 보냅니다. ARM Cortex-A의 GIC는 SGI(Software Generated Interrupt) 0~15를 IPI에 할당합니다.

/* Linux */
smp_call_function_single(target_cpu, fn, arg, /*wait=*/true);
/* FreeRTOS 11 SMP */
portYIELD_CORE(target_core);

대표 용도가 셋입니다. 첫째, higher-priority task가 다른 core에서 ready가 되었을 때 그 core를 깨워 reschedule을 trigger합니다. 둘째, MMU page table을 바꾼 뒤 모든 core의 TLB를 invalidate시키는 TLB shootdown입니다. 셋째, kernel panic 시 모든 core를 정지시키는 broadcast입니다.

embedded SMP에서 가장 자주 보는 패턴은 첫 번째입니다. core 0이 ISR에서 task를 unblock했는데 그 task의 priority가 core 1에서 실행 중인 task보다 높다면, core 1에 IPI를 보내 PendSV를 trigger해야 즉시 preempt됩니다.

#Critical Section을 다시 정의하기

single-core에서 IRQ를 끄면 critical section이 보장됐습니다. SMP에서는 내 core의 IRQ만 막힙니다. 다른 core는 그대로 진행하므로 spinlock + IRQ disable 조합이 필수입니다.

spinlock_t lock;
unsigned long flags;
spin_lock_irqsave(&lock, &flags);
critical_section();
spin_unlock_irqrestore(&lock, flags);

IRQ disable은 내 core 안에서 ISR이 끼어드는 것을 막고, spinlock은 다른 core가 같은 자료구조에 들어오는 것을 막습니다. 두 가지가 함께 있어야 race가 없습니다.

__disable_irq(); /* ← 다른 core 안 보호 — SMP에서 깨짐 */
critical_section();
__enable_irq();

single-core에서 동작하던 코드가 SMP에서 silent하게 깨지는 가장 흔한 패턴입니다.

#Cache Coherency가 곧 비용

SMP의 모든 동기화 비용은 결국 cache line 이동으로 환산됩니다. Cortex-A는 CCI(Cache Coherent Interconnect)나 DSU(DynamIQ Shared Unit)로 hardware coherency를 제공하지만, 그 동작도 공짜는 아닙니다.

Cortex-A72 측정 (uncontended → contended):
같은 cache line 읽기 : 4 cycle → 80 cycle
같은 cache line 쓰기 : 4 cycle → 150 cycle
spinlock acquire (한가) : 12 cycle → 70 cycle
context switch overhead : ~500 cycle → 1500 cycle (cold cache)

같은 task가 다른 core로 옮겨가면 모든 cache가 cold입니다. RT critical task는 affinity로 한 core에 고정해 cache locality를 유지하는 편이 결정성에 좋습니다.

dual-core Cortex-M(RP2040 등)은 hardware coherency가 아예 없습니다. core 0이 SRAM에 쓴 값을 core 1이 읽으려면 명시적 cache flush 또는 hardware FIFO를 거쳐야 합니다.

#include "pico/multicore.h"
void core1_entry(void) {
for (;;) {
uint32_t v = multicore_fifo_pop_blocking();
process(v);
}
}
int main(void) {
multicore_launch_core1(core1_entry);
for (;;) {
multicore_fifo_push_blocking(value);
}
}

RP2040의 SIO FIFO는 coherency가 없는 SoC에서 안전하게 통신하기 위해 hardware로 제공되는 통로입니다.

#Affinity 결정 — RT에는 좁게, Best-Effort에는 넓게

항목Affinity 고정Migration 허용
Cache locality좋음매번 cold
결정성높음낮음
Load balance불균등자동
적합한 taskRT critical, control loop일반 worker, idle background

자동차 ADAS 같은 RT task는 특정 core에 affinity 고정이 안전합니다. core 사이를 옮겨다니면 cache miss가 누적되어 WCET 분석이 깨집니다. 반면 background sync나 best-effort UI task는 migration을 허용해 전체 core 활용률을 높이는 편이 낫습니다.

/* RT control loop — core 0 고정 */
vTaskCoreAffinitySet(rt_task, 1U << 0);
/* Best-effort worker — 어느 core든 OK */
vTaskCoreAffinitySet(worker_task, (1U << 0) | (1U << 1) | (1U << 2) | (1U << 3));

#Load Balancing의 기본 패턴

per-CPU runqueue 구조에서는 주기적 balancing이 필요합니다. 가장 단순한 형태는 가장 loaded core에서 가장 idle core로 task 하나 옮기기입니다.

void balance_load(void) {
int max_load = -1, min_load = INT_MAX;
int max_core = -1, min_core = -1;
for (int i = 0; i < NUM_CORES; i++) {
int l = runqueue_load(i);
if (l > max_load) { max_load = l; max_core = i; }
if (l < min_load) { min_load = l; min_core = i; }
}
if (max_load - min_load > MIGRATE_THRESHOLD) {
Task *t = pick_movable(max_core);
if (t != NULL) migrate(t, min_core);
}
}

Linux CFS는 이 위에 NUMA topologyenergy model까지 얹어 훨씬 정교한 결정을 내립니다. big.LITTLE이나 DynamIQ 환경에서는 foreground UI는 big core, background sync는 little core로 자동 배치하는 EAS(Energy-Aware Scheduler)가 표준입니다.

embedded SMP에서는 보통 훨씬 단순한 정책으로 충분합니다. 4 core 시스템이면 RT task는 affinity로 고정하고 나머지는 global ready list에 맡기는 조합이 합리적입니다.

#SMP context switch overhead 측정

같은 core 안의 switch와 cross-core switch는 비용 차이가 큽니다. Cortex-A53 1.2 GHz, 4 core SMP에서 측정한 예입니다.

같은 core, hot cache : 1200 cycle (≈1.0 µs)
같은 core, cold cache : 2500 cycle (≈2.1 µs)
cross-core migration : 5000 cycle (≈4.2 µs)
cross-cluster (big↔little) : 12000 cycle (≈10 µs)

cross-core migration이 hot cache 대비 4배입니다. cross-cluster는 10배입니다. RT task에 affinity를 거는 이유가 숫자로 드러납니다.

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

경고 — single-core 패턴을 SMP에 그대로 가져다 씀

__disable_irq() 한 줄로 critical section을 만들던 코드는 SMP에서 조용히 깨집니다. 컴파일러는 경고하지 않습니다. 모든 critical section을 spinlock + IRQ disable로 검토해야 합니다.

경고 — RT task에 affinity 없이 방치

xTaskCreate(rt_control_task, ...); /* affinity = ALL → migration 발생 */

core 사이를 옮겨다니면서 cache miss가 누적되어 주기마다 WCET가 다르게 나옵니다. RT task는 반드시 affinity로 고정합니다.

경고 — dual-M에서 coherency 가정

RP2040 같은 dual Cortex-M0+는 hardware coherency가 없습니다. core 0이 쓴 값을 core 1이 읽을 때 낡은 값을 볼 수 있습니다. SIO FIFO나 atomic register 또는 명시적 memory barrier를 써야 안전합니다.

경고 — global lock 남발

spin_lock(&global);
do_long_critical(); /* core 4개가 모두 대기 */
spin_unlock(&global);

global lock 위의 critical section이 길면 시스템 처리량 자체가 떨어집니다. fine-grained lock으로 쪼개거나, lock-free 자료구조로 옮기는 것이 답입니다.

#정리

  • SMP RTOS는 한 OS가 모든 core를 관리하는 모델로, 모든 ready list와 critical section이 동시 접근 가능성을 가집니다.
  • ready list는 globalper-core 두 갈래로 갈라지며, 소규모 embedded(2~4 core)는 global, 그 이상은 per-core가 합리적입니다.
  • FreeRTOS 11 SMP는 single ready list + task/ISR 이중 spinlock 구조를 채택했습니다.
  • Zephyr SMP는 per-CPU runqueue + push/pull balancing으로 확장성을 가집니다.
  • IPI는 cross-core wake와 TLB shootdown의 기본 도구로, ARM에서는 GIC SGI를 씁니다.
  • SMP critical section은 spinlock + IRQ disable 조합이 필수이며, IRQ disable만으로는 보호되지 않습니다.
  • cache coherency는 hardware로 제공되지만 cross-core migration은 hot cache 대비 수 배의 비용을 가져옵니다.
  • RT critical task는 affinity로 한 core에 고정, best-effort task는 migration 허용이 일반적인 결정 기준입니다.

다음 편은 4-08 SMP Spinlock에서 LDREX/STREX와 ticket/MCS lock 구조를 풉니다.

#관련 항목

Practical RTOS Internals · 40 of 53

  1. 1Practical RTOS Internals — 실시간 커널 내부 분석 시리즈 소개
  2. 2RTOS가 필요한 이유 — 일반 OS와의 결정적 차이
  3. 3Task와 Thread 개념 — TCB·상태 머신·생명 주기 분석
  4. 4실시간 스케줄링 알고리즘 비교 — RR·Priority·EDF·RMS
  5. 5Preemption과 Cooperation — 강제 전환 vs 자발 양보
  6. 6인터럽트와 RTOS — ISR Context·Deferred Processing·FromISR API
  7. 7동기화 기초 분석 — Critical Section·Mutual Exclusion·Race Condition
  8. 8Semaphore 개념 분해 — Counting·Binary·P/V 연산
  9. 9Mutex 개념 분해 — Ownership·Recursive·Priority Inheritance
  10. 10큐와 메시지 패싱 — Producer-Consumer·Ring Buffer·전달 의미
  11. 11실시간성 분석 — Latency·Jitter·Deadline·WCET·RMA
  12. 12Ready List 자료구조 분석 — Linked List·Bitmap·O(1) Scheduler
  13. 13Blocked List 자료구조 — Timeout 정렬·Delta List·Two-List Scheme
  14. 14Scheduler 알고리즘 구현 추적 — Next-Task Selection 로직
  15. 15Context Switch 원리 분석 — 레지스터 저장·복원·Stack Frame
  16. 16ARM Cortex-M Context Switch — PendSV·MSP/PSP 어셈블리 추적
  17. 17ARM Cortex-A Context Switch — Mode 전환·SVC·Banked Registers
  18. 18RISC-V Context Switch 분석 — ECALL·mret·CSR
  19. 19RTOS Tick과 타이머 — SysTick·Generic Timer·configTICK_RATE_HZ
  20. 20Tickless 모드 구현 — Idle Tick Suppression·Sleep·Wake 보정
  21. 21Scheduler Latency 측정 기법 — GPIO Toggle·DWT·ftrace·cyclictest
  22. 22RTOS Tracing과 Observability — Tracealyzer·SystemView·ITM/ETM
  23. 23Critical Section 구현 비교 — IRQ Disable·BASEPRI·Spinlock
  24. 24Semaphore 내부 구현 추적 — Counter·Wait List·ISR-Safe Variant
  25. 25Mutex 내부 구현 추적 — Owner·Recursion Count·ISR 금지
  26. 26Priority Inversion 문제 — Mars Pathfinder 사례·Bounded vs Unbounded
  27. 27Priority Inheritance 구현 — Inherit·Disinherit·Chain
  28. 28Priority Ceiling Protocol — Immediate vs Original 비교
  29. 29Queue 내부 구현 추적 — Ring Buffer·2 Wait Lists·Atomic Send/Receive
  30. 30Event Group 분석 — Bit Flag·AND/OR Wait·Sync Barrier
  31. 31ISR-Safe API 설계 — FromISR 패턴·Higher Priority Wake·Deferred Work
  32. 32Deadlock 분석 — 4 조건·Wait-for Graph·Lock Ordering·Timeout
  33. 33Stream Buffer와 Message Buffer — FreeRTOS 10의 Lock-Free SPSC
  34. 34실시간 메모리 요구사항 — Determinism·Fragmentation·WCET
  35. 35FreeRTOS Heap_1~5 분석 — 5종 Allocator의 구조와 트레이드오프
  36. 36TLSF Allocator 분석 — Two-Level Segregated Fit O(1)
  37. 37Static Allocation — 컴파일 타임으로 동적 위험 제거하기
  38. 38Memory Pool — Fixed-Size Block Allocator의 단순함과 강력함
  39. 39Stack Overflow 탐지 — Canary·MPU·Watermark 3중 방어
  40. 40SMP RTOS 설계 — Ready List·Affinity·IPI·Load Balancing
  41. 41SMP Spinlock 구현 — LDREX/STREX·Ticket Lock·MCS·WFE/SEV
  42. 42Software Timer 분석 — Daemon Task·자료구조·ISR-Safe API
  43. 43RTOS System Call — SVC·ECALL·User/Kernel 분리·FreeRTOS-MPU
  44. 44TrustZone과 TF-M — Secure/Non-Secure·NSC Veneer·PSA
  45. 45AMP와 OpenAMP — Heterogeneous SoC·RPMsg·remoteproc
  46. 46C++ in RTOS — RAII·std::thread·ETL·Coroutine
  47. 47FreeRTOS 소스 분석 — tasks.c·queue.c·port.c 추적
  48. 48Zephyr 커널 분석 — k_thread·k_sem·Driver Model
  49. 49RT-Thread 분석 — Object 모델·Components·Smart·Studio
  50. 50RTOS 포팅 가이드 — 새 아키텍처에 옮기는 절차
  51. 51RTOS 선택 가이드 — Footprint·License·Certification·Ecosystem
  52. 52Apache NuttX 분석 — POSIX·PX4·NASA Ingenuity
  53. 53PREEMPT_RT Linux — Mainline 6.12·Xenomai 4·EVL