Repository: OpenAssetIO/OpenAssetIO (language: python)

Issue:
Python GIL Management
## What

Establish where we need to manage the GIL, and update the bindings accordingly.

## ACs

- Ensure `CODING_STANDARDS.md` is updated to include any GIL-related guidelines.
- Impact on GIL management for the current Python -> C++ -> C -> C++ -> Python code path (and variations) is understood and mitigations implemented (currently looking like GIL release in all `Manager`, `Host`, `ManagerInterface` and `HostInterface` methods - as interface methods may be called directly (Eg: gRPC), `ManageFactory`, `ManagerImplementationFactoryInterface` etc).
- Tests cover:
   - Python calling into C++ manager interface that tries to acquire the GIL from another awaited thread (or similar).
   - (manual check that pybind does do what its docs say)

```[tasklist]
### Tasks
- [x] `gil_scoped_release` all the things
- [x] Add issue to revert usage of `OPENASSETIO_PYBIND11_OVERRIDE_PURE_NAME` once fixed upstream
```

Python GIL Management
## What

Establish where we need to manage the GIL, and update the bindings accordingly.

## ACs

- Ensure `CODING_STANDARDS.md` is updated to include any GIL-related guidelines.
- Impact on GIL management for the current Python -> C++ -> C -> C++ -> Python code path (and variations) is understood and mitigations implemented (currently looking like GIL release in all `Manager`, `Host`, `ManagerInterface` and `HostInterface` methods - as interface methods may be called directly (Eg: gRPC), `ManageFactory`, `ManagerImplementationFactoryInterface` etc).
- Tests cover:
   - Python calling into C++ manager interface that tries to acquire the GIL from another awaited thread (or similar).
   - (manual check that pybind does do what its docs say)

```[tasklist]
### Tasks
- [x] `gil_scoped_release` all the things
- [x] Add issue to revert usage of `OPENASSETIO_PYBIND11_OVERRIDE_PURE_NAME` once fixed upstream
```



Discussion / hints:
Probably worth littering these around the bindings: https://pybind11.readthedocs.io/en/stable/advanced/functions.html#call-guard
We don't acquire the GIL within `createPythonPluginSystemManagerImplementationFactory`, so currently the host must acquire it before calling this. 

The symptom in a Release build is an unhelpful segfault. In a Debug build you get a failed assertion telling you that the GIL must be acquired before importing modules.

On the plus side, once you have a `ManagerImplementationFactoryInterfacePtr` to the Python plugin system, calling `identifiers()` on it works without explicitly acquiring the GIL. Could be just luck that it works, but I believe since the `identifiers()` method is managed by pybind11, it'll ensure the GIL is held. This is backed up by [the docs](https://pybind11.readthedocs.io/en/stable/advanced/misc.html#global-interpreter-lock-gil)

> pybind11 will ensure that the GIL is held when it knows that it is calling Python code [and] the PYBIND11_OVERRIDE family of macros will acquire the GIL before calling back into Python.
This is becoming more urgent - as certain integration projects have come across issues due to us not releasing the GIL in Manager Interface bindings.
Probably worth littering these around the bindings: https://pybind11.readthedocs.io/en/stable/advanced/functions.html#call-guard
We don't acquire the GIL within `createPythonPluginSystemManagerImplementationFactory`, so currently the host must acquire it before calling this. 

The symptom in a Release build is an unhelpful segfault. In a Debug build you get a failed assertion telling you that the GIL must be acquired before importing modules.

On the plus side, once you have a `ManagerImplementationFactoryInterfacePtr` to the Python plugin system, calling `identifiers()` on it works without explicitly acquiring the GIL. Could be just luck that it works, but I believe since the `identifiers()` method is managed by pybind11, it'll ensure the GIL is held. This is backed up by [the docs](https://pybind11.readthedocs.io/en/stable/advanced/misc.html#global-interpreter-lock-gil)

> pybind11 will ensure that the GIL is held when it knows that it is calling Python code [and] the PYBIND11_OVERRIDE family of macros will acquire the GIL before calling back into Python.
This is becoming more urgent - as certain integration projects have come across issues due to us not releasing the GIL in Manager Interface bindings.

Fix patch:
diff --git a/CPPLINT.cfg b/CPPLINT.cfg
index c0a9baaef..b91bb6ddf 100644
--- a/CPPLINT.cfg
+++ b/CPPLINT.cfg
@@ -16,5 +16,7 @@ root=.
 #   This check is also performed by Clang-Tidy, anyway.
 # -whitespace/parens: Conflicts with ClangFormat in function pointer
 #   signatures.
+# -build/c++11: Disallows some useful std headers based on Google's
+#  preferences, e.g. <future>.
 filter=-runtime/references,-readability/nolint,-readability/check,-whitespace/braces
-filter=-readability/casting,-whitespace/parens
+filter=-readability/casting,-whitespace/parens,-build/c++11
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index c7ffbabeb..f41ed912e 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -100,6 +100,10 @@ v1.0.0-alpha.xx
 
 ### Improvements
 
