본문 바로가기
공부/게임서버

가시성 문제와 보장 방법

by samosa 2024. 11. 10.

가시성(visibility)의 개념은 멀티스레드 프로그래밍에서 스레드 간의 메모리 상태가 어떻게 공유되고 관찰되는지를 설명하는 중요한 개념이다. 가시성 문제는 특정 스레드에서 업데이트된 메모리 상태가 다른 스레드에서 즉각적으로 보이지 않을 때 발생할 수 있다.

가시성 문제의 원인

  • 캐시: 각 CPU 코어는 자신의 캐시를 사용하여 메모리 접근을 최적화한다. 이로 인해 한 스레드가 변경한 변수가 다른 스레드에서 참조할 때 캐시된 이전 값을 읽을 수 있다.
  • 명령어 재정렬: 컴파일러나 CPU가 성능 최적화를 위해 명령어의 순서를 재배치할 수 있다. 이는 스레드 간 메모리 접근이 프로그램 코드에서 작성된 순서대로 이루어지지 않게 만들 수 있다.
  • 메모리 모델: 각 프로그래밍 언어는 자체 메모리 모델을 가지고 있어, 메모리 접근의 일관성과 가시성 보장 수준이 다를 수 있다.

예시

두 개의 스레드가 있다고 가정해보자. 스레드 A가 어떤 변수 x1로 설정하고, 스레드 B가 x의 값을 읽는다고 할 때, 스레드 B가 x의 변경된 값을 즉시 볼 수 있는지 여부는 가시성의 문제이다. 만약 스레드 A의 변경이 메인 메모리에 즉시 반영되지 않거나 스레드 B가 메모리 캐시에서 이전 값을 읽는다면, 스레드 B는 변경된 값을 보지 못할 수 있다.

