Multi-Threading

Usually, when people hear the term multi-threading they panic. They think, that it is too high-level stuff for them and figure they come back later to learn about the subject. Worry not! multi-threading in Unreal Engine is relative easy thing to achieve and gaining can be enormous! multi-threading allows us to pass the heavy calculations to different thread, allowing our game-thread to work flawlessly while calculations are in progress.

If we don’t multi-thread and do something like finding the Prime Number. Game would literally freeze while the number is searched and player would get frustrated very quickly. This happens because almost all the game calculations are done within the game-thread and game cannot continue until current thread-task is completed.

There are three main ways to use the multi-threading and we are going to go through them. All three ways works a bit differently, but ultimately goes toward same goal. First, we are going to look multi-threading using class FNonAbandonableTask. This is the most easiest way to do it, but also weakest. FNonAbandonableTask can sometimes use the game-thread if necessary and because of that, it is not 100% fool-proof way to keep every calculations within the different thread. However, with basic tasks, it is more than enough!

After that, we are going to see FRunnable class. This is the most powerful way to do multi-theading and should be used if you have a huge calculating you need to do. FRunnable never uses game-tread. Finally, we are going to look Task Graph System. It allows us to do multiple smaller task simultaneously. Note that Task Graph System may use game-thread.

There are some things that combines all of these. You Cannot:

  • Modify or create any UObjects within the other threads! Don’t do that or you get crashes!
  • Use timers. I know, bummer! But you can’t. Game will crash if you set timers with other threads.
  • Draw any kind of debug lines (DrawDebugLine).

If you pass variables, pass them by giving your class reference through parameters to other thread and set variable that way. You can also pass variables using delegates! Delegates are thread-safe to use. If you go all rambo, in some machines the game may crash! If you want to do something with UObjects within the other threads, like spawning or modifying them, you can use game-thread using AsyncTask. It is usable anywhere in code! Just remember that it adds ms:s to game-thread.

AsyncTask(ENamedThreads::GameThread, []() {
		// Your game-thread code comes here
	});

FNonAbandonableTask.

Okay! I think we are good to go. Let’s start with FNonAbandonableTask. We are going to strip this system to bare bones, so can see exactly what is happening.

Start by creating a C++ class. You can base that on None or Actor. You can also use any class you already have. Inside the class, we create another class by hand. You write this after the base class ends –> };

// Name of our class is MyAbandonableTaskTest. It is inherited from FNonAbandonableTask!
class MyAbandonableTaskTest : public FNonAbandonableTask 
{
// Here we can specify our class variables
  int32 SomeVariable;
  

public:
  //Default constructor.
  MyAbandonableTaskTest(int32 IAmParameter) // Here we can set parameters our class can get
  {
    // We set our variable to be the one we got through parameter
    this->SomeVariable = IAmParameter;

  }

  // Default destructor
  ~MyAbandonableTaskTest(){
    // We are going to print something when our object is destroyed.
    UE_LOG(LogTemp, Warning, TEXT("Task Deleted"));
  
  }

  //This function is needed from the API of the engine. So always add this.
  FORCEINLINE TStatId GetStatId() const
  {
    RETURN_QUICK_DECLARE_CYCLE_STAT(MyAbandonableTaskTest, STATGROUP_ThreadPoolAsyncTasks);
  }

