Unreal Engine5

[UE5] 안정적인 비동기 시스템 구축 (Async to FRunnable)

yeoul0714 2025. 7. 7. 15:53

1. 개요


안녕하십니까 오늘은 언리얼 엔진5에서 비동기로 스레드를 나눠서 작업을 진행할 때에 발생했던 문제와

 

그 과정을 해결하는 데 사용한 지식과 경험을 공유하고자 합니다.


 

2. 문제상황


현재 저희 팀은 JPS를 이용해서 3D공간에서의 빠른 길찾기 플러그인을 개발중에 있습니다.

 

길찾기 로직은 GameThread에서 실행하는 것이 아닌 비동기로 실행해주는 상황입니다. (길찾기에 따른 게임 멈춤 방지)

void UPathfindingSubsystem::ProcessPathfindingQueue()
{
    // 현재 처리 중인 태스크가 너무 많거나, 큐가 비어있으면 아무것도 하지 않음
    if (ActiveRequests.Num() >= MaxTasksPerFrame || RequestQueue.IsEmpty())
    {
        return;
    }
    // 처리할 요청을 큐에서 꺼냄
    FPathfindingRequest Request = RequestQueue[0];
    RequestQueue.RemoveAt(0);

    // 처리 시작을 알림
    ActiveRequests.Add(Request.RequestID, Request); // 이 줄로 교체합니다.
    UE_LOG(LogTemp, Log, TEXT("Processing pathfinding request %d..."), Request.RequestID);

    // 비동기 태스크 실행
    Async(EAsyncExecution::ThreadPool, [Request, this]()
    {
        TArray<FIntVector> PathIndices;
        bool bSuccess = false;
        if (Request.Strategy.IsValid())
        {
            // 실제 길찾기 로직 실행 (이 부분은 스레드에서 실행됨)
            bSuccess = Request.Strategy->
            FindPathWithStrategy(Request.StartIndex, Request.EndIndex, 
            Request.AgentRadiusInVoxels, PathIndices);
        }

        // 결과 처리를 위해 게임 스레드로 다시 작업을 보냄
        AsyncTask(ENamedThreads::GameThread, [this, RequestID = Request.RequestID, PathIndices, bSuccess]()
        {
            // OnPathfindingTaskComplete 함수는 게임 스레드에서 안전하게 호출됨
            OnPathfindingTaskComplete(RequestID, PathIndices, bSuccess);
        });
    });
}

 

현재 Async를 이용해서 비동기로 작업을 실행하고 있습니다.

 

길찾기 로직이 완료된다면 GameThread에서 OnPathfindingTaskComplete가 호출되는 구조로 되어있습니다.

 

Async를 이용해서 비동기 작업을 할때의 장점은 한줄의 함수 호출로 비동기 작업 실행이 가능하다는 점입니다.

 

그러나 Async에는 문제점이 몇가지 존재합니다.

 

  1. 제어 불가 - 작업중에 멈추거나 취소할 수 없습니다.

  2. 생명주기 관리 불가 - Async함수는 자신을 호출한 객체(UPathfindingSubsystem)의 생명주기를 알 수 없습니다.
    그래서 비동기 작업중 level의 변경등으로 UPathfindingSubsystem 객체가 파괴될 경우 크래시가 발생합니다.
    (Dangling Pointer)
Level변경후 Crash나는 모습

 

결론적으로 플러그인 사용자가 할 수 있는 행동에 대한 예외를 확실하게 처리해줄 필요가 있습니다.

 

이렇게 길찾기도중 Level을 바꾸는 상황에 대한 대응이 필요하다는 이야기이기도 합니다.


 

3. FRunnable Interface구현하는 Worker로 해결


비동기는 언리얼 엔진의 FRunnable인터페이스를 이용해서도 구현하는 것이 가능합니다.

 

FRunnable를 이용하면 Async와는 다르게 생명주기를 안전하게 관리하는 것이 가능합니다.

 

PathfindingSubsystem이 파괴될때 안전하게 종료하는 것이 가능합니다.

 

실제 어떠한 방식으로 구현했고 작동하는지 설명하도록 하겠습니다.

 

우선 FPathfindWorker라는 클래스를 생성했습니다.

 

이 클래스는 FRunnable 인터페이스를 상속받고, 비동기 작업을 실행하게 됩니다.

 

PathfindingSubsystem이 PathfindWorker를 가지고 있고, Initialize에서 초기화해줍니다.

