저지연 멀티스레드 시스템에서 False Sharing이 중요한 이유 | Trading Engineering — Deep Dive #2
저지연 멀티스레드 시스템에서 False Sharing이 중요한 이유
Trading Engineering — Deep Dive #2
저지연 트레이딩 시스템에서는 CPU Cache를 이해하는 것만으로 충분하지 않습니다.
데이터가 Cache에 올라와 있더라도 서로 다른 스레드가 데이터를 어떻게 배치하고 접근하는가에 따라 성능이 크게 달라질 수 있습니다.
그 대표적인 문제가 False Sharing입니다.
False Sharing은 여러 스레드가 서로 다른 데이터를 사용하고 있음에도 불구하고, 그 데이터가 같은 Cache Line에 위치하면서 불필요한 Cache Line 이동과 동기화 비용이 발생하는 현상입니다.
특히 다음과 같은 환경에서 문제가 될 수 있습니다.
- 멀티스레드 Trading System
- Market Data Processing
- Order Processing
- Ring Buffer
- Shared Memory
- Producer / Consumer 구조
- 고빈도 Counter 및 Statistics
- Lock-Free / Low-Lock 구조
이번 글에서는 False Sharing이 왜 발생하고, 실제 C 코드에서는 어떻게 나타나는지 살펴보겠습니다.
1. False Sharing이란?
먼저 간단한 예제를 보겠습니다.
struct SharedData {
int producer_count;
int consumer_count;
};
두 개의 스레드가 각각 다른 변수를 사용한다고 가정해보겠습니다.
Thread A
producer_count++
Thread B
consumer_count++
논리적으로 보면 두 스레드는 서로 다른 데이터를 사용합니다.
따라서 우리는 두 스레드가 서로 영향을 주지 않는다고 생각하기 쉽습니다.
하지만 CPU Cache에서는 조금 다릅니다.
CPU는 일반적으로 개별 int 단위가 아니라 Cache Line 단위로 데이터를 가져옵니다.
많은 현대 CPU에서 Cache Line은 64 bytes이지만, 정확한 크기는 CPU 아키텍처에 따라 확인해야 합니다.
만약 위의 두 변수가 같은 Cache Line에 존재한다면 문제가 발생할 수 있습니다.
Cache Line
┌──────────────────────────────────────────────┐
│ producer_count │ consumer_count │ ... │
└──────────────────────────────────────────────┘
↑ ↑
Thread A Thread B
두 스레드는 서로 다른 데이터를 수정하고 있지만,
CPU 입장에서는 같은 Cache Line을 수정하고 있는 상황이 됩니다.
이것이 False Sharing입니다.
2. 왜 성능 문제가 발생하는가?
멀티코어 CPU에서는 각 Core가 자신만의 Cache를 가지고 있을 수 있습니다.
예를 들어:
Core 0
└── Cache
└── Cache Line A
Core 1
└── Cache
└── Cache Line A
두 Core가 동일한 Cache Line을 가지고 있다고 생각해보겠습니다.
Thread A가 producer_count를 변경합니다.
그러면 해당 Cache Line은 다른 Core에서 가지고 있던 복사본과 일관성을 유지해야 합니다.
다시 Thread B가 consumer_count를 변경합니다.
이번에는 반대로 Cache Line의 상태가 영향을 받습니다.
결과적으로 데이터 자체는 서로 관련이 없는데도 Cache Line이 계속 Core 사이에서 이동하거나 무효화될 수 있습니다.
Core 0 Core 1
producer_count++ consumer_count++
│ │
└──── Cache Line ──────────────┘
↕
Cache Coherency Traffic
문제의 핵심은 이것입니다.
데이터는 서로 독립적이지만 Cache Line은 독립적이지 않다.
3. False Sharing은 Lock이 없어도 발생한다
이 부분은 특히 중요합니다.
False Sharing은 반드시 Mutex나 Spinlock을 사용해야 발생하는 문제가 아닙니다.
다음 코드처럼 단순한 변수 증가에서도 발생할 수 있습니다.
counter_a++;
counter_b++;
각 변수가 서로 다른 스레드에서 변경되고 있고 같은 Cache Line에 위치한다면,
Lock을 사용하지 않더라도 Cache Coherency에 따른 비용이 발생할 수 있습니다.
따라서 다음과 같은 구조가 반드시 빠르다고 할 수는 없습니다.
No Lock
↓
No Waiting
↓
Fast
실제 시스템에서는
No Lock
↓
Shared Cache Line
↓
Cache Line Invalidation
↓
Coherency Traffic
↓
Performance Degradation
과 같은 상황도 발생할 수 있습니다.
4. 간단한 C 예제
다음과 같은 구조체를 생각해보겠습니다.
struct Counters {
volatile long producer;
volatile long consumer;
};
Thread A:
void *producer_thread(void *arg)
{
struct Counters *c = arg;
for (long i = 0; i < 100000000; ++i)
c->producer++;
return NULL;
}
Thread B:
void *consumer_thread(void *arg)
{
struct Counters *c = arg;
for (long i = 0; i < 100000000; ++i)
c->consumer++;
return NULL;
}
논리적으로는 매우 단순합니다.
각 Thread가 자신의 Counter만 변경합니다.
하지만 두 Counter가 같은 Cache Line에 위치한다면 False Sharing이 발생할 가능성이 있습니다.
여기서 중요한 것은 volatile이 False Sharing을 해결해 주지 않는다는 것입니다.
volatile은 메모리 접근에 대한 컴파일러 최적화와 관련된 의미를 가지며, 멀티스레드 동기화나 Cache Coherency 문제를 해결하기 위한 도구가 아닙니다.
5. Padding으로 Cache Line을 분리하기
가장 대표적인 해결 방법 중 하나가 Padding입니다.
예를 들어 두 Counter 사이에 충분한 공간을 두어 서로 다른 Cache Line에 배치하는 방법입니다.
개념적으로는 다음과 같습니다.
Cache Line A
┌──────────────────────────────────────────────┐
│ producer counter │
│ padding │
└──────────────────────────────────────────────┘
Cache Line B
┌──────────────────────────────────────────────┐
│ consumer counter │
│ padding │
└──────────────────────────────────────────────┘
C11에서는 _Alignas를 사용할 수 있습니다.
#include <stdalign.h>
struct alignas(64) Counter {
long value;
};
그리고:
struct Counters {
struct Counter producer;
struct Counter consumer;
};
다만 이것만으로 항상 원하는 메모리 배치가 보장된다고 단정해서는 안 됩니다.
실제 Cache Line 크기와 ABI, 구조체 배치, 컴파일러 및 CPU 아키텍처를 함께 고려해야 합니다.
또한 64 bytes는 흔히 사용되는 값이지 모든 CPU에서 반드시 동일하다는 의미는 아닙니다.
6. 더 중요한 방법은 Data Ownership이다
Padding은 유용하지만 더 근본적인 방법이 있습니다.
바로 데이터의 소유권을 명확하게 만드는 것입니다.
예를 들어:
Thread A
└── Producer Data
Thread B
└── Consumer Data
가능하다면 서로 다른 Thread가 동일한 데이터를 계속 수정하도록 만들지 않는 것입니다.
저지연 시스템에서는 다음과 같은 구조가 유리할 수 있습니다.
Market Data Thread
│
▼
Owns Market Data
Strategy Thread
│
▼
Owns Strategy State
Order Thread
│
▼
Owns Order State
각 데이터에 명확한 Owner를 두면 공유 데이터 자체를 줄일 수 있습니다.
이는 False Sharing뿐 아니라 Lock Contention과 Cache Coherency Traffic을 줄이는 데도 도움이 될 수 있습니다.
7. Ring Buffer에서도 False Sharing을 조심해야 한다
Ring Buffer는 저지연 시스템에서 매우 유용한 구조입니다.
Producer
│
▼
┌────┬────┬────┬────┬────┬────┐
│ │ │ │ │ │ │
└────┴────┴────┴────┴────┴────┘
│
▼
Consumer
하지만 Ring Buffer의 head와 tail이 같은 Cache Line에 위치하면 문제가 될 수 있습니다.
예를 들어:
struct RingBuffer {
uint64_t head;
uint64_t tail;
char buffer[1024 * 1024];
};
Producer가 head를 계속 변경하고,
Consumer가 tail을 계속 변경한다면,
두 값이 같은 Cache Line에 존재하는 경우 Cache Line이 계속 영향을 받을 수 있습니다.
그래서 고성능 Ring Buffer 구현에서는 다음과 같은 형태를 자주 고려합니다.
Cache Line A
┌──────────────────────────────────────────────┐
│ head │
└──────────────────────────────────────────────┘
Cache Line B
┌──────────────────────────────────────────────┐
│ tail │
└──────────────────────────────────────────────┘
Cache Line C ...
┌──────────────────────────────────────────────┐
│ buffer │
└──────────────────────────────────────────────┘
이러한 설계는 Producer와 Consumer가 서로 다른 위치의 메타데이터를 주로 변경하도록 만들어 Cache Line 간의 불필요한 경쟁을 줄이는 데 목적이 있습니다.
8. Atomic을 사용한다고 해결되는 것도 아니다
저지연 시스템을 개발하다 보면 이런 생각을 할 수 있습니다.
"그럼 Mutex 대신 Atomic을 사용하면 되겠네."
Atomic은 매우 중요한 도구입니다.
하지만 Atomic이라고 해서 Cache 문제가 사라지는 것은 아닙니다.
예를 들어:
atomic_fetch_add(&counter, 1);
이 연산은 원자성을 제공할 수 있지만,
여러 Core가 동일한 Cache Line을 계속 수정한다면 여전히 Cache Coherency Traffic이 발생할 수 있습니다.
즉,
Atomic
≠
Cache Contention 없음
입니다.
Atomic Operation 자체의 비용뿐 아니라,
- Cache Line ownership
- Cache Coherency
- Memory Ordering
- Core 간 통신
- 데이터 접근 패턴
까지 함께 고려해야 합니다.
9. False Sharing과 Lock Contention은 다르다
두 문제는 비슷하게 성능을 떨어뜨릴 수 있지만 원인은 다릅니다.
Lock Contention
Thread A
│
├── Lock 획득
│
└── Critical Section
Thread B
│
└── Lock 대기
Thread가 Lock을 기다리는 것이 핵심입니다.
False Sharing
Thread A Thread B
│ │
producer_count++ consumer_count++
│ │
└──── Same Cache Line ───┘
│
Cache Coherency
Lock이 없어도 서로 다른 데이터를 수정하면서 Cache Line 때문에 성능 문제가 발생할 수 있습니다.
따라서 두 문제를 구분해서 분석해야 합니다.
10. Trading System에서는 어디에서 발생할 수 있는가?
False Sharing은 다양한 곳에서 발생할 수 있습니다.
Market Data Processing
Thread 1 → received_count
Thread 2 → processed_count
두 Counter가 같은 Cache Line에 있을 수 있습니다.
Order Processing
Thread 1 → order_count
Thread 2 → cancel_count
고빈도 Counter라면 작은 비용도 누적될 수 있습니다.
Ring Buffer
Producer → head
Consumer → tail
대표적으로 고려할 만한 구조입니다.
Statistics
Thread 1 → buy_count
Thread 2 → sell_count
Thread 3 → reject_count
각 Thread가 독립적인 Counter를 사용하는 것처럼 보이더라도 메모리 배치를 확인할 필요가 있습니다.
11. False Sharing을 어떻게 확인하는가?
여기서 중요한 원칙이 있습니다.
의심한다고 해서 바로 Padding부터 적용해서는 안 됩니다.
먼저 측정해야 합니다.
다음과 같은 순서가 좋습니다.
Measure
↓
Identify
↓
Verify
↓
Redesign
↓
Benchmark
↓
Measure Again
확인할 수 있는 지표는 다음과 같습니다.
- Thread별 실행 시간
- Throughput
- CPU utilization
- Cache Miss
- Cache References
- Cache-to-Cache Transfer
- Coherency 관련 Hardware Counter
- Context Switch
- Tail Latency
Linux 환경에서는 CPU Performance Counter를 활용하여 Cache 관련 이벤트를 분석할 수도 있습니다.
중요한 것은 코드를 보고 False Sharing이 있을 것이라고 추측하는 것과 실제 성능 저하를 측정하는 것은 다르다는 점입니다.
12. Padding이 항상 좋은 것은 아니다
Padding을 무조건 많이 넣는 것도 좋은 방법은 아닙니다.
예를 들어:
struct HugeData {
long counter;
char padding[4096];
};
처럼 지나치게 메모리를 늘리면 오히려 문제가 될 수 있습니다.
메모리 사용량이 증가하고,
Cache 효율이 떨어지며,
전체 Working Set이 커질 수 있습니다.
결국 중요한 것은:
필요한 데이터만 적절하게 분리하는 것
입니다.
False Sharing을 제거하기 위해 데이터를 무조건 멀리 떨어뜨리는 것이 아니라, 실제로 동시에 수정되는 데이터가 불필요하게 같은 Cache Line을 공유하지 않도록 설계하는 것이 핵심입니다.
13. 저지연 시스템에서의 실용적인 원칙
False Sharing을 줄이기 위한 실용적인 원칙을 정리하면 다음과 같습니다.
1. Data Ownership을 명확하게 한다
가능하면 하나의 데이터는 하나의 Thread가 주로 관리합니다.
2. 공유 데이터를 줄인다
여러 Thread가 같은 데이터를 계속 수정하는 구조를 피합니다.
3. Hot Data의 배치를 확인한다
고빈도로 접근되는 데이터가 어떻게 배치되어 있는지 확인합니다.
4. Cache Line 경계를 고려한다
필요한 경우 Alignment와 Padding을 사용합니다.
5. Ring Buffer의 Metadata를 분리한다
Producer와 Consumer가 자주 변경하는 값을 적절하게 분리합니다.
6. Atomic의 비용도 고려한다
Atomic이 Lock보다 단순하더라도 Cache Coherency 비용은 남을 수 있습니다.
7. 반드시 Benchmark한다
Padding 전후의 성능을 실제 환경에서 측정합니다.
14. False Sharing은 "메모리 구조"의 문제다
저지연 시스템에서 성능을 이야기할 때 보통 다음을 먼저 생각합니다.
CPU
Network
Algorithm
Compiler
하지만 실제 성능은 데이터가 메모리에 어떻게 배치되어 있는가에도 영향을 받습니다.
다음 두 구조는 논리적으로는 거의 동일합니다.
A B
하지만 CPU 관점에서는
Cache Line 1
┌──────────┬──────────┐
│ A │ B │
└──────────┴──────────┘
와
Cache Line 1
┌──────────┐
│ A │
└──────────┘
Cache Line 2
┌──────────┐
│ B │
└──────────┘
가 전혀 다른 동작 특성을 만들 수 있습니다.
따라서 저지연 시스템에서는 자료구조의 논리적 구조뿐 아니라 물리적인 메모리 배치까지 고려할 필요가 있습니다.
15. FontesFintech의 접근
저지연 Trading System에서 False Sharing을 해결하는 것은 단순히 Padding을 추가하는 작업이 아닙니다.
FontesFintech는 다음과 같은 관점에서 데이터 구조를 설계합니다.
- Data Ownership
- Shared Data 최소화
- Cache-friendly Data Layout
- Cache Line Alignment
- False Sharing 최소화
- Ring Buffer 설계
- Atomic Operation 최소화 및 적절한 사용
- Lock Contention 최소화
- Memory Copy 최소화
- Critical Path 단순화
- End-to-End Latency 측정
결국 중요한 것은 특정 최적화 기법 하나가 아닙니다.
데이터가 어디에 있고, 누가 변경하며, 얼마나 자주 접근하고, 다른 Core와 얼마나 자주 Cache Line을 공유하는가를 이해하는 것이 중요합니다.
Conclusion
False Sharing은 코드만 봐서는 쉽게 발견하기 어려운 성능 문제입니다.
두 Thread가 서로 다른 데이터를 사용하고 있더라도,
그 데이터가 같은 Cache Line에 있다면 CPU 수준에서는 서로 영향을 줄 수 있습니다.
저지연 시스템에서는 다음의 관계를 이해할 필요가 있습니다.
Data Layout
↓
Cache Line
↓
Cache Coherency
↓
Core-to-Core Traffic
↓
Latency
따라서 성능을 높이기 위해서는 단순히 더 빠른 CPU나 더 빠른 알고리즘을 선택하는 것뿐만 아니라 데이터 구조와 메모리 배치까지 함께 설계해야 합니다.
“저지연 시스템에서는 코드의 실행 방식만큼, 데이터를 어디에 배치하는가가 중요합니다.”
다음 편에서는 한 단계 더 들어가서,
CPU Affinity와 Core Pinning이 왜 저지연 Trading System에서 중요한가
를 살펴보겠습니다.