Варианты распределенных сценариев

Ахтунг! В первой части статьи примеры кода на С++20 и вынос моска; в целях безопасности туда лучше не смотреть — общие рассуждения на этот код не опираются.

Статья рассматривает сценарии (многоэтапные последовательности действий) в асинхронных (распределенных) системах — какие реализации бывают, какие плюсы и минусы у разных подходов. Стиль написания логики сценария будем характеризовать по двум параметрам: активный или реактивный и явный или неявный. Функционального программирования не будет — я его не знаю.

Трактатъ может быть интересен: новичкам — как обзорник по подходам к асинхронным сценариям (код можно пропустить); синьорам, работающим с такой гадостью — подумать, почему на проекте используют именно этот подход, а не какой-то другой (тоже код нафиг); плюсовикам и эмбедерам — глянуть на возможности нового стандарта, которые скоро выкатятся в мейнстрим.

Содержание:

  1. Активные и реактивные сценарии (с примерами на С++)
  2. Явные и неявные сценарии (саги в картинках)
  3. Сравнение вышеизложенного

Терминология (установившейся нет, поэтому здесь договоримся о понятиях):

Явный (explicit) сценарий — когда есть единый объект, функция или другая сущность, содержащая всю логику выполнения сценария.

Неявный (implicit) сценарий — когда логика выполнения конкретного сценария «размазана» по разным сущностям и перемешана с логикой других сценариев.

Активный (императивный) сценарий — когда отсылка запросов и получение их результатов записаны в коде последовательно и обычно совмещены.

Пассивный (реактивный) сценарий — когда сценарий представлен как цепочка обработчиков событий, каждый из которых может сохранить данные или запустить следующее действие.

Например:

Если функция добавляет 1 к значениям х, у и еще какой-то переменной — это явный (вся логика в одном месте) активный (функция управляет событиями) сценарий.

Если у подписан на изменение значения х, мы добавляем 1 к х, у узнает об этом и добавляет к своему значению 1 — это неявный (логика размазана по методам х и у) пассивный (логика в колбеках) сценарий.

Тестовый сценарий: Будем красить дом, гараж и собачью будку.

Итак, погнали.

***** Активные и реактивные сценарии: обработчики событий, промисы и корутины на С++ *****

Есть асинхронная система (акторы или микросервисы) с несколькими объектами:

class Handler {
public:
	virtual void Post(Message* const msg) = 0;
};

Handler*	g_house;
Handler*	g_garage;
Handler*	g_doghouse;

которым мы можем отсылать сообщения:

class PaintHouseReqMsg : public HouseMsg {
public:
	PaintHouseReqMsg(const Cookie id, Handler& source);
};

class PaintGarageReqMsg : public GarageMsg {
public:
	PaintGarageReqMsg(const Cookie id, Handler& source);
};

class PaintDoghouseReqMsg : public DoghouseMsg {
public:
	PaintDoghouseReqMsg(const Cookie id, Handler& source);
};

Запрос на покраску всех компонентов и ответы от компонентов падают нам в очередь сообщений и диспатчатся в соответствующие обработчики:

class MainHandler : public Handler {
public:
	virtual void OnPaintAllReq(const Cookie id, Handler& source) = 0;
	virtual void OnPaintHouseCfm(const Cookie id, const bool result) = 0;
	virtual void OnPaintGarageCfm(const Cookie id, const bool result) = 0;
	virtual void OnPaintDoghouseCfm(const Cookie id, const bool result) = 0;
};

Handler*	g_main;

Когда нас просят покрасить все, нужно разослать запросы компонентам и ответить, всех ли смогли успешно покрасить:

class PaintAllCfmMsg : public ClientMsg {
public:
	PaintAllCfmMsg(const Cookie id, const bool result);
};

Обработчики событий (aka проактор или реактор, колбеки) — все гениальное тупо (почти неявный пассивный сценарий)

enum	PaintResult {
	DOING,
	SUCCESS,
	FAILURE,

	LAST
};

struct PaintData {
	PaintData(Handler&	cl, const Cookie co)
		: client(cl), id(co), house(DOING), garage(DOING), doghouse(DOING) {}

	bool	operator==(const Cookie co) const {return id == co;}

	Handler&		client;
	const Cookie	id;
	PaintResult		house;
	PaintResult		garage;
	PaintResult		doghouse;
};

class MainOnReactor :  public MainHandler {
public:
	MainOnReactor() {}

	virtual void OnPaintAllReq(const Cookie id, Handler& source) {
		assert(!requests_.In(id));
		requests_.Add(source, id);
		printf("Request paint all %d\n", id);

		// multicast the subrequests
		g_house->Post(new PaintHouseReqMsg(id, *this));
		g_garage->Post(new PaintGarageReqMsg(id, *this));
		g_doghouse->Post(new PaintDoghouseReqMsg(id, *this));
	}
	virtual void OnPaintHouseCfm(const Cookie id, const bool result) {
		PaintData* const	state = requests_.TryFind(id);
		assert(state);
		assert(state->house == DOING);

		printf("Paint house %d result %d\n", id, result);
		state->house = result ? SUCCESS : FAILURE;
		ProcessState(state);
	}
	virtual void OnPaintGarageCfm(const Cookie id, const bool result) {
		PaintData* const	state = requests_.TryFind(id);
		assert(state);
		assert(state->garage == DOING);

		printf("Paint garage %d result %d\n", id, result);
		state->garage = result ? SUCCESS : FAILURE;
		ProcessState(state);
	}
	virtual void OnPaintDoghouseCfm(const Cookie id, const bool result) {
		PaintData* const	state = requests_.TryFind(id);
		assert(state);
		assert(state->doghouse == DOING);

		printf("Paint doghouse %d result %d\n", id, result);
		state->doghouse = result ? SUCCESS : FAILURE;
		ProcessState(state);
	}

private:
	void	ProcessState(const PaintData* const	state) {
		bool	failed = false;

		// gather the results
		switch(state->house) {
		case DOING:
			return;
		case FAILURE:
			failed = true;
		}

		switch(state->garage) {
		case DOING:
			return;
		case FAILURE:
			failed = true;
		}

		switch(state->doghouse) {
		case DOING:
			return;
		case FAILURE:
			failed = true;
		}

		state->client.Post(new PaintAllCfmMsg(state->id, !failed));
		requests_.Delete(state);
	}

