Акторы и AJAX

в 11:28, , рубрики: actors, ajax, comet, java, javascript, Песочница, метки: , , , ,

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

Но у потоков есть альтернативы. Из известных мне — модель акторов (actors) и software transaction memory. Героем этой статьи, как понятно из заголовка, являются первые. Впрочем, по STM есть достаточно много статей в интернете, которые удовлетворят ваше любопытство.

Что такое модель акторов

В Интернете полно упоминаний о модели акторов. Поэтому я лишь напишу о том, что такое акторы, не вдаваясь в историю, применение, паттерны использования.

Итак, акторы — это такие объекты, которые:

  • не разделяют друг с другом состояния;
  • друг с другом взаимодействуют только через посылку асинхронных сообщений;
  • никакие два сообщения актор не обрабатывает одновременно; вместо этого актор собирает пришедшие сообщения в очередь и обрабатывает их последовательно.

Акторы очень напоминают различные системы очередей сообщений (message queue), вроде JMS, ActiveMQ или MSMQ. Кроме того, во многих GUI-фреймворках контролы так же, как и акторы, взаимодействуют друг с другом с помощью асинхронных сообщений.

Распределённые вычисления

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

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

Существующие реализации

Традиционно модель акторов любят в среде программистов на Erlang и Scala, где акторы поддерживаются нативно. Есть библиотеки для различных языков, в том числе и для Java, самой известной из которых является Akka. На хабре есть обзор этой библиотеки.

Однако, даже наличие Akka не остановило меня в желании сделать свою библиотеку для поддержки акторов. Зачем мне это нужно было, я объясню ниже. А пока я покажу, как акторы работают в моей библиотеке.

nop.actors

Поддержку акторов я внедрил в свой фреймворк. Механизм напоминает typed actors из Akka, причём есть только асинхронная передача сообщений.

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

@Actor
public interface Pingable {
    void ping(String token, Pinger pinger);
}
@Actor
public interface Pinger {
    void pong(String token);
}
public class DefaultPingable implements Pingable {
    @Override
    public void ping(String token, Pinger pinger) {
        System.out.println("Pinging with: " + token);
        pinger.pong(token);
    }
}

А вот как он будет использоваться:

Pingable pingable = Actors.wrap(Pingable.class, new DefaultPingable());
pingable.ping("hello", new Pinger() {
    @Override
    public void pong(String a) {
        System.out.println("Ping received: " + a);
    }
});

Существуют определённые ограничения на данные, передаваемые в аргументах. Неформально их можно понимать так, что передаваться могут только примитивы, POJO,
другие акторы и списки. Формально это описывается так. Пусть S — это множество всех типов, которые передаются в сообщениях. Тогда:

  • примитивные типы и классы-обёртки входят в S;
  • любой интерфейс, помеченный аннотацией Actor, входит в S;
  • любое перечисление (enum) входит в S;
  • List<T>, Set<T>, T[] входят в S в том случае, если T входит в S;
  • Любой класс, состоящий из приватных полей и пары методов-аксессоров для каждого из них, входит в S, если тип каждого из полей входит в S.

Теперь посмотрим, как сделать акторов доступными удалённо через протокол HTTP. Для этого существует механизм, интегрированный в nop и использующий средства фреймворка. Чтобы сделать актор доступным удалённо, необходимо вызвать метод exportActor у объекта ActorManager. ActorManager можно получить через dependency injection:

public class PingController {
    private ActorManager actorManager;
    
    @Injected
    public PingController(ActorManager actorManager) {
        this.actorManager = actorManager;
    }
    
    public Content pingDemo() {
        ActorInfo fooInfo = actorManager.exportActor(new DefaultPingable());
        // А здесь идёт код, который формирует страницу hello
        // ...
    }
}

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

Акторы на стороне браузера

Перечисленные свойства акторов наводят на мысль, что акторы просто идеально подходят для веб-приложений, в которых нужно активно обмениваться данными между браузером и веб-сервером в обоих направлениях. Но для этого необходима поддержка модели акторов в JavaScript. И она есть в nop.actors!

Если брать акторы только в пределах самого браузера, то с ними всё просто. Во-первых, конструктор класса-актора нужно обернуть в вызов actor. Во-вторых, чтобы экземпляр класса сделать актором, нужно обернуть его с помощью метода actor. Вот как это будет выглядеть на примере актора Pinger:

DefaultPinger = actor(function(elem) {
    this.elem = elem;
});
DefaultPinger.prototype.pong = function(token) {
    var messageElem = document.createElement("div");
    messageElem.textContent = token;
    this.elem.appendChild(messageElem);
}
var pinger = actor(new DefaultPinger(document.getElementById("pingResult")));
pinger.pong("hello");

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