  //This function is executed when we tell our task to execute. All the work done here is done in our new thread!
  void DoWork()
  {
    UE_Log(LogTemp, Warning, TEXT("Thread Started");
    UE_Log(LogTemp, Warning, TEXT("Parameter value was %i."), SomeVariable);

    
  }
};

That is our class! Now, in your main class create a function and inside that function we are going to create an object of our class. We can use one of the two types. One is FAutoDeleteAsyncTask. This means, when our multi-thread task is done. It will automatically delete itself. Other one is FASyncTask, it needs to be manually deleted. We start by using FAutoDeleteAsyncTask.

void APuzzleProjectCCharacter::Activate() // Our class in game-thread. This will call the function that will start the other thread.
{
  // Create FAutoDeleteAsyncTask. Give 100 as our parameter value. Remember we wanted one parameter!
  (new FAutoDeleteAsyncTask<MyAbandonableTaskTest>(100))->StartBackgroundTask();
}

When we run our function, we’ll see this. Note that DoWork() is run automatically! We don’t run class functions directly. StartBackgroundTask() is doing that for us. Note also that destructor is run automatically! That is because we used FAutoDeleteAsyncTask.

Let’s try FASyncTask next. For this, we wan’t to get the pointer reference so we can later get data from it. We are going to need create it outside of the scope. So on top of our class, just below the includes.

FAsyncTask<MyAbandonableTaskTest>* Tasker;

Now, inside the same function where we previously started multi-theading, we are going to get reference to our task and then call the function.

void APuzzleProjectCCharacter::Activate()
{
  Tasker = (new FAsyncTask<MyAbandonableTaskTest>(100));
  Tasker->StartBackgroundTask();
}

If we start game now and run function, we see that destructor isn’t called anymore. So we have loose ends.

Let’s create another function called DestroyThread(). Function will get our tasker, ensure it’s completion(game will likely crash if we don’t!) and then delete it. After we run this function, we will see the message we created inside destructor and we are good to go.

void APuzzleProjectCCharacter::DestroyThread()
{
  if (Tasker)
  {
    Tasker->EnsureCompletion();
    delete Tasker;
    Tasker = NULL;
  }
}

To see all function available you can check out the docs. There isn’t anything fancy. Mostly functions that helps you check if there is any work in progress currently. Note that FAutoDeleteAsyncTask doesn’t have any of these functions, because it is fully automatic. You only get these by using FASyncTask.

So what if we want to run function that resides within our basic class? To begin with, we are in different scope so we cannot just type function name and expect it to work. There is easy way to solve this however. We just pass our class object as variable and have access to everything.

class MyAbandonableTaskTest: public FNonAbandonableTask
{
  // Here we can specify our class variables
  APuzzleProjectCCharacter* CharRef;


public:
  //Default constructor.
  MyAbandonableTaskTest(APuzzleProjectCCharacter* CharRef) // Here we can set parameters our class can get
  {
    // We set our variable to be the one we got through parameter
    this->CharRef = CharRef;

  }

In object creation we pass this. Because we are inside the Character this equals the object of the character class.

// Activates The "Machine"
void APuzzleProjectCCharacter::Activate()
{
  Tasker = (new FAsyncTask<MyAbandonableTaskTest>(this));
  Tasker->StartBackgroundTask();
}

Now in DoWork we could.

//This function is executed when we tell our task to execute. All the work done here is done in our new thread!
void DoWork()
{

  UE_LOG(LogTemp, Warning, TEXT("Thread Started"));
  CharRef->SomeFunctionWehaveCrated();


}

That’s about it! You can now create a basic background thread and run it whenever you want!


FRunnable

FRunnable is the best way to do huge calculations within the other threads. Again, we start by creating a C++ class based on anything you like. We create our own class inside the class. Just after };

// We inherid from FRunnable
class FMultiThreadTest : public FRunnable
{
 // We declare event that we are going to broadcast once this thread has completed the job!
 DECLARE_EVENT(FMultiThreadTest, FThreadFinishEvent);
 // We can access our Runnable anywhere, because it is static
 static FMultiThreadTest* Runnable;

 // Thread to run the worker FRunnable on 
 FRunnableThread* Thread;


public:
// Create object from our event.
FThreadFinishEvent ResultEvent;


 // Create prototype of everything. This is basically .H file.
 // Constructor
 FMultiThreadTest();
 // Destructor
 virtual ~FMultiThreadTest();

 // Interface calls. These can be found in Runnable.h
 virtual bool Init();
 virtual uint32 Run();

 /** Makes sure this thread has stopped properly */
 void EnsureCompletion();

 //~~~ Starting and Stopping Thread ~~~
 static FMultiThreadTest* StartInitialize();

