---
title: "false sharing 은 공유한 적이 없어서 false 다"
description: "스레드마다 자기 카운터를 줬는데 병렬화가 오히려 느려집니다. 락도 없고 겹쳐 쓴 값도 없습니다. 두 카운터가 같은 64바이트 캐시 라인에 앉아 있어서 그렇습니다. false sharing 이 무엇이고 어떻게 확인하고 어떻게 고치는지를 직접 재본 숫자로 봅니다."
author: "유니얼"
pubDate: 2026-09-14
category: "programming/csharp/concurrency"
tags: ["C#", "동시성", "최적화"]
canonical: https://blog.unialgames.com/posts/csharp-false-sharing/
---
일을 스레드 넷으로 쪼갰는데 더 느려졌습니다.

락은 하나도 없습니다. 스레드마다 자기 카운터를 따로 줬고, 두 스레드가 같은 값을 고친 적도 없습니다.
그런데 스레드를 늘릴수록 느려집니다.

재봤습니다. **카운터 사이를 64바이트 벌리는 것만으로 되돌아옵니다.** 코드는 한 글자도 안 고쳤습니다.

이 현상에 이름이 있습니다. **false sharing** 입니다.
공유한 적이 없는데 하드웨어가 공유로 취급해서 느려지는 것이라 `false` 가 붙었습니다.
락이 없는데도 락이 걸린 것처럼 동작하고, 값은 하나도 안 깨지고 속도만 잃습니다.
그래서 테스트로는 절대 안 잡힙니다.

## 재봤습니다

`.NET 8` 콘솔에서 스레드마다 자기 슬롯 하나를 2억 번 올립니다.
슬롯은 `NativeMemory.AlignedAlloc` 으로 64바이트 경계에 맞춰 잡고,
슬롯 사이 간격만 바꿔 가며 잽니다. 코어 두 개짜리 x86-64 리눅스입니다.

`Volatile.Write` 로 쓰는 것이 중요합니다.
그냥 `*slot += 1` 로 두면 JIT 이 값을 레지스터에 들고 있다가 마지막에 한 번만 메모리에 씁니다.
그러면 캐시가 오갈 일이 없어서 측정이 통째로 무의미해집니다.

<x-compare>
<script type="application/json">
{
  "tabs": [
    { "label": "붙여 놓음", "code": "long* a = buf + 0;   // 0번 칸\nlong* b = buf + 1;   // 8바이트 옆\n\n// 스레드 둘이 각자 자기 것만 2억 번 올립니다", "stats": [["1스레드", "308ms"], ["2스레드", "424ms"], ["늘어난 시간", "+116ms"]], "tone": "bad" },
    { "label": "64바이트 벌림", "code": "long* a = buf + 0;   // 0번 칸\nlong* b = buf + 8;   // 64바이트 옆\n\n// 루프는 위와 완전히 같습니다", "stats": [["1스레드", "318ms"], ["2스레드", "317ms"], ["늘어난 시간", "0ms"]], "tone": "good" }
  ]
}
</script>
</x-compare>

오른쪽을 보면 **2스레드가 1스레드와 같은 시간**에 끝났습니다. 일은 두 배인데 시간이 그대로입니다.
병렬화가 제대로 먹은 모습입니다. 왼쪽은 같은 코드인데 116ms 를 더 씁니다.

간격만 바꿔 가며 잰 결과입니다. 2스레드, 각 2억 번, 라운드로빈으로 다섯 바퀴 돌린 것 중 최소값입니다.

| 두 카운터 사이 간격 | 걸린 시간 |
| --- | --- |
| 8바이트 | 424ms |
| 16바이트 | 431ms |
| 32바이트 | 427ms |
| **64바이트** | **317ms** |
| 128바이트 | 322ms |

8, 16, 32바이트가 전부 비슷하고 **64바이트에서 뚝 떨어집니다.**
32바이트나 떨어뜨렸는데도 아무 소용이 없다가, 64바이트가 되는 순간 정상으로 돌아옵니다.
이 계단이 이 글의 전부입니다. 경계가 정확히 64바이트에 있습니다.

