싱글 쓰레드와 멀티 쓰레드란?

 

싱글 쓰레드란?

  • 한개의 쓰레드에 모든 컨텐츠 로직을 처리하는 설계를 말합니다.




멀티 쓰레드란?

  • 여러개의 쓰레드에서 동일한 작업 또는 서로 다른 작업을 처리하는 설계를 말합니다.




싱글 쓰레드 장.단점

 

장점

 

  • 머리 쓰레드로 인한 동기화 문제를 고려하지 않고 개발할 수 있어서 생산성 및 유지 보수성에 이점이 있습니다.
  • 멀티 쓰레드 환경에서 동기화로 인해 발생되는 잦은 커널 모드 전환 및 컨택스트 스위칭 비용을 아낄 수 있습니다.




단점

 

  • 멀티 코어 CPU를 활용하기에 적합하지 못합니다.
  • 사용자에 대한 응답을 하나의 쓰레드로 처리하기 때문에 응답성이 떨어집니다.
  • 여러 개의 작업을 하나의 쓰레드로 일렬로 처리할 수밖에 없습니다.




멀티 쓰레드 장.단점

 

장점

  • 멀티 코어 CPU를 적극 활용할 수 있습니다.
  • 멀티 쓰레드로 여러 개의 작업을 처리할 수 있기 때문에 병렬성을 극대화할 수 있습니다.
  • Accept, Send, Recv를 담당하는 쓰레드를 만들어서 설계를 단순화할 수 있습니다.
  • 사용자 응답을 위한 별도의 쓰레드를 만들어서 사용자에게 보다 빠르게 응답할 수 있습니다.




단점

  • 멀티 쓰레드 간의 동기화 작업으로 인한 경합이 자주 발생될 경우 오히려 성능이 떨어질 수 있습니다.
  • 프로그래머가 멀티 쓰레드 환경을 고려하며 코딩해야 하기 때문에 컨텐츠 생산성 및 유지 보수성이 떨어질 수 있습니다.
  • 쓰레드가 처리하는 작업량보다 컨텍스트 스위칭 비용이 더 클 경우 멀티 쓰레드를 사용함으로써의 이점이 없습니다.




싱글, 멀티 쓰레드 결론

  • 무조건 멀티 쓰레드가 좋다고 생각하진 않습니다. 성능의 중요성이 떨어지는 프로젝트라면 되도록 싱글 쓰레드로 개발하여 생산성 및 유지보수성을 증가 시키는 것도 좋은 방법이라 생각됩니다.


    하지만, 사용자 응답성 및 성능을 중요시 한다면 싱글 쓰레드는 적합하지 못하다고 생각합니다. 그리고 멀티 쓰레드 설계를 통해 Accept, Send, Recv 로직을 담당하는 쓰레드를 별도로 둠으로써 오히려 구조적 단순화의 이점을 얻을 수 있기 때문에 여러가지 상황을 고려한 쓰레드 설계가 필요하다 생각합니다.

데드락 ( Deadlock ) 이란?

  • 둘 이상의 쓰레드가 서로 점유하고 있는 자원에 대해서 독점적인 접근을 시도하여 서로 무한정 기다리는 상태를 말합니다.




데드락 ( Deadlock ) 발생 조건

 

상호 배제 ( Mutual exclusion )

  • 자원에 대한 배타적 접근만을 허용하는 것을 의미합니다.




점유와 대기 ( Hold and wait )

  • 특정 자원에 대한 접근 권한을 가진 상태에서 다른 자원에 대한 접근 권한을 기다리는 상태를 의미합니다.




비선점 ( No preemption )

  • 다른 쓰레드가 소유중인 자원에 대해서 선점할 수 없는 것을 의미합니다.




순환대기 ( Circular wait )

  • 각 쓰레드가 누군가 소유중인 자원에 대해서 접근을 시도합니다.




데드락 ( Deadlock ) 재현 방법

 

  • 위 그림과 같이 1, 2번 쓰레드가 서로가 소유하고 있는 자원에 접근을 시도할 경우 100% 데드락 ( Deadlock ) 이 발생됩니다.




데드락 ( Deadlock ) 회피 방법

 

  • 데드락 ( Deadlock )데드락 ( Deadlock ) 발생 조건 에서 하나라도 부합하지 않다면 발생되지 않습니다.




회피 방법 1

  • 중복 락을 제거하고 하나의 락으로 통일합니다.




회피 방법 2

  • 락을 사용하는 자원에 대해서 중복되지 않는 정수의 seed 값을 할당하여 중복 락을 시도하기 전에 정렬하여 락에 대한 순서를 지키도록 합니다.



Interlocked 함수란?

  • Interlocked 계열의 함수들은 Interlocked 계열의 함수들 간의 원자적 연산을 보장해 주는 함수입니다.




Interlocked 함수의 원리

  • x86 아키텍처의 CPU라면 메모리 버스에 Interlocked 하드웨어 시그널을 실어서 다른 코어에서 해당 캐시 라인에 접근하지 못하도록 하여 Interlocked 함수를 이용한 원자적 연산을 지원합니다.




