[게임 서버 프로그래밍 교과서] 1. 멀티스레딩

sanghoon·2023년 9월 4일
0

game_server

목록 보기
1/2

게임 서버 프로그래밍 교과서(배현직 저)를 보고 공부하면서 정리한 내용입니다. 제 뇌피셜도 있고, 도서에서 알기 힘든 부분은 검색을 통해 보충한 부분도 있으므로 잘못된 정보가 있다면 언제든지 댓글 남겨주세요!!


프로그램과 프로세스

  • 프로그램
    on disk
    code + data
  • 프로세스
    on memory
    code + data + heap + stack
  • 멀티 프로세싱
    여러 프로세스가 동시에 실행되는 것

스레드

  • 특징
    • 스레드는 한 프로세스 안에 여러 개 존재 가능
    • 한 프로세스 안에 있는 스레드들은 프로세스의 메모리 공간을 공유할 수 있음(heap)
    • 스레드마다의 메모리 공간을 가짐(stack)
  • 싱글스레딩과 멀티스레딩
    한 프로세스 안에서 몇개의 스레드가 실행되느냐 기준
  • modern c++에서의 멀티스레딩
std::thread t1(ThreadProc, 123);

멀티스레드 프로그래밍은 언제 해야할까?

  • 오래 걸리는 일 하나 + 빨리 끝나는 일 여럿
    ex) 게임 로딩 도중 애니메이션 재생
  • 긴 처리를 진행하는 동안 다른 짧은 일들을 처리해야할 때
    ex) 디스크(혹은 데이터베이스 서버)에 있는 사용자 정보를 읽어오는 동안 다른 일 처리
  • 모든 cpu 코어를 활용해야 할 때
    ex) 10000000 이하의 숫자 중 소수 리스트를 구하는 프로그램

스레드 정체

컨텍스트 스위칭

스레드를 다룰 때 주의사항

  • 레이스 컨디션
    • 이상한 값이 들어가는 경우
      ex) global_var += 1;
    • 접근할 수 없는 메모리영역에 접근하는 경우
      ex) gloval_Array.Add(1); // 의사코드

임계 영역과 뮤텍스(windows)

임계영역과 상호배제는 본질적으로는 다른 말이지만, 윈도우에서의 CRITICAL_SECTION과 std::mutex는 동일한 목적으로 사용됨(이러한 의미에서 저자는 동일한 용어로 취급한 듯?). (std::mutex는 CRITICAL_SECTION으로 구현되어 있음; 성능상의 이슈로 인해 윈도우에서는 CRITICAL_SECTION 사용을 권장함)

  • CRITICAL_SECTION(win32 api)
// CRITICAL_SECTION을 사용한 임계영역 관리 예시 코드
class CriticalSection 
{
	CRITICAL_SECTION m_critSec;
public:
	CriticalSection()
    {
    	InitializeCriticalSectionEx(&m_critSec, 0, 0);
    }
    ~CriticalSection()
    {
    	DeleteCriticalSection(&m_critSec);
    }
    
    void Lock()
    {
    	EnterCriticalSection(&m_critSec);
    }
    void Unlock()
    {
    	LeaveCriticalSection(&m_critSec);
    }
}

class CriticalSectionLock
{
	CriticalSection* m_pCritSec;
public:
    CriticalSectionLock(CriticalSection& critSec)
    {
    	m_pCritSec = &critSec;
        m_pCritSec->Lock();
    }
    ~CriticalSectionLock()
    {
    	m_pCritSec->UnLock();
    }
}

// in main.cpp
int a;
CriticalSection a_cs;

int main()
{
	thread t1([]()
    {
    	while(1)
        {
        	CriticalSectionLock cslock(a_cs);
            a++;
        }
    })
    
    t1.join();
    
    return 0;
}

