Published on

멀티 스레드 프로그래밍

Authors

스레드 풀(Thread Pool)

스레드풀이미지

스레드 풀은 작업을 처리할 스레드를 재사용하는 구조다. .NET의 풀은 부하에 따라 작업자 수를 조정하며, 최소 스레드 수 설정이 그 수만큼 즉시 스레드를 미리 만든다는 뜻은 아니다.

스레드 풀을 사용하는 이유

스레드가 생성되거나 수거될 때 무시하지 못할 양의 생성 비용이 들어간다. 또한 스레드의 개수가 많으면 몇가지 문제가 있는데, 첫번째로는 스레드가 하나하나 할 작업이 별로 없어져 오히려 스레드를 생성하고 수거하는 비용이 실제로 하는 작업보다 커진다. 두번째로, 스레드의 컨텍스트 스위치에 인한 부하가 커져 오히려 효율이 떨어진다. 마지막으로 서버의 메모리 부족을 유발할 수 있기 때문이다.

스레드 풀을 사용할 때 주의점

스레드 풀의 스레드가 전부 사용되고 반환되지 못하면 프로그램의 실행이 더 이상 되지 못 할 수 있다.

코드

C# 에서 스레드 풀을 사용하지 않고 스레드를 생성하는 코드는 아래와 같다. start()를 통해 스레드를 시작하고 join()을 사용해 해당 쓰레드가 종료되기까지 기다렸다가 다음으로 넘어간다.

using System;
using System.Threading;

namespace ServerCore
{
    class Program
    {
        static void MainThread()
        {
            for (int i = 0; i < 5; i++)
                Console.WriteLine("Hello Thread!");
        }

        static void Main(string[] args)
        {
            Thread t = new Thread(MainThread);
            t.Name = "Test Thread";
            t.IsBackground = true;
            t.Start();

            Console.WriteLine("Waiting for Thread!");

            t.Join();
            Console.WriteLine("Hello World!");
        }
    }
}

만약 다음과 같이 스레드를 1000개 실행한다면 스레드의 생성을 1000개 하는 것 자체는 가능하지만 이는 위에서 설명한 것처럼 오히려 실행의 효율을 떨어뜨릴 수 있다.

for (int i = 0; i < 1000; i++)
{
	Thread t = new Thread(MainThread);
	t.Name = "Test Thread";
	t.IsBackground = true;
	t.Start();
}

스레드 풀은 아래와 같이 사용할 수 있다. serMinThreads()와 SetMaxThreads()를 통해 스레드 풀의 스레드 개수를 제어할 수 있다.

using System;
using System.Threading;

namespace ServerCore
{
    class Program
    {
        static void MainThread(object state)
        {
            for (int i = 0; i < 5; i++)
                Console.WriteLine("Hello Thread!");
        }

        static void Main(string[] args)
        {
            ThreadPool.SetMinThreads(1, 1);
            ThreadPool.SetMaxThreads(5, 5);

            ThreadPool.QueueUserWorkItem(MainThread);
        }
    }
}

Task

스레드 풀의 작업자는 백그라운드 스레드이므로 주 스레드가 종료되면 작업이 끝나기 전에 프로세스가 종료될 수 있다. 작업 완료를 기다리는 수명 관리가 필요하다.

Task는 비동기 작업을 나타내는 추상화이며 스레드 자체가 아니다. Task.Run은 보통 스레드 풀에서 CPU 작업을 실행하지만, 비동기 I/O의 Task는 대기하는 동안 전용 스레드를 점유하지 않을 수 있다. await나 Wait()로 완료를 기다리고 결과·예외를 받을 수 있다. Task만 만들었다고 프로세스 수명이나 모든 동시성 문제가 자동으로 해결되지는 않는다. Microsoft Task 문서

컴파일러 최적화에 의한 오류

아래의 코드를 visual studio에서 debug 모드로 실행시키면 문제없이 작동한다.

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

namespace ServerCore
{
    class Program
    {
        // 스레드는 각자 자신의 스택 메모리를 갖지만, 전역으로 선언된 변수는 모든 스레드가 공통으로 사용을 해서 동시 접근이 가능하다.
        static bool _stop = false;

