C++프로그래밍/이론 정리

쓰레드, 코루틴, 델리게이트와 스마트포인터

season97 2023. 10. 12. 22:37

▶ 쓰레드란?

 

ㆍ 프로세스 내에서 실행되는 가장 작은 실행 단위

 

ㆍ 프로세스와 메모리 자원을 공유하며, 여러 쓰레드가 하나의 프로스세스 내에서 동시에 수행될 수 있다.

 

 

▶ 쓰레드의 주요 특징

 

1. 경량성 

ㆍ 쓰레드는 같은 프로세스 내에서 실행되기 때문에, 새로운 프로세스를 생성하는 것 보다 훨씬 적은 자원을 소모한다.

 

2. 공유자원

ㆍ 같은 프로세스 내의 스레드는 메모리, 파일, 데이터와 같은 자원을 공유한다.

ㆍ 쓰레드는 프로세스 내에서 코드,데이터,힙 영역은 공유하며, 각 쓰레드는 자신만의 '스택'을 가지고 있다.

 

ㆍ 이러한 구조는 쓰레드 간에 컨텍스트 스위칭을 매우 빠르게 만든다.

※ 컨텍스트 스위칭이란 CPU가 한 작업에서 다른 작업으로 전환하는 과정을 말한다.

-> 프로세스간에 컨텍스트 스위칭은 많은 정보를 저장하고 불러와야 하므로 오랜 시간이 걸린다... 하지만? 멀티쓰레드 환경에서 대부분의 메모리 영역(힙) 을 공유하기 때문에 이 시간을 크게 줄일 수 있다.

 

 멀티쓰레딩

 

ㆍ 동시에 여러 작업을 수행하는 기법, 각 쓰레드는 독립적으로 실행됨

 

ㆍ 따라서 한 쓰레드에서 에러나 예외 상황이 발생해도 다른 쓰레드에 영향을 미치지 않음

 

ㆍ 쓰레드는 별도의 스택을 가지지만 힙,메모리,코드 영역은 공유함 -> 이로인해 동기화 문제가 발생할 수도 있다. 

 

※ 게임 게발에서 쓰레드를 활용하는 예시

 

ㆍ 리소스 로딩 : 로딩창 화면을 보여주며 다음 레벨의 리소스를 미리 로딩해둔다

 

ㆍ 물리 시뮬레이션 : 게임 내 물리 시뮬레이션은 비용이 많이 소모되므로 별도의 쓰레드에서 처리한다

 

ㆍ AI 계산 : AI역시 비용이 많이 소모되므로 별도의 쓰레드에서 처리한다.

 

ㆍ 이 외에 네트워킹, 오디오 처리 등에서 활용 가능

 

 

 

코루틴

IEnumerator DelayedEvent()
{
    // 이벤트 발생
    // ...

    // 5초 기다림
    yield return new WaitForSeconds(5);

    // 5초 후에 다른 이벤트 발생
    // ...
}

ㆍ 프로그램의 흐름을 세부적으로 제어할 수 있는 기법

ㄴ> 여러 개의 진입점을 가질 수 있다, 즉 실행을 중간에 멈추고 나중에 다시 시작할 수 있는 특별한 형태의 함수

 

ㆍ 특정 시점에서 실행을 중지하고 다시 재개할 수 있다.

 

ㆍ '코루틴'은 협력적인 루틴 이라는 의미로 하나의 코루틴이 실행을 중지할 때 다른 코루틴이 실행된다.

 

ㆍ 이로 인해 마치 작업이 동시에 실행되는 것 처럼 보이지만 실제로는 하나의 코루틴만 실행됨.

 

ㆍ 보통 비동기 작업을 처리하는데 사용 되며 특히 지연시간이 필요한 작업에 유용하다

 

ㆍ 게임에서의 예시) 보스의 체력이 60%가되면 시퀀서가 출력되고 광폭화를 한다. 이후 10초를 버티면 광폭화가 해제되는 시퀀서가 재생된다.

 

 

▶ 델리게이트

DECLARE_DELEGATE_OneParam(FMyDelegate, int32);
FMyDelegate MyDelegate;

void Start()
{
    MyDelegate.BindUFunction(this, FName("PrintNum"));
}

UFUNCTION()
void PrintNum(int32 Num)
{
    UE_LOG(LogTemp, Warning, TEXT("%d"), Num);
}

