Server programming

01_15_멀티쓰레드_ThreadLocalStorage

devRiripong 2023. 3. 31.
반응형

Q1. TLS를 사용하는 게 무슨 이득이 있는가?

Q2. TLS의 Value에 값을 반복해서 값을 넣지 않게할 방법은 무엇인가?

Q3. TLS를 날리고 싶다면?

 

 

전역 변수인데 스레드마다 고유하게 접근할 수 있는 전역변수라고 생각하면 된다. 이게 왜 필요한지 알아보자.

각 일들이 서로 연관성이 있을 거야.

이리저리 화살표로 옮겨다니면서 하는 경우가 있어.

경합이 일어날만한 모든 장소에다 락을 걸면 어떻게 될까?

주방 내에서도 동기화 작업이 필요할 수 있다.

요리사가 두명 존재하는데 만약 같은 주방 공간 사용하면 서로 방해가 될거야. 그래서 락이 필요하게 될거고,

서빙하는 사람 입장에서도 10번 테이블이라고 하면 동시 다발적으로 10번 테이블로 올 수 있다. 두명의 직원이 동시에 한 테이블에 몰리는 건 말이 안되는 상황이야.

일감 분배를 어떻게 해야 할지가 멀티쓰레드의 핵심이야. 정해진 방법은 없고 최선의 방법을 계속 찾아 나서는 거 밖에는 없다.

게임로직, 로그, DB, 클라이언트 얘네들이 다 코드 로직적으로 연동이 되어 있을거야.

모든 로직들이 서로 연관성이 있게 얽혀있다.

락을 모든 부분에 하나씩 배치를 하면 간단히 생각을 하면 해결이 될거 같지만 치명적인 문제가 있다. 한쪽에 몰리는 경우에 대한 처리가 어려워진다.

예를 들어 MMORPG에서 많은 유저들이 한 마을에 쳐들어 가는 경우가 있을 거야.

모든 유저들이 같은 공간에서 패킷을 쏘게 될거야. 결국 게임 로직 자체가 실행할 수 있는 영역이 넓은데 특정 구역으로만 일감이 한번에 쏠리는 현상이 발생하면 그것을 처리하고 싶으니까 모든 직원이 모이게 될거야.

모든 행위들에 락을 걸면 문제는 상호 배타적인 개념이라 모든 직원들이 몰린다고 해도 한번에 처리할 수 있는 분량은 한번에 한명씩 밖에 못들어가게 된다.

멀티 스레드로 돌려도 직원을 많이 채용을 해봤자 결국 처리할 수 있는 공간은 좁다 보니까 한명으로 처리해도 똑같은 분량을 뭉쳐서 처리하는게 된다. 오히려 멀티스레드로 돌리는게 더 안좋아. 문 잠그고 여는데도 부하가 걸리기 때문이야.

무조건 멀티스레드라고 해서 락만 걸고 들어가는게 최선의 방법이라고 장담 할 수 없다 라는게 가장 큰 문제다.

 

그래서 일감 분배하는게 중요한데 그 중에서 ThreadLocalStorage가 중요하게 사용이 되기에 알아보기로 하자.

TLS

만약 식당에서 같은 한식 메뉴를 동시 다발적으로 시켰다고 가정하자. 직원이 한그릇씩 가져다 갖다놓고 하면 비효율적이 될거야. 보통 어떻게 하냐면 서빙할 때 아주 큰 쟁반에 반찬들을 최대한 담을 수 있을 만큼 담아서 한번에 옮긴다. 결국에는 일감이 몰릴 때는 한번에 많이 가져가서 가져간 다음에 분배하는 것도 유용하게 사용이 된다.

스레드 별로 공유하는 영역이 있고 독립적인 영역이 있다고 했었어. 힙 영역과 데이터 영역은 모든 스레드들이 공유해서 사용하고 있는데 스택 같은 경우는 제각각 따로 사용하고 있었어.