Pingable = {};
Pinger = {};
Pingable.ping = ["value", actorRef(Pinger)];
Pinger.pong = ["value"];

Общий принцип тут такой. Протокол, по которому нужно общаться с актором, описывается в виде объекта. Свойства объекта соответствуют сообщениям, обрабатываемым акторами. Значением свойства должен быть всегда массив, перечисляющий типы аргументов, передаваемых с сообщением. Фреймворк понимает следующие типы аргументов:

  • строка «value» соответствует любому примитивному типу или enum'у;
  • actorRef(A), где A — описание актора;
  • массив, состоящий из одного элемента A указывает на то, что аргументом является коллекция с элементом типа A;
  • произвольный объект означает POJO, при этом значения свойств объекта указывают на типы свойств POJO.

За работу с удалёнными акторами отвечает класс ActorRemoting. Вот пример инициализации и использования класса:

var remoting = new ActorRemoting("/actors/" + sessionId);
var pingable = remoting.importActor(Pingable, nodeId, actorId);
remoting.start(pregable.ping("hello", pinger);

Разумеется, сервер должен для начала как-то передать sessionId, nodeId и actorId браузеру. Например, он это может сделать при генерации страницы, добавив на неё код JavaScript, инициализирующий эти переменные.

Законченный пример

Выше я рассказал о реализации модели акторов в nop, как на стороне сервера, так и на стороне браузера. Однако, я пока умалчивал о том, как всё это связать вместе в одно небольшое приложение. Итак, нам потребуются так же следующие классы:

@Route(prefix = "pingpong")
public interface PingRoute {
    @RoutePattern("/")
    String main();
}
@RouteBinding(PingRoute.class)
public class PingController extends AbstractController {
    private ActorManager actorManager;
    
    @Injected
    public PingController(ActorManager actorManager) {
        this.actorManager = actorManager;
    }
    
    public Content main() {
        ActorInfo pingInfo = actorManager.exportActor(Pingable.class,
                new DefaultPingable());
        return html(createView(PingView.class).setActorInfo(pingInfo));
    }
}
public class PingView extends Template {
    PingView setActorInfo(ActorInfo actorInfo);
}
@ModuleRequires(modules = ActorsModule.class)
public class PingModule extends AbstractModule {
    @Override
    public void load() {
        app.loadPackage(PingModule.class.getPackage().getName());
    }
}

Кроме того, необходимо добавить шаблон PingView.xml со следующим содержимым:

<?xml version="1.0" encoding="UTF-8"?>
<t:template xmlns:t="http://nop.org/schemas/templating/core">
  <t:head>
    <t:parameter name="actorInfo"/>
    <t:service name="actorsRoute" class="org.nop.actors.ActorsRoute"/>
  </t:head>
  <t:body>
    <html>
      <head>
        <title>A simple actors example</title>
        <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
        <script src="${actorsRoute.resource('actors.js')}"
                type="text/javascript"/>
      </head>
      <body>
        <div>
          <input type="text" id="argument"/>
          <button id="pingButton" type="button">Ping</button>
        </div>
        <div id="pingResult"/>
        <script>
          Pingable = {};
          Pinger = {};
          Pingable.ping = ["value", actorRef(Pinger)];
          Pinger.pong = ["value"];
          DefaultPinger = actor(function(elem) {
              this.elem = elem;
          });
          DefaultPinger.prototype.pong = function(token) {
              var messageElem = document.createElement("div");
              messageElem.textContent = token;
              this.elem.appendChild(messageElem);
          }
          var pinger = actor(new DefaultPinger(
                  document.getElementById("pingResult")));
          var remoting = new ActorRemoting("/actors/${actorInfo.sessionId}");
          var pingable = remoting.importActor(Pingable,
                  ${actorInfo.nodeId}, ${actorInfo.actorId});
          remoting.start();
          var argumentElem = document.getElementById("argument"); 
          document.getElementById("pingButton").onclick = function() {
              pingable.ping(argumentElem.value, pinger);
          }
        </script>
      </body>
    </html>
  </t:body>
</t:template>

Готовый к запуску пример можно скачать тут.

Шахматы на акторах

Здесь вроде бы всё очевидно: в игре участвует три актора: два игрока и одно игровое поле. Последний актор проверяет ходы игроков и оповещает их о ходах друг друга. Так что можно было бы написать примерно такие интерфейсы:

@Actor
public interface Board {
    void registerPlayer(Player player, PieceColor color);

    void move(Player player, BoardLocation source, BoardLocation destination);
}
@Actor
public interface Player {
    void moveRejected();

    void moved(BoardLocation source, BoardLocation destination);
}

Реализация Board, очевидно, высылает сообщение moveRejected обратно игроку, если он совершил неправильный ход или сходил не вовремя. Сообщение moved рассылается обоим игрокам. Ходившему — в знак подтверждения хода, оппоненту — как уведомление о ходе противника.

Вот только этот интерфейс сделан очень наивно. Игрок, делающий ход, сам указывает, от чьего имени сделан ход. Злоумышленник может этим коварно воспользоваться. Кроме того, если по какой-то причине один из акторов-игроков «упал», то у него нет возможности восстановить состояние игры. Итак, мы пишем следующие интерфейсы, чтобы преодолеть указанные недостатки:

@Actor
public interface Board {
    void authorizePlayer(PlayerObserver observer, String key);
}
@Actor
public interface Player {
    void move(BoardLocation source, BoardLocation destination, PieceType promotedType);
}
@Actor
public interface PlayerObserver {
    void authorizationAccepted(PieceColor color, Player player);

    void authorizationRejected();

    void moveRejected();

    void boardStateChanged(BoardState state);

    void moved(BoardLocation source, BoardLocation destination);
}

Итак, вот что произошло. Теперь игрок скрывается за интерфейсом PlayerObserver. А игровое поле представлено одним Board и одним Player. Player — это что-то вроде ракурса игрового поля, доступного одному конкретному игроку. Вступая в игру, актор-игрок сообщает игровому полю пароль и передаёт ссылку на себя. От игрового поля он получает подтверждение в виде сообщения authorizationAccepted. Кроме того, подключившись к игре, актор получает состояние игрового поля на данный момент в виде сообщения boardStateChanged.

Обратите внимание, что обра актора Player и Board должны разделять состояние между собой. Так оно на самом деле и есть, nop поддерживает это. Можно сказать, что это один актор, который виден с разных ракурсов. Вот как на самом деле выглядит обработка сообщения authorizePlayer:

    @Override
    public void authorizePlayer(PlayerObserver observer, String key) {
        boolean matches = false;
        for (PlayerImpl player : players.values()) {
            if (player.token.equals(key)) {
                observer.authorizationAccepted(player.color, player);
                sendFullState(observer);
                player.setPlayerObserver(observer);
                updateObserverList();
                matches = true;
                break;
            }
        }
        if (!matches) {
            observer.authorizationRejected();
        }
    }

    private void sendFullState(PlayerObserver observer) {
        BoardState state = new BoardState();
        // Пропущена логика подготовки текущего состояния доски.
        // ...
        observer.boardStateChanged(state);
    }

Здесь player передаётся observer'у во время обработки сообщения board'ом. Т.к. player явно не делался actor'ом, фреймворк автоматически сделает его таковым и при этом объединит с board'ом. На самом деле, чтобы создать новый актор, не имеющий общего состояния с другими, необходимо его явно сделать актором с помощью Actors.wrap.

Полный код акторов Board/Player можно посмотреть тут.

Для части на JavaScript требуется описание интерфейса акторов. Вот как оно выглядит:

Board = {};
Player = {};
PlayerObserver = {};
BoardLocation = { row : "value", column : "value" };
PieceState = { type : "value", color : "value", location : BoardLocation };
Move = { source : BoardLocation, destination : BoardLocation, piece : "value",
        capturedPiece : "value" };
BoardState = { moves : [Move], pieces : [PieceState] };
Board.authorizePlayer = [actorRef(PlayerObserver), "value"];
Player.move = [BoardLocation, BoardLocation];
PlayerObserver.authorizationAccepted = ["value", actorRef(Player)];
PlayerObserver.authorizationRejected = [];
PlayerObserver.moveRejected = [];
PlayerObserver.boardStateChanged = [BoardState];
PlayerObserver.moved = [BoardLocation, BoardLocation];

Реализация PlayerObserver просто перерисовывает страничку при поступлении сообщений с сервера и отсылает на сервер сообщения, когда игрок двигает фигуру.

Целиком реализация браузерного актора доступна здесь.

Пример с шахматами входит в дистрибутив фреймворка nop в качестве демонстрационного приложения. Код приложения находится в папке /demo/chess. Так же я поднял готовый сервис.

Преимущества

Помимо того, что удалось добиться прозрачного обмена сообщениями, nop.actors умеет ещё и следующие вещи.

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

Во-вторых, асинхронная обработка long-poll запросов с использованием сервлетов версии 3.0. Если к серверу подключились 1000 клиентов, то это вовсе не будет означать, что он создаст 1000 ничего не делающих потоков.

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

Автор: konsoletyper

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js