Skip to content

SO 5.8 ByExample Minimal Ping Pong

Yauheni Akhotnikau edited this page Jul 6, 2023 · 3 revisions

Introduction

There is an explanation of some SObjectizer's basics throught a simple 'ping-pong' example. Two agents are sending signals each other: the first agent (pinger) sends ping signals and the second one (ponger) sends pong signals back. The signal sending loop is stopped after the required number of ping signals has been sent.

The description is organized as follows:

  • the next section contains full sample source code. It is the code from SObjectizer 5.8 distribution;
  • the following sections explain the code in details.

Sample Code

Here is the full sample code. Just look it through. It is not necessary to understand the code in details right now. It will be explained below.

Note. It is better to read SO-5.8 Basics before this text. But in this particular case, it is not necessary because the sample is pretty simple.

#include <iostream>

#include <so_5/all.hpp>

// Types of signals for the agents.
struct msg_ping final : public so_5::signal_t {};
struct msg_pong final : public so_5::signal_t {};

// Class of pinger agent.
class a_pinger_t final : public so_5::agent_t
	{
	public :
		a_pinger_t( context_t ctx, so_5::mbox_t mbox, int pings_to_send )
			:	so_5::agent_t{ ctx }
			,	m_mbox{ std::move(mbox) }
			,	m_pings_left{ pings_to_send }
			{}

		void so_define_agent() override
			{
				so_subscribe( m_mbox ).event( &a_pinger_t::evt_pong );
			}

		void so_evt_start() override
			{
				send_ping();
			}

	private :
		const so_5::mbox_t m_mbox;

		int m_pings_left;

		void evt_pong(mhood_t< msg_pong >)
			{
				if( m_pings_left > 0 )
					send_ping();
				else
					so_environment().stop();
			}

		void send_ping()
			{
				so_5::send< msg_ping >( m_mbox );
				--m_pings_left;
			}
	};

class a_ponger_t final : public so_5::agent_t
	{
	public :
		a_ponger_t( context_t ctx, const so_5::mbox_t & mbox )
			:	so_5::agent_t( std::move(ctx) )
		{
			so_subscribe( mbox ).event(
				[mbox]( mhood_t<msg_ping> ) {
					so_5::send< msg_pong >( mbox );
				} );
		}
	};

int main()
	{
		try
			{
				so_5::launch( []( so_5::environment_t & env ) {
						env.introduce_coop( [&env]( so_5::coop_t & coop ) {
							// Mbox for agent's interaction.
							auto mbox = env.create_mbox();

							// Pinger.
							coop.make_agent< a_pinger_t >( mbox, 100000 );

							// Ponger agent.
							coop.make_agent< a_ponger_t >( std::cref(mbox) );
						});
					});

				return 0;
			}
		catch( const std::exception & x )
			{
				std::cerr << "*** Exception caught: " << x.what() << std::endl;
			}

		return 2;
	}

Sample Code In Details

Includes

There is just one important #include directive here:

#include <so_5/all.hpp>

This header file -- so_5/all.hpp -- contains the whole SObjectizer's stuff. It is the main header file user works with.

Signals Definitions

There are two types of messages in SObjectizer: ordinary messages with some data inside and signals. Each message must be represented by dedicated C++ class. The signals must be classes/structs derived from so_5::signal_t. The ordinary messages can be classes/structs derived from so_5::message_t class, but it is not necessary.

We need only signals in this sample. So, the appropriate structures are defined:

struct msg_ping : public so_5::signal_t {};
struct msg_pong : public so_5::signal_t {};

Please note, that we cannot use typedefs for defining messages/signal types for SObjectizer because typedef is just an alias, not a new type definition. SObjectizer requires that every message type is a separate type with some unique typeid -- this typeid is used for message subscription and delivery.

Pinger Agent Definition

The pinger agent should store information about pings sent. So, this agent is stateful and it is implemented by the a_pinger_t class.

Inheritance from agent_t

Every agent must be descendant of so_5::agent_t class:

class a_pinger_t : public so_5::agent_t

Agent Constructor

The constructor of so_5::agent_t requires an argument of type so_5::agent_t::context_t. It contains the reference to SObjectizer Environment object inside of which the agent will work. SObjectizer Environment can be considered as a big container with all the stuff (e.g. agents, mboxes, timers, dispatchers with threads, layers and so on) inside plus all of the mechanics which brings all these to life (it is called SObjectizer RunTime).

An application can have several SObjectizer Environments running at the same time. That's why every agent must know the Environment it has been created for.