	List<PaintData>	requests_;
};

При получении запроса в OnPaintAllReq() добавляем в список сценариев в обработке структуру, в которую собираем ответы от субподрядчиков. Когда все ответы получены — отсылаем суммарный результат заказчику и забываем о выполненной работе.

Штука самая быстрая из того, что вообще можно написать. При этом код плохо читаемый, так как куски сценария разбросаны по всем методам класса. Если разных сценариев будет несколько — то код в обработчиках событий усложнится (надо будет разбираться, какой именно сценарий захотел красить будку — может, ее перекрашивают для того, чтобы потом снести забор). Для поддержки нескольких видов сценариев логику реакции на ответы переносим в объект состояния запроса —

Оркестратор — у нас ООП (явный пассивный сценарий)

Вот интерфейс логики сценария:

class Orchestrator {
public:
	Orchestrator(const Cookie id, Handler& client) : id_(id), client_(client) {}
	virtual ~Orchestrator() {}

	bool operator==(const Cookie id) const {return id_ == id;}

	virtual void Run() = 0;
	virtual bool OnHouseResult(const bool result) = 0;
	virtual bool OnGarageResult(const bool result) = 0;
	virtual bool OnDoghouseResult(const bool result) = 0;

protected:
	const Cookie	id_;
	Handler& 		client_;
};

Конкретный сценарий покраски всего наследуем от него, и прописываем логику:

class PaintAllOrchestrator : public Orchestrator {
public:
	PaintAllOrchestrator(const Cookie id, Handler& source)
		: Orchestrator(id, source),
		  house_res_(false),
		  garage_res_(false),
		  doghouse_res_(false) {}

	virtual void Run() {
		g_house->Post(new PaintHouseReqMsg(id_, *g_main));
		g_garage->Post(new PaintGarageReqMsg(id_, *g_main));
		g_doghouse->Post(new PaintDoghouseReqMsg(id_, *g_main));
	}
	virtual bool OnHouseResult(const bool result) {
		if(!result) {
			client_.Post(new PaintAllCfmMsg(id_, false));
			return true;
		} else {
			house_res_ = true;
			return CheckDone();
		}
	}
	virtual bool OnGarageResult(const bool result) {
		if(!result) {
			client_.Post(new PaintAllCfmMsg(id_, false));
			return true;
		} else {
			garage_res_ = true;
			return CheckDone();
		}
	}
	virtual bool OnDoghouseResult(const bool result) {
		if(!result) {
			client_.Post(new PaintAllCfmMsg(id_, false));
			return true;
		} else {
			doghouse_res_ = true;
			return CheckDone();
		}
	}

private:
	bool	CheckDone() {
		if(house_res_ && garage_res_ && doghouse_res_) {
			client_.Post(new PaintAllCfmMsg(id_, true));
			return true;
		} else
			return false;
	}

	bool	house_res_;
	bool	garage_res_;
	bool	doghouse_res_;
};

Когда приходят запросы — создаем конкретный сценарий, соответствующий типу запроса, когда приходит ответ от периферии — находим его сценарий по id и вызываем его колбек, не зная типа сценария (полиморфизм сценариев):

class MainOnOrchestrator :  public MainHandler {
public:
	MainOnOrchestrator() {}

	virtual void OnPaintAllReq(const Cookie id, Handler& source) {
		assert(!requests_.In(id));
		printf("Request paint all %d\n", id);

		Orchestrator* const	req = requests_.Add(new PaintAllOrchestrator(id, source));
		req->Run();
	}
	virtual void OnPaintHouseCfm(const Cookie id, const bool result) {
		printf("Paint house %d result %d\n", id, result);
		Orchestrator* const	req = requests_.TryFind(id);
		if(req) {
			if(req->OnHouseResult(result))
				requests_.Delete(req);
		} else
			printf("Paint house %d is obsolete\n", id);
	}
	virtual void OnPaintGarageCfm(const Cookie id, const bool result) {
		printf("Paint garage %d result %d\n", id, result);
		Orchestrator* const	req = requests_.TryFind(id);
		if(req) {
			if(req->OnGarageResult(result))
				requests_.Delete(req);
		} else
			printf("Paint garage %d is obsolete\n", id);
	}
	virtual void OnPaintDoghouseCfm(const Cookie id, const bool result) {
		printf("Paint doghouse %d result %d\n", id, result);
		Orchestrator* const	req = requests_.TryFind(id);
		if(req) {
			if(req->OnDoghouseResult(result))
				requests_.Delete(req);
		} else
			printf("Paint doghouse %d is obsolete\n", id);
	}

private:
	PtrList<Orchestrator>	requests_;
};

Если обработчик события в сценарии возвращает true — сценарий завершился, его можно удалить из списка.

Почти такой же быстрый подход, как колбеки. Плюс — прячет всю логику сценария в выделенный класс, что позволит не заморачиваясь поддерживать десятки различных сценариев. Минус — логика все еще разбросана по нескольким методам оркестратора. Чтобы попытаться собрать ее вместе, нам нужно выделить поток выполнения под обработку каждого запроса — используем

Промисы (с фьючерами) — у нас настоящая многопоточность (явный активный сценарий)

Промисы и фьючеры — механизм синхронизации, типа семафоры на стероидах. В промис можно пихать, а из фьючерса — вынимать значение. При этом если значения там еще нет — то вынимающий поток тормознет до тех пор, пока значение не появится. Итак, заводим потоки, по одному на запрос:

struct Promises {
	std::promise<Handler&>	run;
	std::promise<bool>		house;
	std::promise<bool>		garage;
	std::promise<bool>		doghouse;
};

class FutureProcessor {
public:
	FutureProcessor()
		: idle_(true),
		  id_(-1),
		  promises_(new Promises),
		  thread_(&FutureProcessor::Run, this) {}

