← 문제로

4. 워커별 카운터의 false sharing

난이도 상
내 리뷰 · C#
해설 · C#

해설 — 워커별 카운터의 false sharing

난이도: 상

요약

워커들은 논리적으로 서로 다른 카운터를 갱신하지만, WorkerStat이 24바이트밖에 안 돼 여러 워커의 통계가 같은 캐시라인(보통 64바이트)에 올라탄다. 한 워커가 자기 카운터를 쓸 때마다 CPU 캐시 일관성 프로토콜(MESI)이 그 라인을 공유하는 다른 코어의 캐시를 무효화(invalidate) 한다. 실제 데이터 충돌은 없는데도 캐시라인 단위로 핑퐁이 일어나는 false sharing이다. 그래서 락도 공유도 없는데 스레드를 늘릴수록 라인 쟁탈로 느려진다. 해법은 각 카운터를 캐시라인 경계에 맞춰 패딩/정렬하는 것. (false sharing은 C++/C# 등 언어 무관한 하드웨어 현상이지만, C#에선 배열 원소 정렬을 직접 제어하기 어려워 [StructLayout] 패딩이나 분리 배열로 풀어야 한다.)

문제점

(A) WorkerStat이 24바이트 — 한 라인에 여러 워커가 공존 (분류: 성능/동시성)

  • 증상: workers를 늘려도 throughput이 선형 증가하지 않고, 어느 지점부터 오히려 감소.
  • 재현조건: 멀티코어에서 각 스레드가 인접한 인덱스(_stats[t])를 고빈도로 쓸 때. 코어 수가 많을수록 심해진다.
  • 근본원인: 캐시 일관성은 바이트가 아니라 캐시라인(64B) 단위로 동작한다. _stats[0](24B), _stats[1], _stats[2]의 일부가 같은 64바이트 라인에 들어간다. 코어 0이 _stats[0].Packets를 쓰면, 그 라인을 캐시에 가진 코어 1·2는 MESI에 의해 라인이 Invalid가 되고, 자기 카운터를 쓰려면 라인을 다시 가져와야 한다(RFO, Read-For-Ownership). 코어들이 같은 라인의 소유권을 계속 뺏고 빼앗긴다 → "캐시라인 핑퐁".

(B) WorkerStat[] 연속 배열 — 패딩/정렬 부재 (분류: 성능)

  • 증상: (A)의 직접 원인. 배열이 빽빽해 인접 워커가 물리적으로 한 라인을 공유.
  • 근본원인: C#의 값 타입 배열은 원소를 sizeof(WorkerStat)(여기선 24바이트) 간격으로 촘촘히 인라인 저장한다(object[]처럼 참조 배열이 아니다). 캐시라인 정렬·패딩이 없으므로 false sharing이 구조적으로 발생한다. 게다가 배열 자체의 시작 주소도 64바이트 정렬이 보장되지 않아 경계가 어긋날 수 있다.

핵심: 이건 정확성 버그가 아니라 순수 성능 버그다. 결과값(TotalPackets)은 맞지만 확장성이 죽는다. ETW/dotnet-trace + 하드웨어 카운터(또는 Linux perf c2c)로 보면 캐시 미스/HITM 이벤트가 폭증한다.

수정안

각 워커 통계를 캐시라인 크기로 패딩해 한 워커당 한 라인을 독점하게 한다. C#에선 [StructLayout]로 구조체 크기를 64바이트로 강제한다.

using System.Runtime.InteropServices;

// (A)(B) 구조체 자체를 캐시라인 크기로 패딩
[StructLayout(LayoutKind.Explicit, Size = 64)]
public struct WorkerStat
{
    [FieldOffset(0)]  public long Packets;
    [FieldOffset(8)]  public long Bytes;
    [FieldOffset(16)] public long Errors;
    // 나머지 17..63 바이트는 패딩으로 비워둔다(Size=64가 보장)
}

public sealed class ThroughputCounters
{
    private readonly WorkerStat[] _stats;
    public ThroughputCounters(int n) => _stats = new WorkerStat[n];

    public void OnPacket(int t, long sz)
    {
        _stats[t].Packets += 1;   // 이제 다른 워커 라인과 독립
        _stats[t].Bytes   += sz;
    }

    public long TotalPackets()
    {
        long sum = 0;
        for (int i = 0; i < _stats.Length; i++) sum += _stats[i].Packets;
        return sum;
    }
}

주의:

  • [StructLayout(LayoutKind.Explicit, Size = 64)]WorkerStat이 64바이트가 되어 인접 원소가 다른 라인에 놓인다(배열 시작 주소 정렬이 64의 배수가 아니면 첫 원소가 라인 경계에 안 맞을 수 있으나, 원소 간 간격이 64라 적어도 두 워커가 한 라인을 공유하진 않는다).
  • 카운터가 단일 스레드 전용이면(워커가 자기 슬롯만 쓰면) Interlocked가 필요 없다. 모니터 스레드가 읽는 값은 통계 용도라 약간의 stale을 허용하면 OK. 엄밀한 가시성이 필요하면 Interlocked.Read/Volatile.Read로 읽되, 라인이 분리돼 있으면 쓰기 경합은 없다.

대안 — 더 견고한 패딩(.NET이 구조체 정렬을 더 보수적으로 다룰 때):

// 라인 절반 앞뒤로 패딩을 둬서 인접 라인 프리페치(128B 페어링)까지 방어
[StructLayout(LayoutKind.Explicit, Size = 128)]
public struct PaddedCounter
{
    [FieldOffset(64)] public long Value;   // 값을 중앙에 배치
}

측정 방법

  • 수정 전후로 workers=1,2,4,8에서 Mops/s를 측정. 수정 후엔 코어 수에 거의 선형 증가해야 한다.
  • BenchmarkDotNet으로 워커 수별 처리량 회귀 측정. Linux면 perf stat -e cache-misses,L1-dcache-load-misses, perf c2c(cache-to-cache, false sharing 전용 진단). Windows면 dotnet-trace + ETW 또는 VTune "Memory Access" 분석에서 HITM(modified line을 다른 코어에서 가져옴) 카운트가 급감하는지 확인.

더 나은 설계

  1. 스레드 로컬 누적 + 머지(권장): 카운터를 [ThreadStatic]/ThreadLocal<T> 또는 워커 스택 지역 변수에 두고 종료/주기마다 합산하면 공유 자료구조 접근 자체가 사라진다. false sharing 원천 차단. 트레이드오프: 모니터가 실시간 합계를 보려면 워커가 주기적으로 공유 영역에 flush 필요.

  2. PaddedReference<T>/패딩 구조체: 매직넘버 64 대신 의도를 드러내는 패딩 타입을 두고 재사용. 일부 CPU는 L2 프리페처가 인접 두 라인(128B)을 쌍으로 가져와 false sharing 영향 범위가 128B인 경우가 있어, 보수적으로 128B 패딩을 쓰기도 한다(Intel 일부). 트레이드오프: 메모리 사용량.

  3. 분리 배열(SoA) 주의: long[] packets; long[] bytes;로 종류별 분리하면 오히려 인접 워커의 동종 카운터가 한 라인에 몰려 false sharing이 더 심해질 수 있다. 이 경우엔 워커별 패딩 구조(AoS + 64B 패딩)가 정답.

  4. 읽기 측 일관성: 모니터가 정확한 스냅샷이 필요하면 Volatile.Read/Interlocked.Read로 읽되, 라인 분리로 쓰기 경합은 없게 유지.

면접 포인트

  1. False sharing이 무엇이며 true sharing과 어떻게 다른가? MESI 프로토콜의 Invalid/RFO/HITM 관점에서 설명하라. C#에서 값 타입 배열이 왜 false sharing에 취약한가(인라인 저장)?
  2. C#에서 캐시라인 패딩을 어떻게 강제하나? [StructLayout(LayoutKind.Explicit, Size=64)] vs Sequential + 더미 필드의 차이는? 왜 64가 아니라 128로 패딩하는 경우가 있나(프리페처/라인 페어링)?
  3. 카운터가 워커 전용인데도 Interlocked/Volatile이 필요한 경우와 불필요한 경우는? 스레드 로컬 누적이 false sharing을 어떻게 원천 제거하는가?