C# 서버

캐시

영춘권의달인 2024. 4. 28. 22:48

캐시 

CPU의 코어에는 ALU라고 하는 연산을 하는 장치와 데이터를 기억하는 장치인 캐시가 있다.

RAM과 CPU는 물리적으로 거리가 있기 때문에 매번 RAM에서 데이터를 가져와서 사용하는 것은 느리다.

그래서 필요한 데이터들을 임시로 저장해놓고 사용하고, 이 임시 데이터들을 저장하는 장소를 캐시하고 한다. 데이터를 변경할때도 마찬가지이다.

바로 메모리에 변경된 데이터를 올리는 것이 아니라, 일단 자신의 캐시에 저장해놓고 나중에 실제 메모리에 한번에 올린다.

 

캐시는 RAM에 비해서는 공간이 매~~~우 작은데 그럼 RAM에서 어떤 데이터를 임시로 가지고 있어야 할까?

캐시에는 Temporal Locality(시간적 지역성), Spacial Locality(공간적 지역성)이라는 두가지 철학이 있다.

Temporal Locality는 최근에 참조된 메모리는 곧 다시 참조될 가능성이 높다는 것이고, Spacial Locality는 참조된 메모리 근처의 메모리들이 참조될 가능성이 높다는 것이다.

캐시는 이 두 철학을 바탕으로 데이터를 관리한다.

 

요즘 컴퓨터는 대부분 멀티코어이고, 이는 멀티쓰레드 환경에서 두개의 코어가 하나의 프로세스에 동시에 할당될 수 있다는 뜻이다.

자신의 코어에 임시로 데이터를 저장해서 사용하는 캐싱은 싱글쓰레드에서는 문제없지 동작하지만 멀티쓰레드에서는 문제가 발생할 수 있다.

예를들어 두개의 코어가 동시에 할당되었을때 1번코어에서 데이터가 변경이 되었고 나중에 한번에 이것을 적용하기 위해 캐시에 기록해 놓는다. 그리고 그 직후에 2번코어에서 해당 데이터를 RAM에서 가져와서 사용하려고 하면 1번코어의 캐시에서만 변경이 되었고 RAM에는 아직 적용이 되지 않았기 때문에 의도하지 않은 결과를 얻을 수 있다.

그래서 공유 메모리에서 데이터를 읽어오거나 수정할때는 모든 스레드가 같은 데이터를 볼 수 있어야 하고, 이를 가시성이라고 한다.

 

메모리 배리어

class Program
{
    static int x = 0;
    static int y = 0;
    static int r1 = 0;
    static int r2 = 0;

    static void Thread_1()
    {
        y = 1; // Store y
        r1 = x; // Load x
    }

    static void Thread_2()
    {
        x = 1; // Store x
        r2 = y; // Load y
    }

    static void Main(string[] args)
    {
        int count = 0;
        while(true)
        {
            count++;
            x = y = r1 = r2 = 0;
            Task t1 = new Task(Thread_1);
            Task t2 = new Task(Thread_2);
            t1.Start();
            t2.Start();

            Task.WaitAll(t1, t2);

            if (r1 == 0 && r2 == 0)
                break;
        }
        Console.WriteLine($"{count}");
    }
}

 

위의 코드에서 while문을 빠져나올 수 있을까?

가능한 경우들 :

1->2->3->4

1->3->2->4

1->3->4->2

3->1->2->4

3->1->4->2

3->4->1->2

어떤 경우를 생각해봐도 r1과 r2이 같이 0이 되는 경우는 없다.

하지만 실행해보면 빠져나오는 경우가 있다. 대체 어떻게 빠져나올 수 있는 것일까???

 

논리적으로 도저히 나올 수 없는 결과지만 하드웨어의 최적화 작업으로 인해 이런 결과가 나올 수 있다.

CPU의 입장에서 일련의 명령어들을 봤을때 각각의 명령어들이 서로 연관성이 없고 순서를 바꿔서 실행하면 더 효율적이라고 판단이 된다면 명령의 순서를 바꿔서 실행할 수 있다. 즉, r1=x를 실행한 뒤에 y=1을 실행할 수도 있다.

서로의 명령어가 서로 연관성이 없기 때문에 싱글쓰레드에서는 전혀 문제가 되지 않지만 멀티쓰레드에서는 위와 같이 의도하지 않은 방향으로 동작할 수 있다.

static void Thread_1()
{
    y = 1; // Store y
    Thread.MemoryBarrier();
    r1 = x; // Load x
}

이런 경우에 메모리 배리어를 사용하여 코드를 재배치하는것을 막을 수 있다.

 

그런데 캐시 얘기를 하다가 갑자기 왜 메모리 배리어 얘기를 하는 것인가?

사실 메모리 배리어는 코드 재배치 억제와 더불어 가시성에도 도움을 준다.

위에서 말했듯이 가시성이란 모든 스레드가 같은 데이터를 볼 수 있는 것이다.

공유 데이터를 수정한 뒤와 가져오기 전에 메모리 배리어를 실행하면 메모리 정보가 동기화되고 가시성이 보장되어 모두 같은 데이터를 볼 수 있게 된다.

메모리 배리어를 이용해 멀티쓰레드 환경에서 캐시를 사용할때의 가시성 문제를 해결할 수 있다.

 

메모리 배리어를 명시적으로 사용하지 않더라도 나중에 사용할 Lock, Atomic 등의 기능에서 내부적으로 메모리 배리어 기능이 들어가 있기 때문에 메모리 배리어를 직접 사용하는 일은 거의 없을 것이다.

'C# 서버' 카테고리의 다른 글

게임 입장  (0) 2024.05.04
서버 - Unity클라 통신 테스트  (0) 2024.05.03
Lock  (0) 2024.04.29
Race Condition(경합 조건), Atomic(원자성)  (0) 2024.04.29
멀티쓰레드 프로그래밍  (0) 2024.04.28