위 예시에서는 t1 스레드가 계속 돌아가지만, 어떠한 조건에 의하여 t1 스레드에게 람다식으로 주어져있는 while루프를 빠져나오게 될 경우, cslock이라고 이름 붙여진 CriticalSectionLock 객체의 소멸자가 호출되면서 임계영역을 닫고 초기화한다.

  • std::mutex
    락을 관리하는 개체의 도움을 받아 임계영역에 대한 상호배제를 관리함
// 일반 mutex
std::mutex mx;
// 방법 1
mx.lock();
doSomething(x) // 예외 발생 시 unlock이 반드시 실행되도록 구현해야 함!
mx.unlock();

// 방법 2
if(mx.try_lock())
{
	doSomething();
    mx.unlock();
}


// 락 관리 개체를 사용한 mutex; RAII
std::reculsive_mutex mx;
{
	lock_guard<recursive_mutex> lock(mx); // scope_lock은 여러 뮤텍스에 대한 다중잠금 가능(해제는 반대 순서로 됨)
	doSomething(x); // 예외가 발생하더라도 락가드 변수의 생명주기가 끝나면 소멸자에서 뮤텍스에 대한 unlock이 자동으로 실행됨
}
  • 뮤텍스와 성능
    임계영역 범위가 클 경우 임계영역에 접근중인 스레드를 제외하고는 idle해지는 스레드가 많이 생길 가능성이 높음.
    그렇다고 임계영역을 너무 잘게 나누면 다음과 같은 문제점 발생
    • 뮤텍스 액세스 과정 자체가 무거워서 프로그램의 성능이 오히려 떨어질 수 있음
    • 데드락 문제가 쉽게 발생할 수 있음

교착상태

  • 게임 서버에서 교착상태가 발생하였을 때의 대표적인 증상
    • CPU 사용량이 현저히 낮거나 0%임
    • 클라이언트에서 요청을 보내도 서버가 응답하지 않음
  • 데드락의 4가지 조건(참고)
    • 상호배제
    • 순환대기
    • 비선점
    • 점유대기

잠금 순서의 규칙

여러 뮤텍스를 사용할 때 데드락 예방을 위한 해결책. 잠금 순서를 정해놓고 순방향으로만 잠금이 일어나도록 설정.

ex) 5개의 임계영역에 대한 뮤텍스(A -> B -> C -> D -> E)
1. 교착상태 예방
t1 : lock(A) lock(B) lock(E) unlock(E) unlock(B) unlock(A)
t2 : lock(B) lock(C) lock(E) unlock(E) unlock(C) unlock(B)
2. 교착상태 발생 가능
t1 : lock(A) lock(B) lock(E) unlock(E) unlock(B) unlock(A)
t2 : lock(B) lock(E) lock(A) unlock(A) unlock(E) unlock(B)

병렬성과 시리얼 병목

  • 시리얼 병목
    프로그램을 병렬로 실행하더라도 어떤 이유에 의해 한 CPU만 연산을 수행하는 현상
  • 암달의 법칙
    코어를 늘려 멀티스레딩 환경을 구성하더라도, 전체 실행 중 병렬화시킬 수 있는 부분이 많지 않다면 오히려 총 처리 효율성이 떨어지는 현상
  • 비주얼 스튜디오에서 병목 확인하기(with Visual Studio Community 2022, Concurrency Visualizer)
    Extensions > Manage Extensions > Concurrency Visualizer 검색 후 다운로드
    Analyze > Concurrency Visualizer > Start with Current Project(Shift + alt + f5)를 통해 스레드 별 상태 확인

싱글스레드 게임 서버

싱글스레드 서버를 구동하는 경우, CPU 코어 개수만큼 프로세스를 띄우는 것이 일반적

  • 각 서버 프로세스는 여러개의 방을 가짐. 각 방에서는 한명 이상의 플레이어가 싱글플레이/멀티플레이로 게임을 즐김.
  • 위 구조에서 디스크에서 플레이어 정보를 로딩할 때 엄청난 병목이 발생함(비동기 함수나 코루틴으로 해결)
  • 더 많은 동접자를 관리하기 위해 더 많은 프로세스를 올린다고 해도 컨텍스트 스위칭 횟수 증가
  • 결국 실제 처리 가능한 동시접속자 수를 떨어뜨릴 수 있음