## 캐시는 줄 단위로 움직입니다

CPU 는 메모리에서 1바이트만 가져오지 않습니다. 정해진 크기의 덩어리를 통째로 가져옵니다.
이 덩어리를 **캐시 라인**이라고 하고, x86-64 와 대부분의 ARM 에서 64바이트입니다.
Apple Silicon 은 128바이트입니다.

`long` 하나가 8바이트니까 한 줄에 여덟 개가 들어갑니다.
배열에 카운터를 나란히 잡으면 앞의 여덟 개가 같은 줄에 앉습니다.

여기까지는 좋은 일입니다. 하나만 읽어도 옆의 일곱 개가 같이 딸려 오니까
순서대로 훑는 코드는 거의 공짜로 읽습니다. 캐시가 이득을 주는 방식이 이것입니다.

**문제는 쓸 때입니다.** 캐시는 일관성을 줄 단위로 관리합니다.
어떤 코어가 줄 안의 1바이트를 고치려면, 그 줄 전체를 **자기가 단독으로 쥐고 있어야** 합니다.
다른 코어가 같은 줄의 사본을 들고 있으면 그것부터 버리게 만들어야 합니다.

코드로 보면 이렇습니다.

```csharp
long[] counters = new long[2];

// 스레드 A: counters[0]++ 를 2억 번
// 스레드 B: counters[1]++ 를 2억 번
```

주소는 이렇게 잡힙니다.

```text
0x1000  counters[0]   <- A 가 고치는 칸
0x1008  counters[1]   <- B 가 고치는 칸
0x1010  (빈 칸)
  ...
0x1038  (빈 칸)
|______ 이 64바이트 전체가 캐시 라인 하나 ______|
```

`counters[0]` 과 `counters[1]` 이 서로 다른 변수라는 것은 **소스 코드가 보는 구분**입니다.
하드웨어는 `0x1000` 부터 `0x103F` 까지를 한 덩어리로만 봅니다.
그 안 어디를 고치든 규칙이 같습니다. **쓰려면 그 64바이트 전체를 혼자 쥐어야 합니다.**

그래서 하드웨어는 "이 코어가 0번 칸을 쓰고 저 코어가 1번 칸을 쓴다" 를 구분하지 않습니다.
**줄 하나를 누가 쥐고 있는가**만 봅니다.

## false sharing 은 줄이 코어 사이를 오가는 것이다

그래서 두 코어가 같은 줄 안의 서로 다른 칸을 번갈아 고치면, 줄이 계속 오갑니다.
한쪽이 쓸 때마다 반대쪽 사본이 무효가 되고, 반대쪽이 쓰려면 다시 가져와야 합니다.

<x-cacheline>
<script type="application/json">
{
  "label": "두 코어가 각자 자기 칸만 고치는 동안",
  "mode": "both",
  "writes": 12,
  "slots": 8,
  "note": "겹쳐 쓴 값은 처음부터 끝까지 0입니다. 오가는 것은 값이 아니라 줄입니다."
}
</script>
</x-cacheline>

붙여 놓으면 열두 번 쓰는 동안 줄이 열한 번 옮겨 갑니다.
벌려 놓으면 같은 열두 번에 한 번도 안 옮겨 갑니다.

공책 하나를 둘이 나눠 쓰는 것과 같습니다. A 는 1페이지에만 쓰고 B 는 2페이지에만 씁니다.
같은 페이지에 쓴 적이 한 번도 없습니다. 그런데 규칙이 "뭐라도 쓰려면 공책을 통째로
손에 들고 있어야 하고, 그동안 남은 사본조차 못 갖는다" 입니다.
그래서 둘은 한 글자 쓸 때마다 공책을 주고받습니다.
**소유 단위가 쓰기 단위보다 크다는 것**, 이것이 전부입니다.

이름을 다시 정리하면 이렇습니다.

> **false sharing** 은 서로 다른 변수를 쓰는 스레드들이 같은 캐시 라인을 건드려서,
> 라인 소유권이 코어 사이를 왕복하느라 느려지는 것입니다.
> 코드에서 공유한 적이 없으므로 `false` 입니다.