+- Modified Python bindings for all non-trivial methods such that they
+  release the GIL before calling any underlying C++ implementation.
+  [#554](https://github.com/OpenAssetIO/OpenAssetIO/issues/554)
+
 - Improved error messaging when `defaultManagerForInterface` points to a
   directory not a file.
 
diff --git a/doc/contributing/CODING_STANDARDS.md b/doc/contributing/CODING_STANDARDS.md
index 97de316d2..084a9357b 100644
--- a/doc/contributing/CODING_STANDARDS.md
+++ b/doc/contributing/CODING_STANDARDS.md
@@ -404,6 +404,33 @@ So for types that are converted to/from native Python types, it is safe
 to assign them as default argument values.  Note that this includes
 e.g. `std::vector` (Python `list`), which is _not_ safe in pure Python.
 
+### The GIL
+
+The GIL should be released for non-trivial bound methods using
+```c++
+py::call_guard<py::gil_scoped_release>{}
+```
+in the pybind11 `.def(...)` arguments (see [pybind11
+docs](https://pybind11.readthedocs.io/en/stable/advanced/misc.html#global-interpreter-lock-gil)).
+This allows other Python threads to continue while the C++ function
+body runs.
+
+Releasing the GIL also prevents deadlocks in case the C++ function body
+spawns and waits on a thread that calls out to Python. This is
+particularly important for methods that ultimately call out to functions
+provided externally (e.g. manager plugins).
+
+Any methods not released should be in O(1) time and guaranteed to not
+cause a deadlock by calling out to python off-thread.
+
+Until https://github.com/pybind/pybind11/issues/4878 is fixed, pure
+virtual bindings should use the `OPENASSETIO_PYBIND11_OVERRIDE_PURE`
+macro in the pybind11 [trampoline
+class](https://pybind11.readthedocs.io/en/stable/advanced/classes.html),
+rather than `PYBIND11_OVERRIDE_PURE`. See the issue description for more
+details.
+
+
 ## Environment variables
 
 All environment variables should be prefixed with `OPENASSETIO_`.
diff --git a/doc/doxygen/src/ForHosts.dox b/doc/doxygen/src/ForHosts.dox
index dd1a01e55..cc9ace252 100644
--- a/doc/doxygen/src/ForHosts.dox
+++ b/doc/doxygen/src/ForHosts.dox
@@ -56,9 +56,19 @@
  *   behavior as required.
  *
  * - You should resolve an entity reference with the minimal set of @ref
- * trait "traits" required, and use resulting data as the authoritative
- * source of information about an asset. For example, to determine the
- * final colorspace of an image.
+ *   trait "traits" required, and use resulting data as the authoritative
+ *   source of information about an asset. For example, to determine the
+ *   final colorspace of an image.
+ *
+ * - If the manager executes code from Python, then the Global
+ *   Interpreter Lock (GIL) will be released before executing any C++
+ *   implementation of the @fqref{hostApi.HostInterface} "HostInterface"
+ *   and @fqref{log.LoggerInterface} "LoggerInterface" class
+ *   methods. Similarly for methods of the
+ *   @fqref{hostApi.ManagerImplementationFactoryInterface}
+ *   "ManagerImplementationFactoryInterface" class (though this is an
+ *   implementation detail of the plugin system and only of concern in
+ *   advanced use cases).
  *
  * @section host_reading Recommended Reading
  *
diff --git a/doc/doxygen/src/ForManagers.dox b/doc/doxygen/src/ForManagers.dox
index 2a870f1bf..f2718543b 100644
--- a/doc/doxygen/src/ForManagers.dox
+++ b/doc/doxygen/src/ForManagers.dox
@@ -89,6 +89,12 @@
  *   terms that are meaningful for the user. Eg. the state token will be
  *   shared across distributed multi-host renders.
  *
+ * - If the host executes code from Python, then the Global Interpreter
+ *   Lock (GIL) will be released before executing any C++ implementation
+ *   of the @fqref{managerApi.ManagerInterface} "ManagerInterface" and
+ *   @fqref{managerApi.EntityReferencePagerInterface}
+ *   "EntityReferencePagerInterface" class methods.
+ *
  * @section manager_todo Implementation Check List
  *
  * @note You can use the @ref testing_manager_plugins
diff --git a/src/openassetio-python/cmodule/src/_openassetio.hpp b/src/openassetio-python/cmodule/src/_openassetio.hpp
index 166ef8d91..29448e114 100644
--- a/src/openassetio-python/cmodule/src/_openassetio.hpp
+++ b/src/openassetio-python/cmodule/src/_openassetio.hpp
@@ -41,6 +41,33 @@ OPENASSETIO_FWD_DECLARE(hostApi, ManagerImplementationFactoryInterface)
   PYBIND11_OVERRIDE_IMPL(PYBIND11_TYPE(Ret), PYBIND11_TYPE(Class), #Fn, __VA_ARGS__); \
   return Class::Fn CppArgs
 
+/**
+ * Work around https://github.com/pybind/pybind11/issues/4878
+ *
+ * In a Debug build, where `assert` is enabled, pybind11 will do an
+ * `assert(!PyErr_Occurred())` before throwing "Tried to call pure
+ * virtual function". Since Python 3.9, the PyErr_Occurred() function
+ * requires the GIL to be acquired.
+ *
+ * The following macro duplicates PYBIND11_OVERRIDE_PURE_NAME and
+ * inserts a `gil_scoped_acquire`, in order to work around the problem
+ * until it is fixed upstream.
+ *
+ * @todo Revert to using PYBIND11_OVERRIDE_PURE once upstream fix is
+ * available.
+ */
+#define OPENASSETIO_PYBIND11_OVERRIDE_PURE_NAME(ret_type, cname, name, fn, ...)               \
+  do {                                                                                        \
+    PYBIND11_OVERRIDE_IMPL(PYBIND11_TYPE(ret_type), PYBIND11_TYPE(cname), name, __VA_ARGS__); \
+    const pybind11::gil_scoped_acquire gil{};                                                 \
+    pybind11::pybind11_fail(                                                                  \
+        "Tried to call pure virtual function \"" PYBIND11_STRINGIFY(cname) "::" name "\"");   \
+  } while (false)
+
+#define OPENASSETIO_PYBIND11_OVERRIDE_PURE(ret_type, cname, fn, ...)                              \
+  OPENASSETIO_PYBIND11_OVERRIDE_PURE_NAME(PYBIND11_TYPE(ret_type), PYBIND11_TYPE(cname), #fn, fn, \
+                                          __VA_ARGS__)
+
 /**
  * Declare a `RetainPyArgs` alias with common template arguments.
  *
diff --git a/src/openassetio-python/cmodule/src/hostApi/EntityReferencePagerBinding.cpp b/src/openassetio-python/cmodule/src/hostApi/EntityReferencePagerBinding.cpp
index a743c3663..4fe76a12f 100644
--- a/src/openassetio-python/cmodule/src/hostApi/EntityReferencePagerBinding.cpp
+++ b/src/openassetio-python/cmodule/src/hostApi/EntityReferencePagerBinding.cpp
@@ -22,7 +22,7 @@ void registerEntityReferencePager(const py::module& mod) {
       .def(py::init(RetainCommonPyArgs::forFn<&EntityReferencePager::make>()),
            py::arg("entityReferencePagerInterface").none(false),
            py::arg("hostSession").none(false))
-      .def("hasNext", &EntityReferencePager::hasNext)
-      .def("get", &EntityReferencePager::get)
-      .def("next", &EntityReferencePager::next);
+      .def("hasNext", &EntityReferencePager::hasNext, py::call_guard<py::gil_scoped_release>{})
+      .def("get", &EntityReferencePager::get, py::call_guard<py::gil_scoped_release>{})
+      .def("next", &EntityReferencePager::next, py::call_guard<py::gil_scoped_release>{});
 }
diff --git a/src/openassetio-python/cmodule/src/hostApi/HostInterfaceBinding.cpp b/src/openassetio-python/cmodule/src/hostApi/HostInterfaceBinding.cpp
index f0b7244b2..cd7ce24dc 100644
--- a/src/openassetio-python/cmodule/src/hostApi/HostInterfaceBinding.cpp
+++ b/src/openassetio-python/cmodule/src/hostApi/HostInterfaceBinding.cpp
@@ -20,11 +20,11 @@ struct PyHostInterface : HostInterface {
   using HostInterface::HostInterface;
 
   [[nodiscard]] Identifier identifier() const override {
-    PYBIND11_OVERRIDE_PURE(Identifier, HostInterface, identifier, /* no args */);
+    OPENASSETIO_PYBIND11_OVERRIDE_PURE(Identifier, HostInterface, identifier, /* no args */);
   }
 
   [[nodiscard]] Str displayName() const override {
-    PYBIND11_OVERRIDE_PURE(Str, HostInterface, displayName, /* no args */);
+    OPENASSETIO_PYBIND11_OVERRIDE_PURE(Str, HostInterface, displayName, /* no args */);
   }
 
   [[nodiscard]] InfoDictionary info() override {
@@ -43,7 +43,7 @@ void registerHostInterface(const py::module& mod) {
 
   py::class_<HostInterface, PyHostInterface, HostInterfacePtr>(mod, "HostInterface")
       .def(py::init())
-      .def("identifier", &HostInterface::identifier)
-      .def("displayName", &HostInterface::displayName)
-      .def("info", &HostInterface::info);
+      .def("identifier", &HostInterface::identifier, py::call_guard<py::gil_scoped_release>{})
+      .def("displayName", &HostInterface::displayName, py::call_guard<py::gil_scoped_release>{})
+      .def("info", &HostInterface::info, py::call_guard<py::gil_scoped_release>{});
 }
diff --git a/src/openassetio-python/cmodule/src/hostApi/ManagerBinding.cpp b/src/openassetio-python/cmodule/src/hostApi/ManagerBinding.cpp
index 98e24d8f6..be7273112 100644
--- a/src/openassetio-python/cmodule/src/hostApi/ManagerBinding.cpp
+++ b/src/openassetio-python/cmodule/src/hostApi/ManagerBinding.cpp
@@ -74,50 +74,60 @@ void registerManager(const py::module& mod) {
   pyManager
       .def(py::init(RetainCommonPyArgs::forFn<&Manager::make>()),
            py::arg("managerInterface").none(false), py::arg("hostSession").none(false))
-      .def("identifier", &Manager::identifier)
-      .def("displayName", &Manager::displayName)
-      .def("info", &Manager::info)
-      .def("settings", &Manager::settings)
-      .def("initialize", &Manager::initialize, py::arg("managerSettings"))
-      .def("flushCaches", &Manager::flushCaches)
+      .def("identifier", &Manager::identifier, py::call_guard<py::gil_scoped_release>{})
+      .def("displayName", &Manager::displayName, py::call_guard<py::gil_scoped_release>{})
+      .def("info", &Manager::info, py::call_guard<py::gil_scoped_release>{})
+      .def("settings", &Manager::settings, py::call_guard<py::gil_scoped_release>{})
+      .def("initialize", &Manager::initialize, py::arg("managerSettings"),
+           py::call_guard<py::gil_scoped_release>{})
+      .def("flushCaches", &Manager::flushCaches, py::call_guard<py::gil_scoped_release>{})
       .def("managementPolicy", &Manager::managementPolicy, py::arg("traitSets"),
-           py::arg("policyAccess"), py::arg("context").none(false))
-      .def("createContext", &Manager::createContext)
+           py::arg("policyAccess"), py::arg("context").none(false),
+           py::call_guard<py::gil_scoped_release>{})
+      .def("createContext", &Manager::createContext, py::call_guard<py::gil_scoped_release>{})
       .def("createChildContext", &Manager::createChildContext,
-           py::arg("parentContext").none(false))
+           py::arg("parentContext").none(false), py::call_guard<py::gil_scoped_release>{})
       .def("persistenceTokenForContext", &Manager::persistenceTokenForContext,
-           py::arg("context").none(false))
-      .def("contextFromPersistenceToken", &Manager::contextFromPersistenceToken, py::arg("token"))
-      .def("isEntityReferenceString", &Manager::isEntityReferenceString, py::arg("someString"))
+           py::arg("context").none(false), py::call_guard<py::gil_scoped_release>{})
+      .def("contextFromPersistenceToken", &Manager::contextFromPersistenceToken, py::arg("token"),
+           py::call_guard<py::gil_scoped_release>{})
+      .def("isEntityReferenceString", &Manager::isEntityReferenceString, py::arg("someString"),
+           py::call_guard<py::gil_scoped_release>{})
       .def("createEntityReference", &Manager::createEntityReference,
-           py::arg("entityReferenceString"))
+           py::arg("entityReferenceString"), py::call_guard<py::gil_scoped_release>{})
       .def("createEntityReferenceIfValid", &Manager::createEntityReferenceIfValid,
-           py::arg("entityReferenceString"))
+           py::arg("entityReferenceString"), py::call_guard<py::gil_scoped_release>{})
       .def("entityExists", &Manager::entityExists, py::arg("entityReferences"),
-           py::arg("context").none(false), py::arg("successCallback"), py::arg("errorCallback"))
-      .def("hasCapability", &Manager::hasCapability, py::arg("capability"))
-      .def("updateTerminology", &Manager::updateTerminology, py::arg("terms"))
+           py::arg("context").none(false), py::arg("successCallback"), py::arg("errorCallback"),
+           py::call_guard<py::gil_scoped_release>{})
+      .def("hasCapability", &Manager::hasCapability, py::arg("capability"),
+           py::call_guard<py::gil_scoped_release>{})
+      .def("updateTerminology", &Manager::updateTerminology, py::arg("terms"),
+           py::call_guard<py::gil_scoped_release>{})
       .def(
           "resolve",
           py::overload_cast<const EntityReferences&, const trait::TraitSet&, access::ResolveAccess,
                             const ContextConstPtr&, const Manager::ResolveSuccessCallback&,
                             const Manager::BatchElementErrorCallback&>(&Manager::resolve),
           py::arg("entityReferences"), py::arg("traitSet"), py::arg("resolveAccess"),
-          py::arg("context").none(false), py::arg("successCallback"), py::arg("errorCallback"))
+          py::arg("context").none(false), py::arg("successCallback"), py::arg("errorCallback"),
+          py::call_guard<py::gil_scoped_release>{})
       .def("resolve",
            py::overload_cast<const EntityReference&, const trait::TraitSet&, access::ResolveAccess,
                              const ContextConstPtr&,
                              const Manager::BatchElementErrorPolicyTag::Exception&>(
                &Manager::resolve),
            py::arg("entityReference"), py::arg("traitSet"), py::arg("resolveAccess"),
-           py::arg("context").none(false), py::arg("errorPolicyTag"))
+           py::arg("context").none(false), py::arg("errorPolicyTag"),
+           py::call_guard<py::gil_scoped_release>{})
       .def("resolve",
            py::overload_cast<const EntityReference&, const trait::TraitSet&, access::ResolveAccess,
                              const ContextConstPtr&,
                              const Manager::BatchElementErrorPolicyTag::Variant&>(
                &Manager::resolve),
            py::arg("entityReference"), py::arg("traitSet"), py::arg("resolveAccess"),
-           py::arg("context").none(false), py::arg("errorPolicyTag"))
+           py::arg("context").none(false), py::arg("errorPolicyTag"),
+           py::call_guard<py::gil_scoped_release>{})
       .def(
           "resolve",
           // TODO(DF): Technically we shouldn't need this overload,
@@ -131,21 +141,23 @@ void registerManager(const py::module& mod) {
             return self.resolve(entityReference, traitSet, access, context);
           },
           py::arg("entityReference"), py::arg("traitSet"), py::arg("resolveAccess"),
-          py::arg("context").none(false))
+          py::arg("context").none(false), py::call_guard<py::gil_scoped_release>{})
       .def("resolve",
            py::overload_cast<const EntityReferences&, const trait::TraitSet&,
                              access::ResolveAccess, const ContextConstPtr&,
                              const Manager::BatchElementErrorPolicyTag::Exception&>(
                &Manager::resolve),
            py::arg("entityReferences"), py::arg("traitSet"), py::arg("resolveAccess"),
-           py::arg("context").none(false), py::arg("errorPolicyTag"))
+           py::arg("context").none(false), py::arg("errorPolicyTag"),
+           py::call_guard<py::gil_scoped_release>{})
       .def("resolve",
            py::overload_cast<const EntityReferences&, const trait::TraitSet&,
                              access::ResolveAccess, const ContextConstPtr&,
                              const Manager::BatchElementErrorPolicyTag::Variant&>(
                &Manager::resolve),
            py::arg("entityReferences"), py::arg("traitSet"), py::arg("resolveAccess"),
-           py::arg("context").none(false), py::arg("errorPolicyTag"))
+           py::arg("context").none(false), py::arg("errorPolicyTag"),
+           py::call_guard<py::gil_scoped_release>{})
       .def(
           "resolve",
           // TODO(DF): Technically we shouldn't need this overload,
@@ -159,14 +171,16 @@ void registerManager(const py::module& mod) {
             return self.resolve(entityReferences, traitSet, resolveAccess, context);
           },
           py::arg("entityReferences"), py::arg("traitSet"), py::arg("resolveAccess"),
-          py::arg("context").none(false))
+          py::arg("context").none(false), py::call_guard<py::gil_scoped_release>{})
       .def("defaultEntityReference", &Manager::defaultEntityReference, py::arg("traitSets"),
            py::arg("defaultEntityAccess"), py::arg("context").none(false),
-           py::arg("successCallback"), py::arg("errorCallback"))
+           py::arg("successCallback"), py::arg("errorCallback"),
+           py::call_guard<py::gil_scoped_release>{})
       .def("getWithRelationship", &Manager::getWithRelationship, py::arg("entityReferences"),
            py::arg("relationshipTraitsData").none(false), py::arg("pageSize"),
            py::arg("relationsAccess"), py::arg("context").none(false), py::arg("successCallback"),
-           py::arg("errorCallback"), py::arg("resultTraitSet") = trait::TraitSet{})
+           py::arg("errorCallback"), py::arg("resultTraitSet") = trait::TraitSet{},
+           py::call_guard<py::gil_scoped_release>{})
       .def(
           "getWithRelationships",
           [](Manager& self, const EntityReference& entityReference,
@@ -182,7 +196,8 @@ void registerManager(const py::module& mod) {
           },
           py::arg("entityReference"), py::arg("relationshipTraitsDatas"), py::arg("pageSize"),
           py::arg("relationsAccess"), py::arg("context").none(false), py::arg("successCallback"),
-          py::arg("errorCallback"), py::arg("resultTraitSet") = trait::TraitSet{})
+          py::arg("errorCallback"), py::arg("resultTraitSet") = trait::TraitSet{},
+          py::call_guard<py::gil_scoped_release>{})
       .def(
           "preflight",
           [](Manager& self, const EntityReferences& entityReferences,
@@ -195,21 +210,24 @@ void registerManager(const py::module& mod) {
                            successCallback, errorCallback);
           },
           py::arg("entityReferences"), py::arg("traitsHints"), py::arg("publishAccess"),
-          py::arg("context").none(false), py::arg("successCallback"), py::arg("errorCallback"))
+          py::arg("context").none(false), py::arg("successCallback"), py::arg("errorCallback"),
+          py::call_guard<py::gil_scoped_release>{})
       .def("preflight",
            py::overload_cast<const EntityReference&, const TraitsDataPtr&,
                              access::PublishingAccess, const ContextConstPtr&,
                              const Manager::BatchElementErrorPolicyTag::Exception&>(
                &Manager::preflight),
            py::arg("entityReference"), py::arg("traitsHint").none(false), py::arg("publishAccess"),
-           py::arg("context").none(false), py::arg("errorPolicyTag"))
+           py::arg("context").none(false), py::arg("errorPolicyTag"),
+           py::call_guard<py::gil_scoped_release>{})
       .def("preflight",
            py::overload_cast<const EntityReference&, const TraitsDataPtr&,
                              access::PublishingAccess, const ContextConstPtr&,
                              const Manager::BatchElementErrorPolicyTag::Variant&>(
                &Manager::preflight),
            py::arg("entityReference"), py::arg("traitsHint").none(false), py::arg("publishAccess"),
-           py::arg("context").none(false), py::arg("errorPolicyTag"))
+           py::arg("context").none(false), py::arg("errorPolicyTag"),
+           py::call_guard<py::gil_scoped_release>{})
       .def(
           "preflight",
           [](Manager& self, const EntityReference& entityReference,
@@ -218,7 +236,7 @@ void registerManager(const py::module& mod) {
             return self.preflight(entityReference, traitsHint, publishingAccess, context);
           },
           py::arg("entityReference"), py::arg("traitsHint").none(false), py::arg("publishAccess"),
-          py::arg("context").none(false))
+          py::arg("context").none(false), py::call_guard<py::gil_scoped_release>{})
       .def(
           "preflight",
           [](Manager& self, const EntityReferences& entityReferences,
@@ -229,7 +247,8 @@ void registerManager(const py::module& mod) {
             return self.preflight(entityReferences, traitsHints, publishingAccess, context, tag);
           },
           py::arg("entityReferences"), py::arg("traitsHints"), py::arg("publishAccess"),
-          py::arg("context").none(false), py::arg("errorPolicyTag"))
+          py::arg("context").none(false), py::arg("errorPolicyTag"),
+          py::call_guard<py::gil_scoped_release>{})
       .def(
           "preflight",
           [](Manager& self, const EntityReferences& entityReferences,
@@ -240,7 +259,8 @@ void registerManager(const py::module& mod) {
             return self.preflight(entityReferences, traitsHints, publishingAccess, context, tag);
           },
           py::arg("entityReferences"), py::arg("traitsHints"), py::arg("publishAccess"),
-          py::arg("context").none(false), py::arg("errorPolicyTag"))
+          py::arg("context").none(false), py::arg("errorPolicyTag"),
+          py::call_guard<py::gil_scoped_release>{})
       .def(
           "preflight",
           [](Manager& self, const EntityReferences& entityReferences,
@@ -250,7 +270,7 @@ void registerManager(const py::module& mod) {
             return self.preflight(entityReferences, traitsHints, publishingAccess, context);
           },
           py::arg("entityReferences"), py::arg("traitsHints"), py::arg("publishAccess"),
-          py::arg("context").none(false))
+          py::arg("context").none(false), py::call_guard<py::gil_scoped_release>{})
       .def(
           "register",
           [](Manager& self, const EntityReferences& entityReferences,
@@ -263,7 +283,8 @@ void registerManager(const py::module& mod) {
                            successCallback, errorCallback);
           },
           py::arg("entityReferences"), py::arg("entityTraitsDatas"), py::arg("publishAccess"),
-          py::arg("context").none(false), py::arg("successCallback"), py::arg("errorCallback"))
+          py::arg("context").none(false), py::arg("successCallback"), py::arg("errorCallback"),
+          py::call_guard<py::gil_scoped_release>{})
 
       .def("register",
            py::overload_cast<const EntityReference&, const TraitsDataPtr&,
@@ -271,14 +292,16 @@ void registerManager(const py::module& mod) {
                              const Manager::BatchElementErrorPolicyTag::Exception&>(
                &Manager::register_),
            py::arg("entityReference"), py::arg("entityTraitsData").none(false),
-           py::arg("publishAccess"), py::arg("context").none(false), py::arg("errorPolicyTag"))
+           py::arg("publishAccess"), py::arg("context").none(false), py::arg("errorPolicyTag"),
+           py::call_guard<py::gil_scoped_release>{})
       .def("register",
            py::overload_cast<const EntityReference&, const TraitsDataPtr&,
                              access::PublishingAccess, const ContextConstPtr&,
                              const Manager::BatchElementErrorPolicyTag::Variant&>(
                &Manager::register_),
            py::arg("entityReference"), py::arg("entityTraitsData").none(false),
-           py::arg("publishAccess"), py::arg("context").none(false), py::arg("errorPolicyTag"))
+           py::arg("publishAccess"), py::arg("context").none(false), py::arg("errorPolicyTag"),
+           py::call_guard<py::gil_scoped_release>{})
       .def(
           "register",
           [](Manager& self, const EntityReference& entityReference,
@@ -287,7 +310,8 @@ void registerManager(const py::module& mod) {
             return self.register_(entityReference, entityTraitsData, publishingAccess, context);
           },
           py::arg("entityReference"), py::arg("entityTraitsData").none(false),
-          py::arg("publishAccess"), py::arg("context").none(false))
+          py::arg("publishAccess"), py::arg("context").none(false),
+          py::call_guard<py::gil_scoped_release>{})
       .def(
           "register",
           [](Manager& self, const EntityReferences& entityReferences,
@@ -299,7 +323,8 @@ void registerManager(const py::module& mod) {
                                   errorPolicyTag);
           },
           py::arg("entityReferences"), py::arg("entityTraitsDatas"), py::arg("publishAccess"),
-          py::arg("context").none(false), py::arg("errorPolicyTag"))
+          py::arg("context").none(false), py::arg("errorPolicyTag"),
+          py::call_guard<py::gil_scoped_release>{})
       .def(
           "register",
           [](Manager& self, const EntityReferences& entityReferences,
@@ -311,7 +336,8 @@ void registerManager(const py::module& mod) {
                                   errorPolicyTag);
           },
           py::arg("entityReferences"), py::arg("entityTraitsDatas"), py::arg("publishAccess"),
-          py::arg("context").none(false), py::arg("errorPolicyTag"))
+          py::arg("context").none(false), py::arg("errorPolicyTag"),
+          py::call_guard<py::gil_scoped_release>{})
       .def(
           "register",
           [](Manager& self, const EntityReferences& entityReferences,
@@ -321,5 +347,6 @@ void registerManager(const py::module& mod) {
             return self.register_(entityReferences, entityTraitsDatas, publishingAccess, context);
           },
           py::arg("entityReference"), py::arg("entityTraitsData").none(false),
-          py::arg("publishAccess"), py::arg("context").none(false));
+          py::arg("publishAccess"), py::arg("context").none(false),
+          py::call_guard<py::gil_scoped_release>{});
 }