	bool operator==(const Cookie id) const {return id_ == id;}

	const bool	idle() const {return idle_;}
	void OnRequest(const Cookie id, Handler& source) {
		idle_ = false;
		id_ = id;
		promises_->run.set_value(source);
	}

	void OnHouseResult(const bool result) {promises_->house.set_value(result);}
	void OnGarageResult(const bool result) {promises_->garage.set_value(result);}
	void OnDoghouseResult(const bool result) {promises_->doghouse.set_value(result);}

private:
	void Run() {
		while(true) {
			// wait for the request
			Handler& client = promises_->run.get_future().get();

			std::future<bool>	house_res = SendToHouse();
			std::future<bool>	garage_res = SendToGarage();
			std::future<bool>	doghouse_res = SendToDoghouse();

			// gather the results
			const bool	success = house_res.get() && garage_res.get() && doghouse_res.get();
			client.Post(new PaintAllCfmMsg(id_, success));

			delete promises_;
			promises_ = new Promises;
			id_ = -1;
			idle_ = true;
		}
	}
	std::future<bool>	SendToHouse() {
		g_house->Post(new PaintHouseReqMsg(id_, *g_main));
		return promises_->house.get_future();
	}
	std::future<bool>	SendToGarage() {
		g_garage->Post(new PaintGarageReqMsg(id_, *g_main));
		return promises_->garage.get_future();
	}
	std::future<bool>	SendToDoghouse() {
		g_doghouse->Post(new PaintDoghouseReqMsg(id_, *g_main));
		return promises_->doghouse.get_future();
	}

	bool		idle_;
	Cookie		id_;
	Promises*	promises_;
	std::thread	thread_;
};

class MainOnPromises :  public MainHandler {
public:
	MainOnPromises() {}

	virtual void OnPaintAllReq(const Cookie id, Handler& source) {
		printf("Request paint all %d\n", id);

		for(unsigned i = 0; i < numslaves_; ++i)
			if(slaves_[i].idle()) {
				slaves_[i].OnRequest(id, source);
				return;
			}

		printf("Out of resources for %d\n", id);
		source.Post(new PaintAllCfmMsg(id, false));
	}
	virtual void OnPaintHouseCfm(const Cookie id, const bool result) {
		printf("Paint house %d result %d\n", id, result);

		for(unsigned i = 0; i < numslaves_; ++i)
			if(slaves_[i] == id) {
				slaves_[i].OnHouseResult(result);
				return;
			}
		printf("Paint house %d is obsolete\n", id);
	}
	virtual void OnPaintGarageCfm(const Cookie id, const bool result) {
		printf("Paint garage %d result %d\n", id, result);

		for(unsigned i = 0; i < numslaves_; ++i)
			if(slaves_[i] == id) {
				slaves_[i].OnGarageResult(result);
				return;
			}
		printf("Paint garage %d is obsolete\n", id);
	}
	virtual void OnPaintDoghouseCfm(const Cookie id, const bool result) {
		printf("Paint doghouse %d result %d\n", id, result);

		for(unsigned i = 0; i < numslaves_; ++i)
			if(slaves_[i] == id) {
				slaves_[i].OnDoghouseResult(result);
				return;
			}
		printf("Paint doghouse %d is obsolete\n", id);
	}

private:
	static const unsigned	numslaves_ = 10;
	FutureProcessor			slaves_[numslaves_];
};

Это выглядит примерно как предыдущий пример (оркестратор на колбеках), но запросы обрабатываются потоками из пула.

Здесь вся логика сценария уместилась в небольшой метод Run(), вернее — в 6 строк внутри него. Очень хорошо по сравнению с предыдущими вариантами. Но цена высокая: кроме кучи вспомогательного кода вокруг, мы пользуемся очень дорогими потоками, и даже не можем себе позволить создавать потоки во время работы — а берем их из ограниченного пула. И количество запросов, которые у нас могут одновременно быть в работе, ограничено размером пула потоков. Попробуем решить эту проблему, используя

Корутины — назад к однопоточности (явный активный сценарий)

Корутины переключают поток выполнения на другой стек, там что-то происходит, потом поток переключается назад, а на дополнительном стеке осталось сохраненным то состояние, в котором функция находилась при переключении. Если переключиться обратно — функция запускается не с начала, а с середины кода с сохраненными переменными и прочими плюшками. Таким образом, один поток может делать вид, что его несколько. Что нам и надо — чтобы не плодить настоящие большие потоки, как у промисов.

Итак, много С++20 магии (объяснение тут blog.panicsoftware.com/co_awaiting-coroutines )

struct CoMagicPromise;
typedef std::coroutine_handle<CoMagicPromise>	CoHandle;

struct CoMagicPromise {
	auto get_return_object() {return CoHandle::from_promise(*this);}
	auto initial_suspend() {return std::suspend_never();}
	auto final_suspend() {return std::suspend_always();}
	constexpr void return_void() {}
	void unhandled_exception() {std::terminate();}
};

struct CoMagicResumable {
	typedef CoMagicPromise promise_type;

	CoMagicResumable() : valid_(false) {}
	CoMagicResumable(CoHandle handle) : valid_(true), handle_(handle) {}
	CoMagicResumable(CoMagicResumable&) = delete;
	CoMagicResumable(CoMagicResumable&& other) = delete;
	~CoMagicResumable() {
		if(valid_)
			handle_.destroy();
	}
	void operator=(CoMagicResumable&& other)
	{
		if(&other == this)
			return;
		if(valid_)
			handle_.destroy();
		valid_ = other.valid_;
		handle_ = other.handle_;
		other.valid_ = false;
	}

	bool	done() const {return handle_.done();}

private:
	bool		valid_;
	CoHandle 	handle_;
};

template<class T>
struct Awaiter {
	Awaiter(T& master) : master_(master) {}

	bool	await_ready() const {return master_.Ready();}
	void	await_suspend(CoHandle handle) {master_.Suspend(handle);}
	bool	await_resume() {return master_.Resume();}

private:
	T& master_;
};

