반응형

[PART11.비동기와 동시성(11/12)] volatile과 Interlocked — lock 없이 스레드 안전을 확보하는 법

lock의 대안이 아니라, lock이 닿지 못하는 지점을 채우는 도구 / 메모리 재정렬을 막는 volatile, 원자적 연산을 제공하는 Interlocked / ARM 모바일에서 더 중요한 이유

1. 문제 제기 — lock 하나면 다 되는 줄 알았는데

Unity 모바일 게임에서 백그라운드 스레드가 씬을 로딩하는 상황을 생각해 보자. 메인 스레드는 매 프레임 Update()에서 "로딩 끝났나?" 하고 플래그 하나만 확인하고 싶다. 이 플래그에 lock을 걸면 어떨까.

C#
// 60fps × 수천 번 반복되는 Update에서 lock을 거는 게 맞을까?
void Update()
{
    lock (_lockObj)
    {
        if (_isLoaded) OnLoadComplete();
    }
}

플래그 하나 읽자고 lock을 거는 건 과하다. lock은 임계 구역 전체를 직렬화하는 무거운 도구라서, 단일 변수 하나를 읽거나 카운터 하나를 올릴 때 쓰면 오버헤드만 커진다. 경합이 발생하면 스레드가 OS 커널 레벨로 내려가 블로킹되고, 컨텍스트 스위칭 비용(수 μs ~ 수십 μs)이 발생한다. 60fps를 유지하려면 프레임당 예산이 16.67ms인데, 이 중 수 μs를 락 경합에 쓰는 건 누적되면 스파이크로 돌아온다.

그렇다고 bool _isLoaded를 그냥 공유하면 더 큰 문제가 있다. 한 스레드에서 _isLoaded = true로 쓴 값을 다른 스레드가 영원히 못 볼 수 있다. 또 _counter++ 같은 간단한 연산도 멀티스레드에서는 깨진다. lock 없이 공유 변수를 안전하게 다루려면, 우리는 두 가지 다른 문제를 구분해서 풀어야 한다.

  1. 가시성(visibility) 문제 — 한 스레드의 쓰기가 다른 스레드에게 언제 보이는가
  2. 원자성(atomicity) 문제 — 읽고-수정하고-쓰기(read-modify-write)가 중간에 끼어들기 없이 한 번에 끝나는가

이 글은 두 문제를 각각 해결하는 도구인 volatile 키워드와 System.Threading.Interlocked 클래스를 다룬다. 둘은 서로를 대체하지 않고 역할이 다르다.


2. 개념 정의 — volatile은 가시성, Interlocked는 원자성

2-1. 비유 — 화이트보드와 스티키노트

사무실에 공용 화이트보드(메인 메모리)가 있고, 각 직원(CPU 코어)은 자기 책상 메모장(캐시)에 베껴 적어 쓴다고 하자.

  • 일반 변수: 직원은 자기 메모장에 적힌 값만 본다. 화이트보드가 바뀌어도 모른다.
  • volatile 변수: "이 항목은 볼 때마다 화이트보드를 직접 보고, 쓸 때도 화이트보드에 바로 적어라"는 규칙. 다른 직원이 쓴 변경사항이 즉시 보인다.
  • Interlocked 연산: "화이트보드 앞에 줄 서서, 내 차례에 읽고-고치고-쓰기를 끊김 없이 한 번에 처리하라"는 규칙. 그 순간만큼은 다른 사람이 끼어들지 못한다.

volatile가시성만 보장한다. 두 직원이 동시에 줄을 서지 않고 각자 고치면, 한 명의 수정이 다른 한 명의 수정을 덮어쓸 수 있다. 이걸 막는 게 Interlocked다.

2-2. volatile이 하는 일 — 메모리 재정렬 차단

volatile — 휘발성 필드 한정자 필드의 모든 읽기는 acquire(획득) 의미를, 모든 쓰기는 release(해제) 의미를 갖는다. 컴파일러·JIT·CPU가 이 필드 주변의 메모리 접근 순서를 최적화로 재정렬하는 것을 금지한다.
예시: private volatile bool _isReady; _isReady = true 이전의 모든 쓰기는 다른 스레드가 _isReady를 true로 읽는 순간 반드시 보인다.
메모리 재정렬과 volatile 배리어

volatile 필드는 컴파일러가 생성하는 IL에 volatile. prefix(접두사 명령어)를 붙인다. 이 prefix가 CLR(Common Language Runtime, .NET 런타임)에게 "이 접근은 재정렬 금지"라고 알린다.