        static void ThreadMain()
        {
            Console.WriteLine("쓰레드 시작!");

            while (_stop == false)
            {
                //누군가가 stop 신호를 해주기를 기다린다.
            }

            Console.WriteLine("쓰레드 종료!");
        }
        static void Main(string[] args)
        {
            Task t = new Task(ThreadMain);
            t.Start();

            // 1초 동안 대기
            Thread.Sleep(1000);

            _stop = true;

            Console.WriteLine("Stop 호출");
            Console.WriteLine("종료 대기중");

            t.Wait();

            Console.WriteLine("종료 성공");
        }
    }
}

결과:

쓰레드 시작!
Stop 호출
쓰레드 종료!
종료 대기중
종료 성공

작성 당시 Release 실행에서는 아래와 같이 무한 대기하는 결과를 관찰했다. JIT·.NET 버전·CPU에 따라 재현 여부는 달라질 수 있으며, 다른 환경에서 종료되더라도 동기화 없는 코드가 안전하다는 뜻은 아니다. 결과:

쓰레드 시작!
Stop 호출
종료 대기중

이러한 현상이 일어나는 이유는 컴파일러가 최적화를 하는 과정에서

while(_stop == false)
{

}

는 아래과 같은 모습으로 최적화가 되기 때문이다.

if (_stop == false)
{
	while (true)
    {

    }
}

동기화가 없는 필드는 다른 스레드의 변경을 원하는 시점에 관찰한다는 보장이 없다. 위의 변형은 읽기를 루프 밖으로 옮길 수 있다는 설명용 모델이며 실제 JIT 생성 코드를 확인한 결과는 아니다.

단순한 플래그에는 Volatile.Read/Volatile.Write나 volatile 필드가 읽기·쓰기 순서를 정하는 데 쓰인다. 이것이 number++ 같은 복합 연산의 원자성까지 제공하지는 않는다. 여러 값을 함께 보호해야 한다면 lock, 원자적 증감에는 Interlocked, 종료 요청에는 CancellationToken처럼 의도에 맞는 수단을 고른다.

캐시 이론

아래의 그림은 저장공간의 계층 구조를 나타낸다.

저장공간계층구조

그림에서 확인 할 수 있듯이 캐시 메모리는 저장 공간의 계층 구조 상 최상단에 위치해 메인 메모리에 비해 훨씬 작고 가격이 비싸지만 CPU와 물리적으로 가깝기 때문에 속도가 빠르다는 장점이 있다. 캐시는 추가적으로 L1 캐시, L2 캐시, L3 캐시로 나뉜다.

캐시에는 2가지 철학이 있는데,

  • 시간적 지역성(TEMPORAL LOCALITY)
  • 공간적 지역성(SPATIAL LOCALITY) 이다.

시간적 지역성은 시간적으로 보면 방금 사용한 변수가 또 다시 사용될 확률이 높다는 것이다. 그렇기 때문에 방금 사용한 변수를 캐싱한다.

공간적 지역성은 공간적으로 방금 접근한 변수와 인접한 주소에 있는 변수가 다시 사용될 확률이 높다는 것이다. 그렇기 때문에 방금 사용한 변수와 인접한 변수를 캐싱한다.

아래의 코드로 캐싱을 확인해볼 수 있다.

using System;

namespace ServerCore
{
    class Program
    {
        static void Main(string[] args)
        {
            int[,] arr = new int[10000, 10000];

            {
                long now = DateTime.Now.Ticks;
                for (int y = 0; y < 10000; y++)
                    for (int x = 0; x < 10000; x++)
                        arr[y, x] = 1;
                long end = DateTime.Now.Ticks;
                Console.WriteLine($"(y, x) 순서 걸린 시간 {end - now}");
            }

            {
                long now = DateTime.Now.Ticks;
                for (int y = 0; y < 10000; y++)
                    for (int x = 0; x < 10000; x++)
                        arr[x, y] = 1;
                long end = DateTime.Now.Ticks;
                Console.WriteLine($"(x, y) 순서 걸린 시간 {end - now}");
            }
        }
    }
}

