Skip to content

Instantly share code, notes, and snippets.

@xixasdev
Last active January 13, 2022 06:57
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save xixasdev/c7867d51ee54847bec7053548d39fd99 to your computer and use it in GitHub Desktop.
Save xixasdev/c7867d51ee54847bec7053548d39fd99 to your computer and use it in GitHub Desktop.
C++ Gaming: World-state snapshot example for non-interrupting saves
// SaveStateExample.cpp (C++11)
// AUTHOR: xixas | DATE: 2021.12.31 | LICENSE: WTFPL/PDM/CC0... your choice
// DESCRIPTION: Gaming: World-state snapshot example for non-interrupting saves.
// BUILD: g++ -o save-state-example SaveStateExample.cpp -lpthread -std=c++11
#include <cstdio> // printf
#include <atomic> // std::atomic
#include <mutex> // std::mutex
#include <thread> // std::thread, std::this_thread
#include <chrono> // std::chrono
std::atomic<bool> isSaving(false); // Thread-safe save state flag
// Thread-safe generic type wrapper with snapshot capability.
// Temporarily allocates memory for a second value if updated during save.
template <typename T> class Saveable {
protected:
mutable T *a, *b;
mutable std::mutex mtx;
public:
Saveable() { a = b = new T; }
Saveable(const T& t) : Saveable() { *a = t; }
Saveable(const Saveable<T>& o) : Saveable() { *a = *(o.a); if (o.a != o.b) { b = new T; *b = *(o.b); }}
~Saveable() { if (a != b) { delete a; } delete b; }
Saveable<T>& operator=(const T& t) { setValue(t); return *this; } // Direct assignment
operator T() { return getValue(); } // Direct use as type
T operator +() { return getSaveValue(); } // Retrieve save value with a '+' prefix
void setValue(const T& t) {
std::lock_guard<std::mutex> lock(mtx);
if (isSaving) {
if (a == b) { b = new T; }
} else {
if (a != b) { delete a; a = b; }
}
*b = t;
}
T getValue() const {
std::lock_guard<std::mutex> lock(mtx);
if (a != b && !isSaving) { delete a; a = b; }
return *b;
}
T getSaveValue() const {
std::lock_guard<std::mutex> lock(mtx);
if (a != b && !isSaving) { delete a; a = b; }
return *a;
}
};
typedef Saveable<bool> bool_s;
typedef Saveable<int> int_s;
typedef Saveable<float> float_s;
//-[ Example ]----------------------------------------------------------------
const size_t threadCount = 4, maxSet = 25, saveEvery = 4;
std::mutex mtx;
bool_s worldBool = false;
int_s worldInt = 0;
float_s worldFloat = 0.0f;
void thread(int id, bool canInitSave, bool canPerformSave) {
do {
std::this_thread::sleep_for(std::chrono::milliseconds(1));
// Lock and update world values
mtx.lock();
if (!isSaving || !canPerformSave) {
if (worldInt >= maxSet) { mtx.unlock(); continue; }
worldBool = !worldBool;
worldInt = worldInt + 1;
worldFloat = worldFloat + 0.5f;
}
const bool b = worldBool, b_ = +worldBool;
const int n = worldInt, n_ = +worldInt;
const float f = worldFloat, f_ = +worldFloat;
bool initSave = canInitSave && !isSaving && n && !(n % saveEvery);
int state = !isSaving ? 1 : !canPerformSave ? 2 : (isSaving = false);
if (initSave) { isSaving = true; }
mtx.unlock();
// Display thread status and world state
switch (state) {
case 1: printf("Thread %d: Active: [%d, %d, %.1f]\n", id, b, n, f); break;
case 2: printf("Thread %d: Active: [%d, %d, %.1f] -> Snapshot: [%d, %d, %.1f]\n", id, b, n, f, b_, n_, f_); break;
default: printf("Thread %d: Save Complete... [%d, %d, %.1f]\n", id, b_, n_, f_); break;
}
if (initSave) { printf("Thread %d: Saving...\n", id); }
} while (worldInt < maxSet || (isSaving && canPerformSave));
};
int main() {
// Spawn threads
std::thread threads[threadCount];
for (size_t i = 0; i < threadCount; ++i) { threads[i] = std::thread(thread, i+1, true, i+1 == threadCount); }
for (size_t i = 0; i < threadCount; ++i) { threads[i].join(); }
return 0;
}
@xixasdev
Copy link
Author