class Awaitable {
public:
	Awaitable() {}
	Awaitable(Awaitable&) = delete;
	Awaitable(Awaitable&&) = delete;

	Awaiter<Awaitable>	operator co_await() {return *this;}

	bool	Ready() const {return result_.has_value();}
	void	Suspend(CoHandle handle) {
		assert(!waiter_);
		waiter_ = handle;
	}
	bool	Resume() {
		assert(waiter_);
		waiter_.reset();
		return *move(result_);
	}

	bool Push(const bool result) {
		assert(!result_);
		result_ = result;
		if(waiter_) {
			CoHandle waiter = *move(waiter_);
			assert(!waiter.done());
			waiter.resume();
			return waiter.done();
		}
		printf("Double cfm\n");
		return false;
	}

private:
	std::optional<CoHandle>	waiter_;
	std::optional<bool>		result_;
};

Магия позволяет использовать Awaitable как пару промис/фьючер:

// One by one operations
class LinearCoWorker {
public:
	LinearCoWorker(const Cookie id) : id_(id) {}
	LinearCoWorker(LinearCoWorker&) = delete;
	LinearCoWorker(LinearCoWorker&&) = delete;

	bool operator==(const Cookie id) const {return id_ == id;}

	void OnRequest(Handler& source) {runner_ = std::move(Run(source));}
	bool OnHouseResult(const bool result) {return house_.Push(result);}
	bool OnGarageResult(const bool result) {return garage_.Push(result);}
	bool OnDoghouseResult(const bool result) {return doghouse_.Push(result);}

private:
	CoMagicResumable Run(Handler& client) {
		bool	success = false;

		if(co_await SendToHouse())
			if(co_await SendToGarage())
				if(co_await SendToDoghouse())
					success = true;

		client.Post(new PaintAllCfmMsg(id_, success));
	}
	Awaitable&	SendToHouse() {
		g_house->Post(new PaintHouseReqMsg(id_, *g_main));
		return house_;
	}
	Awaitable&	SendToGarage() {
		g_garage->Post(new PaintGarageReqMsg(id_, *g_main));
		return garage_;
	}
	Awaitable&	SendToDoghouse() {
		g_doghouse->Post(new PaintDoghouseReqMsg(id_, *g_main));
		return doghouse_;
	}

	const Cookie		id_;
	CoMagicResumable	runner_;
	Awaitable			house_;
	Awaitable			garage_;
	Awaitable			doghouse_;
};

class MainOnCoroutines :  public MainHandler {
	typedef	LinearCoWorker	Worker;
	//typedef	GatherCoWorker	Worker;

public:
	MainOnCoroutines() {}

	virtual void OnPaintAllReq(const Cookie id, Handler& source) {
		printf("Request paint all %d\n", id);

		assert(!coworkers_.In(id));
		Worker* const	worker = coworkers_.Add(id);
		worker->OnRequest(source);
	}
	virtual void OnPaintHouseCfm(const Cookie id, const bool result) {
		printf("Paint house %d result %d\n", id, result);

		Worker* const	worker = coworkers_.TryFind(id);
		if(worker) {
			if(worker->OnHouseResult(result))
				coworkers_.Delete(worker);
		} else
			printf("Paint house %d is obsolete\n", id);
	}
	virtual void OnPaintGarageCfm(const Cookie id, const bool result) {
		printf("Paint garage %d result %d\n", id, result);

		Worker* const	worker = coworkers_.TryFind(id);
		if(worker) {
			if(worker->OnGarageResult(result))
				coworkers_.Delete(worker);
		} else
			printf("Paint garage %d is obsolete\n", id);
	}
	virtual void OnPaintDoghouseCfm(const Cookie id, const bool result) {
		printf("Paint doghouse %d result %d\n", id, result);

		Worker* const	worker = coworkers_.TryFind(id);
		if(worker) {
			if(worker->OnDoghouseResult(result))
				coworkers_.Delete(worker);
		} else
			printf("Paint doghouse %d is obsolete\n", id);
	}

private:
	List<Worker>	coworkers_;
};

Код весьма похож на код с фьючерсами (даже тоже 6 строк логики), со следующими отличиями:

  1. Мы позволили себе создавать корутину при получении запроса, так как под капотом там обычное выделение памяти, а не вызов в ядро. То есть, мы практически не ограничены в количестве одновременно обрабатываемых сценариев.
  2. Запросы к периферии выполняются по-очереди, а не одновременно. Фьючер можно сначала создать, а позже — вынуть результат. С Awaitable такое ленивое доставание не прокатит.

Чтобы починить второй пункт, придется вспомнить оркестратор и переписать LinearCoWorker:

// Multicast support
class GatherCoWorker {
public:
	GatherCoWorker(const Cookie id) : id_(id) {}
	GatherCoWorker(LinearCoWorker&) = delete;
	GatherCoWorker(LinearCoWorker&&) = delete;

	bool operator==(const Cookie id) const {return id_ == id;}

	void OnRequest(Handler& source) {runner_ = std::move(Run(source));}
	bool OnHouseResult(const bool result) {
		house_res_ = result;
		return TryGather();
	}
	bool OnGarageResult(const bool result) {
		garage_res_ = result;
		return TryGather();
	}
	bool OnDoghouseResult(const bool result) {
		doghouse_res_ = result;
		return TryGather();
	}

private:
	CoMagicResumable Run(Handler& client) {
		g_house->Post(new PaintHouseReqMsg(id_, *g_main));
		g_garage->Post(new PaintGarageReqMsg(id_, *g_main));
		g_doghouse->Post(new PaintDoghouseReqMsg(id_, *g_main));

		client.Post(new PaintAllCfmMsg(id_, co_await gatherer_));
	}
	bool TryGather() {
		if(house_res_ && garage_res_ && doghouse_res_)
			return gatherer_.Push(*house_res_ && *garage_res_ && *doghouse_res_);
		else
			return false;
	}

	const Cookie		id_;
	CoMagicResumable	runner_;
	Awaitable			gatherer_;
	std::optional<bool>	house_res_;
	std::optional<bool>	garage_res_;
	std::optional<bool>	doghouse_res_;
};

