본문 바로가기
C#

volatile는 멀티스레드에서 “가벼운 동기화”를 위한 읽기/쓰기 가시성 보장 장치

by 공부봇 2025. 9. 19.
반응형

volatile는 멀티스레드에서 “가벼운 동기화”를 위한 읽기/쓰기 가시성 보장 장치라고 이해하면 편합니다.
private volatile bool _isGrabbing = false;처럼 플래그에 쓰면, 다른 스레드가 값 변경을 즉시 보고 잘 멈추거나 시작할 수 있게 해줘요.


volatile가 뭔가요?

  • 효과: 해당 필드에 대한 **읽기(Read)**와 **쓰기(Write)**가
    1. **CPU/컴파일러 재정렬(reordering)**로 서로 엇갈리지 않게 만들고,
    2. 코어별 캐시/레지스터에만 머물다 안 보이는(stale) 값이 되는 일을 막아,
    3. 다른 스레드에서 최신 값을 즉시 관측할 수 있게 합니다.
      (쉽게 말해: “바로 보이게 하고, 읽기/쓰기 순서를 뒤바꾸지 말라”는 메모리 장벽을 넣어줍니다. 읽기는 acquire, 쓰기는 release 장벽에 해당.)
  • 어디에 붙이나: 필드에 붙입니다. 로컬 변수/프로퍼티에는 직접 못 붙여요.
    (프로퍼티나 배열 원소에는 System.Threading.Volatile.Read/Write 메서드를 사용)
  • 어떤 타입에 가능?
    C#에서는 주로 참조형, bool/char/int/uint/short/ushort/byte/sbyte/float 등 기본형 일부가 허용됩니다. (그 외 타입은 Volatile.Read/Write나 다른 동기화 기법을 권장)

왜/언제 쓰나요?

1) “상태 플래그” 알리기 (취소, 종료, 시작)

백그라운드 루프를 멈추는 신호용 bool에 가장 흔히 씁니다.
volatile이 아니면, 어떤 CPU/최적화 조합에서는 작업 스레드가 변경을 못 보고 무한 루프에 빠질 수 있어요.

class Worker
{
    private volatile bool _running = false; // 변경 즉시 보이게

    public void Start()
    {
        _running = true;
        new Thread(Run).Start();
    }

    public void Stop()
    {
        _running = false; // 다른 스레드가 바로 관측
    }

    private void Run()
    {
        while (_running)
        {
            // 작업...
            Thread.Sleep(10);
        }
    }
}

2) 더블 체크 잠금(DCL: Double-Checked Locking)에서의 가시성

게으른 초기화 시 volatile 없으면 초기화가 부분적으로 보이는 문제가 생길 수 있어요.
(요즘은 Lazy<T>가 더 안전하고 간편합니다.)

class Cache
{
    private static volatile Foo _instance; // 생성 재정렬 가시성 보장
    private static readonly object _lock = new object();

    public static Foo Instance
    {
        get
        {
            var tmp = _instance;
            if (tmp == null)
            {
                lock (_lock)
                {
                    if (_instance == null)
                        _instance = new Foo(); // 완전 초기화 보장
                    tmp = _instance;
                }
            }
            return tmp;
        }
    }
}

3) 락 없이 “읽기 전용 참조”를 교체할 때

읽기는 많고 쓰기는 드문 경우, 전체 참조를 통째로 교체하는 패턴에서 가시성 확보용으로 사용합니다.
(복합 업데이트가 필요하면 락이나 Interlocked로 전환해야 함)

class Router
{
    private volatile Handler _handler = new HandlerA();

    public void SwapToB() => _handler = new HandlerB(); // 쓰기 즉시 보임

    public void Handle(Request r) => _handler.Process(r); // 항상 최신 핸들러를 봄
}

volatile가 해주지 않는 것들 (중요!)

  • 원자적 복합 연산 보장 X: x++, x += n, list.Add() 같은 연산은
    “읽기→계산→쓰기”의 여러 단계로 이루어져 있어, volatile만으로는 경쟁 조건을 막지 못합니다.
    → 이런 경우 lock 또는 Interlocked 계열을 써야 해요.
  • 상호 배제/동시 접근 보호 X: 임계영역 보호를 원하면 lock이 필요합니다.
  • 트랜잭션/일관성 보장 X: 여러 필드를 한꺼번에 일관되게 갱신하려면 lock으로 묶으세요.

잘못된 예

volatile int _count;

void Bad()
{
    _count++; // (읽기 -> +1 -> 쓰기) 사이에 다른 스레드 끼어들 수 있음
}

올바른 대안

int _count;

void GoodWithInterlocked()
{
    Interlocked.Increment(ref _count);
}

readonly object _lock = new object();
void GoodWithLock()
{
    lock (_lock)
    {
        _count++;
        // 여러 필드 갱신 등 임계영역
    }
}

volatile가 필요한지 판단 기준

  • 필드가 여러 스레드에서 읽히고/쓰이고, 그 용도가 단순 신호/상태 플래그인가?
    → 예: 취소/종료/시작 플래그, 참조 교체 플래그 → volatile 적합
  • 연산이 복합적이거나 여러 필드를 함께 갱신해야 하나?
    lock 또는 Interlocked 사용 (필요 시 volatile도 병행)
  • 항상 같은 락으로 접근한다면?
    → 이미 lock이 가시성과 재정렬을 모두 보장하므로 volatile은 불필요합니다.

프로퍼티/배열 원소엔 어떻게?

직접 volatile을 붙일 수 없으니 System.Threading.Volatile 유틸을 쓰세요.

using System.Threading;

class Flags
{
    private bool _stop;

    public bool Stop // 프로퍼티 내부에서 가시성 보장
    {
        get => Volatile.Read(ref _stop);
        set => Volatile.Write(ref _stop, value);
    }
}

class Arr
{
    private int[] _a = new int[10];

    public int ReadAt(int i)  => Volatile.Read(ref _a[i]);
    public void WriteAt(int i, int v) => Volatile.Write(ref _a[i], v);
}

정리

  • volatile = 다른 스레드에 값 변경이 즉시 보이도록 하고 읽기/쓰기 재정렬을 막는 장치(가벼운 메모리 장벽).
  • 주 용도: 취소/상태 플래그, 참조 교체 같은 단순 신호.
  • 아닌 것: 락 대체품 아님, 복합 연산의 원자성 보장 못 함 → 그땐 lock/Interlocked.
  • 프로퍼티/배열엔 Volatile.Read/Write를 사용.

_isGrabbing 같은 “루프 on/off 플래그”에는 volatile이 딱 맞아요. 루프 스레드가 바로 멈추거나 시작할 수 있게, 변경이 즉시 가시화되도록 해주니까요.

반응형