cc_tutorial/tutorials/tutorial6 at master · commschamp/cc_tutorial

Tutorial 6

Deeper understanding of message dispatch.

All the previous tutorials used a polymorphic dispatch functionality exposed by the common message interface. Let's remember how it looks and works.

The common interface definition uses comms::option::app::Handler option to specify type of of the handler.

class ClientSession
{
public:
    using Message =
        tutorialX::Message<
            ...,
            comms::option::Handler<ClientSession>
        >;

};

As the result the defined above Message interface exposes the following type and functions:

struct Message
{
public:
    using Handler = ClientSession;

    void dispatch(Handler& handler)
    {
        dispatchImpl(handler);
    }

protected:
    virtual void dispatchImpl(Handler& handler) = 0; // Implemented by the comms::MessageBase
}

See also comms::Message for details.

The comms::MessageBase, which is used as a base class for all the defined messages, implements the dispatchImpl() virtual member function in the following way:

template <...>
class comms::MessageBase
{
protected:
    virtual dispatchImpl(Handler& handler) override
    {
        handler.handle(static_cast<RealMessageType&>(*this));
    }
};

Where the RealMessageType is a type of the message passed to comms::MessageBase using comms::option::def::MsgType option.

Basically it's Double Dispatch Idiom.

The call to comms::processAllWithDispatch() function performs a compile-time analysis of the used interface class and if it exposes polymorphic dispatch functionality (with comms::option::app::Handler) then it is used to dispatch message to appropriate handling function. It results in O(1) run-time performance complexity. However, it generates dispatchImpl() for every used message class (which just redirects the call to the handling function) as well as extends v-table of every message to contain a pointer to the function. In some constrained environments, such as bare-metal, the code size penalty might be too high. Let's take a look at other available dispatch options when comms::option::app::Handler option is not used, i.e. the message object does NOT support polymorphic dispatch.

The ServerSession of this tutorial defines the common message interface as:

using Message =
    tutorial6::Message<
        comms::option::app::ReadIterator<const std::uint8_t*>, // Polymorphic read
        comms::option::app::WriteIterator<std::uint8_t*>, // Polymorphic write
        comms::option::app::LengthInfoInterface, // Polymorphic length calculation
        comms::option::app::IdInfoInterface // Polymorphic message ID retrieval
    >;

Note that comms::option::app::Handler is not used. The ServerSession::processInputImpl() member function still uses the comms::processAllWithDispatch() and the message object is dispatched to template handle() member function with the correctly recognized message type.

template <typename TMsg>
void handle(TMsg& msg)
{
    std::cout << "Received message \"" << msg.doName() << "\" with ID=" << (unsigned)msg.doGetId() << std::endl;
    sendMessage(msg);
}

Everything seems to work the same way out of the box without any additional effort on the integrating application side. However, the integrating developer needs to understand what's going on "under the hood" and how the dispatch is implemented in order to be able to fine-tune the code size and/or run-time performance if needed. The Advanced Guide to Message Dispathing tutorial page from the COMMS Library documentation contains a detailed description of various available dispatch functionalities and their implications.

In short, there are 3 ways to dispatch:

  • Polymorphic - If the message does NOT provide polymorphic dispatch, the independent polymorphic (with virtual functions) dispatch tables are created and used. Depending on how sparce the message IDs are the runtime complexity of this approach can be either O(1) or O(log(n)).
  • Static Binary Search - The generated dispatch code is equivalent of having multiple folded if comparison statements to find the right type to downcast the message object to. The runtime complexity is always O(log(n)).
  • Linear Switch - Implemented as a sequence of folded switch statements. It heavily depends on the compiler's optimizations. It has been noticed that only clang of quite advanced versions is smart enough to unfold it and create O(1) dispatch tables. All other major compilers implement it as a sequence of simple comparisons which result in O(n) runtime complexity. Hence using this type of dispatch is really not recommended.

The comms::processAllWithDispatch() function calls comms::dispatchMsg() (defined in comms/dispatch.h) after the message object is successfully created. The comms::dispatchMsg() function performs the following compile-time analysis to choose a proper dispatch option.

  • If message interface class defines polymorphic dispatch (comms::option::app::Handler option) then it is used.
  • If polymorphic dispatch via interface is not supported, then the message IDs are analysed to determine if it's worthwhile to create separate polymorphic dispatch tables. The condition for such a dispatch is that messages are not too sparse (no more than 10% holes in the sequence of message IDs or the max ID number of all the messages does not exceed 10). If this is the case (like with this tutorial) then static dispatch array is created where the access index is actually message numeric ID, resulting in O(1) run-time performance.
  • If message IDs are too sparse then Static Binary Search dispatch is used.