C#
public class Flags
{
    public bool normalFlag;              // 일반 필드
    public volatile bool volatileFlag;   // volatile 필드

    public void SetNormal()   { normalFlag = true; }
    public void SetVolatile() { volatileFlag = true; }
    public bool ReadNormal()   { return normalFlag; }
    public bool ReadVolatile() { return volatileFlag; }
}
IL
// 일반 쓰기 — 재정렬/캐싱 가능
.method public instance void SetNormal () cil managed
{
    IL_0001: ldarg.0                                  // this를 스택에
    IL_0002: ldc.i4.1                                 // 상수 1(true)
    IL_0003: stfld bool Flags::normalFlag             // 그냥 stfld
    IL_0008: ret
}

// volatile 쓰기 — volatile. prefix가 붙음
.method public instance void SetVolatile () cil managed
{
    IL_0001: ldarg.0
    IL_0002: ldc.i4.1
    IL_0003: volatile.                                // ← prefix: release 배리어 요청
    IL_0005: stfld bool modreq(IsVolatile) Flags::volatileFlag
    IL_000a: ret
}

// volatile 읽기 — 여기도 prefix
.method public instance bool ReadVolatile () cil managed
{
    IL_0001: ldarg.0
    IL_0002: volatile.                                // ← prefix: acquire 배리어 요청
    IL_0004: ldfld bool modreq(IsVolatile) Flags::volatileFlag
    ...
}