Мы починили скорость обработки запроса, но теперь логика размазалась по двум методам: Run() и TryGather().

Итак, по корутинам: они несколько менее удобные, чем промисы с потоками, но на порядки легковеснее по ресурсам.

Усложнение сценария — откат изменений при ошибке

Добавляем требование: Если один из компонентов не покрасился, остальным нужно вернуть старый цвет. Это значит:

  1. Нужно ждать, пока придут все ответы на отправленные запросы
  2. Если хоть один из ответов неудачен — нужно поотправлять запросы об откате

В пассивных вариантах (на колбеках) это легко решается расширением энума с состоянием запроса к компоненту {спрашиваем, успех, ошбика, откатываем} и несколькими строками кода в разных местах.

На фьючерех и корутинах после сбора результатов если есть хоть одна ошибка — рассылаем запрос на откат тем компонентам, которые успешно покрасились. Тоже без проблем.

Еще усложнение — отмена сценария

Пользователь хочет уметь отменить запрос, который сейчас в обработке. То есть:

  1. Нужно сообщить сценарию, что его отменяют
  2. Сценарий должен откатить уже сделанные успешные изменения
  3. Сценарий должен отправить отмену текущего запроса модулям, которые поддерживают отмену
  4. Сценарий должен дождаться результатов от модулей, не поддерживающих отмену, и откатить успешные изменения

С пассивными типами сценариев (обработчики событий и оркестратор) все настолько же просто, как и в прошлый раз. Несколько строк кода, и либо новый статус в энум, либо новый флаг о том, что запрос отменен.

С активными сценариями (промисы и корутины) какой-то треш. Надо разбудить поток (вероятно, отправив ему исключение), понять, какие переменные на стеке у него еще не заполнены результатами, отослать в незаполненные отмену, а заполненные должны быть обернуты в RAII классы, отправляющие запрос на откат из деструктора (который вызовется исключением). В промис можно послать исключение, но для этого нужно знать, на каком из фьючеров в данный момент ожидает поток. Либо можно разослать во все незаполненные. Для отправления исключения в корутину нужно написать метод Awaitable::SetException() по подобию Awaitable::Push() и кинуть сохраненное исключение из Awaitable::Resume(). И опять, нужно знать, на каком Awaitable корутина ожидает, иначе огребем мусор в переменных корутины, которая проснется на незаполненном результате.

Осложнение 3 — соревнование за ресурсы

Если несколько сценариев возьмутся красить дом в разные цвета, а дом большой, и красится в несколько этапов — можно получить очень разноцветный результат в горошек. Либо два заказчика будут долго наперегонки пытаться перекрасить результаты друг друга (livelock).

Для защиты нужен прокси, моделирующий дом (знающий его текущее состояние) и пропускающий, кеширующий или отклоняющий запросы. При этом в него переползает часть логики дома и сценариев. И мы понемногу сползаем к самому первому подходу (обработчики событий), когда на каждого субподрядчика была своя функция с бизнес-логикой, совмещающей знания о возможных сценариях. А сценарии в виде отдельных объектов не могут таким заниматься, так как не знают друг о друге, и работают независимо (а на промисах — еще и многопоточно).

Сводка

ПараметрКолбекиОркестраторПромисыКорутины
Простота логики-Плохо-Боль-мень++ Супер +++ Хорошо +
Простота обертки++ Идеально (нет обертки) +++ Простая +Так себе— - Сложная с магией — -
Потребление ресурсов+ Малое ++ Малое +— - Очень большое — -Среднее
Скорость++ Супер +++ Высокая +-Низкая-Средняя
Откат+ Хорошо ++ Хорошо +++ Супер ++++ Супер ++
Отмена+ Хорошо ++ Хорошо +— - Треш — -— - Угар — -
Общие ресурсыС пуэром покатит-Жесть-— - Угар — -— - Треш — -

Интересно, что ситуация с общими ресурсами выбивается из общей картины — если по остальным параметрам явно видны две пары: колбеки+оркестратор против промисов с корутинами, то в соревновании за ресурсы колбеки стоят особняком. И связано это с тем, что данная проблема упирается не в измерение активный<->пассивный сценарий, а в:

***** Явные и неявные сценарии: хореография и оркестрация в сагах *****

Сага — это распределенная транзакция, то есть — сценарий, который либо применяет изменения ко всем вовлеченным объектам, либо — ни к одному. В литературе (вспомним Microservices Patterns by Chris Richardson) описаны два вида:

Хореография (неявный пассивный сценарий)

Это когда объекты знают друг о друге и управляют друг другом. В нашем случае — дом говорит гаражу покраситься, гараж говорит покраситься будке, потом красит себя, и если себя покрасить не получилось — тогда гараж сам откатывает покраску будки, говорит дому, что не вышло, дом себя не красит, а сообщает нам, что такая вот фигня. В результате никто не покрасился.

Оркестрация (явный пассивный сценарий)

А это мы сегодня уже видели. Создаем объект, который раздает приказы, слушает ответы, и откатывает изменения, если кто-то обломился.

В книжках пишут, что хореография работает быстрее (меньше сообщений) и проще написать (не нужно заморачиваться с инфраструктурой для оркестратора), но когда много сценариев — все запутывается. И мы это сегодня уже проходили между колбеками и оркестратором в первой части статьи.

То есть:

  • явных сценариев можно наплодить много, а неявные запутываются при большом количестве
  • неявные проще заимплементить — не нужна дополнительная инфраструктура
  • неявные сценарии позволяют объектам самим контролировать свое поведение

Последний пункт довольно интересен. Если сценарий сложный и меняет состояние одних компонентов системы в зависимости от совокупного состояния других компонентов, его логику будет нереально записать одной функцией, кроме того — за время асинхронного сбора данных реальное состояние участников сценария может измениться. В результате принятое в явном сценарии решение будет устаревшим. Выход — дать участникам максимум автономности в принятии решений, и оставить в сценарии только высокоуровневую логику. Но это значит, что сценарий становится гибридным — часть логики содержится в явном виде в объекте сценария, часть — неявно размазана по компонентам системы.