The dispatch strategy in case of the used schema (with sequential IDs and low number of messages) and lack of polymorphic dispatch via interface is chosen to still be polymorphic but with separate static dispatch tables. The ServerSession code uses compile time verification:

std::size_t ServerSession::processInputImpl(const std::uint8_t* buf, std::size_t bufLen)
{
    ...
    using AllMessages = tutorial6::input::AllMessages<Message>;
    static_assert(comms::dispatchMsgTypeIsPolymorphic<AllMessages>(), "Unexpected dispatch type");
    ...
}

SIDE NOTE: The definition of the frame receives std::tuple of all the supported input message types which defaults to tutorial6::input::AllMessages

template <
   typename TMessage,
   typename TAllMessages = tutorial6::input::AllMessages<TMessage>,
   typename TOpt = tutorial6::options::DefaultOptions
>
class Frame ...

That's why tutorial6::input::AllMessages was passed as a template parameter to comms::dispatchMsgTypeIsPolymorphic() in the code above. Please note that this is a boilerplate code that may become irrelevant and/or incorrect when definition of the frame type is changed to have a different set of input messages.

using Frame = tutorial6::frame::Frame<Message>;

The much better approach would be to reuse AllMessages inner type defined by the comms::frame::FrameLayerBase class which serves as a base type of any framing layer. The better code would be:

using AllMessages = Frame::AllMessages;
static_assert(comms::dispatchMsgTypeIsPolymorphic<AllMessages>(), "Unexpected dispatch type");

The COMMS Library strives to provide sensible default behavior suitable for most cases, but also provides a way to change / fine-tune it for specific cases. As we already discovered the default behavior for this tutorial is to create independent polymorphic dispatch tables and use them in case polymorphic dispatch via message interface is not supported. In this tutorial ClientSession forces Static Binary Search way of dispatching.

std::size_t ClientSession::processInputImpl(const std::uint8_t* buf, std::size_t bufLen)
{
    ...

    // Force static binary search dispatch
    using Dispatcher =
        comms::MsgDispatcher<comms::option::app::ForceDispatchStaticBinSearch>;

    // Process reported input, create relevant message objects and
    // dispatch all the created messages
    // to this object for handling (appropriate handle() member function will be called)
    auto result = comms::processAllWithDispatchViaDispatcher<Dispatcher>(buf, bufLen, m_frame, *this);
    ...
}

The forcing is performed by defining a dispatcher type by aliasing comms::MsgDispatcher with appropriate forcing option and using comms::processAllWithDispatchViaDispatcher() function instead of comms::processAllWithDispatch().


SIDE NOTE: The "Static Binary Search" dispatch is equivalent to having the following if statements folding.

if (msgId < IdOfMidMessageInAllMessages) {
    if (msgId < IdOfOtherRelevantMessageInAllMessages) {
        ...
    }
    else {
        ...
    }

else {
    if (msgId < IdOfSomeRelevantMessageInAllMessages) {
        ...
    }
    else {
        ...
    }
}

The run-time performance complexity of such code is O(log(n)). The benefit of such dispatch logic is that there are no virtual functions and v-tables involved. It might be much better approach for bare-metal systems with small ROM size.


So far we've seen the dispatch fully supported by the COMMS Library itself which does not have any preliminary information on the message types it needs to support. As the result it uses various C++ meta-programming techniques to analyze the provided std::tuple of supported message types at compile-time as well as generate proper code. However, the C++ language itself has certain limitations and the generated code may be not as efficient as it could be. The commsdsl2comms code generator on the other hand knows about all the available messages and may use other means (like a simple switch statement) to map message ID to appropriate type. That's what the generated code inside include/<namespace>/dispatch does.

The provided file(s) contain definition of the MsgDispatcher class which can be used when the switch statement dispatch is desired to be used. Note, that modern compilers generate quite efficient static dispatch tables for most of the switch statements. Such dispatch could have been implemented like this:

std::size_t ClientSession::processInputImpl(const std::uint8_t* buf, std::size_t bufLen)
{
    ...

    // Force switch statements based dispatch
    using Dispatcher =
        tutorial6::dispatch::MsgDispatcherDefaultOptions;

    // Process reported input, create relevant message objects and
    // dispatch all the created messages
    // to this object for handling (appropriate handle() member function will be called)
    auto result = comms::processAllWithDispatchViaDispatcher<Dispatcher>(buf, bufLen, m_frame, *this);
    ...
}

Summary

Read Previous Tutorial <-----------------------> Read Next Tutorial