FPathfindingWorker::FPathfindingWorker(UPathfindingSubsystem* InSubsystem,
TQueue<FPathfindingRequest, EQueueMode::Mpsc>* InRequestQueue)
	: bStopThread(false)
	, PathfindingSubsystemPtr(InSubsystem)
	, RequestQueue(InRequestQueue)
{
	// FRunnableThread::Create를 호출하여 실제 스레드를 생성하고 즉시 실행합니다.
	Thread = FRunnableThread::Create(this, TEXT("PathfindingWorkerThread"), 0, TPri_Normal);
}

 

그러면 생성자가 실행되고 FRunnableThread* 타입의 Thread를 하나 만들어주게 됩니다.

 

이렇게 Create되는 순간 Run()함수가 자동으로 실행됩니다. (Run은 RunnableInterface를 구현한 함수)

 

아래 함수는 이전에 PathfindingSubsystem에서 실행되던 로직을 Run으로 옮겨온 것입니다.

uint32 FPathfindingWorker::Run()
{
	// 이 while 루프가 작업자 스레드의 본체입니다.
	// bStopThread가 true가 될 때까지 계속 반복됩니다.
	while (!bStopThread)
	{
		FPathfindingRequest CurrentRequest;
		// 1. 스레드 안전 큐에서 작업을 꺼내옵니다. 성공하면 true를 반환합니다.
		if (RequestQueue->Dequeue(CurrentRequest))
		{
			// 2. 길찾기 계산을 수행합니다. 이 작업은 백그라운드 스레드에서 실행됩니다.
			TArray<FIntVector> PathIndices;
			bool bSuccess = false;
			if (CurrentRequest.Strategy.IsValid())
			{
				// 실제 계산 수행
				bSuccess = CurrentRequest.Strategy->FindPathWithStrategy(
					CurrentRequest.StartIndex, CurrentRequest.EndIndex, 
                    CurrentRequest.AgentRadiusInVoxels, PathIndices);
			}

			// 3. 계산 결과를 게임 스레드에서 처리하도록 작업을 예약합니다.
			//    델리게이트를 값으로 캡쳐하여, Subsystem의 생명주기와 상관없이 안전하게 호출합니다.
			AsyncTask(ENamedThreads::GameThread, 
            [OnComplete = CurrentRequest.OnPathfindingComplete, PathIndices, bSuccess]()
			{
				// 이 람다 함수는 게임 스레드에서 실행됩니다.
				OnComplete.ExecuteIfBound(PathIndices, bSuccess);
			});
		}
		else
		{
			// 4. 큐가 비어있으면, CPU를 낭비하지 않도록 잠시 대기합니다.
			FPlatformProcess::Sleep(0.01f); // 10ms
		}
	}

	// 루프가 종료되면 0을 반환하여 스레드가 성공적으로 끝났음을 알립니다.
	return 0;
}

 

그렇다면 위의 방식과 어떻게 다르기에 Subsystem이 사라지는 상황에서도 프로그램이 안정성이 보장될까요?

 

그것은 바로 PathfindingSubsystem객체가 소멸될때 실행되는 DeInitialize에 비밀이 있습니다.

 

우선 이 함수가 호출되면 worker스레드의 while루프를 멈추게 됩니다.

void UPathfindingSubsystem::Deinitialize()
{
	// Worker 객체가 유효한지 확인합니다.
	if (PathfindingWorker)
	{
		// Worker에게 종료를 요청하고, 백그라운드 스레드가 완전히 끝날 때까지
		// 이 Deinitialize 함수를 잠시 멈추고 기다립니다.
		// 이 과정이 맵 전환 시 발생하는 크래시를 완벽하게 방지합니다.
		PathfindingWorker->EnsureCompletion();
		
		// TUniquePtr의 Reset()을 호출하여 Worker 객체의 메모리를 해제합니다.
		PathfindingWorker.Reset();
	}

	UE_LOG(LogTemp, Log, TEXT("PathfindingSubsystem Deinitialized and Worker Thread Stopped."));

	Super::Deinitialize();
}

 

 

그 후 스레드가 완전히 종료될 때까지 기다립니다.

 

그리고 모든 일을 끝내게 된다면 그때 서브시스템 객체를 파괴하게 됩니다.

void FPathfindingWorker::EnsureCompletion()
{
	// 1. 스레드에 종료를 요청합니다.
	Stop();

	// 2. 스레드가 Run() 함수를 완전히 끝내고 종료될 때까지 현재 스레드(보통 게임 스레드)를 대기시킵니다.
	if (Thread)
	{
		Thread->WaitForCompletion();
	}
}