큰 쟁반을 일단 스택에 두면 될까 싶은데 스택은 대부분 함수에서 사용하는 용도로 임시적인 메모리로 사용하고 있었어. 함수가 호출 완료 될 때에는 스택 프레임이 올라가면서 일부 영역은 사용하지 않게 된다. 스택 메모리는 사용하기 불안정한 메모리라는 거.

우리한테 필요한 건 Heap처럼 언제나 접근할 수 있지만, 모두가 공유하는 게 아니라 각자 따로 사용할 수 있는 전용공간이 있으면 편리할 거야.

이게 TLS의 개념이야.

Heap에 일감이 몰렸는데 하나씩 꺼내가기 보다는 엄청나게 큰 웅큼을 가져가서 큰 덩어리를 TLS에 가져온 다음에 여기서 자기가 하나씩 까먹는 비유를 할 수 있다.

일단 코드로 돌아와서 간단히 실습해보자.

namespace ServerCore
{
    class Program
    {
        **static ThreadLocal<string> ThreadName = new ThreadLocal<string>();** 
// 자신만의 공간에 저장되기 때문에 특정 스레드에서 ThreadName을 고친다고 해도 다른 애들한테 영향을 주지 않게 된다. 

        static void WhoAmI()
        {
            ThreadName.Value = $"My Name Is {Thread.CurrentThread.ManagedThreadId}";

            Thread.Sleep(1000);     // 이 시간 동안 다른 스레드에서 이름을 고쳤는데 영향을 준다면 이상한 이름이 나올거야. 

            Console.WriteLine(ThreadName.Value); 
        }
        
        static void Main(string[] args)
        {
            Parallel.Invoke(WhoAmI, WhoAmI, WhoAmI, WhoAmI, WhoAmI, WhoAmI, WhoAmI, WhoAmI);
        }
    }
}

이런식으로 해서 실행을 해보면

이런 식으로 제 각각 다른 이름이 나오고 있다.

using System;
using System.Threading;
using System.Threading.Tasks;

namespace ServerCore
{
    class Program
    {
        //static ThreadLocal<string> ThreadName = new ThreadLocal<string>(); // 자신만의 공간에 저장되기 때문에 특정 스레드에서 ThreadName을 고친다고 해도 다른 애들한테 영향을 주지 않게 된다. 
        static string ThreadName; 

        static void WhoAmI()
        {
            ThreadName = $"My Name Is {Thread.CurrentThread.ManagedThreadId}";

            Thread.Sleep(1000);     // 이 시간 동안 다른 스레드에서 이름을 고쳤는데 영향을 준다면 이상한 이름이 나올거야. 

            Console.WriteLine(ThreadName); 
        }
        
        static void Main(string[] args)
        {
            Parallel.Invoke(WhoAmI, WhoAmI, WhoAmI, WhoAmI, WhoAmI, WhoAmI, WhoAmI, WhoAmI);
        }
    }
}

이렇게 static string으로 바꿔주면

이런 식으로 똑같은 애들이 뜨고 있다.

ThreadLocal을 사용하면 전역변수이긴 전역변수인데 스레드마다 고유한 공간이 생겼다고 보면 된다.

하나의 문제점은 WhoAmI할 때 무조건 덮어쓰는 거야.

이미 다른 애가 같은 ID로 실행을 한 상태여서 이미 내 ID를 갖고 있으면 즉 내 Name을 갖고 있으면 굳이

        ThreadName = $"My Name Is {Thread.CurrentThread.ManagedThreadId}";

이걸 실행을 안해도 되는데 한번 더 하고 있다. 무슨 말이냐

using System;
using System.Threading;
using System.Threading.Tasks;

namespace ServerCore
{
    class Program
    {
        static ThreadLocal<string> ThreadName = new ThreadLocal<string>(); // 자신만의 공간에 저장되기 때문에 특정 스레드에서 ThreadName을 고친다고 해도 다른 애들한테 영향을 주지 않게 된다. 