`false` 가 붙은 이유가 핵심입니다. 코드에서는 각자 자기 변수만 만졌고 겹쳐 쓴 값이 하나도 없습니다.
**공유는 소스 코드가 아니라 주소에서 일어났습니다.** 그래서 소스를 아무리 들여다봐도 안 보이고,
메모리 배치를 봐야 보입니다.

진짜 락과 셋이 다릅니다.

| | 락 | false sharing |
| --- | --- | --- |
| 안 고치면 | 값이 깨진다 | **값은 맞고 느리기만 하다** |
| 잠기는 단위 | 코드 구간 | **64바이트 주소 범위** |
| 막힌 스레드 | 운영체제가 재운다 | **계속 깨어서 돈다** |

마지막 줄이 진단을 어렵게 만듭니다. 락 경합은 프로파일러에 대기 시간으로 찍히는데
false sharing 은 그런 것이 없습니다. **CPU 사용률은 100%인데 처리량만 안 나오는** 모양이 됩니다.

실제 하드웨어는 위 그림처럼 한 번 쓸 때마다 꼬박꼬박 넘기지는 않습니다.
스토어 버퍼가 있어서 한 코어가 줄을 쥔 동안 여러 번 몰아 쓰기도 합니다.
그래서 느려지는 배수가 이론값만큼 크게 안 나옵니다.
다만 **쓰는 쪽이 자주 부딪힐수록** 배수가 커집니다.

## false sharing 의 경계는 변수가 아니라 64바이트다

간격을 넣어 보면 몇 개가 한 줄에 같이 앉는지가 그대로 나옵니다.

<x-calc>
<script type="application/json">
{
  "label": "간격을 바꾸면 줄이 몇 개나 필요한가",
  "inputs": [
    { "id": "s", "label": "카운터 사이 간격", "options": [8, 16, 32, 64, 128], "default": 8, "suffix": "바이트" },
    { "id": "t", "label": "스레드 수", "options": [2, 4, 8, 16], "default": 2, "suffix": "개" }
  ],
  "bars": [
    { "label": "한 줄(64바이트) 안에 들어가는 카운터", "expr": "Math.max(1, Math.floor(64 / s))", "unit": "개", "decimals": 0 },
    { "label": "스레드 전체가 쓰는 줄", "expr": "Math.max(1, Math.ceil(t * s / 64))", "unit": "개", "accent": true, "decimals": 0 }
  ],
  "verdict": { "expr": "bars[1] >= t", "yes": "각자 자기 줄을 씁니다", "no": "줄을 나눠 쓰고 있습니다" },
  "note": "캐시 라인 64바이트 기준입니다. Apple Silicon 은 128바이트라 여기서 벌린 것도 모자랍니다."
}
</script>
</x-calc>

**줄 수가 스레드 수와 같아지는 지점부터 안 부딪힙니다.**
그 전까지는 몇 바이트를 벌렸든 결과가 같습니다. 32바이트를 벌려도 둘이 한 줄에 같이 들어가니까요.
측정에서 8, 16, 32바이트가 전부 424ms 근처로 나온 것이 이 이유입니다.

한 가지 더 있습니다. **`new` 로 잡은 객체 여러 개도 마찬가지입니다.**
작은 객체를 연달아 할당하면 힙에서 붙어 앉습니다.
스레드마다 객체를 하나씩 줬다고 해서 나뉜 것이 아닙니다.

## 쓰는 방식에 따라 배수가 달라집니다

같은 코드를 `Interlocked.Increment` 로 바꿔서 재봤습니다.
각 5천만 번입니다.

| | 붙여 놓음 | 64바이트 벌림 | 배수 |
| --- | --- | --- | --- |
| 1스레드 | 276ms | 275ms | 1.0배 |
| 2스레드 | 1056ms | 275ms | **3.8배** |
| 4스레드 | 2496ms | 556ms | **4.5배** |