Interlocked 함수 사용 시 주의 사항

 

주의 사항 1

  • 캐시 라인 단위로 메모리 버스를 잠그기 때문에 Interlocked 계열의 함수에 사용될 변수들은 캐시 라인에 맞추어져 있어야 원자적 연산을 보장받습니다.




주의 사항 2

  • 멀티 쓰레드 환경에서 다른 목적으로 Interlocked 계열 함수에 사용될 변수들은 서로 다른 캐시 라인에 있어야 쓰레드 경합으로 인한 성능 저하가 발생되지 않습니다.




동일 캐시 라인에 대한 쓰레드 경합 테스트

 

테스트 방법

  • 동일한 캐시 라인에 있는 두 개의 변수들을 두 개의 쓰레드를 생성하여 각 쓰레드에서 InterlockedIncrement 를 천 만번 호출할때 소요되는 시간을 측정하여 비교합니다.




테스트 코드

#include <iostream>
#include <Windows.h>
#include <process.h>

#pragma comment(lib, "Winmm.lib")

HANDLE gEvent;

CRITICAL_SECTION gCS;

unsigned __stdcall TestThread(void* p)
{
    long* pNum = static_cast<long*>(p);

    if (WaitForSingleObject(gEvent, INFINITE) != WAIT_OBJECT_0)
    {
        std::cout << "Wait Failed\n";

        return 1;
    }

    LARGE_INTEGER start;
    LARGE_INTEGER end;
    LARGE_INTEGER frequency;
    QueryPerformanceFrequency(&frequency);

    QueryPerformanceCounter(&start);

    for (int i = 0; i < 10000000; ++i)
    {
        InterlockedIncrement(pNum);
    }

    QueryPerformanceCounter(&end);

    EnterCriticalSection(&gCS);

    std::cout << (double)(end.QuadPart - start.QuadPart) / frequency.QuadPart << " 초" <<std::endl;

    LeaveCriticalSection(&gCS);

    return 0;
}

int main()
{
    timeBeginPeriod(1);

    // 64Byte 경계에 맞춤
    __declspec(align(64))
    long num1, num2;

    std::cout << "num1 주소 : " << &num1 << std::endl;
    std::cout << "num2 주소 : " << &num2 << std::endl;

    HANDLE handles[2];

    InitializeCriticalSection(&gCS);

    gEvent = CreateEvent(nullptr, true, false, nullptr);

    handles[0] = (HANDLE)_beginthreadex(nullptr, 0, (_beginthreadex_proc_type)TestThread, &num1, 0, nullptr);
    handles[1] = (HANDLE)_beginthreadex(nullptr, 0, (_beginthreadex_proc_type)TestThread, &num2, 0, nullptr);

    Sleep(2000);

    SetEvent(gEvent);

    if (WaitForMultipleObjects(2, handles, true, INFINITE) != WAIT_OBJECT_0)
    {
        std::cout << "Handles Wait Failed\n";
    }

    timeEndPeriod(1);

    return 1;
}
  • 위와 같이 코드를 작성하여 각 쓰레드에서 InterlockedIncrement 테스트를 진행 하였습니다.




테스트 결과 비교

num1과 num2는 동일 캐시라인 선상

 

num1과 num2는 다른 캐시라인 선상

 

  • 테스트 결과 약 3.5배 정도 차이 나는 것을 확인하였지만, 성능 차이 정도는 쓰레드의 개수 및 하드웨어 환경에 따라 결과는 달라질 수 있습니다.




Interlocked 계열 함수들

 

InterlockedIncrement

  • 인자로 전달된 변수를 원자적으로 1 증가시키고 return 값은 증가시킨 값입니다.




InterlockedDecrement

  • 인자로 전달된 변수를 원자적으로 1 감소시키고 return 값은 감소시킨 값입니다.




InterlockedExchange

  • 원자적으로 첫 번째 인자의 값을 두 번째 인자의 값으로 변경하고 return 값은 첫 번째 인자의 변경 전 값입니다.




InterlockedCompareExchange ( CAS )

  • 원자적으로 첫 번째 인자의 포인터가 가리키는 값과 세 번째 값을 비교하여 동일하면 첫 번째 인자가 가리키는 값을 두 번째 인자의 값으로 치환합니다. return 값은 기존에 첫 번째 인자의 포인터가 가리키는 값입니다.


  • InterlockedCompareExchange 는 Compare And Swap 으로도 불립니다.



동기화 객체란?

  • 여러 쓰레드가 동시에 접근할 수 있는 영역에 쓰레드의 접근 순서를 맞추기 위해 사용하는 객체를 말합니다.
  • 동기화 객체는 크게 유저 모드 동기화 객체, 커널 모드 동기화 객체 가 있습니다.




동기화 객체를 사용하는 이유

  • 여러 쓰레드에서 접근할 수 있는 영역의 데이터를 특정 쓰레드가 변경하고 있을 때 다른 쓰레드가 접근하여 동일한 데이터를 읽거나 쓰기를 하여 의도하지 않은 결과가 발생될 수 있기 때문입니다.




유저 모드 동기화 객체의 특징 및 종류