diff --git a/src/openassetio-python/cmodule/src/hostApi/ManagerFactoryBinding.cpp b/src/openassetio-python/cmodule/src/hostApi/ManagerFactoryBinding.cpp
index 5337e236e..3616029d2 100644
--- a/src/openassetio-python/cmodule/src/hostApi/ManagerFactoryBinding.cpp
+++ b/src/openassetio-python/cmodule/src/hostApi/ManagerFactoryBinding.cpp
@@ -25,7 +25,7 @@ void registerManagerFactory(const py::module& mod) {
       .def(py::init(RetainCommonPyArgs::forFn<&ManagerFactory::make>()),
            py::arg("hostInterface").none(false),
            py::arg("managerImplementationFactory").none(false), py::arg("logger").none(false))
-      .def("identifiers", &ManagerFactory::identifiers);
+      .def("identifiers", &ManagerFactory::identifiers, py::call_guard<py::gil_scoped_release>{});
 
   py::class_<ManagerFactory::ManagerDetail>(managerFactory, "ManagerDetail")
       .def(py::init<openassetio::Identifier, openassetio::Str, openassetio::InfoDictionary>(),
@@ -35,15 +35,18 @@ void registerManagerFactory(const py::module& mod) {
       .def_readwrite("info", &ManagerFactory::ManagerDetail::info)
       .def(py::self == py::self);  // NOLINT(misc-redundant-expression)
 
-  managerFactory.def("availableManagers", &ManagerFactory::availableManagers)
+  managerFactory
+      .def("availableManagers", &ManagerFactory::availableManagers,
+           py::call_guard<py::gil_scoped_release>{})
       .def_readonly_static("kDefaultManagerConfigEnvVarName",
                            &ManagerFactory::kDefaultManagerConfigEnvVarName)
-      .def("createManager", &ManagerFactory::createManager, py::arg("identifier"))
+      .def("createManager", &ManagerFactory::createManager, py::arg("identifier"),
+           py::call_guard<py::gil_scoped_release>{})
       .def_static("createManagerForInterface",
                   RetainCommonPyArgs::forFn<&ManagerFactory::createManagerForInterface>(),
                   py::arg("identifier"), py::arg("hostInterface").none(false),
                   py::arg("managerImplementationFactory").none(false),
-                  py::arg("logger").none(false))
+                  py::arg("logger").none(false), py::call_guard<py::gil_scoped_release>{})
       .def_static("defaultManagerForInterface",
                   RetainCommonPyArgs::forFn<static_cast<ManagerPtr (*)(
                       std::string_view, const HostInterfacePtr&,
@@ -51,12 +54,12 @@ void registerManagerFactory(const py::module& mod) {
                       &ManagerFactory::defaultManagerForInterface)>(),
                   py::arg("configPath"), py::arg("hostInterface").none(false),
                   py::arg("managerImplementationFactory").none(false),
-                  py::arg("logger").none(false))
+                  py::arg("logger").none(false), py::call_guard<py::gil_scoped_release>{})
       .def_static("defaultManagerForInterface",
                   RetainCommonPyArgs::forFn<static_cast<ManagerPtr (*)(
                       const HostInterfacePtr&, const ManagerImplementationFactoryInterfacePtr&,
                       const LoggerInterfacePtr&)>(&ManagerFactory::defaultManagerForInterface)>(),
                   py::arg("hostInterface").none(false),
                   py::arg("managerImplementationFactory").none(false),
-                  py::arg("logger").none(false));
+                  py::arg("logger").none(false), py::call_guard<py::gil_scoped_release>{});
 }
diff --git a/src/openassetio-python/cmodule/src/hostApi/ManagerImplementationFactoryInterfaceBinding.cpp b/src/openassetio-python/cmodule/src/hostApi/ManagerImplementationFactoryInterfaceBinding.cpp
index 40c3efdfb..644b91612 100644
--- a/src/openassetio-python/cmodule/src/hostApi/ManagerImplementationFactoryInterfaceBinding.cpp
+++ b/src/openassetio-python/cmodule/src/hostApi/ManagerImplementationFactoryInterfaceBinding.cpp
@@ -22,14 +22,16 @@ struct PyManagerImplementationFactoryInterface : ManagerImplementationFactoryInt
   using ManagerImplementationFactoryInterface::ManagerImplementationFactoryInterface;
 
   [[nodiscard]] Identifiers identifiers() override {
-    PYBIND11_OVERRIDE_PURE(Identifiers, ManagerImplementationFactoryInterface, identifiers,
-                           /* no args */);
+    OPENASSETIO_PYBIND11_OVERRIDE_PURE(
+        Identifiers, ManagerImplementationFactoryInterface, identifiers,
+        /* no args */);
   }
 
   [[nodiscard]] managerApi::ManagerInterfacePtr instantiate(
       const Identifier& identifier) override {
-    PYBIND11_OVERRIDE_PURE(PyRetainingSharedPtr<managerApi::ManagerInterface>,
-                           ManagerImplementationFactoryInterface, instantiate, identifier);
+    OPENASSETIO_PYBIND11_OVERRIDE_PURE(PyRetainingSharedPtr<managerApi::ManagerInterface>,
+                                       ManagerImplementationFactoryInterface, instantiate,
+                                       identifier);
   }
 
   using ManagerImplementationFactoryInterface::logger_;
@@ -50,8 +52,9 @@ void registerManagerImplementationFactoryInterface(const py::module& mod) {
              ManagerImplementationFactoryInterfacePtr>(mod,
                                                        "ManagerImplementationFactoryInterface")
       .def(py::init<PyRetainingLoggerInterfacePtr>(), py::arg("logger").none(false))
-      .def("identifiers", &ManagerImplementationFactoryInterface::identifiers)
+      .def("identifiers", &ManagerImplementationFactoryInterface::identifiers,
+           py::call_guard<py::gil_scoped_release>{})
       .def("instantiate", &ManagerImplementationFactoryInterface::instantiate,
-           py::arg("identifier"))
+           py::arg("identifier"), py::call_guard<py::gil_scoped_release>{})
       .def_readonly("_logger", &PyManagerImplementationFactoryInterface::logger_);
 }
diff --git a/src/openassetio-python/cmodule/src/log/LoggerInterfaceBinding.cpp b/src/openassetio-python/cmodule/src/log/LoggerInterfaceBinding.cpp
index 5426172d1..e8514868f 100644
--- a/src/openassetio-python/cmodule/src/log/LoggerInterfaceBinding.cpp
+++ b/src/openassetio-python/cmodule/src/log/LoggerInterfaceBinding.cpp
@@ -17,7 +17,7 @@ struct PyLoggerInterface : LoggerInterface {
   using LoggerInterface::LoggerInterface;
 
   void log(Severity severity, const Str& message) override {
-    PYBIND11_OVERRIDE_PURE(void, LoggerInterface, log, severity, message);
+    OPENASSETIO_PYBIND11_OVERRIDE_PURE(void, LoggerInterface, log, severity, message);
   }
 };
 }  // namespace log
@@ -46,12 +46,20 @@ void registerLoggerInterface(const py::module& mod) {
   loggerInterface.def_readonly_static("kSeverityNames", &LoggerInterface::kSeverityNames);
 
   loggerInterface.def(py::init())
-      .def("log", &LoggerInterface::log, py::arg("severity"), py::arg("message"))
-      .def("debugApi", &LoggerInterface::debugApi, py::arg("message"))
-      .def("debug", &LoggerInterface::debug, py::arg("message"))
-      .def("info", &LoggerInterface::info, py::arg("message"))
-      .def("progress", &LoggerInterface::progress, py::arg("message"))
-      .def("warning", &LoggerInterface::warning, py::arg("message"))
-      .def("error", &LoggerInterface::error, py::arg("message"))
-      .def("critical", &LoggerInterface::critical, py::arg("message"));
+      .def("log", &LoggerInterface::log, py::arg("severity"), py::arg("message"),
+           py::call_guard<py::gil_scoped_release>{})
+      .def("debugApi", &LoggerInterface::debugApi, py::arg("message"),
+           py::call_guard<py::gil_scoped_release>{})
+      .def("debug", &LoggerInterface::debug, py::arg("message"),
+           py::call_guard<py::gil_scoped_release>{})
+      .def("info", &LoggerInterface::info, py::arg("message"),
+           py::call_guard<py::gil_scoped_release>{})
+      .def("progress", &LoggerInterface::progress, py::arg("message"),
+           py::call_guard<py::gil_scoped_release>{})
+      .def("warning", &LoggerInterface::warning, py::arg("message"),
+           py::call_guard<py::gil_scoped_release>{})
+      .def("error", &LoggerInterface::error, py::arg("message"),
+           py::call_guard<py::gil_scoped_release>{})
+      .def("critical", &LoggerInterface::critical, py::arg("message"),
+           py::call_guard<py::gil_scoped_release>{});
 }