그냥 쓰기에서는 1.3배였는데 여기서는 3.8배입니다.
`Interlocked` 는 매번 줄을 단독으로 쥐어야 끝나는 연산이라
스토어 버퍼가 몰아 쓸 여지가 없습니다. 부딪힘이 하나도 안 흡수됩니다.

읽어야 할 것은 배수가 아니라 방향입니다.
**1스레드에서는 차이가 없고, 스레드가 늘어야 벌어집니다.**
false sharing 은 언제나 이 모양으로 나타납니다. 혼자 돌리면 멀쩡하고 나눠 돌리면 나빠집니다.

4스레드 줄은 코어가 둘뿐인 기계라 둘씩 나눠 올라갑니다.
그래서 벌린 쪽도 2스레드의 두 배가 됐습니다(275ms → 556ms).
붙여 놓은 쪽은 거기서 더 벌어집니다.

## 패딩이 답이지만 첫 번째 답은 아닙니다

간격을 벌리는 것을 패딩이라고 합니다. C# 에서는 구조체 크기를 고정해서 잡습니다.

```csharp
[StructLayout(LayoutKind.Explicit, Size = 64)]
struct PaddedLong
{
    [FieldOffset(0)] public long Value;
}

// 배열에 나란히 잡아도 요소 하나가 64바이트라 각자 다른 줄에 앉습니다
var counters = new PaddedLong[threadCount];
```

.NET 자신도 이렇게 합니다. `ConcurrentQueue` 내부에서 머리와 꼬리 인덱스를 패딩해 둡니다.
생산자는 꼬리를, 소비자는 머리를 고치는데 둘이 한 줄에 있으면 큐를 쓸 때마다 부딪히기 때문입니다.

다만 패딩은 **어쩔 수 없이 공유 메모리에 계속 써야 할 때** 꺼내는 것입니다.
대부분의 경우 더 나은 답은 **루프 안에서 공유 메모리를 아예 안 만지는 것**입니다.

<x-compare>
<script type="application/json">
{
  "tabs": [
    { "label": "패딩으로 벌리기", "code": "var counters = new PaddedLong[n];   // 요소당 64바이트\n\nfor (long i = 0; i < ITER; i++)\n    counters[id].Value++;            // 매번 공유 메모리에 씀", "stats": [["2스레드", "317ms"], ["추가 메모리", "스레드당 56바이트"]], "tone": "bad" },
    { "label": "지역 변수에 모으기", "code": "long acc = 0;                       // 레지스터에 앉습니다\n\nfor (long i = 0; i < ITER; i++)\n    acc++;\n\nresults[id] = acc;                  // 끝에 딱 한 번", "stats": [["2스레드", "62ms"], ["추가 메모리", "없음"]], "tone": "good" }
  ]
}
</script>
</x-compare>

317ms 가 62ms 가 됐습니다. 패딩으로 얻은 것보다 훨씬 큽니다.
루프가 도는 동안 메모리를 한 번도 안 건드리니 캐시 일관성 이야기가 통째로 사라집니다.
`acc` 는 레지스터에 앉고, 레지스터와 스레드 스택은 애초에 남과 나눠 쓰는 자리가 아닙니다.
**false sharing 이 일어나는 무대는 언제나 공유 메모리입니다.** 힙, 정적 필드,
스레드끼리 나눠 쓰는 배열입니다.
**부딪힘을 싸게 만드는 것보다 부딪힐 자리를 없애는 쪽이 대체로 낫습니다.**

그래서 순서는 이렇습니다.

| 먼저 볼 것 | 어떻게 |
| --- | --- |
| 루프 안에서 공유 메모리에 쓰고 있나 | 지역 변수에 모았다가 끝에 한 번 쓴다 |
| 끝에 한 번 쓰는 것도 자주 일어나나 | 그때 패딩한다 |
| 쓰는 주기가 드물다 | 아무것도 안 한다. 패딩은 메모리와 캐시 용량을 먹는다 |

마지막 줄이 중요합니다. 패딩은 공짜가 아닙니다.
`long` 하나를 64바이트로 불리면 캐시에 들어가는 개수가 8분의 1이 됩니다.
자주 안 부딪히는 자리에 패딩을 두르면 오히려 느려집니다.