        static void WhoAmI()
        {
            ThreadName.Value = $"My Name Is {Thread.CurrentThread.ManagedThreadId}";

            Thread.Sleep(1000);     // 이 시간 동안 다른 스레드에서 이름을 고쳤는데 영향을 준다면 이상한 이름이 나올거야. 

            Console.WriteLine(ThreadName.Value); 
        }
        
        static void Main(string[] args)
        {
            **ThreadPool.SetMinThreads(1, 1);
            ThreadPool.SetMaxThreads(3, 3);** 
            Parallel.Invoke(WhoAmI, WhoAmI, WhoAmI, WhoAmI, WhoAmI, WhoAmI, WhoAmI, WhoAmI);
        }
    }
}

이렇게 하고 실행을 해보면

이런 식으로 겹치는 애가 나온다.

에당초 ThreadPool을 사용하는 것이라 돌아오자마자 다른 애를 실행주는 상태인데 지금 코드 상태에서는 매번마다 ThreadName.Value를 써주고 있어. 그게 마음에 안들면 인자를 추가해줄 수 있다.

ValueFactory라는 애가 있어. 간단히 만들어 보면

using System;
using System.Threading;
using System.Threading.Tasks;

namespace ServerCore
{
    class Program
    {
        **static ThreadLocal<string> ThreadName = new ThreadLocal<string>(() => { return $"My Name Is {Thread.CurrentThread.ManagedThreadId}"; });**

        static void WhoAmI()
        {
            Thread.Sleep(1000);     // 이 시간 동안 다른 스레드에서 이름을 고쳤는데 영향을 준다면 이상한 이름이 나올거야. 

            Console.WriteLine(ThreadName.Value); 
        }
        
        static void Main(string[] args)
        {
            ThreadPool.SetMinThreads(1, 1);
            ThreadPool.SetMaxThreads(3, 3); 
            Parallel.Invoke(WhoAmI, WhoAmI, WhoAmI, WhoAmI, WhoAmI, WhoAmI, WhoAmI, WhoAmI);
        }
    }
}

이렇게 해주면 실행 될 때 마다 ThreadName을 만들어 주는게 아니라 ThreadName이라는 값이 세팅이 안됐으면

return $"My Name Is {Thread.CurrentThread.ManagedThreadId}" 이 부분을 실행해서 ThreadName.Value에 넣어 줄 것이고, 그게 아니면 Value를 그냥 쭉 사용하면 된다.

테스트를 해보면

using System;
using System.Threading;
using System.Threading.Tasks;

namespace ServerCore
{
    class Program
    {
        static ThreadLocal<string> ThreadName = new ThreadLocal<string>(() => { return $"My Name Is {Thread.CurrentThread.ManagedThreadId}"; }); 
// 자신만의 공간에 저장되기 때문에 특정 스레드에서 ThreadName을 고친다고 해도 다른 애들한테 영향을 주지 않게 된다. 

        static void WhoAmI()
        {
            bool repeat = ThreadName.IsValueCreated;
            if (repeat)
                Console.WriteLine(ThreadName.Value + " (repeat)");
            else
                Console.WriteLine(ThreadName.Value); 
        }
        
        static void Main(string[] args)
        {
            ThreadPool.SetMinThreads(1, 1);
            ThreadPool.SetMaxThreads(3, 3); 
            Parallel.Invoke(WhoAmI, WhoAmI, WhoAmI, WhoAmI, WhoAmI, WhoAmI, WhoAmI, WhoAmI);
        }
    }
}
        **bool repeat = ThreadName.IsValueCreated;** 

이미 만들어져 있으면 true를 뱉어 주고, 한번도 만들어준 적 없으면 false를 뱉어줄거야.

이 상태에서 ctrl+f5로 실행해 보면

 

6,6,6,6은 기존에 만든 애니까 캐치를 해서 repeat이라는 게 뜨는 것을 알 수 있다.