멀티스레드 게임 서버

멀티스레딩이 필요한 경우

  • 프로세스 당 로딩해야하는 게임 정보 용량이 매우 클 때
  • 한 프로세스가 모든 코어를 동원해야할 만큼 많은 연산을 수행해야 할 때
  • 코루틴이나 비동기함수를 쓸 수 없을 때
  • 서버 인스턴스를 기기당 하나만 두어야 할 때
  • 서로 다른 방이 같은 힙메모리에 접근하고 싶을 때

UniThread ServerMultiThread server

멀티스레딩 서버에서의 플레이어 행동 처리 순서

// pseudo
MyServer.DoSomething(plyaer)
{
	lock(this.m_critSec);
    room = m_room_list.find(player);
    unlock(this.m_critSec);
    lock(room.m_critSec);
    room.DoSomething(player);
    unlock(room.m_critSec);
}

스레드 풀링

전체 스레드의 개수를 유지하면서, 새로운 이벤트가 들어올 경우 작업큐에 이를 집어넣고 스레드풀에 있는 스레드 하나가 작업큐에 있는 작업 하나씩 맡아서 처리하는 방식.

  • 어떤 서버의 주 역할이 CPU 연산만 하는 스레드라면, 스레드 풀의 max size를 서버의 cpu 코어의 수와 같게 잡아도 됨
  • 서버에서 디스크나 다른 서버(데이터베이스 등)에 액세스하는 상황이 많다면, 스레드의 개수는 코어 개수보다 많아야 많은 수의 이벤트들을 처리하는데 적합

이벤트

잠자는 스레드를 깨우기 위한 도구. 윈도우 API에서 이벤트는 0(reset, clear, non-signal)과 1(set, signal)의 상태를 갖는다. 이를 관리하기 위한 다음과 같은 이벤트 관련 메서드가 존재한다.

  • CreateEvent : 이벤트 생성
  • CloseHandle : 이벤트 파괴
  • WaitForSingleObject : 프로세스나 스레드가 지정된 핸들(이벤트)를 대기하도록 지정함. 이벤트나 스레드 동기화에 사용되는 함수이다.
  • SetEvent : 이벤트에 신호를 준다.
  • PulseEvent : 이벤트에 맥박 신호를 준다.

윈도우 API의 이벤트는 두가지의 모드가 존재하는데, 각각의 설명은 다음과 같다.

  • 자동 리셋 모드 : 이벤트에 신호를 준 후, 곧바로 자동으로 상태값이 0이 됨. 따라서 여러 스레드가 같은 이벤트를 대기하는 경우, 하나의 스레드만이 깨어나게 된다.
  • 수동 리셋 모드 : 위와 다르게 프로그래머가 수동으로 0으로 바꿔줘야 함. 여러 스레드들이 동시에 깨어날 수 있음.

도서에는 수동 리셋 모드의 이벤트에 대해 여러 스레드가 대기중인 경우, 여러 스레드에 대해 각각 상태값을 0으로 바꿔주는 메서드(수도코드의 경우 SetEvent(0)의 형태로 나와있음; ResetEvent()를 뜻하는 듯함)를 쓰는 경우 모든 스레드가 깨어나지 않을 가능성이 있다고 설명되어있다. 그렇기 때문에 PulseEvent()를 사용하여 모든 스레드들이 깨어날 수 있도록 해야한다고...

너무나 궁금해서 검색해 본 결과, 오히려 ResetEvent()를 사용하는 것이 더욱 안전하다고...링크PulseEvent를 사용할 경우, 커널모드 APC에 의해 대기중이던 스레드가 잠시 제거되고 APC가 작업을 끝낸 후에 다시 대기 상태로 진입한다는 내용. 잠시 제거된 스레드는 영원히 대기상태로 남아있을 수 있다고 함.