## false sharing 이 걸리는 자리

붙어 앉기 쉽고 자주 쓰는 자리들입니다.

| 자리 | 왜 붙나 |
| --- | --- |
| 스레드별 통계 배열 (처리 수, 히트 수, 누적 시간) | 배열에 나란히 잡으면 여덟 개가 한 줄입니다 |
| 병렬 루프가 인덱스로 나눠 쓰는 결과 배열 | 이웃 인덱스끼리 바로 옆 칸입니다 |
| 큐나 링 버퍼의 머리와 꼬리 | 생산자와 소비자가 각각 하나씩 고칩니다 |
| 락 객체와 그 락이 지키는 데이터 | 같이 선언하면 같은 줄에 앉습니다 |
| 스레드마다 하나씩 나눠 준 작은 객체 | 연달아 할당하면 힙에서 붙습니다 |

Unity 의 Job System 도 같습니다.
`NativeArray` 를 병렬로 쓸 때 각 잡이 자기 인덱스에만 쓰도록 제한이 걸려 있는데,
`[NativeDisableParallelForRestriction]` 으로 그 제한을 풀고 임의 인덱스에 쓰기 시작하면
여러 워커가 같은 줄에 걸치는 경우가 생깁니다.
값이 겹치지 않아도 줄이 겹치면 이 문제입니다.

나누는 방식도 봅니다.
배열을 번갈아 나눠 가지면(0, 2, 4번은 이 스레드, 1, 3, 5번은 저 스레드)
두 스레드가 처음부터 끝까지 같은 줄을 계속 밟습니다.
**연속된 구간으로 잘라서 나눠야** 각자 자기 줄을 씁니다.

## false sharing 인지 확인하는 순서

겉으로 드러나는 신호가 하나 있습니다. **CPU 사용률은 높은데 처리량이 안 늘어나는 것**입니다.
스레드가 락에 막혔다면 재워져 있으니 사용률이 떨어집니다. 사용률이 꽉 찬 채로 안 빨라지면
스레드들이 계속 돌면서 서로의 캐시를 무효화하고 있을 수 있습니다.

그다음은 순서대로 봅니다.

1. **락이 있나 봅니다.** 있으면 그쪽이 먼저입니다. 경합하는 락은 false sharing 보다 훨씬 크게 먹습니다
2. **1스레드로 돌려 봅니다.** 1스레드 시간과 2스레드 시간이 같으면 병렬화가 먹은 것입니다.
   2스레드가 더 걸리는데 락이 없으면 다음으로 갑니다
3. **코어를 늘려 봅니다.** 늘릴수록 나빠지면 false sharing 이 유력합니다.
   분할 오버헤드는 이런 모양으로 안 나옵니다
4. **주소를 찍어 봅니다.** 두 스레드가 쓰는 주소를 64로 나눈 몫이 같으면 같은 줄입니다

```csharp
// 같은 줄에 앉아 있나
Console.WriteLine((long)pa / 64 == (long)pb / 64);
```

5. **간격만 벌려서 다시 재봅니다.** 시간이 줄면 확정입니다. 이게 가장 빠른 확인입니다
6. 더 확실히 보려면 리눅스에서 `perf c2c`, 윈도우에서 VTune 의 메모리 접근 분석을 씁니다.
   어느 캐시 라인이 코어 사이를 오갔는지 주소 단위로 나옵니다

4번과 5번만으로도 대부분 잡힙니다. 프로파일러를 여는 것보다 간격을 바꿔 한 번 더 재는 쪽이 빠릅니다.

## 직접 재보기

하네스 핵심입니다. 콘솔 프로젝트에 그대로 붙여도 돕니다.

