언어/C#

단순동기화2 - 유저 모드 동기화

tsyang 2022. 5. 6. 02:22

2022.05.01 - [언어/C#] - CLR 단순 동기화1

 

CLR 단순 동기화1

스레드 동기화 스레드 동기화는 일반적으로 다수의 스레드가 공유 데이터에 '동시에' 접근하는 경우에도 데이터가 손상되는 것을 막기 위해서 사용된다. 그러나 스레드 동기화는 많은 문제를

tsyang.tistory.com

 

유저 모드 동기화 요소


CLR은 bool, byte, short, int, float, 참조 타입의 변수에 대해서는 원자적(atomic)으로 값을 읽고 쓸 수 있음을 보장한다. 이게 뭔말이냐면

 

int x = 0;
x = 0x01234567;

x 변수가 0x00000000에서 0x01234567로 한 번에 번경된다는 말이다. 즉, 변경 중인 상태의 값을 얻어올 가능성이 없다는 말이다. 

 

엥.. 그럼 아닌 경우도 있음? ㅇㅇ 있음

Int64 x = 0;
x = 0x0123456789abcdef;

이 경우 다른 스레드가 x변수에 접근했을 때, 0x0123456700000000 이나 0x0000000089abcdef를 얻을 가능성이 있다. 왜냐면 Int64 타입의 변수는 CLR이 원자적인 읽/쓰를 보장하지 않기 때문이다. (double도 마찬가지) 이를 쪼개어진 읽기(torn read)라고 부른다.

 

변수는 컴파일러나 CPU의 최적화 방법에 따라 읽고 쓰는 시기가 변경될 가능성이 있다. 단순 유저 모드 동기화 요소는 바로 변수를 읽고 쓰는 타이밍을 결정 짓는 주요 요소라 할 수 있다. 단순 유저 모드 (스레드) 동기화 요소에는 Volatile과 Interlocked가 존재한다.

 

 

Volatile 


컴파일러는 최적화를 한다. 그러나 멀티 스레드 환경에서 이런 최적화 작업이 문제를 발생시킬 수 있다. 다음의 예를 보자.

public static class Program
{
    private static bool s_stopWorker = false;

    private static void Worker(object obj)
    {
        int x = 0;
        while (!s_stopWorker) x++;
        Console.WriteLine(x);
    }
}

위 코드의 Worker 메서드에서는 s_stopWorker 변수를 변경하지 않으므로, 컴파일러는 while루프롤 돌 때 매번 s_stopWorker 변수의 값을 확인하는게 아니라 루프를 한 번도 수행하지 않거나, 무한하게 x를 증가시키는 코드를 생성한다. (물론 컴파일러마다 다름)

private static void Main()
{
    Thread t = new Thread(Worker);
    t.Start();
    Thread.Sleep(1000);
    s_stopWorker = true;
    Console.WriteLine("Join");
    t.Join();
}

따라서 위의 코드는 원래대로라면 약 1초 뒤에 정상 종료되어야 하지만, 컴파일러에 따라 프로그램이 종료되지 않을 수 있다.

 

 

또 다른 예제를 보자

public class ThreadSharingData
{
    private int m_flag = 0;
    private int m_value = 0;

    //1번 스레드에 의해 수행된다.
    public void Thread1()
    {
        m_value = 5;
        m_flag = 1;
    }

    //2번 스레드에 의해 수행된다.
    public void Thread2()
    {
        if (m_flag == 1) Console.WriteLine(m_value);
    }
}

Thread2에서 콘솔에 값을 출력한다면, 논리적으로 '5'를 출력할 수 밖에 없다고 생각할 수 있다. 

 

그러나 컴파일러나 CPU가 이 코드를 번역할 때, Thread1 메서드 내부의 코드를 뒤집어서 번역할 수도 있다. 왜냐면 Thread1 메서드 내의 실행 순서를 바꿔도 의미가 변경되지 않기 때문이다. 만약 이런 일이 일어난다면 Thread2() 코드에서는 '0'을 출력할 수도 있게 된다. 

 

혹은, 위 코드를 순서대로 실행했다고 하더라도, Thread2가 m_value를 미리 CPU 레지스터에 올려두어 0이 출력될 수도 있다.

 

 

Volatile이라는 정적 클래스는 Read/Write 메서드를 재공한다. 이 메서드들은 C# 컴파일러, JIT컴파일러, CPU가 이 코드를 최적화하려 할 때, 일부 최적화 기법을 적용하지 못하도록 한다.

 

이 메서드들은 메서드를 호출한 위치에서 그 값이 쓰여질(읽혀질) 것을 보장한다. 또한 로드/스토어 (메모리의 값을 CPU에 쓰거나, 반대로 CPU에서 메모리에 값을 저장하는 것) 과정이 의도대로 수행될 것임을 보장한다.

 

public class ThreadSharingData
{
    private int m_flag = 0;
    private int m_value = 0;

    //1번 스레드에 의해 수행된다.
    public void Thread1()
    {
    //m_flag 값이 1로 바뀌기 전에 m_value가 5로 바뀌는걸 보장
        m_value = 5;
        Volatile.Write(ref m_flag, 1);
    }

    //2번 스레드에 의해 수행된다.
    public void Thread2()
    {
    //m_flag 값을 먼저 가져온 다음 m_value 값을 가져오는 것을 보장
        if (Volatile.Read(ref m_flag) == 1)
        	Console.WriteLine(m_value);
    }
}

이런 코드를 앞서 살펴본 예제에 적용하면 위와 같이 된다. 

 

C#의 Volatile 필드

JIT컴파일러는 volatile로 선언된 필드를 사용하는 코드를 컴파일 할 때 Volatile.Read/Write를 사용하도록 해준다. 또한 이 키워드를 사용하면 C# 컴파일러와 JIT 컴파일러에게 이 필드의 값을 CPU 레지스터로 캐싱하지 않도록 하여, 항상 메모리부터 값을 읽고 쓰도록 한다. (속도 느리겠네..)

 

public class ThreadSharingData
{
    private volatile int m_flag = 0;
    private int m_value = 0;

    public void Thread1()
    {
    //m_flag(volatile) 값이 바뀌기 전에 m_value 값이 바뀐다.
        m_value = 5;
        m_flag = 1;
    }

    //2번 스레드에 의해 수행된다.
    public void Thread2()
    {
    //m_flag(volatile)값을 먼저 가져온 다음 m_value 값을 가져온다.
        if (m_flag == 1) Console.WriteLine(m_value);
    }
}

 

그러나 volatile 키워드는 단점이 있는데,

m_value = m_value + m_value; //m_value는 volatile로 선언되어 있음.

위 코드같은 경우, volatile 키워드가 적용 안 된 변수라면 1비트 시프트 연산을 통해 값을 두 배로 만드는 최적화 기법을 적용할 수 있지만 volatile로 선언된다면 이런 최적화 과정이 적용되지 않으므로, 느리다. 만약 이런 코드가 루프 내에서 실행 된다면... (그니까 필요할때만 Volatile.Read/Write를 사용하는 게 좋을지도)

 

 

 

Interlocked


Interlocked 클래스의 메서드들은 메모리 펜스(memory fence, 메모리 베리어라고도 함)기능을 제공한다. 메모리 펜스 기능은 Interlocked 메서드를 호출하기 이전/이후에 수행된 쓰기 작업은 반드시 Interlocked 메서드 호출 이전/이후에 호출될 것임을 보장하는 기능이다.

(그러면 volatile도 똑같은거 아닌가..? 하는데 volatile은 본디 최적화만 막는 것이고 CPU의 명령어 재배치는 어쩔 수 없는 것이라 한다. 그러나 요즘의 volatile은 내부적으로 이런 메모리 베리어가 적용되어 있다고 한다. 그러나 정확하지 않음)

 

Interlocked 메서드는 굉장히 유용하다. 상대적으로 속도도 빠르고 다용도로 활용할 수 있기 때문이다. Interlocked 에는 Increment/Decrement/Add/Exchange 등의 메서드가 존재하며 원자적으로 수행된다. 특이하게 대부분의 작업이 int만을 다룬다.

 

즉, 멀티 스레딩 환경에서 다음과 같이 counter를 증가시킬 수 있다.

Interlocked.Increment(ref this.counter);

 

Interlocked은 And Or Xor같은 기능이나 float, double과 같은 타입은 지원하지 않는데 CompareExchange 메서드로 거의 대부분을 구현 가능하다. 이건 쓸 때나 찾아볼 것.

 

 

스핀 락

Interlocked 메서드를 통해 간단한 스핀락(Spin lock)를 구현할 수 있다. 

internal struct SimpleSpinLock
{
    private int _isResourceInUse;

    public void Enter()
    {
        while (true)
        {
            //Exchange는 변경되기 전의 _isResourceInUse의 값을 반환한다.
            if (Interlocked.Exchange(ref _isResourceInUse, 1) == 0) return;

            //return 되지 못한 스레드는 여기서 무한 루프를 타게 된다.

            //최적화를 위한 흑마술 코드
        }
    }

    public void Leave()
    {
        Volatile.Write(ref _isResourceInUse, 0);
    }
}
public class Example
{
    private SimpleSpinLock _spinLock = new SimpleSpinLock();

    public void OnlyOneThread()
    {
        _spinLock.Enter();
        // 단 하나의 스레드만 이 곳으로 올 수 있다.
        _spinLock.Leave();
    }
}

1,2번 쓰레드가 동시에 OnlyOneThread() 메서드를 호출했고, 동시에 Enter()메서드에 도달했다고 하자. 오직 하나의 스레드만 Interlocked.Exchange에서 _isResource의 값을 바꾼 뒤 루프를 탈출할 수 있다. 다른 스레드는 Enter()의 루프를 무한히 돌다가 다른 스레드가 Leave를 통해 _isResourceInUse의 값을 0으로 바꾼 뒤에야 Enter를 탈출하고 다음 코드로 넘어간다.

 

물론 이런 스핀락 방식은 간단하지만 스레드가 계속 루프를 돌아 CPU타임을 허비하기 때문에 보호하려는 구간이 짧고, 아주 빠르게 그 구간을 벗어날 수 있는 경우에만 사용하는게 좋다.

 

또한 스핀락은 단일 CPU를 가진 컴퓨터에서는 안 쓰는 게 좋다. 왜냐면 락을 소유하지 않은 스레드가 루프를 계속 돌면 락을 소유한 스레드가 락을 빠르게 해제할 수 없기 때문이다. 만약 우선순위까지 밀린다면? 라이브락이 발생한다.

 

루프 내에 흑마술 코드라고 적힌 부분이 이런 문제를 우회하기 위한 코드를 구현하고 있으며 최신의 흑마술 기법을 사용한 것이 System.Threading.SpinWait 구조체이다.

 


참고 : 제프리 리처, CLR via C# 4판 , 비제이퍼블릭, 29장. 단순 스레드 동기화 요소  [883~919p]

https://stackoverflow.com/questions/154551/volatile-vs-interlocked-vs-lock

 

Volatile vs. Interlocked vs. lock

Let's say that a class has a public int counter field that is accessed by multiple threads. This int is only incremented or decremented. To increment this field, which approach should be used, and...

stackoverflow.com

 

'언어 > C#' 카테고리의 다른 글

복합 스레드 동기화 요소  (0) 2022.05.29
단순동기화3 - 커널 모드 동기화  (0) 2022.05.09
CLR 단순 동기화1  (0) 2022.05.01
CLR 스레딩3 - 태스크(Task)  (0) 2022.04.24
CLR 스레딩2 단순 계산 작업  (1) 2022.04.17