그런데 아래와 같은 예시코드를 몇번 돌려본 결과, 스레드 깨우기가 누락된 경우는 없었긴 했다

#include <windows.h>
#include <iostream>
#include <thread>

HANDLE event1;

void Thread1() {
    WaitForSingleObject(event1, INFINITE); // event1 대기, manual reset mode
    std::cout << "Thread1: Event received" << std::endl;
    //ResetEvent(event1); // event1 초기화
}

void Thread2() {
    WaitForSingleObject(event1, INFINITE); // event1 대기
    std::cout << "Thread2: Event received" << std::endl;
    //ResetEvent(event1); // event1 초기화
}

void Thread3() {
    std::this_thread::sleep_for(std::chrono::seconds(2)); // 조금 지연
    std::cout << "Thread3: Setting event" << std::endl;
    //SetEvent(event1); // event1 설정
    PulseEvent(event1); // event1 설정
}

int main() {
    event1 = CreateEvent(NULL, TRUE, FALSE, NULL); // manual reset mode의 이벤트 생성

    std::thread t1(Thread1);
    std::thread t2(Thread2);
    std::thread t3(Thread3);

    t1.join();
    t2.join();
    t3.join();

    CloseHandle(event1); // 이벤트 핸들 닫기

    return 0;
}

뭐가 이리 어려워.... ㅠ

세마포어

세마포어는 std::mutex와 CRITICAL_SECTION과 달리 여러 스레드에서 동시에 자원에 엑세스할 수 있도록 도와줌. 윈도우 API의 세마포어 조작 관련 메서드

  • CreateSemaphore : 세마포어 생성
  • WaitFOrSingleObject : 엑세스 요청 및 허가 대기
  • ReleaseSemaphore : 엑세스 완료 통보
  • CloseHandle : 세마포어 파괴
#include <windows.h>
#include <stdio.h>

#define MAX_SEM_COUNT 10
#define THREADCOUNT 12

HANDLE ghSemaphore;

DWORD WINAPI ThreadProc(LPVOID);

int main(void)
{
    HANDLE aThread[THREADCOUNT];
    DWORD ThreadID;
    int i;

    // Create a semaphore with initial and max counts of MAX_SEM_COUNT

    ghSemaphore = CreateSemaphore(
        NULL,           // default security attributes
        MAX_SEM_COUNT,  // initial count
        MAX_SEM_COUNT,  // maximum count
        NULL);          // unnamed semaphore

    if (ghSemaphore == NULL)
    {
        printf("CreateSemaphore error: %d\n", GetLastError());
        return 1;
    }

    // Create worker threads

    for (i = 0; i < THREADCOUNT; i++)
    {
        aThread[i] = CreateThread(
            NULL,       // default security attributes
            0,          // default stack size
            (LPTHREAD_START_ROUTINE)ThreadProc,
            NULL,       // no thread function arguments
            0,          // default creation flags
            &ThreadID); // receive thread identifier

        if (aThread[i] == NULL)
        {
            printf("CreateThread error: %d\n", GetLastError());
            return 1;
        }
    }

    // Wait for all threads to terminate

    WaitForMultipleObjects(THREADCOUNT, aThread, TRUE, INFINITE);

    // Close thread and semaphore handles

    for (i = 0; i < THREADCOUNT; i++)
        CloseHandle(aThread[i]);

    CloseHandle(ghSemaphore);

    return 0;
}

DWORD WINAPI ThreadProc(LPVOID lpParam)
{

    // lpParam not used in this example
    UNREFERENCED_PARAMETER(lpParam);

    DWORD dwWaitResult;
    BOOL bContinue = TRUE;

    while (bContinue)
    {
        // Try to enter the semaphore gate.

        dwWaitResult = WaitForSingleObject(
            ghSemaphore,   // handle to semaphore
            0L);           // zero-second time-out interval

        switch (dwWaitResult)
        {
            // The semaphore object was signaled.
        case WAIT_OBJECT_0:
            // TODO: Perform task
            printf("Thread %d: wait succeeded\n", GetCurrentThreadId());
            bContinue = FALSE;

            // Simulate thread spending time on task
            Sleep(5);

            // Release the semaphore when task is finished

            if (!ReleaseSemaphore(
                ghSemaphore,  // handle to semaphore
                1,            // increase count by one
                NULL))       // not interested in previous count
            {
                printf("ReleaseSemaphore error: %d\n", GetLastError());
            }
            break;

            // The semaphore was nonsignaled, so a time-out occurred.
        case WAIT_TIMEOUT:
            printf("Thread %d: wait timed out\n", GetCurrentThreadId());
            break;
        }
    }
    return TRUE;
}