```csharp
// 64바이트 경계에 맞춰 잡습니다. 안 맞추면 "같은 줄" 케이스가
// 우연히 두 줄에 걸쳐서 결과가 흔들립니다.
static unsafe long* Alloc(int slots, int strideLongs)
{
    nuint bytes = (nuint)(slots * strideLongs * 8 + 128);
    var p = (long*)NativeMemory.AlignedAlloc(bytes, 64);
    NativeMemory.Clear(p, bytes);
    return p;
}

// strideLongs 1 이면 8바이트 간격(한 줄에 여덟 개),
// 8 이면 64바이트 간격(슬롯마다 줄 하나).
static unsafe double Run(int threads, int strideLongs, long iter)
{
    long* buf = Alloc(threads, strideLongs);
    var ts = new Thread[threads];
    var start = new ManualResetEventSlim(false);
    var ready = new CountdownEvent(threads);

    for (int t = 0; t < threads; t++)
    {
        int id = t;
        ts[t] = new Thread(() =>
        {
            long* slot = buf + (long)id * strideLongs;
            ready.Signal();
            start.Wait();                 // 다 같이 출발해야 겹치는 구간이 생깁니다
            for (long i = 0; i < iter; i++)
                Volatile.Write(ref *slot, Volatile.Read(ref *slot) + 1);
        });
        ts[t].IsBackground = true;        // 없으면 프로그램이 안 끝납니다
        ts[t].Start();
    }

    ready.Wait();
    var sw = Stopwatch.StartNew();
    start.Set();
    foreach (var th in ts) th.Join();
    sw.Stop();

    NativeMemory.AlignedFree(buf);
    return sw.Elapsed.TotalMilliseconds;
}
```

함정이 두 개 있습니다.

**`Volatile` 을 빼면 안 됩니다.** `*slot += 1` 로 두면 JIT 이 레지스터에 들고 있다가
루프가 끝난 뒤 한 번만 메모리에 씁니다. 그러면 간격을 어떻게 바꿔도 시간이 똑같이 나옵니다.

**한 케이스를 몰아서 재면 안 됩니다.** 터보 클럭과 스레드 배치가 시간에 따라 흔들려서
먼저 잰 쪽이 유리하거나 불리해집니다. 케이스를 라운드로빈으로 돌리고 최소값을 쓰면 그게 걷힙니다.
위 표의 숫자는 전부 다섯 바퀴 중 최소값입니다.

## 정리

- **false sharing** 은 서로 다른 변수를 쓰는 스레드들이 같은 캐시 라인을 건드려서 느려지는 것입니다.
  코드에서 공유한 적이 없는데 하드웨어가 공유로 취급하니 `false` 입니다
- CPU 는 메모리를 바이트가 아니라 **64바이트 줄 단위**로 읽고 소유합니다
- 어떤 코어가 줄 안의 1바이트를 쓰려면 **그 줄 전체를 단독으로 쥐어야** 합니다.
  하드웨어는 칸을 구분하지 않고 줄만 봅니다
- 그래서 **각자 다른 변수를 써도** 같은 줄에 있으면 줄이 코어 사이를 계속 오갑니다.
  락을 안 걸었는데 락이 걸린 것처럼 동작합니다. 다만 **값은 안 깨지고 속도만 잃습니다**
- 증상은 늘 같은 모양입니다. **1스레드는 멀쩡하고 스레드를 늘릴수록 나빠집니다**
- 경계는 변수 개수가 아니라 바이트입니다. 32바이트를 벌려도 둘이 한 줄에 들어가면 그대로입니다.
  2억 번 증가에서 8, 16, 32바이트가 전부 424ms 근처였고 64바이트에서 317ms 로 떨어졌습니다
- `Interlocked` 처럼 매번 줄을 단독으로 쥐어야 하는 연산은 배수가 훨씬 큽니다. 2스레드에서 3.8배였습니다
- 고치는 순서는 **지역 변수에 모았다가 끝에 한 번 쓰기**가 먼저고, 패딩이 그다음입니다.
  같은 측정에서 패딩은 317ms, 지역 변수는 62ms 였습니다
- 패딩은 공짜가 아닙니다. `long` 을 64바이트로 불리면 캐시에 들어가는 개수가 8분의 1이 됩니다
- 확인은 주소를 64로 나눠 보고, 간격만 벌려서 다시 재는 것이 가장 빠릅니다