IL 분석 포인트

  1. volatile. prefixstfld/ldfld 바로 앞에 붙는 1바이트 접두사다. JIT는 이 prefix를 보면 해당 메모리 접근을 레지스터에 캐싱하거나 다른 메모리 접근과 순서를 바꾸지 않는다.
  2. modreq(IsVolatile) — 필드 시그니처에 "필수 수정자(required modifier)"로 IsVolatile이 박힌다. 다른 .NET 언어(F#, VB)도 이 타입 정보를 보고 volatile 의미를 강제할 수 있다.
  3. 일반 쓰기와 비교SetNormalstfld뿐이다. CPU와 JIT가 이 쓰기를 레지스터에 모아뒀다가 나중에 한꺼번에 반영해도 언어 스펙상 허용된다.

2-3. Interlocked가 하는 일 — 읽고-고치고-쓰기를 원자적으로

volatile가시성만 해결한다. counter++처럼 "읽어서 → 1 더해서 → 다시 쓴다"는 세 단계 연산은 여전히 깨진다. 세 단계 사이에 다른 스레드가 끼어들 수 있기 때문이다.

counter++ 가 깨지는 이유
C#
using System.Threading;

public class Counter
{
    public int count;

    // ❌ 원자적이지 않음 — 두 스레드가 같이 부르면 값이 누락됨
    public void NaiveIncrement() { count++; }

    // ✅ CPU 수준에서 원자 연산
    public void AtomicIncrement() { Interlocked.Increment(ref count); }
}
IL
// ❌ 3단계로 쪼개진 ++
.method public instance void NaiveIncrement () cil managed
{
    IL_0001: ldarg.0
    IL_0002: ldarg.0
    IL_0003: ldfld int32 Counter::count        // 1. 읽기
    IL_0008: ldc.i4.1
    IL_0009: add                               // 2. 계산
    IL_000a: stfld int32 Counter::count        // 3. 쓰기
    IL_000f: ret
}

// ✅ 단 한 번의 호출
.method public instance void AtomicIncrement () cil managed
{
    IL_0001: ldarg.0
    IL_0002: ldflda int32 Counter::count       // ← 필드의 "주소"를 스택에
    IL_0007: call int32 [System.Threading]System.Threading.Interlocked::Increment(int32&)
    IL_000c: pop
    IL_000d: ret
}

IL 분석 포인트

  1. ldfld vs ldflda — 일반 ++ldfld로 값을 복사하지만, Interlocked.Incrementldflda로 필드의 주소(int32&, managed reference)를 스택에 올린다. ref 매개변수는 IL 레벨에서 "관리되는 포인터"로 전달된다.
  2. 하드웨어 명령어로 번역 — JIT는 Interlocked.Increment 호출을 x86에서는 LOCK XADD, ARM에서는 LDADD(ARMv8.1+) 혹은 LDREX/STREX 루프로 변환한다. LOCK prefix는 메모리 버스를 잠가 읽기-수정-쓰기가 분할되지 않도록 보장한다.
  3. full memory fenceInterlocked 메서드는 내부적으로 양방향 메모리 배리어도 함께 건다. 따라서 Interlocked로 쓰면 volatile이 주는 재정렬 금지 효과도 자동으로 얻는다.

3. 내부 동작 — CPU 메모리 모델과 캐시 라인

3-1. 왜 이런 장치가 필요한가

현대 CPU는 코어마다 L1/L2 캐시를 둔다. 한 코어가 변수를 쓰면 그 값은 자기 캐시에만 먼저 반영되고, 다른 코어에 전파되는 시점은 CPU의 캐시 일관성 프로토콜(MESI 등)이 결정한다. 여기에 더해 CPU는 명령어 실행 순서를 마음대로 바꿀 수 있다. 이걸 메모리 모델이 정의한다.

CPU 메모리 계층과 재정렬

x86 CPU(데스크톱) 는 TSO(Total Store Order)라는 강한 메모리 모델을 따른다. 이것 때문에 PC에서는 volatile 없이도 대체로 동작해버려서, 개발 중엔 버그가 안 보이다가 갤럭시·아이폰 같은 ARM 기반 모바일에 빌드하면 이상한 버그가 재현된다. ARM은 약한 메모리 모델(weak memory model) 이라 쓰기 순서도 재정렬한다. Unity가 모바일을 타겟한다면 volatileInterlocked를 올바르게 쓰는 건 선택이 아니라 필수다.

3-2. .NET이 약속하는 것 — ECMA vs 실제 CLR

.NET에는 두 가지 층의 메모리 모델이 있다.

  • ECMA-335 표준 — 이론상 약한 메모리 모델. volatile·Interlocked가 없으면 재정렬이 거의 무제한 허용된다.
  • 실제 CoreCLR 구현 — ECMA보다 더 강한 모델. x86/x64에서는 거의 TSO에 가깝고, ARM64에서도 일부 보장을 추가한다.

이식성을 생각한다면 ECMA 표준 기준으로 작성해야 한다. CoreCLR이 친절해서 돌아가는 코드가 Mono(IL2CPP 전신), 다른 런타임, 또는 .NET의 다음 버전에서 깨질 수 있기 때문이다.

3-3. Interlocked가 주는 보장 3가지

Interlocked 메서드는 호출 하나당 다음 세 가지를 한꺼번에 제공한다.

보장 내용
원자성 read-modify-write가 하드웨어 수준에서 끊기지 않는다
가시성 결과가 모든 코어의 캐시에 즉시 전파된다
순서 호출 전후의 메모리 접근이 이 지점을 넘어 재정렬되지 않는다 (full fence)

Interlocked는 "강화된 volatile"이다. Interlocked.Exchange(ref _flag, 1)는 쓰기도 하면서 동시에 재정렬 차단도 해준다.


4. 실전 적용 — Unity 모바일에서 어떻게 쓰는가

4-1. Interlocked의 원자 연산 5종

메서드 하는 일 반환값
Increment(ref x) x를 1 증가 증가 후
Decrement(ref x) x를 1 감소 감소 후
Add(ref x, n) x에 n을 더함 (n이 음수면 뺄셈) 더한 후
Exchange(ref x, newVal) x를 newVal로 교체 교체 전 원래 값
CompareExchange(ref x, newVal, compare) x == compare이면 newVal로 교체, 아니면 그대로 항상 연산 전 원래 값

Read(ref long x)도 있는데 이건 32비트 환경에서 long을 원자적으로 읽기 위한 전용 메서드다(아래 4-4에서 설명).

4-2. 사례 1: 로딩 완료 플래그 (volatile)

Before/After — 백그라운드 스레드에서 씬을 로딩하고, 메인 스레드의 Update()에서 완료 여부를 체크한다.
C#
using System.Threading;
using UnityEngine;

public class SceneLoader : MonoBehaviour
{
    // ❌ 일반 bool — ARM 모바일에서 무한 루프에 빠질 수 있음
    private bool _isLoadedBad;

    // ✅ volatile — release/acquire 보장
    private volatile bool _isLoaded;

    void StartLoading()
    {
        new Thread(() =>
        {
            // ... 에셋 로딩 (수백 ms) ...
            _isLoaded = true;   // volatile write = release 배리어
        }).Start();
    }

    void Update()
    {
        if (_isLoaded)          // volatile read = acquire 배리어
        {
            OnLoadComplete();
            enabled = false;
        }
    }
}

왜 volatile이 필요한가: _isLoaded = true가 메인 스레드 캐시에 전파되지 않으면 Update()는 영원히 false를 본다. 실제로 x86 PC에서는 캐시 일관성이 강해 잘 돌아가다가, 안드로이드 실기에서 재현되는 유명한 버그 패턴이다.

왜 Interlocked는 과한가: _isLoaded는 단순히 한 번 쓰고 여러 번 읽을 뿐이다. read-modify-write가 아니므로 원자성이 필요 없다. volatile만으로 충분하고 더 싸다.

4-3. 사례 2: 다운로드 바이트 누적 카운터 (Interlocked.Add)

Before/After — 여러 워커 스레드가 청크를 받을 때마다 누적 바이트 수를 갱신한다.
C#
using System.Threading;

public class DownloadTracker
{
    private long _totalBytes;

    // ❌ race condition — 일부 청크 크기가 누락됨
    public void OnChunkBad(int size) { _totalBytes += size; }

    // ✅ 원자적 누적
    public void OnChunk(int size)
    {
        Interlocked.Add(ref _totalBytes, size);
    }

    // 메인 스레드(UI)에서 안전하게 읽기 — 32비트/64비트 모두 안전
    public long GetTotal() => Interlocked.Read(ref _totalBytes);
}

_totalBytes += size는 IL에서 ldfld → add → stfld로 분리되어 나온다(섹션 2의 NaiveIncrement IL과 동일 패턴). 동시에 10개 스레드가 이걸 호출하면 평균적으로 30~40% 누락된다. Interlocked.Add는 CPU가 보장하는 원자 연산 하나로 대체된다.

4-4. 사례 3: CAS 루프로 "최댓값 갱신" 원자 구현

일부 연산(예: Max, Min, "지금이 이 상태일 때만 바꾼다" 같은 조건부 갱신)은 Interlocked 기본 메서드에 없다. 이럴 때 CompareExchange로 직접 만든다. 이게 lock-free 알고리즘의 핵심 패턴이다.

CAS 루프 (Compare-And-Swap)
C#
using System.Threading;

public class HighScoreTracker
{
    public int max;

    public void UpdateMax(int value)
    {
        int snapshot;
        do
        {
            snapshot = max;                    // ① 스냅샷 찍기
            if (value <= snapshot) return;     // ② 새 값이 작으면 포기
        }
        while (Interlocked.CompareExchange(ref max, value, snapshot) != snapshot);
        // ③ "max가 snapshot 그대로면 value로 바꿔" — 그 사이 누가 바꿨으면 실패, 루프 재시도
    }
}
IL
.method public instance void UpdateMax (int32 value) cil managed
{
    .locals init ([0] int32 snapshot, [1] bool, [2] bool)
    IL_0001: nop
    // --- 루프 시작 ---
    IL_0002: ldarg.0
    IL_0003: ldfld int32 CasExample::max              // ① max를 읽어서
    IL_0008: stloc.0                                  //    snapshot에 저장
    IL_0009: ldarg.1
    IL_000a: ldloc.0
    IL_000b: cgt                                      // ② value > snapshot?
    IL_000d: ldc.i4.0
    IL_000e: ceq
    IL_0012: brfalse.s IL_0016
    IL_0014: br.s IL_002e                             //    아니면 return
    IL_0016: nop
    IL_0017: ldarg.0
    IL_0018: ldflda int32 CasExample::max             // ③ max의 주소
    IL_001d: ldarg.1                                  //    새 값 (value)
    IL_001e: ldloc.0                                  //    비교값 (snapshot)
    IL_001f: call int32 Interlocked::CompareExchange(int32&, int32, int32)
    IL_0024: ldloc.0
    IL_0025: ceq                                      //    반환값 == snapshot?
    IL_002c: brtrue.s IL_0001                         //    아니면 루프 재시도
    // --- 루프 끝 ---
    IL_002e: ret
}

IL 분석 포인트

  1. 루프 헤더 IL_0002 — 실패할 때마다 여기로 돌아와 최신 max를 다시 읽어 스냅샷을 갱신한다. 이게 lock-free 재시도의 본질이다.
  2. CompareExchange의 반환 — "이 호출이 수행하기 직전의 값"을 돌려준다. 내가 전달한 snapshot과 같으면 내가 마지막으로 읽은 뒤 아무도 안 바꿨다는 뜻이므로 성공이고, 다르면 실패다.
  3. full fence 포함CompareExchange 하나가 원자성·가시성·순서를 모두 걸어준다. 추가로 volatile을 쓸 필요는 없다.

4-5. lock vs Interlocked 성능 감각 (ARM 모바일 기준 러프 추정)

상황 lock (Monitor) Interlocked
경합 없음 ~20~30 ns ~5~20 ns
경합 있음 ~100 ns ~ μs (컨텍스트 스위칭) ~50~150 ns (CAS 재시도)
여러 변수 동시 보호 ⭕ 자연스럽다 ❌ 코드가 복잡해진다
단일 카운터·플래그 과함 ⭕ 최적
복합 상태 전이 ⚠️ CAS 루프로 가능하지만 ABA 위험

4-6. 판단 플로우차트

공유 변수를 쓰는가?
├─ 여러 줄의 코드를 묶어야 하는가? → lock 또는 SemaphoreSlim
├─ 단일 변수의 read-modify-write? → Interlocked
├─ 단일 변수의 단순 플래그 쓰기/읽기? → volatile
└─ 단일 변수의 단일 쓰기 + 단일 읽기만? → volatile (Interlocked는 오버헤드)

5. 함정과 주의사항

5-1. ❌ volatile로 카운터 보호

C#
private volatile int _count;
// ❌ "volatile이니까 ++도 안전하겠지?"
void Hit() { _count++; }        // 여전히 race condition
C#
// ✅ volatile을 지우고 Interlocked 사용
private int _count;
void Hit() { Interlocked.Increment(ref _count); }

volatile은 가시성일 뿐이다. counter++는 IL에서 ldfld → add → stfld 세 단계로 나뉘고, 그 사이에 다른 스레드가 끼어든다. volatile은 원자성을 주지 않는다는 규칙을 암기해 두자.

5-2. ❌ Interlocked에 long을 volatile로 표시하려고 시도

longdoublevolatile 대상이 될 수 없다. C# 컴파일러가 막는다. 이유는 32비트 플랫폼(IL2CPP 32비트 타겟, 일부 구형 안드로이드 32비트 ABI)에서 64비트 값의 단일 읽기·쓰기 자체가 원자적이지 않기 때문이다.

C#
// ❌ 컴파일 에러: volatile은 int, bool, 참조 등만 가능
// private volatile long _total;

// ✅ 대신 Interlocked.Read / Interlocked.Exchange 로 접근
private long _total;
public long Get()     => Interlocked.Read(ref _total);           // 원자적 64비트 읽기
public void Set(long v) => Interlocked.Exchange(ref _total, v);  // 원자적 64비트 쓰기

IL2CPP 32비트 타겟에서 이 문제가 실제로 재현된 적이 있다. 64비트 값(경험치, 재화, 타임스탬프)은 기본 읽기/쓰기가 아니라 Interlocked.Read/Exchange를 통해 접근하는 습관을 들여야 한다.

5-3. ❌ ABA 문제 — CAS가 값만 보고 포인터를 잘못 판단

lock-free 스택·큐를 CAS로 직접 구현할 때 마주치는 함정이다.

시점 1: head → A
시점 2: 다른 스레드가 A를 pop하고, B를 push, 그 뒤 A(주소 재사용)를 다시 push
        → head → A (다시)
시점 3: 내 CAS는 "head == A" 라고 보고 성공하지만, 내용물은 완전히 달라져 있음

CAS는 값(주소)만 비교한다. A가 한 번 사라졌다가 다시 A로 돌아온 것을 구분하지 못한다. Unity 앱에서 직접 lock-free 컬렉션을 만들 일은 드물지만, 꼭 필요하다면 ConcurrentQueue<T>·ConcurrentDictionary<TKey, TValue> 같은 .NET 표준 컬렉션을 쓰는 게 훨씬 안전하다.

5-4. ❌ Interlocked.Exchange로 읽은 값이 일관성 있는 상태라고 착각

C#
// ❌ x와 y를 "묶어서" 안전하게 읽고 싶다
int x0 = Interlocked.Exchange(ref x, x);
int y0 = Interlocked.Exchange(ref y, y);
// (x0, y0)는 일관된 상태가 아니다 — 중간에 다른 스레드가 x만, y만 바꿨을 수 있음

두 변수를 함께 보호해야 한다면 Interlocked는 맞지 않는 도구다. 두 개 이상의 변수가 엮이면 lock으로 가야 한다. 또는 두 변수를 하나의 구조체로 합쳐 참조 교체(Interlocked.CompareExchange<T>)로 처리하는 패턴이 가능하지만, 이건 상당히 고급 기법이다.

5-5. ⚠️ Unity Burst/IL2CPP 환경 주의

IL2CPP는 IL을 C++로 변환해 AOT(Ahead-Of-Time, 사전 컴파일)로 네이티브를 만든다. volatile. prefix와 Interlocked 호출은 IL2CPP가 C++의 std::atomic 혹은 플랫폼 고유 원자 명령으로 정확히 매핑한다. 따라서 언어 수준에서 volatile/Interlocked를 제대로 쓰기만 하면 IL2CPP 빌드에서도 의미가 유지된다.

반면 Burst Compiler는 일반 C# 멀티스레드 동기화 프리미티브를 대부분 지원하지 않는다. Burst 안의 공유 데이터는 Job System의 NativeArrayReadOnly/WriteOnly 속성으로 관리하는 것이 원칙이다.


6. C# 버전별 변화

6-1. C# 1.0 — 최초 도입

volatile 키워드와 System.Threading.Interlocked가 .NET Framework 1.0부터 존재했다. 당시 InterlockedIncrement·Decrement·Exchange·CompareExchange 네 가지가 중심이었다.

6-2. .NET 2.0 — 메모리 모델 강화

.NET 2.0에서 CLR이 ECMA보다 강한 메모리 모델을 채택했다. 이 시점부터 lock 진입과 탈출이 자동으로 full fence를 걸게 되었고, ARM 등 비-x86 아키텍처에서도 예측 가능한 동작을 제공하기 시작했다.

6-3. .NET 4.5 — Volatile 클래스 추가

System.Threading.Volatile.Read/Write 메서드가 도입됐다. volatile 키워드는 필드 전체에 적용되지만, Volatile 클래스는 특정 시점에만 배리어를 건다.

C#
// volatile 키워드 — 필드 전체
private volatile bool _flag;

// Volatile 클래스 — 지역 변수, ref 매개변수 등 상황에 따라 사용
private bool _flag2;
void Writer() { Volatile.Write(ref _flag2, true); }
bool Reader() => Volatile.Read(ref _flag2);

volatile 키워드는 ref로 넘길 수 없다(Interlocked.CompareExchange(ref _flag, ...) 불가)는 제한이 있어 Volatile.Write 쪽이 더 유연하다.

6-4. .NET 5 — Interlocked.And / Or 추가

C#
// .NET 5+
private int _flags;
Interlocked.Or(ref _flags, 0b0010);   // 특정 비트 세팅 (원자)
Interlocked.And(ref _flags, ~0b0010); // 특정 비트 해제 (원자)

이전에는 비트 플래그를 원자적으로 조작하려면 CAS 루프를 직접 돌려야 했다.

6-5. .NET 9 — 제네릭 CompareExchange/Exchange 확장

Interlocked.CompareExchange<T>는 원래 참조 타입(where T : class)만 지원했다. .NET 9에서 원시 정수 크기(4, 8바이트)에 맞는 일부 값 타입과 열거형에 대해서도 제네릭 오버로드가 확장됐다.

C#
// 열거형 원자 교체
enum LoadState { Idle, Loading, Done }
LoadState state = LoadState.Idle;
Interlocked.Exchange(ref state, LoadState.Loading);   // .NET 9+

7. 정리

이것만 기억하면 된다.

  • volatile = 가시성. 한 스레드의 쓰기가 다른 스레드에 "즉시" 보이게 만든다. 재정렬도 막는다. 원자성은 없다.
  • Interlocked = 원자성 + 가시성 + 순서. read-modify-write를 한 번의 CPU 명령으로 수행한다.
  • 간단한 플래그 한 번 쓰고 여러 번 읽기volatile
  • 카운터, CAS, 참조 교체Interlocked
  • 여러 변수를 함께 보호lock
  • ARM 모바일은 약한 메모리 모델 — x86 PC에서 안 보이던 버그가 실기에서 터진다. volatile/Interlocked를 생략하지 말 것.
  • long·double은 volatile 불가Interlocked.Read / Interlocked.Exchange로 접근.
  • volatile int x; x++;는 안전하지 않다. volatile은 원자성이 없다.
  • 두 개 이상의 변수를 엮어서 보호할 때 Interlocked로 해결하려 하지 말 것. lock이 맞다.
  • ABA 문제와 false sharing을 염두에 두고 lock-free 컬렉션을 직접 만들지 말 것. ConcurrentQueue<T>·ConcurrentDictionary<TKey, TValue>를 쓴다.
반응형

+ Recent posts