위 예시에서는 세마포어의 최대 카운트는 10, 스레드의 개수는 12이다. 따라서 처음 10개의 스레드는 정상적으로 임계영역에 접근할 수 있지만, 나머지 두개의 스레드는 10개의 스레드 중 2개 이상이 세마포어 카운트를 감소시켜야지만 임계영역에 접근할 수 있게 된다.

실행 결과 예시)

세마포어는 이벤트로는 보장할 수 없는 문제를 해결할 수 있다.

// pseudo
Queue queue
Event qIsNotEmpty

void Thread1()
{
	while(true)
    {
    	qIsNotEmpty.Wait()
        queue.PopFront()
    }
}
void Thread2()
{
	while(true)
    {
        queue.PushBack()
    	qIsNotEmpty.SetEvent()
    }
}

위 코드를 얼핏 보면 두 스레드가 번갈아가며 큐를 채웠다 비웠다를 반복할 것 같지만, 실제로는 그렇지 않을 수 있다.

  1. 스레드 1이 대기
  2. 스레드 2가 큐를 채우고 이벤트 상태값 변경
  3. 스레드 2가 큐를 채우고 이벤트 상태값 변경
  4. 스레드 1이 신호를 받고 큐에서 하나 빼기

위 순서대로 코드가 실행되는 경우, 큐에 대기중인 항목이 있을 때도 상태값이 0이 된다. 큐가 차있음에도 뺄 수 없는 상태가 된다는 것이다. 이를 세마포어를 이용하면 해결할 수 있다.

// pseudo
Queue queue
Semaphore qIsNotEmpty = new Semaphore(0)

void Thread1()
{
	while(true)
    {
    	qIsNotEmpty.Wait()
        queue.PopFront()
    }
}
void Thread2()
{
	while(true)
    {
        queue.PushBack()
    	qIsNotEmpty.Release()
    }
}

위 코드에서는 세마포어 카운트가 0으로 초기화되는데, 카운트가 0일 때는 큐에 값이 없다는 것을 의미하게 된다. 따라서 스레드1은 스레드2가 실제로 큐에 값을 넣고 카운트를 증가시켜줬을 때만 Wait() 조건을 충족하여 다음 줄로 넘어가 큐에서 값을 뺄 수 있게 된다.
약간 생산자-소비자 문제랑 비슷한 느낌?

원자 조작

원자조작은 여러 임계영역 잠금이나 뮤텍스 없이도 여러 스레드가 안전하게 접근할 수 있는 것을 의미한다. 원자조작은 하드웨어 기능이며, 대부분의 컴파일러는 원자 조작 기능을 쓸 수 있게 한다. 대표적인 원자 조작으로는 1. 원자성을 가진 값 더하기, 2. 원자성을 가진 값 맞바꾸기, 3. 원자성을 가진 값 조건부 맞바꾸기 등이 있다.
여기까지가 도서에 적혀있는 설명. 좁은 의미에서의 원자 조작을 말하는 것 같다.

일반적인 의미에서의 원자 조작은 원자성 있는 연산을 의미함(나누어질 수 없는 연산; 한번에 수행될 수 있는 연산). 특히 동시성 프로그래밍에서는 여러 연산에 대해 원자적 연산을 수행해야 할 때가 많다. cpp의 경우, 잦으면서도 단순한 연산이 원자적으로 계산되기를 원할 때 <atomic> 헤더와 volatile키워드를 이용하는 등의 방법을 사용할 수 있다.

