공부/게임서버

가시성 문제와 보장 방법

samosa 2024. 11. 10. 22:42

가시성(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이나 다른 동기화 메커니즘을 사용하는 것이 일반적이다.