Попробуем скомпоновать закономерности:

***** Рисовашки *****

Пускай по оси Х явность-неявность, а по У — активность-реактивность. Вспомним, что мы уже рассмотрели:

Остается пустым сектор активных неявных сценариев. Если подумать — что бы это могло такое быть? Когда нет единого объекта или функции с логикой и состоянием сценария, но при этом действия императивны. Да, это RPC chain. Мы говорим дому краситься и блокируемся до получения результата. Дом говорит краситься гаражу и тоже блокируется. Когда гараж покрасился — стек вызовов размотался и все пошли жить дальше. Также, раз вспомнили об RPC, нужно добавить на картинку RPC iteration когда мы говорим краситься дому и ждем его, потом блокируемся на покраске гаража, потом — будки.

Если отвлечься от нашей модели с домохозяйством, можно попробовать набросить на картинку serverless и pub/sub подходы, для которых я не смогу подобрать юз кейс покраски:

Теперь можно попробовать замапить свойства систем и их положение на диаграмме:

Активные распределенные сценарии:

  • — Требуют инфраструктуру в коде для запуска —
  • — Тяжелые по системным ресурсам и скорости —
  • — Почти невозможно отменить сценарий —
  • + Код идет подряд, легко читать и дебажить +

Пассивные сценарии, напротив:

  • + Проще в инфраструктуре +
  • + Поэтому легче и быстрее +
  • + Можно откатить на любом шаге +
  • — Код разбросан по куче методов, плохо дебажить —

Явные подходы:

  • + Код в одном объекте, легче читать +
  • + Можно относительно просто написать много сценариев +
  • — Проблемы, когда сценарии борются за участников —
  • — Проблемы с очень сложными сценариями —

Неявные подходы:

  • — Код сценария разбросан по 100500 файлов — нечитаемый —
  • — Поддержка только пары сценариев —
  • + Участники контролируют свое состояние +
  • + Сложность сценария распределяется между участниками +

Что с этим делать? Наверное, на старте проекта посмотреть, сколько и каких сценариев предполагается. Понятно, что они потом наверняка станут сложнее, и размножатся. Но это будет потом, а сейчас нужно понять, как с наименьшими усилиями стартануть.

  • Если сценарии отменяемы — активные системы под большим вопросом.
  • Если нужно выжать все из железа — тоже идем в более легкие пассивные.
  • Если сценарии сложные, и/или вероятны конфликты сценариев за участников — участники должны быть умными, чтобы разгрузить высокоуровневую логику. То есть — появляется неявный компонент в лучшем стиле OOP и DDD.
  • Если сценариев много — значит, их нужно описывать в явном виде, иначе код быстро протухнет до неузнаваемости.

Что делать, если много сложных или конфликтных сценариев? Разбить логику системы на два уровня:

  1. неявная логика в доменных объектах: поведение компонентов — для поддержки инварианта и устранения конфликтов и высокоуровневый интерфейс — для упрощения использования компонентов в сценариях
  2. явные сценарии использования компонентов в транзакционных скриптах с логикой, опирающейся на высокоуровневые интерфейсы из первого пункта

Можно почитать в Domain Driven Design by Eric Evans. Там разделяют транзакционные скрипты и логику сценариев при использовании умных доменных объектов, но я вот так сходу не найду правильный термин для последних.

delete this;

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті

👍ПодобаєтьсяСподобалось5
До обраногоВ обраному5
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Треба будувати окрестратор промісів через корутини... Тоді буде треш, угар та содомія в одному флаконі!

Любая асинхронная система в нормализированном виде должна:
А) Выполнять свое действие
Б) Сообщать о невозможности выполнить свое действие

Все остальное — в голове программиста, который сводит всю сложность к обычной бинарной логике.

как у вас в чистом с++ все запутанно

нетипізовані вказівники callback* це жесть, я уявляю якщо розвести по 2 командах, і використовувати різні версії + параметри змінювати. Це неможливо підтримувати.

Очень интересный материал, спасибо большое !

Почти ничего из статьи не понял, но если автор ответит на некоторые мои вопросы буду очень признателен.
У нас тут покраска трех различных домиков. И все их можно красить почти одновременно? Но тремя художниками.
Или одним художником, но тогда последовательно. Это и будет сценарий?
А когда тремя художниками — это будет распределенно, то есть одновременно. Накладные расходы увеличились на два дополнительных художнико-часа.

Художники это акторы (микросервисы) ?

Да, художники — это акторы. Вот многобукв habr.com/en/post/322250
Суть в том, что каждый актор живет в своем потоке, в его переменные никто не может залезть, и у него есть одна дырка наружу, через которую ему приходят сообщения. Так как дырка одна, то сообщения приходят по-очереди, он каждое из них переваривает, выплевывает ответ(ы) если хочет, и ждет следующего сообщения. Как дракон в Хоббите.

Если каждый объект (дом, гараж и будка) живет в своем акторе (то есть, у дома свой художник, у гаража — свой, у будки — свой), и у нас больше 3 ядер на проце, или 3 разных компа, то каждый из них может красить себя одновременно. Расходы в художнико-часах будут такие же (все равно все надо выкрасить), но общее время работы сократится, так как 3 человека работают сразу, а не один бегает между домом, гаражом и будкой.

У нас куча вариантов:
1) Сказать всем трем работать сейчас, и дождаться результатов от всех троих.
2) Сказать первому работать, когда он закончит — сказать второму, когда второй закончит — сказать третьему
3) Сказать первому, чтобы он сказал второму, и сразу сам у себя красить начал. А второй скажет третьему и начнет у себя красить гараж. Потом третий покрасит будку, скажет второму что готово, второй докрасит гараж и скажет первому, что гараж и будка покрашены. Первый докрасит дом и скажет нам, что все готово.