마이크로소프트 cpp stl atomic 헤더 중 원자 조작 관련 부분 발췌

cpp 에서의 <atomic>을 이용한 원자 조작은 아래와 같은 두 가지의 특성을 기반으로 <mutex> 락킹 없이 멀티스레딩 환경에서의 올바른 객체의 조작을 가능케 한다.

  • 원자 조작은 나누어질 수 없다. 따라서 두 스레드에서 같은 객체에 대해 원자 조작 연산을 하려고 할 때, 한 스레드는 다른 스레드에서의 원자 조작 연산 시작 전/후에만 원자 조작 연산을 수행할 수 있다.
  • 아토믹 변수 선언 시 memory_order인자를 이용하면 동일 스레드 내에서의 원자 조작 순서를 결정할 수 있다. (컴파일러가 최적화 과정에서 순서를 임의 변경하는 것을 방지할 수 있다.)

멀티스레드 프로그래밍의 흔한 실수들

멀티스레드 환경에서 프로그래밍을 하다 보면 신경 써야할 부분도 많고, 그만큼 버그를 잡아야 할 일도 많다. 다음은 흔히 일어나는 실수들에 대한 케이스이다.

읽기와 쓰기 모두를 잠그지 않은 경우

보통 값을 읽고만 있으면 잠금을 하지 않더라도 안전하다는 생각을 많이 한다.

int a;
mutex a_mu;

void fun1()
{
	// lock(a_mu);
    print(a);
}
void fun2()
{
	lock(a_mu);
    a += 10;
}

예시 자체만 보면 정상적으로 동작할 것 같지만, 가끔 a에 저장된 값을 읽는 경우에서 정상적이지 않은 값에 대해 읽기를 시도한다는 오류가 발생할 때가 있다.

잠금 순서 꼬임

앞서 살펴본 것처럼 잠금 순서를 지키지 않는 경우 교착상태가 발생하기 쉽다.

int a, b;
mutex a_mu, b_mu;

void fun1()
{
	lock(a_mu);
    // do sth with a
	lock(b_mu);
    // do sth with b
}
void fun2()
{
	lock(b_mu);
    // do sth with b
	lock(a_mu);
    // do sth with a
}

너무 넓거나 좁은 잠금 범위

잠금 범위가 너무 넓은 경우 컨텍스트 스위칭 코스트가 크기에 운영체제가 해야할 일이 늘어나고 병렬성이 떨어지게 된다.
잠금 범위가 너무 좁은 경우 컨텍스트 스위칭 빈도가 줄긴 하지만, 락킹/언락킹 코스트가 커지게 된다.

class A
{
	int a1;
    mutex a1_mu;
    
	int a1;
    mutex a1_mu;
}


class B
{
	int b1;
    int b2;
    mutex mu;
}

변수마다 뮤텍스를 선언하는 경우(class A) 락킹코스트가 지나치게 커지기 때문에 가능하다면 동일 클래스 내의 모든 멤버변수는 하나의 뮤텍스로 관리하는 것이 좋다(class B. 단, 다음으로 설명할 경우는 예외).

디바이스 타임이 섞인 잠금

DB 접근이나 네트워크를 IO 외에도 디버깅용 콘솔 출력 함수 역시 일반적인 게임 로직 처리 연산보다 훨씬 많은 시간이 걸린다. 게임 서버 개발 시 디버깅 용으로 콘솔 출력이나 로그 출력을 하는 경우가 많은데, 이때 잠금이 걸려있을 경우 CPU 아이들 타임이 늘어나게 된다.

void func()
{
	lock(mutex);
    
    // do sth with a (mutex에 의해 보호받고 있는 변수 1)
    // do sth with b (mutex에 의해 보호받고 있는 변수 2)
    
    log(a, b);
}

동접자 수는 많은데, 서버 모니터링 시 CPU 사용량이 적게 나오는 경우가 바로 이 경우이다.