위의 코드의 실행 결과는 아래와 같다.

(y, x) 순서 걸린 시간 2797474
(x, y) 순서 걸린 시간 4785704

논리적으로 생각하면 2개의 2 중 for문은 같은 실행 시간을 가져야 하지만 실제로는 적지않은 차이가 나타나는데 이는 캐시의 공간적 지역성과 관련이 있다.

위의 코드는 10000 × 10000 배열이지만 간단히 3 3 배열이라고 생각해보자.

[][][]
[][][]
[][][]

(y, x) 순서에서는 아래와 같은 순서로 접근할 것이다.

[1][2][3]
[4][5][6]
[7][8][9]

이는 배열에서 메모리가 인접한 순서로 접근한 것이기 때문에 Cache Hit가 일어나 실행 속도가 빠른 것이다.

이에 반해 (x, y) 순서로 메모리에 접근하면

[1][4][7]
[2][5][8]
[3][6][9]

위와 같은 순서로 접근하여 Cache Miss가 나 (y, x)에 비해 실행 시간이 길어졌다.

이런 캐싱은 편리하지만 멀티 스레드 환경에서는 아래에서 설명할 메모리 가시성 문제를 일으킨다.

메모리 배리어

아래의 코드를 보고 논리적으로 생각해보면 Main 메소드의 while 문은 절대 탈출 할 수가 없어보인다.

using System;
using System.Threading.Tasks;

namespace ServerCore
{
    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} 번만에 빠져나옴");
        }
    }
}

파란색이 Thread_1, 노란색이 Thread_2 라고 했을 때, Thread1 이 y = 1 => r1 = x 순서로 동작하고, Thread_2가 x = 1 => r2 = y 순서로 동작한다면, r1 과 r2 가 동시에 0 인 경우의 수는 없어야한다.

메모리베리어1

하지만 실제로 실행을 해보면 r1과 r2가 동시에 0인 상황이 발생해 while문을 빠져나오게 된다.

메모리베리어콘솔

이런 결과가 나타나는 이유는 CPU에서 일련의 명령어를 실행 할 때 의존성이 없는 명령어라고 판단이 되면 명령어의 순서를 뒤바꿀 수 있기 때문이다.

즉 CPU가 보기에는 y = 1 과 r1 = x는 연관성이 없는 작업이기 때문에 이 순서를 뒤바꿀 수 있고 아래와 같이 최적화가 되면 반복문을 탈출하게 된다.

메모리베리어2

이런식의 최적화는 싱글 스레드 환경에서는 문제가 되지 않는다. 하지만 멀티 스레드 환경에서는 위의 상황처럼 우리가 예상한 로직이 꼬이는 결과를 초래한다.

이러한 문제를 해결하기 위해 메모리 배리어가 사용된다.

메모리 배리어의 용도

메모리 배리어에는 2가지 용도가 있다.

  • 코드 재배치 억제
  • 가시성

코드 재배치 억제

코드 재배치 억제는 말 그대로 코드 재배치를 억제하는 것이다.

위에서 Thread_1 메소드와 Thread_2 메소드를 아래와 같이 수정해보자.

        static void Thread_1()
        {
            y = 1; // Store y

            //-------------------------------
            Thread.MemoryBarrier();

            r1 = x; // Load x
        }

        static void Thread_2()
        {
            x = 1; // Store x

            //-------------------------------
            Thread.MemoryBarrier();

            r2 = y; // Load y
        }

Thread_1 메서드에서 Thread.MemoryBarrier(), 메모리 배리어를 통해 y = 1 과 r1 = x가 멋대로 재배치 되는 상황을 방지해주는 경계선을 만들어 수 있다. 실제로 수정 후 코드를 실행하면 무한 루트를 돌게 된다.