xixasdev commented Jan 1, 2022

Update: See here for a modified example working on large datasets

Quick Save: Storing game state without interrupting the user experience...

Threads and concurrency and the joys of game development, eh?

In simpler (often single-threaded) games, progression can be saved incrementally with little-to-no interruption of the user experience. But as your game grows more complex and larger datasets with significant memory overhead need to be stored, division of concerns and optimal resource management become more important.

There are a variety of solutions to this problem, many based around third-party caching libraries, others as simple as holding duplicate copies of the data at all times. The example herein aims to provide a bare-bones, zero-dependency instant snapshot solution for the purpose of teaching the basics.

Sample Output

Thread 1: Active: [1, 1, 0.5]
Thread 2: Active: [0, 2, 1.0]
Thread 3: Active: [1, 3, 1.5]
Thread 4: Active: [0, 4, 2.0]
Thread 4: Saving...
Thread 1: Active: [1, 5, 2.5] -> Snapshot: [0, 4, 2.0]
Thread 2: Active: [0, 6, 3.0] -> Snapshot: [0, 4, 2.0]
Thread 4: Save Complete... [0, 4, 2.0]
Thread 3: Active: [1, 7, 3.5] -> Snapshot: [0, 4, 2.0]
Thread 1: Active: [0, 8, 4.0]
Thread 1: Saving...
Thread 2: Active: [1, 9, 4.5] -> Snapshot: [0, 8, 4.0]
Thread 4: Save Complete... [0, 8, 4.0]
Thread 3: Active: [0, 10, 5.0]
Thread 1: Active: [1, 11, 5.5]
Thread 2: Active: [0, 12, 6.0]
Thread 2: Saving...
Thread 4: Save Complete... [0, 12, 6.0]
Thread 3: Active: [1, 13, 6.5] -> Snapshot: [0, 12, 6.0]
Thread 1: Active: [0, 14, 7.0]
Thread 2: Active: [1, 15, 7.5]
Thread 4: Active: [0, 16, 8.0]
Thread 4: Saving...
Thread 3: Active: [1, 17, 8.5] -> Snapshot: [0, 16, 8.0]
Thread 1: Active: [0, 18, 9.0] -> Snapshot: [0, 16, 8.0]
Thread 2: Active: [1, 19, 9.5] -> Snapshot: [0, 16, 8.0]
Thread 4: Save Complete... [0, 16, 8.0]
Thread 3: Active: [0, 20, 10.0]
Thread 3: Saving...
Thread 1: Active: [1, 21, 10.5] -> Snapshot: [0, 20, 10.0]
Thread 2: Active: [0, 22, 11.0] -> Snapshot: [0, 20, 10.0]
Thread 4: Save Complete... [0, 20, 10.0]
Thread 3: Active: [1, 23, 11.5]
Thread 1: Active: [0, 24, 12.0]
Thread 1: Saving...
Thread 2: Active: [1, 25, 12.5] -> Snapshot: [0, 24, 12.0]
Thread 4: Save Complete... [0, 24, 12.0]

What is this? How does it work?

Short version? In simple terms?

It's a generic type wrapper that uses 2 pointers. Sometimes they point to the same value, sometimes they don't.

Longer version?

Pointer/memory allocation is performed transparently by state-based getters/setters:

  • Save data is always read from pointer a
  • Live data is always read from (and written to) pointer b
  • In normal operation, pointers a and b point to the same memory location
  • When the save flag is flipped, data reads continue as normal from the shared location if the data hasn't been modified since the save began
  • If the data is modified while a save is in progress, b is pointed to a new memory location and the modifications are written there instead
  • When saving is complete, on the next read or write, if a and b point to different locations, the memory at a is freed and a is pointed to the same location as b, thus resuming normal operation

The advantages of this method are:

  • Reads (and the user experience) continue uninterrupted
  • Accidental writes to the save data are not possible
  • "both" versions of the data are available to all threads
  • Any thread can request a save by flipping the save flag
  • Any thread (or multiple threads) can perform the save
  • Memory overhead is only as much data as can be generated in-game before a save completes

This is only 100 lines of code... What's wrong with it?

A lot ;)