An instance on SObjectizer Environment is created by so_5::launch function and we will discuss it later. Currently, the point is that the reference to SObjectizer Environment is passed through constructor of a_pinger_t to the constructor of agent_t inside the instance of context_t type:

a_pinger_t( context_t ctx, so_5::mbox_t mbox, int pings_to_send )
	:	so_5::agent_t{ ctx }
	,	m_mbox{ std::move(mbox) }
	,	m_pings_left{ pings_to_send }
	{}

The second important a_pinger_t's constructor argument is a smart reference to the mbox. The type so_5::mbox_t is a kind of smart-pointer to the mbox-object. It is the reference to the mail box -- a special entity, which is used for message subscription and delivery. It is necessary to mention that the agent should know which mbox should it use to send and receive messages. That mbox is created in another place. The reference to the mbox is passed to a_pinger_t constructor and is stored as a_pinter_t's attribute.

so_define_agent Method

The so_5::agent_t has several virtual methods which could be reimplemented by derived classes. Probably, the most important of them is so_define_agent().

This method is called by SObjectizer during the agent registration in SObjectizer RunTime. It is intended for agent tuning before the agent starts its work inside SObjectizer. The main tuning action is the subscription for messages.

The subscription is done by so_subscribe(mbox).event() method chain:

void so_define_agent() override
	{
		so_subscribe( m_mbox ).event< msg_pong >( &a_pinger_t::evt_pong );
	}

This sample uses signals as messages so the special form of event() method is used: type of the signal must be specified explicitly as the first template parameter for event: event<msg_pong>(...). This construct tells SObjectizer that the type of message is msg_pong and msg_pong is the signal.

The single argument to event() is a pointer to the message handler, which is called event handler in SObjectizer terms.

As a result of this subscription, SObjectizer knows that when the msg_pong signal is delivered to that mbox the evt_pong method of the agent must be invoked.

so_evt_start Method

Sometimes, the agent needs to do something at the very beginning of its work in SObjectizer. Like in this sample, where the pinger agent needs to send the starting ping signal. The virtual method so_evt_start() is called by SObjectizer as the first event handler for any agent. We use that in the sample to send the first ping:

void so_evt_start() override
	{
		send_ping();
	}

There are two subtle but important differences between so_define_agent() and so_evt_start():

  1. so_define_agent() is always called on the context where agent's cooperation is being registered. But so_evt_start() is always called on the agent's working context to which agent is bound during registration. Sometimes it is very important factor.
  2. so_evt_start() is called only when the whole cooperation has been registered successfully. It means that so_define_agent() cannot be used as an indicator of successful agent start. If cooperation contains several agents then there could be a failure in registration process and for some agents so_define_agent() has not been called at all.

Because of that so_define_agent() and so_evt_start() are recommended to use in the following way:

  • all preparation actions, like event subscriptions, must be done in so_define_agent();
  • all actual actions on agent's own execution context must be done in so_evt_start(). For example, switching to some starting state or sending some intitial message.

Note. There is an opposite for so_evt_start() -- agent_t::so_evt_finish() method. It is called by SObjectizer at the very end of agent's work in SObjectizer. The usage of so_evt_finish() is not shown in that sample.

evt_pong Event Handler

The event handler in SObjectizer's terminology is the method of function object (lambda function) which handles message/signal. There are some changes in names: when the message/signal is being sent to the mbox -- it is just a message or a signal. But when the information about message/signal is stored in agent's event queue, that information is called event. Dispatcher is responsible for dequeuing event from the queue and for calling the event handler.

Event handlers for agent can have different formats. In this sample the most simple format is used: the method has no arguments and returns nothing. This is a common case for signal handling.

The only interesting thing in the evt_pong code is calling stop() method for SObjectizer Environment (method so_environment() is inherited from so_5::agent_t, it returns the reference to the corresponding Environment object). This method informs SObjectizer RunTime that work of RunTime should be gracefully finished. After calling stop(), SObjectizer will shutdown all dispatchers/timers/layers and deregister all agents.

Ponger Agent Definition

The ponger agent is a very simple one, it even doesn't need a separate so_define_agent method: the single subscription is made in the constructor:

a_ponger_t( context_t ctx, const so_5::mbox_t & mbox )
    :	so_5::agent_t( std::move(ctx) )
{
    so_subscribe( mbox ).event(
        [mbox]( mhood_t<msg_ping> ) {
            so_5::send< msg_pong >( mbox );
        } );
}

Yes, subscriptions can be created in the constructor of the agent, but it's better to do all subscriptions in the so_define_agent method (sometimes the so_evt_start is used for this purpose too). When all subscriptions are made in the so_define_agent method then it's simple to understand what and how an agent works (especially if this agent is written not by you). But the ponger agent is very simple, so we can discard the unwritten rule "make all subscriptions in so_define_agent".