메모리 베리어는 여러 종류가 있다.

  • full Memory Barrier(ASM MFENCE, C# Thread.MemoryBarrier) : Store/Load 둘 다 막는다.
  • Store Memory Barrier(ASM SFENCE) : Store만 막는다.
  • Load Memory Barrier(ASM LFENCE) : LOAD만 막는다.

하지만 일반적으로 프로그래밍 할 때는 이렇게까지 세분화하지는 않고, 대부분 Full Memory Barrier 가 사용된다.

가시성

가시성이란 "한 Thread에서 변경한 특정 메모리의 값이, 다른 Thread에서 제대로 읽어지는지"를 나타낸다.

현대 CPU에는 캐시 일관성 프로토콜이 있으므로, 코어들이 서로의 캐시 값을 전혀 볼 수 없다는 설명은 맞지 않는다. 다른 스레드가 값을 관찰하는 순서는 컴파일러/JIT 최적화, CPU의 메모리 순서와 store buffer 등도 함께 고려해야 한다.

메모리 배리어는 경계 전후 메모리 접근의 순서를 제한한다. 캐시 전체를 RAM에 쓰고 상대 코어가 RAM에서 다시 읽게 만드는 명령으로 이해해서는 안 된다. Thread.MemoryBarrier()는 호출 전후의 메모리 접근 순서가 서로 넘어가지 않도록 한다. Microsoft MemoryBarrier 문서

lock, Volatile, Interlocked도 각각의 계약에 따라 순서·가시성 또는 원자성을 제공한다. 단일 연산의 원자성과 여러 연산 사이의 순서는 다른 성질이다.

다음 코드는 순서 경계를 설명하기 위해 인용했던 예제다. 메서드 A와 B를 호출하는 실행부는 생략되어 있다.

using System;
using System.Threading;

namespace ServerCore
{
    class Program
    {
        int _answer;
        bool _complete;

        void A()
        {
            _answer = 123;
            Thread.MemoryBarrier(); // Barrier 1
            _complete = true;
            Thread.MemoryBarrier(); // Barrier 2
        }
        void B()
        {
            Thread.MemoryBarrier(); // Barrier 3
            if (_complete)
            {
                Thread.MemoryBarrier(); // Barrier 4
                Console.WriteLine(_answer);
            }
        }

        static void Main(string[] args)
        {
        }
    }
}

Barrier 1은 _answer 쓰기가 _complete 쓰기 뒤로 넘어가지 않도록 한다. Barrier 4는 완료 플래그를 읽은 뒤 _answer를 읽는 순서를 유지한다. 다른 두 배리어도 각각 경계 밖으로 접근이 이동하는 것을 제한하며, 캐시를 특정 방향으로 복사하는 역할로 나누지는 않는다.

완료 플래그를 읽었다고 반드시 true가 되는 것은 아니다. 여기서 필요한 계약은 B가 true를 관찰했다면 앞서 게시한 _answer도 관찰하도록 하는 것이다. 실제 코드에서는 직접 배리어를 조합하기보다 동기화 API의 계약을 사용하는 편이 읽기 쉽다.

Interlocked

아래의 코드를 실행해보자.

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

namespace ServerCore
{
    class Program
    {
        static int number = 0;

        static void Thread_1()
        {
            for (int i = 0; i < 100000; i++)
                number++;

        }

        static void Thread_2()
        {
            for (int i = 0; i < 100000; i++)
                number--;
        }

        static void Main(string[] args)
        {
            Task t1 = new Task(Thread_1);
            Task t2 = new Task(Thread_2);
            t1.Start();
            t2.Start();

            Task.WaitAll(t1, t2);

            Console.WriteLine(number);
        }
    }
}

논리적으로 생각해보면 Console.WriteLine(number);가 출력하는 값은 0이 되어야 한다. 하지만 실제로 출력되는 값은 그렇지 않다.

NoInterlocked콘솔

이런 문제가 발생하는 이유는 RACE CONDITION(경합 조건) 때문이다.

number++는 c# 코드 상에서는 한 줄에 나타나지만 실제로는

  1. 메모리 주소에 있는 값을 레지스터에 옮기고,
  2. 레지스터에서 값을 1 늘려준 다음에,
  3. 늘린 값을 과정 1의 메모리에 다시 넣어준다. 이런 3 단계에 걸쳐 일어난다.

즉 number++는 의사 코드로 나누어 표현하면

int temp = number;
temp += 1;
number = temp;

라는 과정을 거친다는 것인데, 이를 Thread_1과 Thread_2에 대입해 보면,

        static void Thread_1()
        {
            for (int i = 0; i < 100000; i++)
            {
                int temp = number; // 0
                temp += 1; // 1
                number = temp; // 1
            }
        }

        static void Thread_2()
        {
            for (int i = 0; i < 100000; i++)
            {
                int temp = number; // 0
                temp -= 1; // -1
                number = temp; // -1
            }
        }

이렇게 바꿔볼 수 있다.

만약 Thread_1과 Thread_2가 동시에 number 변수에 접근을 하면 위에 달려있는 주석과 같은 흐름에 따라 Thread_1 과 Thread_2 가 각각 한번 실행되어도 0 이 아니라 -1, 1과 같이 다른 값이 number가 될 수 있다. 이런 문제가 for 문에서 여러번 발생해, 위에 처럼 2059 같은 값이 결과로 나올 수 있는 것이다. 또한 이런 현상이 일어가는 횟수는 일정하지 않기 때문에 실행 결과는 매번 다르게 나온다.

이런 현상을 나타낼 때 원자성(atomic)이라는 단어를 자주 사용하게 되는데, 어떤 것이 원자성을 가지고 있다고 하면 원자적이라고 하고, 이는 다른 스레드가 중간의 부분 결과를 관찰하거나 끼어들 수 없는 하나의 연산으로 취급된다는 뜻이다. 여기서의 원자적 메모리 연산과 DB 트랜잭션의 실패 시 전체 롤백은 서로 다른 계약이다.

이 원자성이란 개념은 멀티 스레드에서만 사용되는 개념이 아니라 데이터베이스에서도 사용되는 개념이다.

게임으로 비유를 하자면 USER1의 아이템을 USER2에게 넘길때 다음과 같은 두 작업으로 나눌 수 있다.

  1. USER2의 인벤토리에 아이템을 넣어라.
  2. USER1의 인벤토리에서 아이템을 삭제해라. 원자성이 보장이 되지 않고 1번 작업이 실행이 되었는데, 모종의 이유로 2번 작업은 실행이 되지 않으면 해당 아이템이 복사가 되는 문제가 발생할 수 있다.

Thread1 과 Thread2를 아래와 같이 Interlocked를 사용해 수정하면 원자성을 보장받을 수 있다.

        static void Thread_1()
        {
            for (int i = 0; i < 100000; i++)
                Interlocked.Increment(ref number);
        }

        static void Thread_2()
        {
            for (int i = 0; i < 100000; i++)
                Interlocked.Decrement(ref number);
        }

실행을 하면 아래와 같이 정상적으로 0 이 결과로 나온다.

Interlocked콘솔

Interlocked.Increment()의 인자값으로 number가 아니라 주소값을 넣어주는 이유는 만약 number 즉 int 값을 인자로 받게 되면, 값을 복사해 Interlocked.Increment()에 넘겨주게 되는데 이 사이에 number값의 수정이 일어날 수 있기 때문이다. 따라서 주소값을 받아서 참조를 해야한다.

같은 맥락으로 아래와 같은 예시에서,

        static void Thread_1()
        {
            for (int i = 0; i < 100000; i++)
            {
                int prev = number;
                Interlocked.Increment(ref number);
                int next = number;
            }
        }

prev에서 1을 더한 값이 next인 것을 보장받을 수 없다. 이는 int prev= number 를 통해 number의 값을 prev에 할당하거나, int next = number를 통해 number의 값을 next에 할당해주는 순간에도 number 값은 다른 스레드의 작업 통해 변경이 일어날 수 있기 때문이다.

참고자료:
C# 6.0 완벽 가이드(조셉 앨버허리,벤 앨버허리) https://www.inflearn.com/course/%EC%9C%A0%EB%8B%88%ED%8B%B0-mmorpg-%EA%B0%9C%EB%B0%9C-part4/inquiries