Notably:

  1. This is simple sample code and there's no variable registration system. Snapshot data is only cleared on read or write... so if you write to a variable during a save, finish the save, and don't touch that variable again before you begin the next save, it's going to save stale data... that's bad. The solution is to "register" (keep track of) your variables and perform a mass-read across all of them as soon as you complete a save. Don't worry, these are memory pointers in RAM we're talking about -- you can read well over 1_000_000 values in a millisecond.

    How To Summary:

    • Create a virtual class/struct with an update() method and make Saveable extend that class
    • Implement the update() method same as getValue() (without the return)
      Note: This is a mutable write! Use std::lock_guard, NOT std::shared_lock (see point #2 below)
    • "Register" your variables by referencing them them in a vector or similar collection using the shared type
    • On save complete, iterate the vector, calling update() on each stored member
      // Basically...
      #include <vector>
      ...
      struct Updateable { virtual void update() const = 0; };
      template <typename T> class Saveable : public virtual Updateable {
          ...
          void update() const override {
              std::lock_guard<std::mutex> lock(mtx);
              if (a != b && !isSaving) { delete a; a = b; }
          }
      }
      ...
      std::vector<Updateable*> vars{ &worldBool, &worldInt, &worldFloat };
      void beginSave() { isSaving = true; }
      void endSave() { isSaving = false; for (Updateable* v : vars) { v->update(); }}
      ...
      // Implement save functions in thread() code...
      Alternately you could pop the vector into a protected static variable or private namespace and register/de-register via the constructor/destructor. Using a protected static vector within the Saveable class would divide the store by template type, but would eliminate the need for the Updateable parent class entirely... but I digress. That's outside the necessary scope here.
  2. To keep things simple (and buildable in C++11) this example uses single-mutex-based locking, which means, sure, a read and write can't occur at the same time... but neither can two reads! If you're using C++14 or higher, use std::shared_lock instead of std::lock_guard in the two getter methods, but only when (a == b || isSaving). If you really need this in C++11, Boost provides an implementation, and there are a number of good, tiny, header-only implementations floating around (I'm not holding your hand... go ahead... open up your favorite search engine).

  3. Whether you consider this a problem or not depends on your situation, but the example code does not guarantee a save on every 4th value -- and this is intentional. In the real world, if a save is in progress and the user requests to save again, we don't interrupt the current save. We either ignore or queue the second request. Increase the threadCount to more readily see what happens when data is updated faster than a save can complete -- spoiler: it skips a save.

  4. I used some shortcuts, overloads, and unlabeled abstractions to make this readable in 100 lines -- particularly for state management stuff.
    I mean... line 80... really?

    int state = !isSaving ? 1 : !canPerformSave ? 2 : (isSaving = false);

    The last bit both performs an assignment and auto-casts to zero, thereby falling into the default: switch case below.
    That's terrible. Don't do any of that in shared production code.

This isn't actually saving anything to disk...

Nope. Serialization / data marshalling is a tutorial in and of itself, and would detract from the purpose of this example.

It simply gets delayed by thread scheduling and tells you when it's complete -- same as a real save minus the disk I/O :)

This code formatting is terrible

I know, right?

Fret not, I originally wrote it in typical modified K&R style. It's presented here in a condensed format to get as much on the screen as possible for the sake of learning and immediate visual reference -- think of it like a PowerPoint presentation.

Once you get the concept, the methods are easy to break down. If you don't... see next heading.

I don't understand...

Well then, this wasn't written for you :)

Why don't all developers do this?

Reason Response
Don't know how Scroll up.... now you do
Didn't think of it, so it's "not a good idea" Typical developer - we do like our ideas best. Sad Panda. User experience comes first
There's too much data to snapshot it in real time That's exactly what this code does, regardless of dataset size
We can't allot any more threads for performance reasons If your saves are bringing your game to a dead stop, those threads aren't doing anything anyway... give an existing thread double duty or, better yet, use a thread pool
Didn't start that way, updating the system would be too much work It's called refactoring and it's part of the job - often most of the job
Can't possibly find every type instance That'll teach you not to have a global typedef file for your game - live and learn
The memory overhead of using all of those class instances instead of primitive types is prohibitive No, it's really not
No, it really is! Then write a generic singleton that holds a 2-step collection of primitive pointers
Our game's written in X language - it doesn't support pointers Quick saves are the least of your problems

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment