한양과학기술고등학교

에듀테크 프로젝트 대시보드

거짓 공유 (False Sharing)

거짓 공유 (False Sharing)

두 스레드가 완전히 다른 변수를 하나씩 나눠 가지고 각자 건드리기만 했는데, 코드는 논리적으로 아무 문제가 없는데도 멀티코어에서 오히려 더 느려지는 현상이 있다. 원인은 데이터가 아니라 캐시 라인(cache line)이라는 하드웨어의 최소 이동 단위 때문이다.

1. 캐시는 "라인" 단위로 도장을 찍는다

CPU 캐시는 바이트 하나하나가 아니라 보통 64바이트짜리 캐시 라인 단위로 메모리를 읽고 쓴다. 문제는 int a, b;처럼 메모리 상 붙어 있는 두 변수가 있으면, 둘 다 같은 캐시 라인에 들어간다는 것이다.

■ counter_a (Core 0 전용) ■ counter_b (Core 1 전용) — 둘 다 같은 64바이트 라인 안에 있다.

Core 0이 counter_a를 쓰면, MESI 캐시 일관성 프로토콜은 "이 라인 전체"가 바뀌었다고 보고 Core 1이 들고 있던 사본을 통째로 무효화한다. Core 1은 counter_b만 읽었을 뿐인데도 캐시가 날아가 다시 메모리(혹은 Core 0)에서 그 라인을 다시 가져와야 한다. 실제로 공유하는 데이터는 없는데, 캐시 라인이라는 단위 때문에 억울하게 공유된 것처럼 취급된다 — 그래서 이름이 "거짓(false)" 공유다.

진짜 공유(true sharing)와 차이: 진짜 공유는 같은 변수를 여러 스레드가 실제로 같이 쓸 때 생기는, 락 등으로 반드시 처리해야 하는 경합이다. 거짓 공유는 데이터 경합은 전혀 없는데도(각자 자기 변수만 건드림) 캐시 라인이 겹친다는 이유만으로 성능이 깎이는, 순수 성능 문제다. MESI 프로토콜 자체나 캐시 매핑 규칙과는 다른, "인접 배치"가 만드는 부작용에 초점을 둔다.

2. 시뮬레이터 — 패딩 유무로 비교해 보기

Core 0과 Core 1이 각자 자기 카운터를 반복해서 +1 한다. 아래에서 배치 방식을 바꿔가며 재생해 보자.

배치:

CORE 0 — counter_a

0
대기 중
캐시 라인

CORE 1 — counter_b

0
대기 중
누적 증가
0
라인 무효화
0
유효 처리율
–
인접 배치를 선택하면 두 코어가 번갈아 가며 같은 라인을 독점(Modified)해야 해서, 쓸 때마다 상대방 캐시를 무효화한다. 재생 버튼을 눌러 라인이 Core 0 ↔ Core 1 사이를 핑퐁하는 걸 확인해 보자.

3. 실측 감각 — 왜 이렇게까지 느려지나

아래는 두 스레드가 각자 카운터를 1억 번 증가시키는 벤치마크에서 흔히 관찰되는 상대적인 소요 시간 비율(자료용 예시)이다. 실제 배수는 CPU·코어 배치·컴파일러 최적화에 따라 다르지만, 몇 배씩 느려지는 경향은 거의 항상 재현된다.

5.8×
인접 배치
(거짓 공유)
1.0×
64B 패딩
(라인 분리)

라인이 코어 사이를 오갈 때마다 수십~수백 사이클의 코어 간 통신 지연이 끼어들기 때문에, 논리적으로 락도 경합도 없는 코드가 캐시 미스만으로 몇 배씩 느려진다.

4. 코드로 보는 해결책

패딩으로 서로 다른 캐시 라인에 떨어뜨리기

// 나쁨: a, b가 같은 64B 라인에 붙어 있을 가능성이 높음
struct Counters {
    long a;   // Core 0이 갱신
    long b;   // Core 1이 갱신
};

// 좋음: 캐시 라인 크기만큼 패딩을 넣어 강제로 라인을 분리
struct Counters {
    alignas(64) long a;   // 이 라인은 Core 0 전용
    alignas(64) long b;   // 이 라인은 Core 1 전용
};

배열로 여러 스레드가 각자 슬롯을 갱신하는 경우(counters[thread_id]++ 패턴)도 슬롯 사이에 패딩을 끼우거나, Java의 @Contended, C++17의 std::hardware_destructive_interference_size처럼 언어·런타임이 제공하는 정렬 힌트를 쓰면 같은 효과를 얻는다.

대응 방법아이디어
패딩(padding)변수 사이에 더미 바이트를 채워 서로 다른 라인에 놓는다
정렬 지시자alignas(64) 등으로 컴파일러가 라인 경계에 맞춰 배치하게 한다
스레드-로컬 누적 후 합산각 스레드가 자기 로컬 변수에 모았다가 마지막에만 한 번 합친다
배열 슬롯 간격 넓히기counters[i] 대신 counters[i * PAD]처럼 인덱스 간격을 늘린다
주의: 패딩은 메모리를 더 쓰는 트레이드오프다. 진짜 핫패스에서 여러 스레드가 각자 독립된 카운터·통계값을 자주 갱신할 때만 적용할 가치가 있다. 의심될 때는 perf c2c 같은 도구로 실제 캐시 라인 경합을 먼저 측정하고 나서 고치는 게 순서다.