ㆍ 사전에 정의된 함수 시그니처에 맞는 함수를 바인딩 하고 나중에 호출할 수 있게 해주는 기능

 

ㆍ 주로 이벤트 처리나 콜백 패턴 구현에 주로 사용된다.

 

ㆍ 게임에서의 예시 ) 적의 총알이 날아오다가 일정 시간이 지나면 5갈래로 퍼져서 날라온다.

 

ㆍ 언리얼에서는 멀티캐스트 델리게이트를 통해 여러 함수를 등록하고 한번에 호출할 수 있다.

 

 

 

 

▶  C++ 스마트포인터에 대해

 

ㆍ 동적 메모리를 안전하게 관리하기 위한 객체(자동화)

 

ㆍ 객체에 대한 포인터를 캡슐화 하고, 그 객체의 수명이 끝나면 자동으로 메모리를 해제하는 역할을 한다.

-> 이를 통해 메모리 누수(memory leak)을 예방하고 코드의 안정성을 높일 수 있다.

※ auto_ptr 은 소유권 이전 등의 문제로 인해 C++11에서 폐지되었다.

◈ 스마트 포인터의 종류

 

1. unique_ptr

 

ㆍ 소유권을 고유하게 가지는 스마트 포인터

 

한번에 하나의 unique_ptr만이 특정 객체를 소유할 수 있다.

 

ㆍ unique_ptr은 복사될 수 없지만 소유권은 이전할 수 있다.

std::unique_ptr<int> ptr1(new int(5));
std::unique_ptr<int> ptr2 = ptr1; // 컴파일 에러
std::unique_ptr<int> ptr3 = std::move(ptr1); // 소유권 이전

 

 

2. shared_ptr

 

ㆍ 여러 스마트 포인터가 하나의 객체를 공유하게 할 수 있는 스마트포인터

 

ㆍ 내부적으로 참조 커운팅 방식을 사용하여 몇 개의 shared_ptr이 해당 객체를 참조하고 있는지 추적한다.

 

ㆍ 참조 카운트는 shared_ptr 이 생성될때나 다른 shared_ptr에 복사될 때 증가한다.

 

ㆍ 참조 카운트는 shared_ptr이 소멸되거나 다른 객체를 가리킬때 감소한다.

 

ㆍ 참조 카운트가 0이 되면 (즉 마지막 shared_ptr이 사라지면)  자동으로 해당 객체를 삭제한다

std::shared_ptr<int> ptr1(new int(5)); // ptr1이 int 객체를 소유, 참조 카운트는 1
std::shared_ptr<int> ptr2 = ptr1; // ptr1과 ptr2가 int 객체를 공유, 참조 카운트는 2

// 이 시점에서 ptr1과 ptr2를 소멸시키면 int 객체는 자동으로 삭제됩니다.

 

※ shared_ptr을 사용할 때는 순환참조를 조심해야 한다. 

※ 두 객체가 서로 shared_ptr로 참조하게 되어 참조 카운트가 절대 0이 되지 않는 상황을 말한다.

※ 이런 경우 weak_ptr을 사용해서 해결할 수 있다.

 

3. weak_ptr

 

ㆍ shared_ptr과 함께 사용되며 참조 카운트에 영향을 주지 않는 포인터

 

ㆍ 즉 객체의 소유권을 가지지 않으면서도 해당 객체에 대한 접근을 가능하게 해준다.

 

ㆍ shared_ptr이 관리하는 객체에 대한 "약한 참조"를 제공한다.

#include <iostream>
#include <memory>
using namespace std;

struct B;

struct A
{
	shared_ptr<B> b_ptr;
	~A() { cout << "A삭제\n"; }

};

struct B
{
	weak_ptr<A> a_ptr;
	~B() { cout << "B삭제\n"; }

};

int main()
{
	shared_ptr<A> a = make_shared<A>();
	shared_ptr<B> b = make_shared<B>();

	a->b_ptr = b;
	b->a_ptr = a;
	
	return 0;
}

ㆍ 서로 참조 하고 있는 예시코드로 B를 weak_ptr로 선언 해 순환 참조 문제를 해결

ㆍ 이렇게 하지 않고 shared_ptr만 사용했을 경우에는 서로 계속 참조 해 소멸자가 호출되지 않고 메모리 릭이 발생

 