diff --git a/src/openassetio-python/cmodule/src/managerApi/EntityReferencePagerInterfaceBinding.cpp b/src/openassetio-python/cmodule/src/managerApi/EntityReferencePagerInterfaceBinding.cpp
index 752a2e7b6..75cf2231b 100644
--- a/src/openassetio-python/cmodule/src/managerApi/EntityReferencePagerInterfaceBinding.cpp
+++ b/src/openassetio-python/cmodule/src/managerApi/EntityReferencePagerInterfaceBinding.cpp
@@ -24,16 +24,16 @@ struct PyEntityReferencePagerInterface : EntityReferencePagerInterface {
   using EntityReferencePagerInterface::EntityReferencePagerInterface;
 
   bool hasNext(const HostSessionPtr& hostSession) override {
-    PYBIND11_OVERRIDE_PURE(bool, EntityReferencePagerInterface, hasNext, hostSession);
+    OPENASSETIO_PYBIND11_OVERRIDE_PURE(bool, EntityReferencePagerInterface, hasNext, hostSession);
   }
 
   EntityReferencePagerInterface::Page get(const HostSessionPtr& hostSession) override {
-    PYBIND11_OVERRIDE_PURE(EntityReferencePagerInterface::Page, EntityReferencePagerInterface, get,
-                           hostSession);
+    OPENASSETIO_PYBIND11_OVERRIDE_PURE(EntityReferencePagerInterface::Page,
+                                       EntityReferencePagerInterface, get, hostSession);
   }
 
   void next(const HostSessionPtr& hostSession) override {
-    PYBIND11_OVERRIDE_PURE(void, EntityReferencePagerInterface, next, hostSession);
+    OPENASSETIO_PYBIND11_OVERRIDE_PURE(void, EntityReferencePagerInterface, next, hostSession);
   }
 
   void close(const HostSessionPtr& hostSession) override {
@@ -53,8 +53,12 @@ void registerEntityReferencePagerInterface(const py::module& mod) {
   py::class_<EntityReferencePagerInterface, PyEntityReferencePagerInterface,
              EntityReferencePagerInterfacePtr>(mod, "EntityReferencePagerInterface")
       .def(py::init())
-      .def("hasNext", &EntityReferencePagerInterface::hasNext, py::arg("hostSession").none(false))
-      .def("get", &EntityReferencePagerInterface::get, py::arg("hostSession").none(false))
-      .def("next", &EntityReferencePagerInterface::next, py::arg("hostSession").none(false))
-      .def("close", &EntityReferencePagerInterface::close, py::arg("hostSession").none(false));
+      .def("hasNext", &EntityReferencePagerInterface::hasNext, py::arg("hostSession").none(false),
+           py::call_guard<py::gil_scoped_release>{})
+      .def("get", &EntityReferencePagerInterface::get, py::arg("hostSession").none(false),
+           py::call_guard<py::gil_scoped_release>{})
+      .def("next", &EntityReferencePagerInterface::next, py::arg("hostSession").none(false),
+           py::call_guard<py::gil_scoped_release>{})
+      .def("close", &EntityReferencePagerInterface::close, py::arg("hostSession").none(false),
+           py::call_guard<py::gil_scoped_release>{});
 }
diff --git a/src/openassetio-python/cmodule/src/managerApi/HostBinding.cpp b/src/openassetio-python/cmodule/src/managerApi/HostBinding.cpp
index 913d57577..98432bbe6 100644
--- a/src/openassetio-python/cmodule/src/managerApi/HostBinding.cpp
+++ b/src/openassetio-python/cmodule/src/managerApi/HostBinding.cpp
@@ -15,7 +15,7 @@ void registerHost(const py::module& mod) {
   py::class_<Host, HostPtr>(mod, "Host", py::is_final())
       .def(py::init(RetainCommonPyArgs::forFn<&Host::make>()),
            py::arg("hostInterface").none(false))
-      .def("identifier", &Host::identifier)
-      .def("displayName", &Host::displayName)
-      .def("info", &Host::info);
+      .def("identifier", &Host::identifier, py::call_guard<py::gil_scoped_release>{})
+      .def("displayName", &Host::displayName, py::call_guard<py::gil_scoped_release>{})
+      .def("info", &Host::info, py::call_guard<py::gil_scoped_release>{});
 }
diff --git a/src/openassetio-python/cmodule/src/managerApi/ManagerInterfaceBinding.cpp b/src/openassetio-python/cmodule/src/managerApi/ManagerInterfaceBinding.cpp
index 0b4dbacc9..a1ebd437e 100644
--- a/src/openassetio-python/cmodule/src/managerApi/ManagerInterfaceBinding.cpp
+++ b/src/openassetio-python/cmodule/src/managerApi/ManagerInterfaceBinding.cpp
@@ -30,11 +30,11 @@ struct PyManagerInterface : ManagerInterface {
   using PyRetainingManagerStateBasePtr = PyRetainingSharedPtr<ManagerStateBase>;
 
   [[nodiscard]] Identifier identifier() const override {
-    PYBIND11_OVERRIDE_PURE(Identifier, ManagerInterface, identifier, /* no args */);
+    OPENASSETIO_PYBIND11_OVERRIDE_PURE(Identifier, ManagerInterface, identifier, /* no args */);
   }
 
   [[nodiscard]] Str displayName() const override {
-    PYBIND11_OVERRIDE_PURE(Str, ManagerInterface, displayName, /* no args */);
+    OPENASSETIO_PYBIND11_OVERRIDE_PURE(Str, ManagerInterface, displayName, /* no args */);
   }
 
   [[nodiscard]] InfoDictionary info() override {
@@ -96,7 +96,7 @@ struct PyManagerInterface : ManagerInterface {
   }
 
   [[nodiscard]] bool hasCapability(ManagerInterface::Capability capability) override {
-    PYBIND11_OVERRIDE_PURE(bool, ManagerInterface, hasCapability, capability);
+    OPENASSETIO_PYBIND11_OVERRIDE_PURE(bool, ManagerInterface, hasCapability, capability);
   }
 
   [[nodiscard]] StrMap updateTerminology(StrMap terms,
@@ -206,56 +206,70 @@ void registerManagerInterface(const py::module& mod) {
   pyManagerInterface.attr("kCapabilityNames") = ManagerInterface::kCapabilityNames;
 
   pyManagerInterface.def(py::init())
-      .def("identifier", &ManagerInterface::identifier)
-      .def("displayName", &ManagerInterface::displayName)
-      .def("info", &ManagerInterface::info)
-      .def("settings", &ManagerInterface::settings, py::arg("hostSession").none(false))
+      .def("identifier", &ManagerInterface::identifier, py::call_guard<py::gil_scoped_release>{})
+      .def("displayName", &ManagerInterface::displayName, py::call_guard<py::gil_scoped_release>{})
+      .def("info", &ManagerInterface::info, py::call_guard<py::gil_scoped_release>{})
+      .def("settings", &ManagerInterface::settings, py::arg("hostSession").none(false),
+           py::call_guard<py::gil_scoped_release>{})
       .def("initialize", &ManagerInterface::initialize, py::arg("managerSettings"),
-           py::arg("hostSession").none(false))
-      .def("flushCaches", &ManagerInterface::flushCaches, py::arg("hostSession").none(false))
+           py::arg("hostSession").none(false), py::call_guard<py::gil_scoped_release>{})
+      .def("flushCaches", &ManagerInterface::flushCaches, py::arg("hostSession").none(false),
+           py::call_guard<py::gil_scoped_release>{})
       .def("managementPolicy", &ManagerInterface::managementPolicy, py::arg("traitSets"),
-           py::arg("access"), py::arg("context").none(false), py::arg("hostSession").none(false))
-      .def("createState", &ManagerInterface::createState, py::arg("hostSession").none(false))
+           py::arg("access"), py::arg("context").none(false), py::arg("hostSession").none(false),
+           py::call_guard<py::gil_scoped_release>{})
+      .def("createState", &ManagerInterface::createState, py::arg("hostSession").none(false),
+           py::call_guard<py::gil_scoped_release>{})
       .def("createChildState", RetainCommonPyArgs::forFn<&ManagerInterface::createChildState>(),
-           py::arg("parentState").none(false), py::arg("hostSession").none(false))
+           py::arg("parentState").none(false), py::arg("hostSession").none(false),
+           py::call_guard<py::gil_scoped_release>{})
       .def("persistenceTokenForState",
            RetainCommonPyArgs::forFn<&ManagerInterface::persistenceTokenForState>(),
-           py::arg("state").none(false), py::arg("hostSession").none(false))
+           py::arg("state").none(false), py::arg("hostSession").none(false),
+           py::call_guard<py::gil_scoped_release>{})
       .def("stateFromPersistenceToken", &ManagerInterface::stateFromPersistenceToken,
-           py::arg("token"), py::arg("hostSession").none(false))
+           py::arg("token"), py::arg("hostSession").none(false),
+           py::call_guard<py::gil_scoped_release>{})
       .def("isEntityReferenceString", &ManagerInterface::isEntityReferenceString,
-           py::arg("someString"), py::arg("hostSession").none(false))
+           py::arg("someString"), py::arg("hostSession").none(false),
+           py::call_guard<py::gil_scoped_release>{})
       .def("entityExists", &ManagerInterface::entityExists, py::arg("entityReferences"),
            py::arg("context").none(false), py::arg("hostSession").none(false),
-           py::arg("successCallback"), py::arg("errorCallback"))
-      .def("hasCapability", &ManagerInterface::hasCapability, py::arg("capability"))
+           py::arg("successCallback"), py::arg("errorCallback"),
+           py::call_guard<py::gil_scoped_release>{})
+      .def("hasCapability", &ManagerInterface::hasCapability, py::arg("capability"),
+           py::call_guard<py::gil_scoped_release>{})
       .def("updateTerminology", &ManagerInterface::updateTerminology, py::arg("terms"),
-           py::arg("hostSession").none(false))
+           py::arg("hostSession").none(false), py::call_guard<py::gil_scoped_release>{})
       .def("resolve", &ManagerInterface::resolve, py::arg("entityReferences"), py::arg("traitSet"),
            py::arg("access"), py::arg("context").none(false), py::arg("hostSession").none(false),
-           py::arg("successCallback"), py::arg("errorCallback"))
+           py::arg("successCallback"), py::arg("errorCallback"),
+           py::call_guard<py::gil_scoped_release>{})
       .def("defaultEntityReference", &ManagerInterface::defaultEntityReference,
            py::arg("traitSets"), py::arg("defaultEntityAccess"), py::arg("context").none(false),
            py::arg("hostSession").none(false), py::arg("successCallback"),
-           py::arg("errorCallback"))
+           py::arg("errorCallback"), py::call_guard<py::gil_scoped_release>{})
       .def("getWithRelationship", &ManagerInterface::getWithRelationship,
            py::arg("entityReferences"), py::arg("relationshipTraitsData").none(false),
            py::arg("resultTraitSet"), py::arg("pageSize"), py::arg("relationsAccess"),
            py::arg("context").none(false), py::arg("hostSession").none(false),
-           py::arg("successCallback"), py::arg("errorCallback"))
+           py::arg("successCallback"), py::arg("errorCallback"),
+           py::call_guard<py::gil_scoped_release>{})
       .def("getWithRelationships", &ManagerInterface::getWithRelationships,
            py::arg("entityReference"), py::arg("relationshipTraitsDatas"),
            py::arg("resultTraitSet"), py::arg("pageSize"), py::arg("relationsAccess"),
            py::arg("context").none(false), py::arg("hostSession").none(false),
-           py::arg("successCallback"), py::arg("errorCallback"))
+           py::arg("successCallback"), py::arg("errorCallback"),
+           py::call_guard<py::gil_scoped_release>{})
       .def("preflight", &ManagerInterface::preflight, py::arg("entityReferences"),
            py::arg("traitsHints"), py::arg("publishingAccess"), py::arg("context").none(false),
            py::arg("hostSession").none(false), py::arg("successCallback"),
-           py::arg("errorCallback"))
+           py::arg("errorCallback"), py::call_guard<py::gil_scoped_release>{})
       .def("register", &ManagerInterface::register_, py::arg("entityReferences"),
            py::arg("entityTraitsDatas"), py::arg("publishingAccess"),
            py::arg("context").none(false), py::arg("hostSession").none(false),
-           py::arg("successCallback"), py::arg("errorCallback"))
+           py::arg("successCallback"), py::arg("errorCallback"),
+           py::call_guard<py::gil_scoped_release>{})
       .def("_createEntityReference", &PyManagerInterface::createEntityReference,
            py::arg("entityReferenceString"));
 }