유저 모드 동기화 객체의 특징

  • 자원에 대한 접근이 가능한지 여부를 유저 모드 상태에서 확인합니다.
  • CriticalSection, SRWLock 은 스핀 락의 일부 기능을 응용하여 스핀 카운트 만큼 루프를 돌면서 자원에 대한 접근이 가능한지 여부를 확인합니다.
  • 스핀 카운트를 모두 소모하고 자원에 대한 접근이 불가능할 경우 커널 모드로 전환되어 접근이 가능해질 때까지 블락됩니다.




유저 모드 동기화 객체의 종류

  • 유저 모드 동기화 객체의 종류로는 CriticalSection, SRWLock 이 있습니다.




CriticalSection

  • 배타적인 접근만을 허용합니다.
  • 중복락이 가능합니다.




SRWLock

  • Read, Write 락을 구분하여 사용할 수 있습니다.
  • 중복락이 불가능합니다.




커널 모드 동기화 객체의 특징 및 종류


커널 모드 동기화 객체의 특징

  • 자원에 대한 접근이 가능한지 여부 조차도 커널 모드로 전환하여 확인합니다.
  • 커널 모드 객체이기 때문에 다른 프로세스에 있는 쓰레드간에 동기화에도 사용될 수 있습니다.
  • 커널 모드의 시그널 상태 여부를 확인하여 동기화를 할 수 있습니다.
  • WaitForSingleObject, WaitForMultipleObject 등을 통해 커널 오브젝트의 시그널을 확인합니다.




커널 모드 동기화 객체의 종류

  • 커널 모드 동기화 객체의 종류로는 Mutex, Semaphore, Event 등이 있습니다.




Mutex

  • 배타적인 접근만을 허용합니다.
  • 중복락이 가능합니다.




Semaphore

  • 한 번에 접근할 수 있는 쓰레드의 개수를 지정할 수 있습니다.




Event

  • Event 는 Manual 또는 Auto 여부에 따라서 작동 방식이 달라지는 커널 모드 동기화 객체입니다.
  • Manual Event는 넌 시그널 상태에서 시그널 상태로 전환되었을 때 해당 Event의 시그널을 대기하고 있던 모든 쓰레드들을 깨우고 현재의 시그널 상태를 유지합니다.
  • Auto Event는 넌 시그널 상태에서 시그널 상태로 전환되었을 때 해당 Event의 시그널을 대기하고 있던 쓰레드들 중 하나를 깨우고 자동으로 넌 시그널 상태로 전환됩니다.

스핀 락 ( Spin Lock ) 이란?

  • 자신의 퀀텀타임을 포기하지 않고 무한 루프를 통해 자원에 대한 배타적인 접근을 시도하는 방식의 락을 말한다.




스핀 락 ( Spin Lock ) 코드

bool isUse = false;

// 접근 시도
while ((bool)InterlockedExchange8((char*)&isUse, false) == true)
{
    YieldProcessor();
}

// 대입은 원자적으로 이루어지기 때문에 Interlocked 함수를 사용하지 않는다.
isUse = false;
  • InterlockedExchange() 함수를 이용해서 원자적으로 isUse 의 값을 확인하면서 true로 값을 셋팅하여 무한 루프를 통해 배타적인 접근을 시도한다.




YieldProcessor() vs SwitchToThread() vs Sleep(0)

  • 배타적인 접근에 실패하였을 때 YieldProcessor(), SwitchToThread(), Sleep(0) 중 하나를 호출하여 다른 쓰레드가 실행될 수 있는 기회를 줄 수 있다.

YieldProcessor()

  • 하이퍼 쓰레드를 지원하는 CPU를 사용하는 머신에서 YieldProcessor()를 호출하였을 때, YieldProcessor()를 호출한 쓰레드가 실행되고 있는 물리 코어에 속해있는 논리 코어에게 실행을 양보한다.




SwitchToThread()

  • SwitchToThread()를 호출하였을 때 우선순위가 낮아 일정시간 동안 퀀텀 타임을 할당받지 못한 쓰레드에게 수행될 기회를 제공한다.




Sleep(0)

  • Sleep(0) 을 호출하였을 때 현재 퀀텀 타임을 포기하고 블락 큐에 인큐되지만, 자신 보다 우선순위가 같거나 높은 쓰레드가 없다면은 운영체제에 의해서 다시 퀀텀 타임을 할당받아 수행된다.




스핀 락 ( Spin Lock )의 장.단점

장점

  • 자신의 퀀텀 타임 이내에 배타적인 접근에 성공하였다면, 동기화 객체를 사용함으로서 발생될 수 있는 커널 모드로의 전환 및 컨택스트 스위칭으로 발생되는 오버헤드를 줄여 그만큼 성능에서 이점을 얻을 수 있다.




단점

  • 자신의 퀀텀 타임 이내에 배타적인 접근에 실패하였다면, 다른 쓰레드가 운영체제로부터 퀀텀 타임을 할당받아 수행될 수 있는 기회를 주지 못했기 때문에 전체적인 쓰레드 처리량이 떨어진다.

+ Recent posts