И несколько возможных видов кода под каждый вариант.
Вот это все набросано на последнюю картинку.
Там стрелочки:
active — это мы говорим художникам и ждем ответа
reactive — это нам сказали что-то, мы передали слова другим, и занимаемся своими делами, пока снова кто-то что-то нам не скажет.
explicit — мы сами следим за всеми художниками, и нам за это платят
implicit — художники между собой разбираются кто как что красит. Может, художник гаража подчиняется художнику дома. Или они завели журнал дежурств, и туда смотрят утром, и подписываются кто что сделал.

Ого, вот это было мега-круто! (в смысле вот теперь все понятно стало) Ты мне сейчас реально все объяснил. Буду читать и перечитывать и много думать.

Только в код на С++ не смотри — там все плохо. Думаю, такие же промисы или корутины есть в джаваскрипте и в джаве, и они там должны быть проще и понятнее.

У акторов плюс — что они независимы, и могут работать одновременно.
Минус — что когда нужно заставить несколько акторов выполнить согласованную работу — там убиться можно, как вот тут в этой статье куча кода на ровном месте. Если бы у нас былы не акторы, а обычные объекты, написали бы:

bool PaintAll() {
    return house_.Paint() && garage_.Paint() && doghouse_.Paint();
}
и вообще не нужно было бы никакой статьи. А как только у нас несколько полностью независимых работников — начинаются большие сложности.

Да, проблемы синхронизации. И вот тут приходит на помощь вероятностное понимание/взгляд на вещи/мышление

Рискну объяснить подробнее: чем больше промежуток времени выбрасывания монеты или кубика тем ближе распределение к равномерному. Но вместе с тем, тем больше шансов выпадения самых невероятных комбинаций — вроде войны и мир обезъянками.
И тут наблюдаем такую строгую зависимость между временем и выполнением всего фронта работ, что кажется что это как-то связано с резиновой стеной или силовым полем невозможности осуществления чего-то чего ну очень сильно хочется осуществить, но вот никак не получается. Отбрасывает назад.
У меня это обычно связано с предсказанием самого маловероятного исхода.
Маловероятный не значит невозможный. Но времени для осуществления маловероятного нужны тонны.

Совместная работа это определенная комбинация из букв и вероятность ее складывания зависит от времени. Горизонтальное складывание (последовательное) и вертикальное складывание ( || ) или (приблизительно одновременное)
Одинаковое время = одинаковые вероятности = выигрыша нет. А?
Выиграли во времени = проиграли в вероятностях. И наоборот.
Выиграли в вероятностях = проиграли во времени.
Но общего выигрыша исполнения задачи вообще нет.
Или будет исполнено вовремя с ошибками
Или будет исполнено за бесконечное время с минимально возможным количеством ошибок.

Да, вот оно ru.wikipedia.org/wiki/Теорема_CAP
Только там формулировка сильно умная, и мне тяжело ее понять. Суть в том, что или держишь все данные на одном компе, или когда пытаешься одновременно поменять данные на разных компах оно будет сильно тормозить, или можешь быстро менять везде, но другой дядя может прочесть данные в таком виде, когда половину уже поменяли на новую, а другая половина данных — старая.
Вот моя статья как раз о последних видах сценария — когда данные меняются вразброс. В микросервисах обычно это используют, и даже придумали название «сага» для кода, который следит, чтобы данные на разных компах или все поменялись, или откатить сделанные изменения и вернуть в изначальный вид. Но даже это не помогает, если в те же данные залазит кто-то другой, пока сага еще не доделала работу. Дом одного цвета, а будка — другого.

Вот я и написал в Индиане, что иногда проще создавать данные а не следить за согласованностью. Создаваемые данные всегда будут максимально актуальными и согласованными даже если нет никакой связи между компами.
И это очень сильно похоже на поведение квантово запутанных частиц.
А создавание замешано на актуальности правил создавания.

Это пока тебе не надо следить, чтобы дважды не создать одинаковые данные.
Например, если два пользователя зарегают одинаковый мейл — кому из них придут письма? Или если двое получат одинаковый номер телефона.

Несмотря на то, что у двух пользователей может быть один и тот же номер телефона, время обращения к номеру телефону может явно указать на то, какого пользователя нам нужно.
То есть, контекст в отличие от идентификатора контента в каждом случае разный. А?

А когда ты звонишь Васе по номеру 1234567, откуда оператор знает, отправить твой звонок Васе или Пете, если у них номер одинаковый?
А Петя на ОЛХ дал объявление, что продает корову, и ему начинает все село звонить.

Ты говоришь о возникающей неопределенности.

Да. В некоторых случаях неопределенность разрушает саму идею бизнеса. Например, при оплате карточкой.

А что не так там, в таком случае, если детально рассматривать?

Например, если у двух контор одинаковый счет, то ты платишь Розетке за обогреватель, а банк переводит деньги в Сильпо. Розетка оплату не получила, и обогреватель тебе не отдает. А деньги с карточки у тебя сняли.

Вроде тут тройная аварийная ситуация?
1. Розетка не получила оплату, а клиент ФИО требует товар.
2. Сильпо получило оплату, которой не заказывало, от клиента ФИО
3. Клиент ФИО не получил товар от Розетки, хотя заплатил Розетке.
Не поможет ли это определиться с выбором из неопределенности какого-то конкретного и уже более точного?
--- Пример неверный подобрал --- удалил ---

Да, но куда проще отслеживать, чтобы у двух пользователей не был одинаковый номер счета, чем потом чинить пересечения. Ведь Сильпо может отказаться возвращать деньги.

Мне почему-то кажется что умный софт будет быстрее в случае обработки ошибок (их мало) чем отслеживания коллизий — которых может и не быть.

Умный софт умеет перебирать решения.
Современный софт не умеет. Он коллизий хеш функции боится.
А умный софт приветствует эти коллизии.
Это как тропу торить. Один раз проторишь, а потом быстро-быстро бегаешь по этому пути. Коллизия это как нетронутый снег, а торить тропу это как перебор.

И вообще — зачем KISS ? Он не помогает продавать, он помогает раздавать бесплатно.

чем отслеживания коллизий — которых может и не быть