ㆍ weak_ptr은 소유권을 가지지 않아 직접 객체에 접근하지 못한다. 따라서 메모리를 직접 조작할 수 있는 권한이 없다.

 

▶ 위 코드에서 make_shared란?

 

ㆍ A타입의 객체를 동적 메모리 할당하고 그 객체를 가리키는 shared_ptr<A>를 생성하는 코드

 

-> 전달된 타입의 객체를 동적으로 생성하고, 그 객체를 관리하는 shared_ptr을 반환

 

-> new를 통해 객체를 생성하고 그 포인터를 shared_ptr에 전달하는 것 보다 효율적이다.

 

※ WHY?

 

ㆍ new와 shared를 따로 사용하면 메모리 할당이 두 번 발생한다

 

ㆍ make_shared는 메모리 할당을 한 번만 수행하고, shared_ptr이 객체와 참조 카운트를 함께 저장한다

 

 

 

 

▶ weak포인터 멀티쓰레드에서 어떻게 사용하면 될까?

 

1. 스레드 안정성을 주의하라

 

ㆍ weak 자체는 스레드 안정성을 보장하지 않는다. 즉, 여러 스레드가 동시에 weak 객체에 접근하거나 수정할 경우 데이터 손상이나 원하지 않은 동작이 발생할 수 있다.

 

ㆍ weak_ptr 이 참조하는 shared_ptr의 상태를 확인하려면 lock() 함수를 사용해서 shared_ptr을 얻어야 한다.

 

※  lock함수를 이용해 shared_ptr을 생성할 수 있다. 

ㆍ  lock 함수는 weak_ptr이 참조하는 객체가 아직 메모리에 존재하는지 검사하고, 그 객체에 대한 shared_ptr을 반환한다. 이렇게 반환된 shared_ptr은 소유권을 가지므로 직접적으로 객체에 접근할 수 있게 된다.

 

2. 참조 카운트

 

ㆍ weak는 shared의 참조카운트를 증가 시키지 않지만, weak 가 참조하는 shared의 수명이 끝나면 더이상 유효하지 않다.

#include <iostream>
#include <memory>
#include <thread>
#include <vector>

class Resource {
public:
    Resource(int id) : id(id) {
        std::cout << "Resource " << id << " created.\n";
    }
    ~Resource() {
        std::cout << "Resource " << id << " destroyed.\n";
    }
private:
    int id;
};

void worker(std::weak_ptr<Resource> weakRes) {
    // weak_ptr을 lock하여 shared_ptr을 얻음
    std::shared_ptr<Resource> res = weakRes.lock();
    if (res) {
        // 자원에 안전하게 접근
        std::cout << "Resource is being used by a thread.\n";
    } else {
        std::cout << "Resource no longer exists.\n";
    }
}

int main() {
    std::shared_ptr<Resource> res = std::make_shared<Resource>(1);
    std::weak_ptr<Resource> weakRes = res;

    std::vector<std::thread> threads;
    for (int i = 0; i < 5; ++i) {
        threads.emplace_back(worker, weakRes);
    }

    res.reset(); // Resource를 소멸시킴

    for (auto& t : threads) {
        t.join();
    }

    return 0;
}

ㆍ 위 예시에서 worker 함수는 weak_ptr을 사용해 자원에 접근하려 하고있다.

 

ㆍ 자원이 소멸된 후에 weak가 shared를 얻으려면 해당 자원이 더이상 존재하지 않을 가능성이 있다. 

 

※ 결론

ㆍ 멀티스레드 환경에서 weak_ptr를 사용할 때는 항상 동기화를 고려해야 하며, shared_ptr과의 상호작용에 주의해야 한다.

 

ㆍ weak_ptr는 소유권을 가지지 않기 때문에, 자원의 생명 주기를 관리하는데 유용하지만, 안전하게 사용하기 위해서는

적절한 동기화 기법을 적용해야 한다.

 

 

# 언리얼에서의 스마트 포인터 사용

'C++프로그래밍 > 이론 정리' 카테고리의 다른 글

unordered_map과 해시  (0) 2023.12.07
상수에 대해 (const)  (0) 2023.12.06
형변환 연산자  (0) 2023.12.04
vitrual키워드와 가상함수테이블  (0) 2023.11.29
STL  (0) 2023.10.13