가시성 보장 방법

  • volatile 키워드: Java나 C#에서는 volatile 키워드를 사용하여 변수의 가시성을 보장할 수 있다. volatile로 선언된 변수는 읽기나 쓰기 연산 시 항상 메인 메모리에서 직접 접근하도록 보장한다.
  • 동기화: synchronized 블록(Java) 또는 lock 구문(C#)과 같은 동기화 기법을 사용하면 한 스레드가 변경한 메모리가 다른 스레드에 즉시 반영되도록 할 수 있다. 동기화는 메모리 배리어의 역할을 하여 명령어 재정렬을 방지하고 가시성을 보장한다.
  • 메모리 배리어: 메모리 배리어를 명시적으로 삽입하여 명령어 재정렬을 방지하고 메모리 쓰기와 읽기의 순서를 제어함으로써 가시성을 개선할 수 있다.

결론

가시성은 멀티스레드 프로그래밍에서 데이터의 일관성과 동기화와 깊이 연관된 개념이다. 이를 제대로 다루지 않으면 예상치 못한 동작이나 레이스 컨디션이 발생할 수 있다. 적절한 키워드, 동기화 방법, 또는 메모리 배리어를 사용하여 가시성 문제를 해결할 수 있다.


그럼 이 코드에서, Thread2가 y를 읽기 전에 Thread1에서 y가 1로 초기화되었다는 걸 어떻게 보장하나?

이 코드에서 Thread1y = 1로 초기화한 후 Thread2r2 = y를 읽을 때 y의 업데이트된 값을 보장받기 위해서는 두 스레드 간의 메모리 가시성을 확보해야 한다. 단순히 Thread.MemoryBarrier()만으로는 한 스레드의 메모리 쓰기가 다른 스레드의 메모리 읽기에서 즉시 관찰될 수 있다고 보장하지 않는다. 메모리 배리어는 코드 순서 재정렬을 방지하지만, 스레드 간의 동기화 문제를 완벽하게 해결하지 못한다.

메모리 배리어의 한계

Thread.MemoryBarrier()는 해당 스레드에서의 메모리 명령어 재정렬을 방지하지만, 다른 스레드에서 그 변경이 즉시 보이는 것을 보장하지는 않는다. 스레드 간의 데이터 가시성 보장을 위해서는 추가적인 동기화 메커니즘이 필요하다.

해결 방법: lock이나 동기화 기법 사용

이 문제를 해결하려면 lock 구문이나 다른 동기화 기법을 사용하여 두 스레드 간의 메모리 변경 사항이 즉시 반영되도록 보장해야 한다.

private static readonly object lockObj = new();

static void Thread1()
{
    lock (lockObj)
    {
        y = 1;
        r1 = x;
    }
}

static void Thread2()
{
    lock (lockObj)
    {
        x = 1;
        r2 = y;
    }
}

설명

  • lock 구문은 두 스레드가 동일한 자원에 동시에 접근하지 못하도록 하여 메모리 가시성과 일관성을 보장한다.
  • lock은 메모리 배리어의 기능을 내포하고 있어, 코드 블록 내의 메모리 쓰기가 다른 스레드에 즉시 반영되도록 보장한다.
  • 이 코드를 통해 Thread1y = 1로 설정한 후 Thread2r2 = y를 읽을 때 올바른 값을 볼 수 있게 된다.

추가적인 설명: volatile의 사용

volatile 키워드도 가시성 문제를 일부 해결할 수 있지만, volatile은 단순히 읽기 및 쓰기 순서를 보장할 뿐이다. 다중 변수에 대한 복잡한 동기화를 다루기 위해서는 lock이나 다른 고급 동기화 메커니즘이 필요하다.


마지막 예시:

namespace ServerCore;

internal class Program
{
    private int _answer;
    private bool _complete;

    void A()
    {
        _answer = 123;
        Thread.MemoryBarrier();
        _complete = true;
        Thread.MemoryBarrier();
    }
    
    void B()
    {
        Thread.MemoryBarrier();
        if (_complete)
        {
            Thread.MemoryBarrier();
            Console.WriteLine(_answer);
        }
    }
    
    static void Main(string[] args)
    {
        Program p = new Program();
        Task t1 = new Task(p.A);
        Task t2 = new Task(p.B);
        t1.Start();
        t2.Start();
        Task.WaitAll(t1, t2);
    }

}

이 코드에서 각 Thread.MemoryBarrier()의 역할을 설명하겠다. MemoryBarrier()는 CPU나 컴파일러의 명령어 재정렬을 방지하여 코드 실행 순서를 보장하는 역할을 한다. 이로 인해 코드의 메모리 읽기 및 쓰기 순서가 정확하게 유지되며, 멀티스레드 환경에서 데이터의 가시성을 높일 수 있다.

A() 메서드 내의 MemoryBarrier()

  1. 첫 번째 MemoryBarrier() ( _answer = 123; 이후):
    • _answer = 123을 메모리에 쓰는 작업이 완료된 후에야 그 뒤의 _complete = true;가 실행되도록 보장한다.
    • 이 배리어는 _answer를 설정한 이후 그 값을 메인 메모리에 즉시 쓰도록 강제하여, 다른 스레드에서 _answer를 읽을 때 최신 값을 보도록 한다.
  2. 두 번째 MemoryBarrier() ( _complete = true; 이후):
    • _complete = true;를 메모리에 쓰는 작업이 메모리 배리어 이전에 반드시 완료되도록 보장한다.
    • 이 배리어는 _complete의 변경이 다른 스레드에서 관찰될 수 있도록 보장하여, 이후의 명령어가 이 변경을 반영하게 한다.

B() 메서드 내의 MemoryBarrier()

  1. 첫 번째 MemoryBarrier() ( if (_complete) 이전):
    • 이 배리어는 if (_complete) 이전에 어떤 읽기/쓰기 작업이 있었다면, 그 작업이 완료된 후에 _complete의 상태를 확인하도록 보장한다.
    • _complete를 읽기 전에 이전 명령어의 완료를 보장해, 정확한 메모리 읽기를 수행하게 한다.
  2. 두 번째 MemoryBarrier() ( Console.WriteLine(_answer); 이전):
    • _completetrue일 때만 실행되는 코드 블록의 시작 부분에 위치해 있다.
    • 이 배리어는 _completetrue로 판별된 후, _answer의 읽기 작업이 그 이전의 읽기/쓰기 명령어 이후에 실행되도록 보장한다. 이는 _answer의 최신 값이 읽히게끔 하여 이전에 쓰인 값을 확실히 읽도록 한다.

전체적인 역할

  • A() 메서드의 배리어들_answer를 먼저 설정하고 그 값이 메인 메모리에 반영된 후 _completetrue로 설정되도록 하여 다른 스레드에서 이 순서대로 보게 만든다.
  • B() 메서드의 배리어들_completetrue로 확인된 후에 _answer를 읽을 때 최신 값을 보장하도록 만든다.
  • 각각의 배리어는 코드 순서가 재정렬되지 않도록 하여, 멀티스레드 프로그램에서 변수의 쓰기 및 읽기 순서가 정확하게 보이게 하고, 코드의 일관성을 유지한다.

이 메모리 배리어들은 스레드 간 데이터의 일관성을 높이지만, 코드의 복잡성을 줄이고 더 높은 수준의 동기화를 보장하려면 lock이나 다른 동기화 메커니즘을 사용하는 것이 일반적이다.