This subscription also shows that a lambda function can be used as an event handler. This lambda does just one task: replies by msg_pong signal to mbox message mbox. This is how the pinger will get replies from the ponger.

so_5::launch Invocation

The most interesting fragment is the so_5::launch() invocation in main() function.

The launch() family is the group of functions for launching SObjectizer RunTime. Every launch() does almost the same things but receives different types of parameters. This sample uses the simplest launch() form.

The launch() does the following things:

  • creates and initializes SObjectizer Environment objects;
  • runs initialization of functional objects (function, method or lambda function) and passes the reference to Environment as an argument to it;
  • runs SObjectizer RunTime and uses Environment object;
  • blocks caller's thread and waits for RunTime shutdown.

Thus, the only way to pass something inside SObjectizer Environment and RunTime is to provide the appropriate functional object with some initialization actions. In this sample the role of such functional objects is implemented with lambda function.

so_5::launch(
	[]( so_5::environment_t & env )
	{
		/* ...Initialization actions are here... */
	} );

Initialization Actions

Here is all initialization actions altogether:

env.introduce_coop( [&env]( so_5::coop_t & coop ) {
    // Mbox for agent's interaction.
    auto mbox = env.create_mbox();

    // Pinger.
    coop.make_agent< a_pinger_t >( mbox, 100000 );

    // Ponger agent.
    coop.make_agent< a_ponger_t >( std::cref(mbox) );
});

They are rather simple.

A cooperation for agents is created. All agents are added and removed from SObjectizer inside cooperations. The cooperation can be considered as a container for agents. User must prepare cooperation by filling it with necessary agents and then the cooperation is passed to SObjectizer Environment for registration. Registration of cooperation is similar to transaction: all agents from the coop are registered or no one of them.

There are several ways for preparing and registering a coop. The old way requires creation of object of coop_t type, filling it and passing it to environment_t::register_coop method. Something like that:

// The old way of coop creation.
auto coop = env.make_coop();
coop->make_agent< a_pinger_t >( mbox, 10000 );
...
env.register_coop( std::move(coop) );

But this way requires some attention and can lead to error (e.g. the call of register_coop is often forgotten). Because of that the more simple way which uses helper method environment_t::introduce_coop() is used in this example. Helper introduce_coop creates coop object and calls register_coop by itself. User should only provide a lambda-function which fills a new coop object up.

The first action to be done during coop preparation is the creation of mbox for agent's interaction. SObjectizer has two kinds of mboxes: anonymous and named. The anonymous mbox is created here.

Later, the pinger agent is created and added to the cooperation. Every agent must be created as a dynamically allocated object. The responsibility of destroying agents is on the cooperation. Helper method coop_t::make_agent dynamically allocates the agent object and passes parameters to its constructors.

If we look at make_agent invocation we will see that only two args are passed:

coop.make_agent< a_pinger_t >( mbox, 100000 );

But the constructor of a_pinger_t requires three arguments! It is a small magic of coop_t::make_agent: it creates the first argument of type agent_t::context_t by itself and passes it to a_pinger_t.

Subsequently, the ponger agent is created. The interaction mbox is passed as constructor's parameter.

If introduce_coop is processed successfully then two agents are registered and started on the default dispatcher. The so_evt_start() is called for the pinger agent. The first msg_ping is sent. It will be received by the ponger agent and msg_pong signal is sent back. It will be received by pinger agent. And so on until there will be no more ping left. Then the pinger agent calls stop() method for the Environment and RunTime will be shut down and launch will be returned to the caller.

Behind the Curtains

Previously it was mentioned that every execution context for agents is defined by a dispatcher. But there was no any mention of any dispatcher in the code. It is because SObjectizer Environment already has the default dispatcher with one working thread. All agents in the sample are bound to that dispatcher automatically because we didn't bind any of them to some another dispatcher.

But if we want to launch each agent on different working threads it can be done by using active_obj dispatcher:

env.introduce_coop(
    // Dispatcher to be used for bounding agents from coop.
    so_5::disp::active_obj::make_dispatcher( env ).binder(),
    [&env]( so_5::coop_t & coop ) {
        // Mbox for agent's interaction.
        auto mbox = env.create_mbox();

        // Pinger.
        coop.make_agent< a_pinger_t >( mbox, 100000 );

        // Ponger agent.
        coop.make_agent< a_ponger_t >( std::cref(mbox) );
    });
Clone this wiki locally