Для такого делают 128-битные или 512-битные ключи en.wikipedia.org/...​ersally_unique_identifier
Там тупо случайное число, но оно достаточно длинное, чтобы на практике не пересекаться с другим случайным числом.

И это всё ради массива из трёх boolean по сути. А теперь прикиньте, во сколько время-денег вам встанет реальный сложный проект, если вы доверите реализацию подобным адептам правильного кода бюрократии.

KISS

В количестве сущностей и связей, которые нужно ВЫЗУБРИТЬ, прежде чем вообще поймёшь а что [плохое слово в 5 этажей] делает этот код? При этом ни одна [плохое слово] ни одну переменную не документировала [плохое слово в 5 этажей]! Не говоря уж о всей системе, с объяснянием что, к чему, зачем.

Да, код работает. Ровно в том единственном случае, когда он написан с первого раза и без ошибок. Найти, что работает не так — малореально, для этого надо знать, как ЗАДУМАНО так. И вот это самое «как задумано» в целом — непонятно ни в одной точке кода.

Да, это всё можно переписать грамотно. И это ещё более утяжелит код. А потому, сам код будет правильным скомпоновать, так чтобы каждый блок кода выражал законченную мысль. И тогда уже блоками документировать.

Отсюда ЕДИНСТВЕННЫЙ вывод, который должна была дать статья, но не дала в силу бюрократической [плохое слово] автора: Сложные системы управления нужны всей системе в целом. А малые задачи должны решаться только простыми подходами. Соответственно на 1 оркестратор приходится 100+ коллбеков. Мало того, хорошей тактикой является написание им анонимных классов, именно потому что они анонимные, и тем самым не [плохое слово] мозги человеку «необходимостью» вызубрить их имена, при этом не понимая что они делают. На анонимных сущностях тебе не нужно этого понимать.

Ну так 100+ колбеков в статье не нарисуешь)
А потрогать разницу между оркестратором на корутинах и без них где — на реальном проекте?

Но можно дать вменяемые имена переменным. Это НЕОБХОДИМО делать, и чем дальше оторван от них общий смысл затеи — тем более многабукв нужно для именования каждой.

В противном случае код нечитаемый в любой произвольной точке, и надо перечитать его десяток раз просто чтобы иметь представления о связях. Грамотно написанный код — ни разу, он должен читаться с произвольной точки.

Комментарии это часть кода. Поддержка их актуальности это часть рабочего процесса.

BTW вот единственное, что тут делается

Запрос на покраску всех компонентов и ответы от компонентов падают нам в очередь сообщений и диспатчатся в соответствующие обработчики:
class MainHandler : public Handler {
public:
	virtual void OnPaintAllReq(const Cookie id, Handler& source) = 0;
	virtual void OnPaintHouseCfm(const Cookie id, const bool result) = 0;
	virtual void OnPaintGarageCfm(const Cookie id, const bool result) = 0;
	virtual void OnPaintDoghouseCfm(const Cookie id, const bool result) = 0;
};
Остальной код — разные варианты имплементации вот этого вот интерфейса посмотреть как оно выглядит и что может в зависимости от внутренностей. И набрасывать туда еще сверху модульности и наследования и несколько полиморфных оркестраторов — так до конца статьи ваще никто не дочитает.

И конечно же Handler& source не вносит цикличности в логику кода и выноса мозга при его чтении?

Я так и написал: нельзя менять смысл под логику кода. Нужно менять логику кода под смысл. А для этого придётся прописывать тот самый смысл в код. И не «рассказать на митинге», а прописать.

И мы вернёмся к тому же хэндлеру, который по мере осмысления превращается... в элегантный коллбэк. А уж с промисами или блэк-джеком, не более чем хотелка к ЧИТАЕМОСТИ кода. Код с промисами легко читается, промисы сами по себе для этого придуманы, чтобы не прерывать исходную мысль разрывом во времени её исполнения.

Тяжёлое ли исполнение на промисах? Зависит только от вашей способности разделять задачу на независимые составляющие. И только тогда придёт понимание, что засрать всё промисами не лучшая идея, потому что они по сути вернутся к спагетти-коду, если эти промисы окажутся вложенными. А значит старые добрые коллбэки внутри мелких блоков. И рост многобуквенности кода ради повышения его читаемости.

Я вижу только один путь разрыва магического круга: абсолютно иные форматы файлов исходников. С форматированием текста для его читаемости, и не автоформатированием, а ручным. Мы уже находимся в той стадии, где код вне родной IDE выглядит тонной бреда.

И конечно же Handler& source не вносит цикличности в логику кода и выноса мозга при его чтении?
virtual void OnPaintAllReq(const Cookie id, Handler& source) = 0;
От кого пришел запрос. Как его назвать правильно? client?
И мы вернёмся к тому же хэндлеру, который по мере осмысления превращается... в элегантный коллбэк.

Нет, базовый класс акторов:

class Handler {
public:
	virtual void Post(Message* const msg) = 0;
};
Тяжёлое ли исполнение на промисах?

для проца и памяти — тяжелое.

И только тогда придёт понимание, что засрать всё промисами не лучшая идея, потому что они по сути вернутся к спагетти-коду, если эти промисы окажутся вложенными. А значит старые добрые коллбэки внутри мелких блоков.
	void Run() {
		while(true) {
			// wait for the request
			Handler& client = promises_->run.get_future().get();

			std::future<bool>	house_res = SendToHouse();
			std::future<bool>	garage_res = SendToGarage();
			std::future<bool>	doghouse_res = SendToDoghouse();

			// gather the results
			const bool	success = house_res.get() && garage_res.get() && doghouse_res.get();
			client.Post(new PaintAllCfmMsg(id_, success));

			delete promises_;
			promises_ = new Promises;
			id_ = -1;
			idle_ = true;
		}
	}
 А как тут их вообще можно сделать вложенными?
Я вижу только один путь разрыва магического круга: абсолютно иные форматы файлов исходников.

А расскажи идею, пожалуйста. Я в эту сторону вообще не думал — пока только вот само понятие оркестраторов осваиваю, и их виды.

Лет через 10 может и до ТФ дойдут

Підписатись на коментарі