잠금이 풀린 객체의 멤버 변수도 보호하고 싶을 때

class player
{
	int money;
    string name;
}

mutex mut;
List<Player*> playerList;

void func()
{
	lock(mut);
    Plyaer* p = plyaerList.GetPlayer("sangho0n");
    // unlock(mut); <- 여기서 unlock을 해버리면 아래 연산을 보호할 수 없음
    p->money += 100;
}

Lack of Compposability(참고)
여러 모듈 또는 컴포넌트가 서로 효율적으로 조합되거나 재사용되기 힘들다는 것을 의미함.
게임 개발 상황에서는 사용자 A가 지불한 돈만큼 사용자 B의 잔고를 증가시키고 싶을 때 발생할 수 있다. A와 B의 잔고 모두가 뮤텍스로 보호받고 있는 상황에서 두 개의 스레드가 이를 담당한다고 했을 때 의도치않은 결과가 생길 수 있다. 그렇다고 사용자 A와 B를 하나의 뮤텍스로 묶고 잠금을 하기에는 프로그램 개발이 기대 이상으로 복잡해질 가능성이 존재한다.

잠긴 뮤텍스나 임계 영역에 대한 의도치 않은 삭제

class A
{
	mutex mut;
    int a;
}

void func()
{
	A* a = new A();
    lock(a->mutex);
    delete a;
}

위와 같이 unlock하지 않고 객체의 소멸자를 호출하는 경우 데드락이나 의도치 않은 결과가 생길 수 있다. 소멸자가 뮤텍스(혹은 CRITICAL SECTION)이 unlock이 되지 않은 상태에서 호출될 경우 오류를 발생시킴으로써 해결하거나, 다음과 같은 방법으로 해결할 수 있다.

class A
{
	mutex mut;
    int a;
}

void func()
{
	A* a = new A();
    {
        std::unique_lock lock(a->mutex);
        // 루프를 빠져나가면서 lock 객체 소멸 -> lock 객체의 소멸자를 통해 unlock
    }
    delete a;
}

일관성 규칙 무시

일관성을 가져아하는 객체들을 한꺼번에 보호하지 않고 각자 보호하려 할 때 생기는 문제

예시)

  • 리스트와 리스트 길이를 각각 다른 뮤텍스로 보호하는 경우
    -> 리스트를 변화시키는 동안 listCount는 보호상태가 아니기 때문에 다른 스레드에서 이를 접근하는 경우 일관성이 깨지는 문제 발생
  • 아이템 큐만 뮤텍스로 보호하고, 큐에서 얻어온 아이템은 atomic으로 관리하는 경우
    -> queue<item>에서 deque한 후 이를 atomic<item>에서 받아온다고 할 때, atomic 객체에 아이템값이 들어가기 직전 다른 스레드에서 이를 사용하고자 하면 없는 변수에 대한 접근을 시도할 수 있음

심화

더 자세한 내용을 알고 싶다면 공룡책의 Part2. 프로세스 관리 참고

atomic을 잘 활용하면 잠금 없이도 여러 스레드가 안전하게 접근할 수 있는 프로그램을 작성할 수 있음.(ex 병렬 자료구조) 다만 여러 자료구조에 대해 잠금 없이 일관성을 유지하는 프로그램을 작성하는 것은 매우 어려움. 아주 적은 확률이라도 오류의 발생 가능성이 존재하기 때문.

멀티스레드 프로그래밍을 편리하게 해주는 도구나 API, 언어 존재

  • Visual Studio Concurrency Visualizer, Intel Parallel Studio, Windows Performance Toolkit
  • cpp의 std::feature, python과 go lang의 코루틴, c#의 async-await (비동기)
  • c, cpp의 OpenMP
  • 액터 모델 : 동시연산을 다루기 위한 개념적 모델(서로 독립적인 액터들이 메시지를 주고받으면서 뭔가를 하는 듯...)
    ex) Erlang(언어), Akka, Spark 등의 프레임워크 등

0개의 댓글