IsValueCretaed가 true이면

=> { return $"My Name Is {Thread.CurrentThread.ManagedThreadId}"; });

얘를 굳이 다시 만들지 않을거야. 재사용하는거야.

이 버전이 아까 보다 조금 더 효율적.

bool repeat이 false면 맨 처음으로 만든다는 뜻이야.

중단점 찍어서 보면 ThreadName.Value가 null인 걸 볼 수 있다.

null일 때

return $"My Name Is {Thread.CurrentThread.ManagedThreadId}"; }

이 부분이 실행이 된다.

ThreadLocal을 사용할 때 꼭 static일 필요는 없지만 대부분의 경우 static을 붙여서 전역으로 사용되는 경우가 많다.

namespace ServerCore
{
    class Program
    {
        static ThreadLocal<string> ThreadName = new ThreadLocal<string>(() => { return $"My Name Is {Thread.CurrentThread.ManagedThreadId}"; }); // 자신만의 공간에 저장되기 때문에 특정 스레드에서 ThreadName을 고친다고 해도 다른 애들한테 영향을 주지 않게 된다. 

        static void WhoAmI()
        {
            bool repeat = ThreadName.IsValueCreated;
            if (repeat)
                Console.WriteLine(ThreadName.Value + " (repeat)");
            else
                Console.WriteLine(ThreadName.Value); 
        }
        
        static void Main(string[] args)
        {
            ThreadPool.SetMinThreads(1, 1);
            ThreadPool.SetMaxThreads(3, 3); 
            Parallel.Invoke(WhoAmI, WhoAmI, WhoAmI, WhoAmI, WhoAmI, WhoAmI, WhoAmI, WhoAmI);

            **ThreadName.Dispose();** 
        }
    }
}

필요 없어져서 날리고 싶으면 Dispose를 써주면 된다.

 

일감들이 어마어마하게 많게 Queue에 저장이 되어 있다고 하면 , Queue에다가 하나씩만 꺼내서 쓰는게 아니라 뭉퉁이를 다 꺼내 와서 다 자기만의 공간에 넣어둔 다음에 그 다음에 TLS에서 사용하는 것은 굳이 락을 걸지 않더라도 얘는 아무런 부담 없이 뽑아 쓸 수 있을거야.

    static ThreadLocal<string> ThreadName = new ThreadLocal<string>(() => { return $"My Name Is {Thread.CurrentThread.ManagedThreadId}"; }); 

여기 들어왔다는 건 여긴 나의 영역이니 다른 스레드들은 접근할 수 없는 공간이니 여기서 일감들을 다 모아 놓은 다음에 필요할 때 마다 하나씩 꺼내서 실행을 하면 될거야. 그런 식으로 어떤 공용 공간에다가 접근하는 횟수를 줄이는 것도 의미가 있는 거다.

 

// [JobQueue]라는 애가 있는데 static이라는 공간에 있다고 하면 모든 스레드들이 다 동시 다발적으로 경합을 하게 될거야. // 그럼 락을 걸었다가 풀었다가 반복을 해야 하는데 한번 락을 잡을 때 일감을 하나만 빼오는게 아니라 TLS공간에다가 실컷 많이 뽑아 오면 된다는 얘기다. // 락을 한 번 걸고 최대한 많은 일감을 빼온 거니까 static ThreadLocal<string> ThreadName 여기 있는 일감을 처리하기 전까지는 JobQueue 이 전역에 있는 좌표에다 접근을 할 필요가 없게 된다. // 그런 식으로 부하를 줄일 수 있다는 얘기다 .

 

그 밖에 Thread 내에서 사용할 고유의 전역 변수를 선언하고 싶다면 이런 느낌으로 ThreadLocal을 사용하면 된다.

이정도가 기본기가 갖춰졌다고 보면 된다.

 

출처: https://inf.run/P4nt

반응형

댓글