Test patch:
diff --git a/src/openassetio-python/tests/cmodule/gil/conftest.py b/src/openassetio-python/tests/cmodule/gil/conftest.py
new file mode 100644
index 000000000..e1d85bd7b
--- /dev/null
+++ b/src/openassetio-python/tests/cmodule/gil/conftest.py
@@ -0,0 +1,66 @@
+#
+#   Copyright 2023 The Foundry Visionmongers Ltd
+#
+#   Licensed under the Apache License, Version 2.0 (the "License");
+#   you may not use this file except in compliance with the License.
+#   You may obtain a copy of the License at
+#
+#       http://www.apache.org/licenses/LICENSE-2.0
+#
+#   Unless required by applicable law or agreed to in writing, software
+#   distributed under the License is distributed on an "AS IS" BASIS,
+#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#   See the License for the specific language governing permissions and
+#   limitations under the License.
+#
+"""
+Common test utils for our GIL handling behaviour
+"""
+# pylint: disable=redefined-outer-name
+# pylint: disable=invalid-name,c-extension-no-member
+# pylint: disable=missing-class-docstring,missing-function-docstring
+import inspect
+
+import pytest
+
+# pylint: disable=no-name-in-module
+from openassetio import _openassetio_test, EntityReference, Context
+from openassetio.trait import TraitsData
+
+
+@pytest.fixture
+def find_unimplemented_test_cases():
+    def finder(target_class, test_class):
+        methods = (
+            name
+            for (name, obj) in inspect.getmembers(target_class)
+            if not name.startswith("_") and inspect.isroutine(obj)
+        )
+
+        return [
+            method
+            for method in methods
+            if not inspect.ismethod(getattr(test_class, f"test_{method}", None))
+        ]
+
+    return finder
+
+
+@pytest.fixture
+def a_threaded_mock_manager_interface(mock_manager_interface):
+    return _openassetio_test.gil.wrapInThreadedManagerInterface(mock_manager_interface)
+
+
+@pytest.fixture
+def a_context():
+    return Context()
+
+
+@pytest.fixture
+def a_traits_data():
+    return TraitsData()
+
+
+@pytest.fixture
+def an_entity_reference():
+    return EntityReference("")
diff --git a/src/openassetio-python/tests/cmodule/gil/test_entityreferencepager_gil.py b/src/openassetio-python/tests/cmodule/gil/test_entityreferencepager_gil.py
new file mode 100644
index 000000000..78010d0d5
--- /dev/null
+++ b/src/openassetio-python/tests/cmodule/gil/test_entityreferencepager_gil.py
@@ -0,0 +1,131 @@
+#
+#   Copyright 2023 The Foundry Visionmongers Ltd
+#
+#   Licensed under the Apache License, Version 2.0 (the "License");
+#   you may not use this file except in compliance with the License.
+#   You may obtain a copy of the License at
+#
+#       http://www.apache.org/licenses/LICENSE-2.0
+#
+#   Unless required by applicable law or agreed to in writing, software
+#   distributed under the License is distributed on an "AS IS" BASIS,
+#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#   See the License for the specific language governing permissions and
+#   limitations under the License.
+#
+"""
+Testing that EntityReferencePager/EntityReferencePagerInterface methods
+release the GIL.
+"""
+# pylint: disable=redefined-outer-name
+# pylint: disable=invalid-name,c-extension-no-member
+# pylint: disable=missing-class-docstring,missing-function-docstring
+import pytest
+
+# pylint: disable=no-name-in-module
+from openassetio import _openassetio_test
+from openassetio.hostApi import EntityReferencePager
+from openassetio.managerApi import EntityReferencePagerInterface
+
+
+class Test_EntityReferencePagerInterface_gil:
+    """
+    Check all methods release the GIL during C++ function body
+    execution.
+
+    See docstring for similar test under `gil/Test_ManagerInterface.py`
+    for details on how these tests are structured.
+    """
+
+    def test_all_methods_covered(self, find_unimplemented_test_cases):
+        """
+        Ensure this test class covers all methods.
+        """
+        unimplemented = find_unimplemented_test_cases(EntityReferencePagerInterface, self)
+
+        if unimplemented:
+            print("\nSome test cases not implemented. Method templates can be found below:\n")
+            for method in unimplemented:
+                print(
+                    f"""
+    def test_{method}(self, a_threaded_entity_ref_pager_interface, a_host_session):
+        a_threaded_entity_ref_pager_interface.{method}(a_host_session)
+"""
+                )
+
+        assert unimplemented == []
+
+    def test_close(self, a_threaded_entity_ref_pager_interface, a_host_session):
+        a_threaded_entity_ref_pager_interface.close(a_host_session)
+
+    def test_get(
+        self,
+        mock_entity_reference_pager_interface,
+        a_threaded_entity_ref_pager_interface,
+        a_host_session,
+    ):
+        mock_entity_reference_pager_interface.mock.get.return_value = []
+        a_threaded_entity_ref_pager_interface.get(a_host_session)
+
+    def test_hasNext(
+        self,
+        mock_entity_reference_pager_interface,
+        a_threaded_entity_ref_pager_interface,
+        a_host_session,
+    ):
+        mock_entity_reference_pager_interface.mock.hasNext.return_value = True
+        a_threaded_entity_ref_pager_interface.hasNext(a_host_session)
+
+    def test_next(self, a_threaded_entity_ref_pager_interface, a_host_session):
+        a_threaded_entity_ref_pager_interface.next(a_host_session)
+
+
+class Test_EntityReferencePager_gil:
+    """
+    Check all methods release the GIL during C++ function body
+    execution.
+
+    See docstring for similar test under `gil/Test_Manager.py`
+    for details on how these tests are structured.
+    """
+
+    def test_all_methods_covered(self, find_unimplemented_test_cases):
+        """
+        Ensure this test class covers all methods.
+        """
+        unimplemented = find_unimplemented_test_cases(EntityReferencePager, self)
+
+        if unimplemented:
+            print("\nSome test cases not implemented. Method templates can be found below:\n")
+            for method in unimplemented:
+                print(
+                    f"""
+    def test_{method}(self, a_threaded_entity_ref_pager):
+        a_threaded_entity_ref_pager.{method}()
+"""
+                )
+
+        assert unimplemented == []
+
+    def test_get(self, a_threaded_entity_ref_pager, mock_entity_reference_pager_interface):
+        mock_entity_reference_pager_interface.mock.get.return_value = []
+        a_threaded_entity_ref_pager.get()
+
+    def test_hasNext(self, a_threaded_entity_ref_pager, mock_entity_reference_pager_interface):
+        mock_entity_reference_pager_interface.mock.hasNext.return_value = False
+        a_threaded_entity_ref_pager.hasNext()
+
+    def test_next(self, a_threaded_entity_ref_pager):
+        a_threaded_entity_ref_pager.next()
+
+
+@pytest.fixture
+def a_threaded_entity_ref_pager(a_threaded_entity_ref_pager_interface, a_host_session):
+    return EntityReferencePager(a_threaded_entity_ref_pager_interface, a_host_session)
+
+
+@pytest.fixture
+def a_threaded_entity_ref_pager_interface(mock_entity_reference_pager_interface):
+    return _openassetio_test.gil.wrapInThreadedEntityReferencePagerInterface(
+        mock_entity_reference_pager_interface
+    )
diff --git a/src/openassetio-python/tests/cmodule/gil/test_host_gil.py b/src/openassetio-python/tests/cmodule/gil/test_host_gil.py
new file mode 100644
index 000000000..42b7403ec
--- /dev/null
+++ b/src/openassetio-python/tests/cmodule/gil/test_host_gil.py
@@ -0,0 +1,117 @@
+#
+#   Copyright 2023 The Foundry Visionmongers Ltd
+#
+#   Licensed under the Apache License, Version 2.0 (the "License");
+#   you may not use this file except in compliance with the License.
+#   You may obtain a copy of the License at
+#
+#       http://www.apache.org/licenses/LICENSE-2.0
+#
+#   Unless required by applicable law or agreed to in writing, software
+#   distributed under the License is distributed on an "AS IS" BASIS,
+#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#   See the License for the specific language governing permissions and
+#   limitations under the License.
+#
+"""
+Testing that Host/HostInterface methods release the GIL.
+"""
+# pylint: disable=redefined-outer-name
+# pylint: disable=invalid-name,c-extension-no-member
+# pylint: disable=missing-class-docstring,missing-function-docstring
+import pytest
+
+# pylint: disable=no-name-in-module
+from openassetio import _openassetio_test
+from openassetio.hostApi import HostInterface
+from openassetio.managerApi import Host
+
+
+class Test_HostInterface_gil:
+    """
+    Check all methods release the GIL during C++ function body
+    execution.
+
+    See docstring for similar test under `gil/Test_ManagerInterface.py`
+    for details on how these tests are structured.
+    """
+
+    def test_all_methods_covered(self, find_unimplemented_test_cases):
+        """
+        Ensure this test class covers all methods.
+        """
+        unimplemented = find_unimplemented_test_cases(HostInterface, self)
+
+        if unimplemented:
+            print("\nSome test cases not implemented. Method templates can be found below:\n")
+            for method in unimplemented:
+                print(
+                    f"""
+    def test_{method}(self, a_threaded_host_interface):
+        a_threaded_host_interface.{method}()
+"""
+                )
+
+        assert unimplemented == []
+
+    def test_displayName(self, a_threaded_host_interface, mock_host_interface):
+        mock_host_interface.mock.displayName.return_value = ""
+        a_threaded_host_interface.displayName()
+
+    def test_identifier(self, a_threaded_host_interface, mock_host_interface):
+        mock_host_interface.mock.identifier.return_value = ""
+        a_threaded_host_interface.identifier()
+
+    def test_info(self, a_threaded_host_interface, mock_host_interface):
+        mock_host_interface.mock.info.return_value = {}
+        a_threaded_host_interface.info()
+
+
+class Test_Host_gil:
+    """
+    Check all methods release the GIL during C++ function body
+    execution.
+
+    See docstring for similar test under `gil/Test_Manager.py`
+    for details on how these tests are structured.
+    """
+
+    def test_all_methods_covered(self, find_unimplemented_test_cases):
+        """
+        Ensure this test class covers all methods.
+        """
+        unimplemented = find_unimplemented_test_cases(Host, self)
+
+        if unimplemented:
+            print("\nSome test cases not implemented. Method templates can be found below:\n")
+            for method in unimplemented:
+                print(
+                    f"""
+    def test_{method}(self, a_threaded_host):
+        a_threaded_host.{method}()
+"""
+                )
+
+        assert unimplemented == []
+
+    def test_displayName(self, a_threaded_host, mock_host_interface):
+        mock_host_interface.mock.displayName.return_value = ""
+        a_threaded_host.displayName()
+
+    def test_identifier(self, a_threaded_host, mock_host_interface):
+        mock_host_interface.mock.identifier.return_value = ""
+        a_threaded_host.identifier()
+
+    def test_info(self, a_threaded_host, mock_host_interface):
+        mock_host_interface.mock.info.return_value = {}
+        a_threaded_host.info()
+
+
+@pytest.fixture
+def a_threaded_host(a_threaded_host_interface):
+    return Host(a_threaded_host_interface)
+
+
+@pytest.fixture
+def a_threaded_host_interface(mock_host_interface):
+    return _openassetio_test.gil.wrapInThreadedHostInterface(mock_host_interface)
diff --git a/src/openassetio-python/tests/cmodule/gil/test_loggerinterface_gil.py b/src/openassetio-python/tests/cmodule/gil/test_loggerinterface_gil.py
new file mode 100644
index 000000000..45ff2b78c
--- /dev/null
+++ b/src/openassetio-python/tests/cmodule/gil/test_loggerinterface_gil.py
@@ -0,0 +1,83 @@
+#
+#   Copyright 2023 The Foundry Visionmongers Ltd
+#
+#   Licensed under the Apache License, Version 2.0 (the "License");
+#   you may not use this file except in compliance with the License.
+#   You may obtain a copy of the License at
+#
+#       http://www.apache.org/licenses/LICENSE-2.0
+#
+#   Unless required by applicable law or agreed to in writing, software
+#   distributed under the License is distributed on an "AS IS" BASIS,
+#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#   See the License for the specific language governing permissions and
+#   limitations under the License.
+#
+"""
+Testing that LoggerInterface methods release the GIL.
+"""
+# pylint: disable=redefined-outer-name
+# pylint: disable=invalid-name,c-extension-no-member
+# pylint: disable=missing-class-docstring,missing-function-docstring
+import pytest
+
+# pylint: disable=no-name-in-module
+from openassetio import _openassetio_test
+from openassetio.log import LoggerInterface
+
+
+class Test_LoggerInterface_gil:
+    """
+    Check all methods release the GIL during C++ function body
+    execution.
+
+    See docstring for similar test under `gil/Test_ManagerInterface.py`
+    for details on how these tests are structured.
+    """
+
+    def test_all_methods_covered(self, find_unimplemented_test_cases):
+        """
+        Ensure this test class covers all methods.
+        """
+        unimplemented = find_unimplemented_test_cases(LoggerInterface, self)
+
+        if unimplemented:
+            print("\nSome test cases not implemented. Method templates can be found below:\n")
+            for method in unimplemented:
+                print(
+                    f"""
+    def test_{method}(self, a_threaded_logger_interface):
+        a_threaded_logger_interface.{method}("")
+"""
+                )
+
+        assert unimplemented == []
+
+    def test_critical(self, a_threaded_logger_interface):
+        a_threaded_logger_interface.critical("")
+
+    def test_debug(self, a_threaded_logger_interface):
+        a_threaded_logger_interface.debug("")
+
+    def test_debugApi(self, a_threaded_logger_interface):
+        a_threaded_logger_interface.debugApi("")
+
+    def test_error(self, a_threaded_logger_interface):
+        a_threaded_logger_interface.error("")
+
+    def test_info(self, a_threaded_logger_interface):
+        a_threaded_logger_interface.info("")
+
+    def test_log(self, a_threaded_logger_interface):
+        a_threaded_logger_interface.log(LoggerInterface.Severity.kInfo, "")
+
+    def test_progress(self, a_threaded_logger_interface):
+        a_threaded_logger_interface.progress("")
+
+    def test_warning(self, a_threaded_logger_interface):
+        a_threaded_logger_interface.warning("")
+
+
+@pytest.fixture
+def a_threaded_logger_interface(mock_logger):
+    return _openassetio_test.gil.wrapInThreadedLoggerInterface(mock_logger)
diff --git a/src/openassetio-python/tests/cmodule/gil/test_manager_gil.py b/src/openassetio-python/tests/cmodule/gil/test_manager_gil.py
new file mode 100644
index 000000000..f793d5c3d
--- /dev/null
+++ b/src/openassetio-python/tests/cmodule/gil/test_manager_gil.py
@@ -0,0 +1,224 @@
+#
+#   Copyright 2023 The Foundry Visionmongers Ltd
+#
+#   Licensed under the Apache License, Version 2.0 (the "License");
+#   you may not use this file except in compliance with the License.
+#   You may obtain a copy of the License at
+#
+#       http://www.apache.org/licenses/LICENSE-2.0
+#
+#   Unless required by applicable law or agreed to in writing, software
+#   distributed under the License is distributed on an "AS IS" BASIS,
+#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#   See the License for the specific language governing permissions and
+#   limitations under the License.
+#
+"""
+Testing that Manager methods release the GIL.
+"""
+# pylint: disable=redefined-outer-name,too-many-public-methods
+# pylint: disable=invalid-name,c-extension-no-member
+# pylint: disable=missing-class-docstring,missing-function-docstring
+import pytest
+
+# pylint: disable=no-name-in-module
+from openassetio import access
+from openassetio.hostApi import Manager
+from openassetio.managerApi import ManagerStateBase
+
+
+class Test_Manager_gil:
+    """
+    Check all methods release the GIL during C++ function body
+    execution.
+
+    Two `ManagerInterface` instances are involved: a pure C++
+    implementation (`ThreadedManagerInterface`) and a Python
+    implementation (`ValidatingMockManagerInterface`). The C++
+    implementation composes the Python implementation.
+
+    The C++ implementation is wrapped in a Manager and called in these
+    tests. It is the Manager implementation that we're testing here, or
+    rather, the Python binding wrapper around it.
+
+    See docstring for the corresponding Test_ManagerInterface for
+    more details.
+    """
+
+    def test_all_methods_covered(self, find_unimplemented_test_cases):
+        """
+        Ensure this test class covers all ManagerInterface methods.
+        """
+        unimplemented = find_unimplemented_test_cases(Manager, self)
+
+        if unimplemented:
+            print("\nSome test cases not implemented. Method templates can be found below:\n")
+            for method in unimplemented:
+                print(
+                    f"""
+    def test_{method}(self, a_threaded_manager, a_context, a_host_session):
+        a_threaded_manager.{method}(a_context, a_host_session)
+"""
+                )
+
+        assert unimplemented == []
+
+    def test_contextFromPersistenceToken(
+        self, mock_manager_interface, a_context, a_threaded_manager
+    ):
+        mock_manager_interface.mock.stateFromPersistenceToken.return_value = a_context
+        a_threaded_manager.contextFromPersistenceToken("")
+
+    def test_createChildContext(self, a_threaded_manager, a_context, a_traits_data):
+        a_context.locale = a_traits_data
+        a_threaded_manager.createChildContext(a_context)
+
+    def test_createContext(self, mock_manager_interface, a_threaded_manager):
+        mock_manager_interface.mock.hasCapability.return_value = True
+        mock_manager_interface.mock.createState.return_value = ManagerStateBase()
+        a_threaded_manager.createContext()
+
+    def test_createEntityReference(self, a_threaded_manager):
+        a_threaded_manager.createEntityReference("")
+
+    def test_createEntityReferenceIfValid(self, a_threaded_manager):
+        a_threaded_manager.createEntityReferenceIfValid("")
+
+    def test_persistenceTokenForContext(self, a_threaded_manager, a_context):
+        a_threaded_manager.persistenceTokenForContext(a_context)
+
+    def test_defaultEntityReference(self, a_threaded_manager, a_context):
+        # Defend against forgetting to include convenience signatures in
+        # this test, once added.
+        assert "Overloaded" not in a_threaded_manager.defaultEntityReference.__doc__
+
+        a_threaded_manager.defaultEntityReference(
+            [],
+            access.DefaultEntityAccess.kRead,
+            a_context,
+            fail,
+            fail,
+        )
+
+    def test_displayName(self, mock_manager_interface, a_threaded_manager):
+        mock_manager_interface.mock.displayName.return_value = "My Name"
+        assert a_threaded_manager.displayName() == "My Name"
+
+    def test_entityExists(self, a_threaded_manager, a_context):
+        # Defend against forgetting to include convenience signatures in
+        # this test, once added.
+        assert "Overloaded" not in a_threaded_manager.entityExists.__doc__
+
+        a_threaded_manager.entityExists([], a_context, fail, fail)
+
+    def test_flushCaches(self, a_threaded_manager):
+        a_threaded_manager.flushCaches()
+
+    def test_getWithRelationship(self, a_threaded_manager, a_traits_data, a_context):
+        # Defend against forgetting to include convenience signatures in
+        # this test, once added.
+        assert "Overloaded" not in a_threaded_manager.getWithRelationship.__doc__
+
+        a_threaded_manager.getWithRelationship(
+            [],
+            a_traits_data,
+            1,
+            access.RelationsAccess.kRead,
+            a_context,
+            fail,
+            fail,
+        )
+
+    def test_getWithRelationships(self, a_threaded_manager, an_entity_reference, a_context):
+        # Defend against forgetting to include convenience signatures in
+        # this test, once added.
+        assert "Overloaded" not in a_threaded_manager.getWithRelationships.__doc__
+
+        a_threaded_manager.getWithRelationships(
+            an_entity_reference,
+            [],
+            1,
+            access.RelationsAccess.kRead,
+            a_context,
+            fail,
+            fail,
+        )
+
+    def test_hasCapability(self, a_threaded_manager):
+        a_threaded_manager.hasCapability(Manager.Capability.kExistenceQueries)
+
+    def test_identifier(self, mock_manager_interface, a_threaded_manager):
+        mock_manager_interface.mock.identifier.return_value = ""
+        a_threaded_manager.identifier()
+
+    def test_info(self, a_threaded_manager):
+        a_threaded_manager.info()
+
+    def test_initialize(self, a_threaded_manager):
+        a_threaded_manager.initialize({})
+
+    def test_isEntityReferenceString(self, a_threaded_manager):
+        a_threaded_manager.isEntityReferenceString("")
+
+    def test_managementPolicy(self, a_threaded_manager, a_context):
+        a_threaded_manager.managementPolicy([], access.PolicyAccess.kRead, a_context)
+
+    def test_preflight(self, a_threaded_manager, an_entity_reference, a_traits_data, a_context):
+        an_access = access.PublishingAccess.kWrite
+        tag = Manager.BatchElementErrorPolicyTag
+        ref = an_entity_reference
+
+        a_threaded_manager.preflight([], [], an_access, a_context, fail, fail)
+        a_threaded_manager.preflight(ref, a_traits_data, an_access, a_context)
+        a_threaded_manager.preflight(ref, a_traits_data, an_access, a_context, tag.kException)
+        a_threaded_manager.preflight(ref, a_traits_data, an_access, a_context, tag.kVariant)
+        a_threaded_manager.preflight([], [], an_access, a_context)
+        a_threaded_manager.preflight([], [], an_access, a_context, tag.kException)
+        a_threaded_manager.preflight([], [], an_access, a_context, tag.kVariant)
+
+    def test_register(self, a_threaded_manager, an_entity_reference, a_traits_data, a_context):
+        an_access = access.PublishingAccess.kWrite
+        tag = Manager.BatchElementErrorPolicyTag
+        ref = an_entity_reference
+
+        a_threaded_manager.register([], [], access.PublishingAccess.kWrite, a_context, fail, fail)
+        a_threaded_manager.register(ref, a_traits_data, an_access, a_context)
+        a_threaded_manager.register(ref, a_traits_data, an_access, a_context, tag.kException)
+        a_threaded_manager.register(ref, a_traits_data, an_access, a_context, tag.kVariant)
+        a_threaded_manager.register([], [], an_access, a_context)
+        a_threaded_manager.register([], [], an_access, a_context, tag.kException)
+        a_threaded_manager.register([], [], an_access, a_context, tag.kVariant)
+
+    def test_resolve(self, a_threaded_manager, an_entity_reference, a_context):
+        an_access = access.ResolveAccess.kRead
+        tag = Manager.BatchElementErrorPolicyTag
+        ref = an_entity_reference
+
+        # Defend against pybind11 changing its auto docs and
+        # invalidating other `"Overloaded" not in` tests, above.
+        assert "Overloaded" in a_threaded_manager.resolve.__doc__
+
+        a_threaded_manager.resolve([], set(), an_access, a_context, fail, fail)
+        a_threaded_manager.resolve(ref, set(), an_access, a_context)
+        a_threaded_manager.resolve(ref, set(), an_access, a_context, tag.kException)
+        a_threaded_manager.resolve(ref, set(), an_access, a_context, tag.kVariant)
+        a_threaded_manager.resolve([], set(), an_access, a_context)
+        a_threaded_manager.resolve([], set(), an_access, a_context, tag.kException)
+        a_threaded_manager.resolve([], set(), an_access, a_context, tag.kVariant)
+
+    def test_settings(self, mock_manager_interface, a_threaded_manager):
+        mock_manager_interface.mock.settings.return_value = {}
+        a_threaded_manager.settings()
+
+    def test_updateTerminology(self, mock_manager_interface, a_threaded_manager):
+        mock_manager_interface.mock.updateTerminology.return_value = {}
+        a_threaded_manager.updateTerminology({})
+
+
+def fail(*_):
+    pytest.fail("shouldn't have been called")
+
+
+@pytest.fixture
+def a_threaded_manager(a_threaded_mock_manager_interface, a_host_session):
+    return Manager(a_threaded_mock_manager_interface, a_host_session)
diff --git a/src/openassetio-python/tests/cmodule/gil/test_managerfactory_gil.py b/src/openassetio-python/tests/cmodule/gil/test_managerfactory_gil.py
new file mode 100644
index 000000000..07a4f4144
--- /dev/null
+++ b/src/openassetio-python/tests/cmodule/gil/test_managerfactory_gil.py
@@ -0,0 +1,237 @@
+#
+#   Copyright 2023 The Foundry Visionmongers Ltd
+#
+#   Licensed under the Apache License, Version 2.0 (the "License");
+#   you may not use this file except in compliance with the License.
+#   You may obtain a copy of the License at
+#
+#       http://www.apache.org/licenses/LICENSE-2.0
+#
+#   Unless required by applicable law or agreed to in writing, software
+#   distributed under the License is distributed on an "AS IS" BASIS,
+#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#   See the License for the specific language governing permissions and
+#   limitations under the License.
+#
+"""
+Testing that ManagerFactory/ManagerImplementationFactoryInterface
+methods release the GIL.
+"""
+# pylint: disable=redefined-outer-name
+# pylint: disable=invalid-name,c-extension-no-member
+# pylint: disable=missing-class-docstring,missing-function-docstring
+from unittest import mock
+
+import pytest
+
+# pylint: disable=no-name-in-module
+from openassetio import _openassetio_test
+from openassetio.hostApi import ManagerImplementationFactoryInterface, ManagerFactory
+
+
+class Test_ManagerImplementationFactoryInterface_gil:
+    """
+    Check all methods release the GIL during C++ function body
+    execution.
+
+    See docstring for similar test under `gil/Test_ManagerInterface.py`
+    for details on how these tests are structured.
+    """
+
+    def test_all_methods_covered(self, find_unimplemented_test_cases):
+        """
+        Ensure this test class covers all methods.
+        """
+        unimplemented = find_unimplemented_test_cases(ManagerImplementationFactoryInterface, self)
+
+        if unimplemented:
+            print("\nSome test cases not implemented. Method templates can be found below:\n")
+            for method in unimplemented:
+                print(
+                    f"""
+    def test_{method}(self, a_threaded_manager_impl_factory, mock_manager_impl_factory):
+        a_threaded_manager_impl_factory.{method}()
+"""
+                )
+
+        assert unimplemented == []
+
+    def test_identifiers(self, a_threaded_manager_impl_factory, mock_manager_impl_factory):
+        mock_manager_impl_factory.mock.identifiers.return_value = []
+        a_threaded_manager_impl_factory.identifiers()
+
+    def test_instantiate(
+        self,
+        a_threaded_manager_impl_factory,
+        mock_manager_impl_factory,
+        mock_manager_interface,
+    ):
+        mock_manager_impl_factory.mock.instantiate.return_value = mock_manager_interface
+        a_threaded_manager_impl_factory.instantiate("")
+
+
+class Test_ManagerFactory_gil:
+    """
+    ManagerFactory is one of the more complex cases for testing GIL
+    release, since it has actual logic inside it, and has multiple
+    dependencies.
+
+    However, all we really need is to ensure each method calls at least
+    one function that we know for sure will assert on the GIL.
+
+    So here we use the ManagerImplementationFactoryInterface (MIFI)
+    dependency. We wire our preconditions to ensure each method under
+    test goes down a code path that uses some method of the threaded
+    mock MIFI wrapper.
+
+    To be sure the code path we want has been taken, we must
+    `.assert_called()` on the relevant MIFI method. Note that the fake
+    MIFI we provide has two layers: the threaded wrapper, which does the
+    GIL assert, composes and forwards to a mock MIFI, which we can
+    manipulate in the tests.
+
+    Also see docstring for similar test under `gil/Test_Manager.py`
+    for details on how these tests are structured.
+    """
+
+    def test_all_methods_covered(self, find_unimplemented_test_cases):
+        """
+        Ensure this test class covers all methods.
+        """
+        unimplemented = find_unimplemented_test_cases(ManagerFactory, self)
+
+        if unimplemented:
+            print("\nSome test cases not implemented. Method templates can be found below:\n")
+            for method in unimplemented:
+                print(
+                    f"""
+    def test_{method}(
+            self, a_threaded_manager_factory, mock_manager_impl_factory, mock_logger,
+            mock_host_interface
+    ):
+        a_threaded_manager_factory.{method}(
+                mock_host_interface, a_threaded_manager_impl_factory, mock_logger
+        )
+"""
+                )
+
+        assert unimplemented == []
+
+    def test_availableManagers(
+        self,
+        a_threaded_manager_factory,
+        mock_manager_impl_factory,
+    ):
+        mock_manager_impl_factory.mock.identifiers.return_value = []
+
+        a_threaded_manager_factory.availableManagers()
+
+        mock_manager_impl_factory.mock.identifiers.assert_called()
+
+    def test_createManager(
+        self, a_threaded_manager_factory, mock_manager_impl_factory, mock_manager_interface
+    ):
+        mock_manager_impl_factory.mock.instantiate.return_value = mock_manager_interface
+
+        a_threaded_manager_factory.createManager("")
+
+        mock_manager_impl_factory.mock.instantiate.assert_called()
+
+    def test_createManagerForInterface(
+        self,
+        a_threaded_manager_impl_factory,
+        mock_logger,
+        mock_host_interface,
+        mock_manager_impl_factory,
+        mock_manager_interface,
+    ):
+        mock_manager_impl_factory.mock.instantiate.return_value = mock_manager_interface
+
+        ManagerFactory.createManagerForInterface(
+            "", mock_host_interface, a_threaded_manager_impl_factory, mock_logger
+        )
+
+        mock_manager_impl_factory.mock.instantiate.assert_called()
+
+    def test_defaultManagerForInterface(
+        self,
+        a_threaded_manager_impl_factory,
+        mock_logger,
+        mock_host_interface,
+        mock_manager_impl_factory,
+        tmp_path,
+        monkeypatch,
+        mock_manager_interface,
+    ):
+        mock_manager_impl_factory.mock.instantiate.return_value = mock_manager_interface
+        config_path = tmp_path / "manager.toml"
+        config_path.write_text('[manager]\nidentifier = "something"')
+
+        # First overload
+
+        ManagerFactory.defaultManagerForInterface(
+            str(config_path),
+            mock_host_interface,
+            a_threaded_manager_impl_factory,
+            mock_logger,
+        )
+
+        mock_manager_impl_factory.mock.instantiate.assert_called()
+
+        # Second overload
+
+        mock_manager_impl_factory.mock.instantiate.reset_mock()
+        monkeypatch.setenv("OPENASSETIO_DEFAULT_CONFIG", str(config_path))
+
+        ManagerFactory.defaultManagerForInterface(
+            mock_host_interface, a_threaded_manager_impl_factory, mock_logger
+        )
+
+        mock_manager_impl_factory.mock.instantiate.assert_called()
+
+    def test_identifiers(
+        self,
+        a_threaded_manager_factory,
+        mock_manager_impl_factory,
+    ):
+        mock_manager_impl_factory.mock.identifiers.return_value = []
+
+        a_threaded_manager_factory.identifiers()
+
+        mock_manager_impl_factory.mock.identifiers.assert_called()
+
+
+@pytest.fixture
+def a_threaded_manager_factory(mock_host_interface, a_threaded_manager_impl_factory, mock_logger):
+    return ManagerFactory(mock_host_interface, a_threaded_manager_impl_factory, mock_logger)
+
+
+@pytest.fixture
+def a_threaded_manager_impl_factory(mock_manager_impl_factory, mock_logger):
+    return _openassetio_test.gil.wrapInThreadedManagerImplFactory(
+        mock_logger, mock_manager_impl_factory
+    )
+
+
+@pytest.fixture
+def mock_manager_impl_factory(mock_logger):
+    return MockManagerImplFactoryInterface(mock_logger)
+
+
+class MockManagerImplFactoryInterface(ManagerImplementationFactoryInterface):
+    """
+    `ManagerImplementationFactoryInterface` implementation that delegates all
+     calls to a public `Mock` instance.
+    """
+
+    def __init__(self, logger):
+        super().__init__(logger)
+        self.mock = mock.create_autospec(
+            ManagerImplementationFactoryInterface, spec_set=True, instance=True
+        )
+
+    def identifiers(self):
+        return self.mock.identifiers()
+
+    def instantiate(self, identifier):
+        return self.mock.instantiate(identifier)
diff --git a/src/openassetio-python/tests/cmodule/gil/test_managerinterface_gil.py b/src/openassetio-python/tests/cmodule/gil/test_managerinterface_gil.py
new file mode 100644
index 000000000..7596a3d98
--- /dev/null
+++ b/src/openassetio-python/tests/cmodule/gil/test_managerinterface_gil.py
@@ -0,0 +1,216 @@
+#
+#   Copyright 2023 The Foundry Visionmongers Ltd
+#
+#   Licensed under the Apache License, Version 2.0 (the "License");
+#   you may not use this file except in compliance with the License.
+#   You may obtain a copy of the License at
+#
+#       http://www.apache.org/licenses/LICENSE-2.0
+#
+#   Unless required by applicable law or agreed to in writing, software
+#   distributed under the License is distributed on an "AS IS" BASIS,
+#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#   See the License for the specific language governing permissions and
+#   limitations under the License.
+#
+"""
+Testing that ManagerInterface methods release the GIL.
+"""
+# pylint: disable=redefined-outer-name,too-many-public-methods
+# pylint: disable=invalid-name,c-extension-no-member
+# pylint: disable=missing-class-docstring,missing-function-docstring
+import pytest
+
+# pylint: disable=no-name-in-module
+from openassetio import access
+from openassetio.managerApi import ManagerInterface, ManagerStateBase
+
+
+class Test_ManagerInterface_gil:
+    """
+    Check all methods release the GIL during C++ function body
+    execution.
+
+    Two `ManagerInterface` instances are involved: a pure C++
+    implementation (`ThreadedManagerInterface`) and a Python
+    implementation (`ValidatingMockManagerInterface`). The C++
+    implementation composes the Python implementation.
+
+    The C++ implementation is called from Python in these tests. It is
+    this implementation that we're testing, or rather, the Python
+    binding wrapper around this implementation.
+
+    Each method of the C++ implementation forwards its call to the
+    Python implementation in a new thread, and awaits the result. The
+    pybind11 trampoline will then acquire the GIL in this new thread
+    (done by pybind11 as part of its trampoline macros), then call out
+    to the pure Python implementation.
+
+    Therefore, the pybind11 bindings must release the GIL on the way in
+    to the C++ implementation, so that the pybind11 trampoline can
+    acquire the GIL in the new thread before calling the  Python
+    implementation.
+
+    If the GIL is not released in the bindings, then we have a deadlock
+    and the test will hang indefinitely. A PyGILState_Check is performed
+    in the C++ implementation to preempt this and throw an exception.
+
+    Arguably, we could get rid of the Python implementation, and instead
+    have the C++ methods spawn and await a thread that simply acquires
+    the GIL and returns. Forwarding to a Python implementation just
+    serves to test that pybind11's trampoline is behaving as expected
+    (i.e. acquires the GIL). It's also a handy way to construct dummy
+    return values in the pytest test case, rather than doing that in
+    the C++ implementation.
+    """
+
+    def test_all_methods_covered(self, find_unimplemented_test_cases):
+        """
+        Ensure this test class covers all ManagerInterface methods.
+        """
+        unimplemented = find_unimplemented_test_cases(ManagerInterface, self)
+
+        if unimplemented:
+            print("\nSome test cases not implemented. Method templates can be found below:\n")
+            for method in unimplemented:
+                print(
+                    f"""
+    def test_{method}(self, a_threaded_mock_manager_interface, a_context, a_host_session):
+        a_threaded_mock_manager_interface.{method}(a_context, a_host_session)
+"""
+                )
+
+        assert unimplemented == []
+
+    def test_createChildState(
+        self, mock_manager_interface, a_threaded_mock_manager_interface, a_host_session
+    ):
+        mock_manager_interface.mock.createChildState.return_value = ManagerStateBase()
+
+        a_threaded_mock_manager_interface.createChildState(ManagerStateBase(), a_host_session)
+
+    def test_createState(
+        self, mock_manager_interface, a_threaded_mock_manager_interface, a_host_session
+    ):
+        mock_manager_interface.mock.createState.return_value = ManagerStateBase()
+        a_threaded_mock_manager_interface.createState(a_host_session)
+
+    def test_defaultEntityReference(
+        self, a_threaded_mock_manager_interface, a_context, a_host_session
+    ):
+        a_threaded_mock_manager_interface.defaultEntityReference(
+            [],
+            access.DefaultEntityAccess.kRead,
+            a_context,
+            a_host_session,
+            fail,
+            fail,
+        )
+
+    def test_displayName(self, mock_manager_interface, a_threaded_mock_manager_interface):
+        mock_manager_interface.mock.displayName.return_value = "My Name"
+        assert a_threaded_mock_manager_interface.displayName() == "My Name"
+
+    def test_entityExists(self, a_threaded_mock_manager_interface, a_context, a_host_session):
+        a_threaded_mock_manager_interface.entityExists([], a_context, a_host_session, fail, fail)
+
+    def test_flushCaches(self, a_threaded_mock_manager_interface, a_host_session):
+        a_threaded_mock_manager_interface.flushCaches(a_host_session)
+
+    def test_getWithRelationship(
+        self, a_threaded_mock_manager_interface, a_traits_data, a_context, a_host_session
+    ):
+        a_threaded_mock_manager_interface.getWithRelationship(
+            [],
+            a_traits_data,
+            set(),
+            1,
+            access.RelationsAccess.kRead,
+            a_context,
+            a_host_session,
+            fail,
+            fail,
+        )
+
+    def test_getWithRelationships(
+        self, a_threaded_mock_manager_interface, an_entity_reference, a_context, a_host_session
+    ):
+        a_threaded_mock_manager_interface.getWithRelationships(
+            an_entity_reference,
+            [],
+            set(),
+            1,
+            access.RelationsAccess.kRead,
+            a_context,
+            a_host_session,
+            fail,
+            fail,
+        )
+
+    def test_hasCapability(self, a_threaded_mock_manager_interface):
+        a_threaded_mock_manager_interface.hasCapability(
+            ManagerInterface.Capability.kManagementPolicyQueries
+        )
+
+    def test_identifier(self, mock_manager_interface, a_threaded_mock_manager_interface):
+        mock_manager_interface.mock.identifier.return_value = ""
+        a_threaded_mock_manager_interface.identifier()
+
+    def test_info(self, a_threaded_mock_manager_interface):
+        a_threaded_mock_manager_interface.info()
+
+    def test_initialize(self, a_threaded_mock_manager_interface, a_host_session):
+        a_threaded_mock_manager_interface.initialize({}, a_host_session)
+
+    def test_isEntityReferenceString(self, a_threaded_mock_manager_interface, a_host_session):
+        a_threaded_mock_manager_interface.isEntityReferenceString("", a_host_session)
+
+    def test_managementPolicy(self, a_threaded_mock_manager_interface, a_context, a_host_session):
+        a_threaded_mock_manager_interface.managementPolicy(
+            [], access.PolicyAccess.kRead, a_context, a_host_session
+        )
+
+    def test_persistenceTokenForState(
+        self, mock_manager_interface, a_threaded_mock_manager_interface, a_host_session
+    ):
+        mock_manager_interface.mock.persistenceTokenForState.return_value = ""
+        a_threaded_mock_manager_interface.persistenceTokenForState(
+            ManagerStateBase(), a_host_session
+        )
+
+    def test_preflight(self, a_threaded_mock_manager_interface, a_context, a_host_session):
+        a_threaded_mock_manager_interface.preflight(
+            [], [], access.PublishingAccess.kWrite, a_context, a_host_session, fail, fail
+        )
+
+    def test_register(self, a_threaded_mock_manager_interface, a_context, a_host_session):
+        a_threaded_mock_manager_interface.register(
+            [], [], access.PublishingAccess.kWrite, a_context, a_host_session, fail, fail
+        )
+
+    def test_resolve(self, a_threaded_mock_manager_interface, a_context, a_host_session):
+        a_threaded_mock_manager_interface.resolve(
+            [], set(), access.ResolveAccess.kRead, a_context, a_host_session, fail, fail
+        )
+
+    def test_settings(
+        self, mock_manager_interface, a_threaded_mock_manager_interface, a_host_session
+    ):
+        mock_manager_interface.mock.settings.return_value = {}
+        a_threaded_mock_manager_interface.settings(a_host_session)
+
+    def test_stateFromPersistenceToken(
+        self, mock_manager_interface, a_threaded_mock_manager_interface, a_host_session
+    ):
+        mock_manager_interface.mock.stateFromPersistenceToken.return_value = ManagerStateBase()
+        a_threaded_mock_manager_interface.stateFromPersistenceToken("", a_host_session)
+
+    def test_updateTerminology(
+        self, mock_manager_interface, a_threaded_mock_manager_interface, a_host_session
+    ):
+        mock_manager_interface.mock.updateTerminology.return_value = {}
+        a_threaded_mock_manager_interface.updateTerminology({}, a_host_session)
+
+
+def fail(*_):
+    pytest.fail("shouldn't have been called")
diff --git a/src/openassetio-python/tests/cmodule/resources/_openassetio_test/CMakeLists.txt b/src/openassetio-python/tests/cmodule/resources/_openassetio_test/CMakeLists.txt
index 3345557a8..b7cd0bf8b 100644
--- a/src/openassetio-python/tests/cmodule/resources/_openassetio_test/CMakeLists.txt
+++ b/src/openassetio-python/tests/cmodule/resources/_openassetio_test/CMakeLists.txt
@@ -34,6 +34,7 @@ target_sources(
     _openassetio_test.cpp
     PyRetainingSharedPtrTest.cpp
     errorsTest.cpp
+    gilTest.cpp
 )
 
 # Give access to private headers.
