lumiera_documentation/src/lib/thread.hpp
Ichthyostega 578af05ebd Library: policy for lifecycle hooks
after some further mulling over the design, it became clear that
a rather loose coupling to the actual usage scenario is preferrable.

Thus, instead of devising a fixed scheme how to reflect the thread state,
rather the usage can directly hook into some points in the thread lifecycle.
So this policy can be reduced to provide additional storage for functon objects.
2023-10-10 12:48:11 +02:00

698 lines
28 KiB
C++

/*
THREAD.hpp - thin convenience wrapper for starting threads
Copyright (C) Lumiera.org
2008, 2010 Hermann Vosseler <Ichthyostega@web.de>
Christian Thaeter <ct@pipapo.org>
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License as
published by the Free Software Foundation; either version 2 of
the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
*/
/** @file thread.hpp
** Convenience front-end to simplify and codify basic thread handling.
** While the implementation of threading and concurrency support is based on the
** C++ standard library, using in-project wrappers as front-end allows to codify some
** references and provide simplifications for the prevalent use case. Notably, threads
** which must be _joined_ are qualified as special case, while the standard case will
** just `detach()` at thread end. The main-level of each thread catches exceptions, which
** are typically ignored to keep the application running. Moreover, similar convenience
** wrappers are provided to implement [N-fold synchronisation](\ref lib::SyncBarrier)
** and to organise global [locking and waiting](\ref lib::Sync) in accordance with the
** _Object Monitor_ pattern. In concert, these allow to package concurrency facilities
** into self-contained RAII-style objects.
**
** # Usage
** Based on experience, there seem to be two fundamentally different usage patterns for
** thread-like entities: In most cases, they are just launched to participate in interactions
** elsewhere defined. However, sometimes dedicated sub-processing is established and supervised,
** finally to join results. And while the underlying implementation supports both usage styles,
** a decision was made to reflect this dichotomy by casting two largely distinct front-ends.
**
** The »just launch it« scheme is considered the default and embodied into lib::Thread.
** Immediately launched on construction using the given _Invokable Functor_ and binding arguments,
** such a thread is not meant to be managed further, beyond possibly detecting the live-ness state
** through `bool`-check. Exceptions propagating to top level within the new thread will be coughed
** and ignored, terminating and discarding the thread. Note however, since especially derived
** classes can be used to create a safe anchor and working space for the launched operations,
** it must be avoided to destroy the Thread object while still operational; as a matter of
** design, it should be assured the instance object outlives the enclosed chain of activity.
** As a convenience, the destructor blocks for a short timespan of 20ms; a thread running
** beyond that grace period will kill the whole application by `std::terminate`.
**
** For the exceptional case when a supervising thread need to await the termination of
** launched threads, a different front-end \ref lib::ThreadJoinable is provided, exposing
** the `join()` operation. This operation returns a [»Either« wrapper](\ref lib::Result),
** to transport the return value and possible exceptions from the thread function to the
** caller. Such threads *must* be joined however, and thus the destructor immediately
** terminates the application in case the thread is still running.
**
** ## Synchronisation
** The C++ standard provides that the end of the `std::thread` constructor _syncs-with_ the
** start of the new thread function, and likewise the end of the thread activity _syncs-with_
** the return from `join()`. According to the [syncs-with definition], this implies the
** _happens before_ relation and thus precludes a data race. In practice thus
** - the new thread function can access all data defined prior to ctor invocation
** - the caller of `join()` is guaranteed to see all effects of the terminated thread.
** Note however, that these guarantees do not extend into the initialisations performed
** in a derived class's constructor, which start only after leaving the ctor of Thread.
** So in theory there is a possible race between the extended setup in derived classes,
** and the use of these facilities from within the thread function. In practice the new
** thread, while already marked as live, still must be scheduled by the OS to commence,
** which does not completely remove the possibility of undefined behaviour however. So
** in cases where a race could be critical, additional means must be implemented; a
** possible solution would be to use a [N-fold synchronisation barrier](\ref lib::SyncBarrier)
** explicitly, or otherwise to ensure there is sufficient delay in the starting thread function.
**
** ##Caveat
** While these thread-wrapper building blocks aim at packaging the complexity away, there is
** the danger to miss a potential race, which is inherent with starting threads: the operation
** in the new thread contends with any initialisation done _after_ launching the thread. Even
** though encapsulating complex concurrent logic into an opaque component, as built on top
** of the thread-wrappers, is highly desirable from a code sanity angle — it is dangerously
** tempting to package self-contained data initialisation into a subclass, leading to the
** kind of _undefined behaviour,_ which „can never happen“ under normal circumstances.
** Even while the OS scheduler typically adds an latency of at least 100µs to the start
** of the new thread function, initialising anything (even subclass data members) after
** creating the thread-wrapper instance *is undefined behaviour*. As a remedy
** - it should be considered to put the thread-warpper into a _member_ (instead of inheriting)
** - an explicit lib::SyncBarrier can be added, to ensure the thread-function touches any
** extended facilities only after the initialisation is complete (as a downside, note that
** any hard synchronisation adds a possibility for deadlock).
**
** @remarks Historical design evolution:
** - Lumiera offered simplified convenience wrappers long before a similar design
** became part of the C++14 standard. These featured the distinction in join-able or
** detached threads, the ability to define the thread main-entry as functor, and a
** two-fold barrier between starter and new thread, which could also be used to define
** a second custom synchronisation point. A similar setup with wrappers was provided
** for locking, exposed in the form of the Object Monitor pattern.
** - The original Render Engine design called for an active thread-pool, which was part
** of a invoker service located in Vault layer; the thread-wrapper could only be used
** in conjunction with this pool, re-using detached and terminated threads. All features
** where implemented in plain-C on top of POSIX, using Mutexes and Condition Variables.
** - In 2023, when actually heading towards integration of the Render Engine, in-depth
** analysis showed that active dispatch into a thread pool would in fact complicate
** the scheduling of Render-Activities — leading to a design change towards _pull_
** of work tasks by competing _active workers._ This obsoleted the Thread-pool service
** and paved the way for switch-over to the threading support meanwhile part of the
** C++ standard library. Design and semantics were retained, while implemented
** using modern features, notably the new _Atomics_ synchronisation framework.
**
** [syncs-with definition] : https://en.cppreference.com/w/cpp/atomic/memory_order#Synchronizes_with
*/
#ifndef LIB_THREAD_H
#define LIB_THREAD_H
#include "lib/error.hpp"
#include "lib/nocopy.hpp"
#include "include/logging.h"
#include "lib/meta/trait.hpp"
#include "lib/format-util.hpp"
#include "lib/result.hpp"
#include <utility>
#include <thread>
#include <string>
#include <tuple>
namespace util {
std::string sanitise (std::string const&);
}
namespace lib {
using std::string;
using std::tuple;
namespace thread {// Thread-wrapper base implementation...
using lib::meta::typeSymbol;
using std::function;
using std::forward;
using std::move;
using std::decay_t;
using std::invoke_result_t;
using std::is_constructible;
using std::make_from_tuple;
using std::tuple_cat;
using std::is_same;
using std::__or_;
/**
* @internal wraps the C++ thread handle
* and provides some implementation details,
* which are then combined by the _policy template_
*/
struct ThreadWrapper
: util::MoveOnly
{
const string threadID_;
std::thread threadImpl_;
bool isLive() const { return threadImpl_.joinable(); }
/** @internal derived classes may create an inactive thread */
ThreadWrapper()
: threadID_{util::BOTTOM_INDICATOR}
, threadImpl_{}
{ }
ThreadWrapper (string const& threadID)
: threadID_{util::sanitise (threadID)}
, threadImpl_{} //Note: deliberately not starting the thread yet...
{ }
/** @internal actually launch the new thread.
* Deliberately the #threadImpl_ is created empty, to allow for complete
* initialisation of all the combined policy classes stacked on top
* @warning Start of the new thread _syncs-with_ the return from std::thread ctor.
* Thus -- in theory -- initialising members of derived classes after constructing
* a non-empty std::thread object would be *undefined behaviour*. In practice however,
* this is more of a „theoretical“ problem, since the OS scheduler has a considerable
* latency, so that the code within the new thread typically starts executing with an
* delay of _at least 100µs_
* @remark non the less, the thread-wrapper framework circumvents this possible undefined behaviour,
* by first creating the threadImpl_ empty, and only later move-assigning the std::thread.
* @param invocation a tuple holding some invokable and possible arguments, together forming the
* threadFunction to be executed in the new thread.
*/
template<class...INVO>
void
launchThread (tuple<INVO...>&& invocation)
{
ASSERT (not isLive(), "Thread already running");
threadImpl_ = make_from_tuple<std::thread> (invocation);
};
/** detect if the currently executing code runs within this thread */
bool invokedWithinThread() const;
void markThreadStart();
void markThreadEnd ();
void setThreadName ();
void waitGracePeriod() noexcept;
/* empty implementation for some policy methods */
void handle_begin_thread() { } ///< called immediately at start of thread
void handle_after_thread() { } ///< called immediately before end of thread
void handle_loose_thread() { } ///< called when destroying wrapper on still running thread
};
/**
* Thread Lifecycle Policy:
* - launch thread without further control
* - errors in thread function will only be logged
* - thread detaches before terminating
* - »grace period« for thread to terminate on shutdown
*/
template<class BAS, typename=void>
struct PolicyLaunchOnly
: BAS
{
using BAS::BAS;
template<class FUN, typename...ARGS>
void
perform_thread_function(FUN&& callable, ARGS&& ...args)
{
try {
// execute the actual operation in this new thread
std::invoke (forward<FUN> (callable), forward<ARGS> (args)...);
}
ERROR_LOG_AND_IGNORE (thread, "Thread function")
}
void
handle_after_thread()
{
if (BAS::isLive())
BAS::threadImpl_.detach();
}
void
handle_loose_thread()
{
BAS::waitGracePeriod();
}
};
/**
* Thread Lifecycle Policy Extension:
* additionally self-manage the thread-wrapper allocation.
* @warning the thread-wrapper must have been heap-allocated.
*/
template<class BAS, class TAR>
struct PolicySelfManaged
: PolicyLaunchOnly<BAS>
{
using BasePol = PolicyLaunchOnly<BAS>;
using BasePol::BasePol;
void
handle_after_thread()
{
TAR* selfAllocation = static_cast<TAR*>(
static_cast<void*> (this));
if (BAS::isLive())
BAS::threadImpl_.detach();
delete selfAllocation;
}
void
handle_loose_thread()
{
ALERT (thread, "Self-managed thread was deleted from outside. Abort.");
}
};
/**
* Thread Lifecycle Policy Extension:
* additionally self-manage the thread-wrapper allocation.
* @warning the thread-wrapper must have been heap-allocated.
*/
template<class BAS, class TAR>
struct PolicyLifecycleHook
: PolicyLaunchOnly<BAS>
{
using BasePol = PolicyLaunchOnly<BAS>;
using BasePol::BasePol;
using Hook = function<void(TAR&)>;
Hook hook_beginThread{};
Hook hook_afterThread{};
Hook hook_looseThread{};
TAR&
castInstance()
{
return static_cast<TAR*>(
static_cast<void*> (this));
}
void
handle_begin_thread()
{
if (hook_beginThread)
hook_beginThread (castInstance());
else
BasePol::handle_begin_thread();
}
void
handle_after_thread()
{
if (hook_afterThread)
hook_afterThread (castInstance());
else
BasePol::handle_after_thread();
}
void
handle_loose_thread()
{
if (hook_looseThread)
hook_looseThread (castInstance());
else
BasePol::handle_loose_thread();
}
};
/**
* Thread Lifecycle Policy:
* - thread with the ability to publish results
* - return value from the thread function will be stored
* - errors in thread function will likewise be captured and retained
* - thread *must* be joined after termination of the thread function
* @warning unjoined thread on dtor call will be a fatal error (std::terminate)
*/
template<class BAS, typename RES>
struct PolicyResultJoin
: BAS
{
using BAS::BAS;
/** Wrapper to capture a success/failure indicator and possibly a computation result */
lib::Result<RES> result_{error::Logic{"No result yet, thread still running; need to join() first."}};
template<class FUN, typename...ARGS>
void
perform_thread_function(FUN&& callable, ARGS&& ...args)
{
static_assert (__or_<is_same<RES,void>
,is_constructible<RES, invoke_result_t<FUN,ARGS...>>>());
// perform the given operation (failsafe) within this thread and capture result...
result_ = std::move (
lib::Result{forward<FUN>(callable)
,forward<ARGS>(args)...});
}
void
handle_after_thread()
{
/* do nothing -- thread must be joined manually */;
}
void
handle_loose_thread()
{
ALERT (thread, "Thread '%s' was not joined. Abort.", BAS::threadID_.c_str());
}
};
/**
* Policy-based configuration of thread lifecycle
*/
template<template<class,class> class POL, typename RES =void>
class ThreadLifecycle
: protected POL<ThreadWrapper, RES>
{
using Policy = POL<ThreadWrapper,RES>;
template<typename...ARGS>
void
invokeThreadFunction (ARGS&& ...args)
{
Policy::handle_begin_thread();
Policy::markThreadStart();
Policy::perform_thread_function (forward<ARGS> (args)...);
Policy::markThreadEnd();
Policy::handle_after_thread();
}
protected:
~ThreadLifecycle()
{
if (Policy::isLive())
Policy::handle_loose_thread();
}
/** derived classes may create a disabled thread */
ThreadLifecycle()
: Policy{}
{ }
public:
/**
* Build a λ actually to launch the given thread operation later,
* after the thread-wrapper-object is fully initialised.
* The member function #invokeThreadFunction will be run as top-level
* within the new thread, possibly handling errors, but delegating to
* the user-provided actual thread-operation
* @param args a invokable + further arguments
* @note the invokable and the arguments will be materialised/copied
* thereby decaying the given type; this is necessary, because
* these arguments must be copied into the new thread. This will
* fail to compile, if the given invokable can not be invoked
* with these copied (and decayed) arguments.
*/
template<class...INVO>
static auto
buildLauncher (INVO&& ...args)
{
tuple<decay_t<INVO>...> argCopy{forward<INVO> (args)...};
return [invocation = move(argCopy)] //Note: functor+args bound by-value into the λ
(ThreadLifecycle& wrapper)
{ //the thread-main function
wrapper.launchThread (tuple_cat (tuple{&ThreadLifecycle::invokeThreadFunction<decay_t<INVO>...>
, &wrapper} // passing the wrapper as instance-this
,move (invocation))); //...invokeThreadFunction() in turn delegates
}; // to the user-provided thread-operation
}
/**
* Configuration builder to define the operation running within the thread,
* and possibly configure further details, depending on the actual Policy used.
* @remark the primary ThreadLifecycle-ctor accepts such a Launch-instance
* and invokes a chain of λ-functions collected in the member #launch
*/
struct Launch
: util::MoveOnly
{
using Act = function<void(ThreadLifecycle&)>;
Act launch;
string id;
template<class FUN, typename...ARGS>
Launch (FUN&& threadFunction, ARGS&& ...args)
: launch{buildLauncher (forward<FUN>(threadFunction), forward<ARGS>(args)...)}
{ }
Launch&&
threadID (string const& threadID)
{
id = threadID;
return move(*this);
}
template<typename HOOK>
Launch&&
atStart (HOOK&& hook)
{
return addHook (&Policy::hook_beginThread, forward<HOOK> (hook));
}
template<typename HOOK>
Launch&&
atEnd (HOOK&& hook)
{
return addHook (&Policy::hook_afterThread, forward<HOOK> (hook));
}
template<typename HOOK>
Launch&&
onOrphan (HOOK&& hook)
{
return addHook (&Policy::hook_looseThread, forward<HOOK> (hook));
}
private:
template<typename HOOK, class FUN>
Launch&&
addHook (FUN Policy::*storedHook, HOOK&& hook)
{
return addLayer ([storedHook, hook = forward<HOOK>(hook)]
(ThreadLifecycle& wrapper)
{
wrapper.*storedHook = move (hook);
chain (wrapper);
});
}
Launch&&
addLayer (Act action)
{
launch = [action=move(action), chain=move(launch)]
(ThreadLifecycle& wrapper)
{
action(wrapper);
chain (wrapper);
};
return move(*this);
}
};
/**
* Primary constructor: Launch the new thread with flexible configuration.
* @param launcher a #Launch builder with a λ-chain to configure and
* finally trigger start of the thread
*/
ThreadLifecycle (Launch launcher)
: Policy{launcher.id}
{
launcher.launch (*this);
}
/**
* Create a new thread to execute the given operation.
* The new thread starts up synchronously, can't be cancelled and it can't be joined.
* @param threadID human readable descriptor to identify the thread for diagnostics
* @param threadFunction a functor holding the code to execute within the new thread.
* Any function-like entity or callable is acceptable; arguments can be given.
* @warning The operation functor and all arguments will be copied into the new thread.
* The return from this constructor _syncs-with_ the launch of the operation.
*/
template<class FUN, typename...ARGS>
ThreadLifecycle (string const& threadID, FUN&& threadFunction, ARGS&& ...args)
: ThreadLifecycle{
Launch{forward<FUN> (threadFunction), forward<ARGS> (args)...}
.threadID(threadID)}
{ }
/**
* Special variant to bind a subclass member function as thread operation.
* @warning potential race between thread function and subclass initialisation
*/
template<class SUB, typename...ARGS>
ThreadLifecycle (RES (SUB::*memFun) (ARGS...), ARGS ...args)
: ThreadLifecycle{
Launch{std::move (memFun)
,static_cast<SUB*> (this)
,forward<ARGS> (args)...
}
.threadID(util::joinDash (typeSymbol<SUB>(), args...))}
{ }
/**
* Is this thread »active« and thus tied to OS resources?
* @note this implies some statefulness, which may contradict the RAII pattern.
* - especially note the possibly for derived classes to create an _empty_ Thread.
* - moreover note that ThreadJoinable may have terminated, but still awaits `join()`.
*/
explicit
operator bool() const
{
return Policy::isLive();
}
/** @return does this call happen from within this thread? */
using Policy::invokedWithinThread;
};
}//(End)base implementation.
/************************************************************************//**
* A thin convenience wrapper to simplify thread-handling.
* The implementation is backed by the C++ standard library.
* Using this wrapper...
* - removes the need to join() threads, catches and ignores exceptions.
* - allows to bind to various kinds of functions including member functions
* The new thread starts immediately within the ctor; after returning, the new
* thread has already copied the arguments and indeed actively started to run.
* @warning The destructor waits for a short grace period of 20ms, but calls
* `std::terminate` afterwards, should the thread still be active then.
*/
class Thread
: public thread::ThreadLifecycle<thread::PolicyLaunchOnly>
{
public:
using ThreadLifecycle::ThreadLifecycle;
/** allow to detach explicitly — independent from thread-function's state
* @warning this function is borderline dangerous; it might be acceptable
* in a situation where the thread totally manages itself and the
* thread object is maintained in a unique_ptr. You must ensure that
* the thread function only uses storage within its own scope.
* @deprecated can't sleep well while this function is exposed;
* need a prime solution to address this relevant use case ////////////////////////////////////////OOO allow for a thread with explicit lifecycle
*/
void detach() { ThreadLifecycle::handle_after_thread(); }
};
/************************************************************************//**
* Variant of the [standard case](\ref Thread), requiring to wait and `join()`
* on the termination of this thread. Useful to collect results calculated
* by multiple threads. Note however that the system resources of the thread
* are kept around until the `join()` call, and thus also the `bool` conversion
* yields `true`, even while the actual operation has already terminated.
* @warning Thread must be joined prior to destructor invocation, otherwise
* the application is shut down immediately via `std::terminate`.
*/
template<typename RES =void>
class ThreadJoinable
: public thread::ThreadLifecycle<thread::PolicyResultJoin, RES>
{
using Impl = thread::ThreadLifecycle<thread::PolicyResultJoin, RES>;
public:
using Impl::Impl;
/**
* put the caller into a blocking wait until this thread has terminated
* @return intermediary token signalling either success or failure.
* The caller can find out by invoking `isValid()` or `maybeThrow()`
* on this result token. Moreover, if the _thread function_ yields a
* result value, this value is copied into the token and can be retrieved
* either by type conversion, or with `get<TY>()`, `value_or(default)`
* or even with an alternative producer `or_else(λ)`.
*/
lib::Result<RES>
join ()
{
if (not Impl::threadImpl_.joinable())
throw lumiera::error::Logic ("joining on an already terminated thread");
Impl::threadImpl_.join();
return Impl::result_;
}
};
/** deduction guide: find out about result value to capture from a generic callable. */
template<typename FUN, typename...ARGS>
ThreadJoinable (string const&, FUN&&, ARGS&&...) -> ThreadJoinable<std::invoke_result_t<FUN,ARGS...>>;
/************************************************************************//**
* Special configuration for a »fire-and-forget«-Thread.
* @internal this class is meant for subclassing. Start with #launchDetached()
* @tparam TAR the concrete type of the subclass to be started as autonomous,
* self-managed thread. Must be passed down since thread deletes itself.
*/
class ThreadAutonomous
: public thread::ThreadLifecycle<thread::PolicySelfManaged, ThreadAutonomous>
{
using Impl = thread::ThreadLifecycle<thread::PolicySelfManaged, ThreadAutonomous>;
public:
using Impl::Impl;
};
/**
* Launch an autonomous self-managing thread (and forget about it).
* The thread-wrapper is allocated to the heap and will delete itself on termination.
* @tparam TAR concrete type of the subclass to be started as autonomous detached thread.
* @param args a valid argument list to call the ctor of thread::ThreadLifecycle
*/
template<typename...INVO>
inline void
launchDetached (INVO&& ...args)
{
new ThreadAutonomous{forward<INVO> (args)...}; // Thread will pick up and manage *this
}
} // namespace lib
#endif /*LIB_THREAD_H*/