 // Shutdown functions. Static, so they are easily accessable
 static void Shutdown();

};

In .CPP we declare all our functions.

//Thread Worker Starts as NULL, prior to being instanced. Crashes if you don't add this line. 
FMultiThreadTest* FMultiThreadTest::Runnable = NULL;
//***********************************************************

FMultiThreadTest::FMultiThreadTest()
{
// In Constructor, we create our thread.
 Thread = FRunnableThread::Create(this, TEXT("FPrimeNumberWorker"), 0, TPri_BelowNormal); //windows default = 8mb for thread, could specify more
}

FMultiThreadTest::~FMultiThreadTest()
{
// In Destructor we delete it.
 UE_LOG(LogTemp, Warning, TEXT("Thread Deleted");
 delete Thread;
 Thread = NULL;
}

//Init
bool FMultiThreadTest::Init()
{
 // This is the build-in Init function. This actually calls other function to start the job.
 UE_LOG(LogTemp, Warning, TEXT("Thread Started")); 
 return true;
}

//Run
uint32 FMultiThreadTest::Run()
{
// This function is the main function that will run. After this is completed, thread has done it's work.
 //Initial wait before starting
 FPlatformProcess::Sleep(0.03);
 UE_LOG(LogTemp, Warning, TEXT("Thread is running"));

 // We broadcast our delegate! 
 ResultEvent.Broadcast();

 return 0;
}

// This is our custom function that will actually create our thread instance and start the actual Init.
FMultiThreadTest* FMultiThreadTest::StartInitialize()
{
 UE_LOG(LogTemp, Warning, TEXT("Thread init!"));
 //Create new instance of thread if it does not exist and the platform supports multi threading!
 if (!Runnable && FPlatformProcess::SupportsMultithreading())
 {
 Runnable = new FMultiThreadTest();
 }
 return Runnable;
}

void FMultiThreadTest::EnsureCompletion()
{
 // We need to WaitForCompletion to avoid crashes.
 Thread->WaitForCompletion();
}

// Function to shutdown thread. Can be called in middle of the work!
void FMultiThreadTest::Shutdown()
{
 if (Runnable)
 {
 // We are going to delete our thread.
 Runnable->EnsureCompletion();
 delete Runnable;
 Runnable = NULL;
 }
}

Next create custom functions(Start(), ShutDownMultiThread(), ThreadFinished())  inside our main class, Not in the thread-class! I show you declarations.

// When this function is called. Our multi-threading starts!
void AC_MyPlayerController::Start()
{
  
  FMultiThreadTest* ThreadRef;
  // We get reference to our thread-object.
  ThreadRef = FMultiThreadTest::StartInitialize();
  // We bind the delegate we created inside our thread class!
  ThreadRef->ResultEvent.AddUObject(this, &AC_MyPlayerController::ThreadFinished);
}

void AC_MyPlayerController::ShutDownMultiThread()
{
  FMultiThreadTest::Shutdown();
}

// This function is called through the delegate when the Thread has done working.
void AC_MyPlayerController::ThreadFinished()
{
  UE_LOG(LogTemp, Warning, TEXT("THREAD IS FINISHED"));
  // We are going to call the ShutDownMultiThread() function from the Game-Thread!
  AsyncTask(ENamedThreads::GameThread, [&]() {
		ShutDownMultiThread();
	});

}

That’s it! Now you can give enormous calculations to different threads! Have fun experienting!


Task Graph System

In Task Graph System, we create multiple tasks that are trying to complete their own task. We can check wether one of the taskers have completed their task or we can check if all of them have completed. Code is almost done purely in .CPP file.

//////////////////////////////////
//Multi thread Test, finding prime number
namespace TaskGraphTest
{
  // This is the array of thread completions to know if all threads are done yet
  FGraphEventArray TaskGraphTest_CompletionEvents;

  // Our custom function where we check that all the tasks are completed! 
  // Very important if you want to wait until all the dispatched threads are finished!
  bool TasksAreComplete()
  {
    //Check all thread completion events. If one fails, we return false.
    for (int32 Index = 0; Index < TaskGraphTest_CompletionEvents.Num(); Index++)
    {
      //If  ! IsComplete()
      if (!TaskGraphTest_CompletionEvents[Index]->IsComplete())
      {
        return false;
      }
    }
    return true;
  }


  // Our class. Notify that we don't need to inherid from anywhere.
  class FTestTask
  {
  public:
    // Constructor
    FTestTask() //send in property defaults here
    {
      //can add properties here
    }

    static void StartJob()
    {
      // We are creating 7 different threads to do our job.
      for (uint32 b = 0; b < 7; b++)
      {
        // NULL means we don't need to wait other FGraph tasks. We also set that GameThread is the place where we dispatch.
        // This will create new element to our array! Remember we created the function what will check if each of the array element is completed. This is the reason! We can add parameters from here if we like.
        TaskGraphTest_CompletionEvents.Add(TGraphTask<FTestTask>::CreateTask(NULL, ENamedThreads::GameThread).ConstructAndDispatchWhenReady()); 
      }
    }

    // API stuff.
    FORCEINLINE static TStatId GetStatId()
    {
      RETURN_QUICK_DECLARE_CYCLE_STAT(FTestTask, STATGROUP_TaskGraphTasks);
    }

    // We can get useful data using these functions.
    static ENamedThreads::Type GetDesiredThread()
    {
      return ENamedThreads::AnyThread;
    }

    static ESubsequentsMode::Type GetSubsequentsMode()
    {
      return ESubsequentsMode::TrackSubsequents;
    }

    //~~~~~~~~~~~~~~~~~~~~~~~~
    //Main Function: Do Task!!
    //~~~~~~~~~~~~~~~~~~~~~~~~
    void DoTask(ENamedThreads::Type CurrentThread, const FGraphEventRef& MyCompletionGraphEvent)
    {
      // Our logic comes here
            // Note that this will run multiple times if you have dispatched more than one thread.
      // You may want to check TaskaAreComplete() function to be sure every task is completed.
      UE_LOG(LogTemp, Warning, TEXT(" A thread completed"));
      
    }
  };


}


After our Task Graph Code is ready. We create few functions for our main game-thread. Remember to prototype these in .H file.

void APuzzleProjectCGameMode::CheckAllThreadsDone()
{
  // If our function returns true, we know that all the TaskGraphTest_CompletionEvents array elements returned true
  if (TaskGraphTest::TasksAreComplete())
  {
    GetWorldTimerManager().ClearTimer(Timer);
    UE_LOG(LogTemp, Warning, TEXT("Multi Thread Test Done!"));	
  }
}

//Starting the Tasks / Threads
void APuzzleProjectCGameMode::StartThreadTest()
{
  // We start our Task Graph
  TaskGraphTest::FTestTask::StartJob();

  // Start a timer to check when all the threads are done!
     GetWorldTimerManager().SetTimer(Timer, this, &APuzzleProjectCGameMode::CheckAllThreadsDone, true);
}

And that’s it! You have successfully created three different ways to run code as background process. Personally, i favor the FRunnable, because it is the most powerful one and rather easy to set up! Have fun coding!

Vastaa

Sähköpostiosoitettasi ei julkaista. Pakolliset kentät on merkitty *