@@ -42,6 +43,8 @@ target_include_directories(openassetio-python-test
 
 target_link_libraries(openassetio-python-test
     PRIVATE
+    # Mocking framework.
+    trompeloeil::trompeloeil
     # Core C++ library.
     openassetio-core
     # Python C++ bridge library.
diff --git a/src/openassetio-python/tests/cmodule/resources/_openassetio_test/_openassetio_test.cpp b/src/openassetio-python/tests/cmodule/resources/_openassetio_test/_openassetio_test.cpp
index d046341ff..34c1bf80c 100644
--- a/src/openassetio-python/tests/cmodule/resources/_openassetio_test/_openassetio_test.cpp
+++ b/src/openassetio-python/tests/cmodule/resources/_openassetio_test/_openassetio_test.cpp
@@ -6,8 +6,10 @@ namespace py = pybind11;
 
 void registerPyRetainingSharedPtrTestTypes(py::module_&);
 void registerExceptionThrower(py::module_& mod);
+void registerRunInThread(py::module_& mod);
 
 PYBIND11_MODULE(_openassetio_test, mod) {
   registerPyRetainingSharedPtrTestTypes(mod);
   registerExceptionThrower(mod);
+  registerRunInThread(mod);
 }
diff --git a/src/openassetio-python/tests/cmodule/resources/_openassetio_test/gilTest.cpp b/src/openassetio-python/tests/cmodule/resources/_openassetio_test/gilTest.cpp
new file mode 100644
index 000000000..5df238525
--- /dev/null
+++ b/src/openassetio-python/tests/cmodule/resources/_openassetio_test/gilTest.cpp
@@ -0,0 +1,222 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright 2023 The Foundry Visionmongers Ltd
+#include <future>
+#include <memory>
+#include <utility>
+
+#include <pybind11/functional.h>
+#include <pybind11/pybind11.h>
+#include <trompeloeil.hpp>
+
+#include <openassetio/hostApi/HostInterface.hpp>
+#include <openassetio/hostApi/ManagerImplementationFactoryInterface.hpp>
+#include <openassetio/log/LoggerInterface.hpp>
+#include <openassetio/managerApi/EntityReferencePagerInterface.hpp>
+#include <openassetio/managerApi/ManagerInterface.hpp>
+
+/*
+ * Hack trompeloeil to make use of its internals, but provide our own
+ * mock implementation body, in order to mock threaded calls.
+ *
+ * The body starts a thread, and forwards requests to a (Python)
+ * wrapped instance in that thread, then waits on the thread and
+ * returns the result (see usage of std::async).
+ */
+
+#undef TROMPELOEIL_IMPLEMENT_MOCK_
+/**
+ * Swap `trompeloeil_interface_name` for `Base`, which must be defined
+ * on the class as an alias to the base class.
+ */
+#define TROMPELOEIL_IMPLEMENT_MOCK_(num, name, ...)                                             \
+  TROMPELOEIL_MAKE_MOCK_(name, , num,                                                           \
+                         decltype(::trompeloeil::nonconst_member_signature(&Base::name))::type, \
+                         TROMPELOEIL_SEPARATE(__VA_ARGS__), )
+
+#undef TROMPELOEIL_IMPLEMENT_CONST_MOCK_
+/**
+ * Swap `trompeloeil_interface_name` for `Base`, which must be defined
+ * on the class as an alias to the base class.
+ */
+#define TROMPELOEIL_IMPLEMENT_CONST_MOCK_(num, name, ...)                                    \
+  TROMPELOEIL_MAKE_MOCK_(name, const, num,                                                   \
+                         decltype(::trompeloeil::const_member_signature(&Base::name))::type, \
+                         TROMPELOEIL_SEPARATE(__VA_ARGS__), )
+
+/**
+ * Override body of mock to add our std::async call.
+ */
+#undef TROMPELOEIL_MAKE_MOCK_
+#define TROMPELOEIL_MAKE_MOCK_(name, constness, num, sig, spec, ...)                           \
+  ::trompeloeil::return_of_t<TROMPELOEIL_REMOVE_PAREN(sig)> name(                              \
+      TROMPELOEIL_PARAM_LIST(num, sig)) constness spec {                                       \
+    if (PyGILState_Check()) {                                                                  \
+      throw std::runtime_error{"GIL was not released"};                                        \
+    }                                                                                          \
+    return std::async(std::launch::async, &Base::name, wrapped_.get() TROMPELOEIL_PARAMS(num)) \
+        .get();                                                                                \
+  }
+
+namespace py = pybind11;
+
+namespace {
+
+/// Used to test calling bound members in another thread.
+struct Flag {
+  virtual ~Flag() = default;
+  virtual void set(bool) = 0;
+  virtual bool get() = 0;
+};
+
+/// Used to test calling bound members in another thread.
+struct PyFlag : Flag {
+  void set(bool value) override { PYBIND11_OVERRIDE_PURE(void, Flag, set, value); }
+  bool get() override { PYBIND11_OVERRIDE_PURE(bool, Flag, get, /* no args */); }
+};
+
+namespace managerApi = openassetio::managerApi;
+
+/*
+ * Note: the IMPLEMENT_* macros, below, expand to the TROMPELOEIL_*
+ * macros, above.
+ */
+
+struct ThreadedManagerInterface : managerApi::ManagerInterface {
+  using Base = ManagerInterface;
+  static Ptr make(Ptr wrapped) {
+    return std::make_shared<ThreadedManagerInterface>(std::move(wrapped));
+  }
+  explicit ThreadedManagerInterface(Ptr wrapped) : wrapped_{std::move(wrapped)} {}
+  Ptr wrapped_;
+
+  IMPLEMENT_CONST_MOCK0(identifier);
+  IMPLEMENT_CONST_MOCK0(displayName);
+  IMPLEMENT_MOCK1(hasCapability);
+  IMPLEMENT_MOCK0(info);
+  IMPLEMENT_MOCK2(updateTerminology);
+  IMPLEMENT_MOCK1(settings);
+  IMPLEMENT_MOCK2(initialize);
+  IMPLEMENT_MOCK1(flushCaches);
+  IMPLEMENT_MOCK4(managementPolicy);
+  IMPLEMENT_MOCK1(createState);
+  IMPLEMENT_MOCK2(createChildState);
+  IMPLEMENT_MOCK2(persistenceTokenForState);
+  IMPLEMENT_MOCK2(stateFromPersistenceToken);
+  IMPLEMENT_MOCK2(isEntityReferenceString);
+  IMPLEMENT_MOCK5(entityExists);
+  IMPLEMENT_MOCK7(resolve);
+  IMPLEMENT_MOCK6(defaultEntityReference);
+  IMPLEMENT_MOCK9(getWithRelationship);
+  IMPLEMENT_MOCK9(getWithRelationships);
+  IMPLEMENT_MOCK7(preflight);
+  IMPLEMENT_MOCK7(register_);
+};
+
+struct ThreadedEntityReferencePagerInterface : managerApi::EntityReferencePagerInterface {
+  using Base = EntityReferencePagerInterface;
+  static Ptr make(Ptr wrapped) {
+    return std::make_shared<ThreadedEntityReferencePagerInterface>(std::move(wrapped));
+  }
+  explicit ThreadedEntityReferencePagerInterface(Ptr wrapped) : wrapped_{std::move(wrapped)} {}
+  Ptr wrapped_;
+
+  IMPLEMENT_MOCK1(hasNext);
+  IMPLEMENT_MOCK1(get);
+  IMPLEMENT_MOCK1(next);
+  IMPLEMENT_MOCK1(close);
+};
+
+namespace hostApi = openassetio::hostApi;
+
+struct ThreadedHostInterface : hostApi::HostInterface {
+  using Base = HostInterface;
+  static Ptr make(Ptr wrapped) {
+    return std::make_shared<ThreadedHostInterface>(std::move(wrapped));
+  }
+  explicit ThreadedHostInterface(Ptr wrapped) : wrapped_{std::move(wrapped)} {}
+  Ptr wrapped_;
+
+  IMPLEMENT_CONST_MOCK0(identifier);
+  IMPLEMENT_CONST_MOCK0(displayName);
+  IMPLEMENT_MOCK0(info);
+};
+
+namespace log = openassetio::log;
+
+struct ThreadedLoggerInterface : log::LoggerInterface {
+  using Base = LoggerInterface;
+  static Ptr make(Ptr wrapped) {
+    return std::make_shared<ThreadedLoggerInterface>(std::move(wrapped));
+  }
+  explicit ThreadedLoggerInterface(Ptr wrapped) : wrapped_{std::move(wrapped)} {}
+  Ptr wrapped_;
+
+  IMPLEMENT_MOCK2(log);
+};
+
+struct ThreadedManagerImplFactory : hostApi::ManagerImplementationFactoryInterface {
+  using Base = ManagerImplementationFactoryInterface;
+  static Ptr make(log::LoggerInterfacePtr logger, Ptr wrapped) {
+    return std::make_shared<ThreadedManagerImplFactory>(std::move(logger), std::move(wrapped));
+  }
+  explicit ThreadedManagerImplFactory(log::LoggerInterfacePtr logger, Ptr wrapped)
+      : ManagerImplementationFactoryInterface(std::move(logger)), wrapped_{std::move(wrapped)} {}
+  Ptr wrapped_;
+
+  IMPLEMENT_MOCK0(identifiers);
+  IMPLEMENT_MOCK1(instantiate);
+};
+}  // namespace
+
+void registerRunInThread(py::module_& mod) {
+  mod.def(
+      "runCallableInThread",
+      [](const py::object& func) {
+        std::async(std::launch::async, [&func]() {
+          // Callable py::objects need explicit GIL re-acquire.
+          [[maybe_unused]] py::gil_scoped_acquire const gil{};
+          func();
+        }).get();
+      },
+      py::arg("func"), py::call_guard<py::gil_scoped_release>());
+
+  mod.def(
+      "runPyFunctionInThread",
+      [](const py::function& func) {
+        std::async(std::launch::async, [&func]() {
+          // py::function needs explicit GIL re-acquire, unlike
+          // std::function.
+          [[maybe_unused]] py::gil_scoped_acquire const gil{};
+          func();
+        }).get();
+      },
+      py::arg("func"), py::call_guard<py::gil_scoped_release>());
+
+  mod.def(
+      "runStdFunctionInThread",
+      [](const std::function<void()>& func) { std::async(std::launch::async, func).get(); },
+      py::arg("func"), py::call_guard<py::gil_scoped_release>());
+
+  mod.def(
+      "flagInThread",
+      [](Flag& flag) {
+        std::async(std::launch::async, [&flag]() { flag.set(true); }).get();
+
+        return flag.get();
+      },
+      py::arg("func"), py::call_guard<py::gil_scoped_release>());
+
+  py::class_<Flag, PyFlag>{mod, "Flag"}
+      .def(py::init())
+      .def("get", &Flag::get)
+      .def("set", &Flag::set);
+
+  auto gil = mod.def_submodule("gil");
+
+  gil.def("wrapInThreadedManagerInterface", &ThreadedManagerInterface::make);
+  gil.def("wrapInThreadedEntityReferencePagerInterface",
+          &ThreadedEntityReferencePagerInterface::make);
+  gil.def("wrapInThreadedHostInterface", &ThreadedHostInterface::make);
+  gil.def("wrapInThreadedLoggerInterface", &ThreadedLoggerInterface::make);
+  gil.def("wrapInThreadedManagerImplFactory", &ThreadedManagerImplFactory::make);
+}
diff --git a/src/openassetio-python/tests/cmodule/test_gil.py b/src/openassetio-python/tests/cmodule/test_gil.py
new file mode 100644
index 000000000..1f126efa9
--- /dev/null
+++ b/src/openassetio-python/tests/cmodule/test_gil.py
@@ -0,0 +1,86 @@
+#
+#   Copyright 2023 The Foundry Visionmongers Ltd
+#
+#   Licensed under the Apache License, Version 2.0 (the "License");
+#   you may not use this file except in compliance with the License.
+#   You may obtain a copy of the License at
+#
+#       http://www.apache.org/licenses/LICENSE-2.0
+#
+#   Unless required by applicable law or agreed to in writing, software
+#   distributed under the License is distributed on an "AS IS" BASIS,
+#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#   See the License for the specific language governing permissions and
+#   limitations under the License.
+#
+"""
+Testing pybind11's GIL handling behaviour
+"""
+
+# pylint: disable=redefined-outer-name
+# pylint: disable=invalid-name,c-extension-no-member
+# pylint: disable=missing-class-docstring,missing-function-docstring
+import threading
+
+# pylint: disable=no-name-in-module
+from openassetio import _openassetio_test
+
+
+class Test_gil:
+    # pylint: disable=attribute-defined-outside-init
+    def test_when_calling_as_callable_object_in_another_thread_then_doesnt_error(self):
+        self.state_to_update = False
+        main_thread_id = threading.current_thread().ident
+
+        def update_state():
+            assert threading.current_thread().ident != main_thread_id
+            self.state_to_update = True
+
+        _openassetio_test.runCallableInThread(update_state)
+
+        assert self.state_to_update is True
+
+    def test_when_calling_as_py_function_in_another_thread_then_doesnt_error(self):
+        self.state_to_update = False
+        main_thread_id = threading.current_thread().ident
+
+        def update_state():
+            assert threading.current_thread().ident != main_thread_id
+            self.state_to_update = True
+
+        _openassetio_test.runPyFunctionInThread(update_state)
+
+        assert self.state_to_update is True
+
+    def test_when_calling_as_std_function_in_another_thread_then_doesnt_error(self):
+        self.state_to_update = False
+        main_thread_id = threading.current_thread().ident
+
+        def update_state():
+            assert threading.current_thread().ident != main_thread_id
+            self.state_to_update = True
+
+        _openassetio_test.runStdFunctionInThread(update_state)
+
+        assert self.state_to_update is True
+
+    def test_when_calling_bound_member_in_another_thread_then_doesnt_error(self):
+        class MyFlag(_openassetio_test.Flag):
+            def __init__(self):
+                self.value = None
+                _openassetio_test.Flag.__init__(self)
+
+            def set(self, value):
+                assert threading.current_thread().ident != main_thread_id
+                self.value = value
+
+            def get(self):
+                return self.value
+
+        flag = MyFlag()
+        main_thread_id = threading.current_thread().ident
+
+        returned_flag_value = _openassetio_test.flagInThread(flag)
+
+        assert flag.value is True
+        assert returned_flag_value is flag.value
diff --git a/src/openassetio-python/tests/package/hostApi/test_hostinterface.py b/src/openassetio-python/tests/package/hostApi/test_hostinterface.py
new file mode 100644
index 000000000..17529db0f
--- /dev/null
+++ b/src/openassetio-python/tests/package/hostApi/test_hostinterface.py
@@ -0,0 +1,41 @@
+#
+#   Copyright 2013-2023 The Foundry Visionmongers Ltd
+#
+#   Licensed under the Apache License, Version 2.0 (the "License");
+#   you may not use this file except in compliance with the License.
+#   You may obtain a copy of the License at
+#
+#       http://www.apache.org/licenses/LICENSE-2.0
+#
+#   Unless required by applicable law or agreed to in writing, software
+#   distributed under the License is distributed on an "AS IS" BASIS,
+#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#   See the License for the specific language governing permissions and
+#   limitations under the License.
+#
+"""
+Tests that cover the openassetio.hostApi.HostInterface class.
+"""
+
+# pylint: disable=invalid-name,redefined-outer-name
+# pylint: disable=missing-class-docstring,missing-function-docstring
+import pytest
+
+from openassetio.hostApi import HostInterface
+
+
+class Test_HostInterface_identifier:
+    def test_is_pure_virtual(self, an_unimplemented_host_interface):
+        with pytest.raises(RuntimeError, match="Tried to call pure virtual function"):
+            an_unimplemented_host_interface.identifier()
+
+
+class Test_HostInterface_displayName:
+    def test_is_pure_virtual(self, an_unimplemented_host_interface):
+        with pytest.raises(RuntimeError, match="Tried to call pure virtual function"):
+            an_unimplemented_host_interface.displayName()
+
+
+@pytest.fixture
+def an_unimplemented_host_interface():
+    return HostInterface()
diff --git a/src/openassetio-python/tests/package/log/test_loggerinterface.py b/src/openassetio-python/tests/package/log/test_loggerinterface.py
new file mode 100644
index 000000000..1ea35fb09
--- /dev/null
+++ b/src/openassetio-python/tests/package/log/test_loggerinterface.py
@@ -0,0 +1,35 @@
+#
+#   Copyright 2013-2023 The Foundry Visionmongers Ltd
+#
+#   Licensed under the Apache License, Version 2.0 (the "License");
+#   you may not use this file except in compliance with the License.
+#   You may obtain a copy of the License at
+#
+#       http://www.apache.org/licenses/LICENSE-2.0
+#
+#   Unless required by applicable law or agreed to in writing, software
+#   distributed under the License is distributed on an "AS IS" BASIS,
+#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#   See the License for the specific language governing permissions and
+#   limitations under the License.
+#
+"""
+Tests that cover the openassetio.log.LoggerInterface class.
+"""
+
+# pylint: disable=invalid-name,redefined-outer-name
+# pylint: disable=missing-class-docstring,missing-function-docstring
+import pytest
+
+from openassetio.log import LoggerInterface
+
+
+class Test_LoggerInterface_log:
+    def test_is_pure_virtual(self, an_unimplemented_logger_interface):
+        with pytest.raises(RuntimeError, match="Tried to call pure virtual function"):
+            an_unimplemented_logger_interface.log(LoggerInterface.Severity.kInfo, "")
+
+
+@pytest.fixture
+def an_unimplemented_logger_interface():
+    return LoggerInterface()
diff --git a/src/openassetio-python/tests/package/managerApi/test_entityreferencepagerinterface.py b/src/openassetio-python/tests/package/managerApi/test_entityreferencepagerinterface.py
new file mode 100644
index 000000000..238f5f779
--- /dev/null
+++ b/src/openassetio-python/tests/package/managerApi/test_entityreferencepagerinterface.py
@@ -0,0 +1,48 @@
+#
+#   Copyright 2013-2023 The Foundry Visionmongers Ltd
+#
+#   Licensed under the Apache License, Version 2.0 (the "License");
+#   you may not use this file except in compliance with the License.
+#   You may obtain a copy of the License at
+#
+#       http://www.apache.org/licenses/LICENSE-2.0
+#
+#   Unless required by applicable law or agreed to in writing, software
+#   distributed under the License is distributed on an "AS IS" BASIS,
+#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#   See the License for the specific language governing permissions and
+#   limitations under the License.
+#
+"""
+Tests that cover the openassetio.managerApi.EntityReferencePagerInterface
+wrapper class.
+"""
+
+# pylint: disable=invalid-name,redefined-outer-name
+# pylint: disable=missing-class-docstring,missing-function-docstring
+import pytest
+
+from openassetio.managerApi import EntityReferencePagerInterface
+
+
+class Test_EntityReferencePagerInterface_next:
+    def test_is_pure_virtual(self, an_unimplemented_entity_ref_pager_interface, a_host_session):
+        with pytest.raises(RuntimeError, match="Tried to call pure virtual function"):
+            an_unimplemented_entity_ref_pager_interface.next(a_host_session)
+
+
+class Test_EntityReferencePagerInterface_hasNext:
+    def test_is_pure_virtual(self, an_unimplemented_entity_ref_pager_interface, a_host_session):
+        with pytest.raises(RuntimeError, match="Tried to call pure virtual function"):
+            an_unimplemented_entity_ref_pager_interface.hasNext(a_host_session)
+
+
+class Test_EntityReferencePagerInterface_get:
+    def test_is_pure_virtual(self, an_unimplemented_entity_ref_pager_interface, a_host_session):
+        with pytest.raises(RuntimeError, match="Tried to call pure virtual function"):
+            an_unimplemented_entity_ref_pager_interface.get(a_host_session)
+
+
+@pytest.fixture
+def an_unimplemented_entity_ref_pager_interface():
+    return EntityReferencePagerInterface()