Для написания программ, выполняющих параллельные вычисления, широко применяются потоки (threads). При том, что потоки позволяют достаточно гибко организовывать парраллелизм в программах, они обладают рядом недостатков. Дело в том, что потоки разделяют между собой память. Это значит, что очень легко по неосторожности нарушить целостность программы. Побороть это можно с помощью блокировок, которые позволяют некоторому коду получать эксклюзивный доступ к обшему ресурсу. Однако, сами блокировки, помимо того, что их нужно не забывать проставлять, порождают, в свою очередь, проблемы. Одна из самых страшных проблем — это возможность породить deadlock. Впрочем, даже без этого написание действительно хорошо работающей многопоточной программы превращается в ювелирный труд.
Но у потоков есть альтернативы. Из известных мне — модель акторов (actors) и software transaction memory. Героем этой статьи, как понятно из заголовка, являются первые. Впрочем, по STM есть достаточно много статей в интернете, которые удовлетворят ваше любопытство.
В Интернете полно упоминаний о модели акторов. Поэтому я лишь напишу о том, что такое акторы, не вдаваясь в историю, применение, паттерны использования.
Итак, акторы — это такие объекты, которые:
Акторы очень напоминают различные системы очередей сообщений (message queue), вроде JMS, ActiveMQ или MSMQ. Кроме того, во многих GUI-фреймворках контролы так же, как и акторы, взаимодействуют друг с другом с помощью асинхронных сообщений.
Почему обязательно при параллельных вычислениях представляют, что вычисления крутятся на одной физической машине? А если у нас кластер? Для высоконагруженных проектов это особенно актуально, т.к. они рано или поздно упираются в границы одной машины. Модель потоков в этом случае совсем не подходит, т.к. у потоков общая память. Если попытаться реализовать что-то подобное в случае расределённой системы, встанет проблема с синхронизацией данных между узлами, ведь потокам, запущенным на разных узлах должно казаться, что у них общая память. А вот акторы взаимодействуют друг с другом только через асинхронные сообщения, которые хорошо передаются и по сети.
У акторов есть ещё одно преимущество для распределённых вычислений. Т.к. своё состояние актор ни с кем не разделяет, это самое состояние легко сериализовать и передать между узлами. С другой стороны, актор не обязан реагировать на сообщения моментально, ведь все сообщения доставляются асинхронно, потому для актора будет нормально, если он некоторое время потратит на сериализацию/десериализацию состояния, отложив ненадолго обработку сообщений.
Традиционно модель акторов любят в среде программистов на Erlang и Scala, где акторы поддерживаются нативно. Есть библиотеки для различных языков, в том числе и для Java, самой известной из которых является Akka. На хабре есть обзор этой библиотеки.
Однако, даже наличие Akka не остановило меня в желании сделать свою библиотеку для поддержки акторов. Зачем мне это нужно было, я объясню ниже. А пока я покажу, как акторы работают в моей библиотеке.
Поддержку акторов я внедрил в свой фреймворк. Механизм напоминает typed actors из Akka, причём есть только асинхронная передача сообщений.
Как описать актор? Для этого необходимо описать интерфейс, т.е. совокупность сообщений, обрабатываемых актором, и реализацию, т.е. собственно обработку сообщений. Если проводить параллели с языками, где поддерживаются паттерн-матчинг, то описание интерфейса равносильно объявлению алгебраического типа, а реализация — самому паттерн-матчингу. Вот пример описания актора:
А вот как он будет использоваться:
Существуют определённые ограничения на данные, передаваемые в аргументах. Неформально их можно понимать так, что передаваться могут только примитивы, POJO, другие акторы и списки. Формально это описывается так. Пусть S — это множество всех типов, которые передаются в сообщениях. Тогда:
Теперь посмотрим, как сделать акторов доступными удалённо через протокол HTTP. Для этого существует механизм, интегрированный в nop и использующий средства фреймворка. Чтобы сделать актор доступным удалённо, необходимо вызвать метод exportActor у объекта ActorManager. ActorManager можно получить через dependency injection:
Конечно, не обязательно использовать возможности, предоставляемые фреймворком nop в целом. Если не нужно тащить все зависимости, достаточно обойтись одним лишь классом HttpActorDispatcher, поместив его в свой сервлет.
Перечисленные свойства акторов наводят на мысль, что акторы просто идеально подходят для веб-приложений, в которых нужно активно обмениваться данными между браузером и веб-сервером в обоих направлениях. Но для этого необходима поддержка модели акторов в JavaScript. И она есть в nop.actors!
Если брать акторы только в пределах самого браузера, то с ними всё просто. Во-первых, конструктор класса-актора нужно обернуть в вызов actor. Во-вторых, чтобы экземпляр класса сделать актором, нужно обернуть его с помощью метода actor. Вот как это будет выглядеть на примере актора Pinger:
Если же нужно обеспечить взаимодействие акторов на стороне браузера с акторами на стороне веб-сервера, то следует описать интерфейс на JavaScript. Для нашего примера описание будет выглядеть так:
Общий принцип тут такой. Протокол, по которому нужно общаться с актором, описывается в виде объекта. Свойства объекта соответствуют сообщениям, обрабатываемым акторами. Значением свойства должен быть всегда массив, перечисляющий типы аргументов, передаваемых с сообщением. Фреймворк понимает следующие типы аргументов:
За работу с удалёнными акторами отвечает класс ActorRemoting. Вот пример инициализации и использования класса:
Разумеется, сервер должен для начала как-то передать sessionId, nodeId и actorId браузеру. Например, он это может сделать при генерации страницы, добавив на неё код JavaScript, инициализирующий эти переменные.
Выше я рассказал о реализации модели акторов в nop, как на стороне сервера, так и на стороне браузера. Однако, я пока умалчивал о том, как всё это связать вместе в одно небольшое приложение. Итак, нам потребуются так же следующие классы:
Кроме того, необходимо добавить шаблон PingView.xml со следующим содержимым:
Готовый к запуску пример можно скачать тут.
Здесь, вроде бы, всё очевидно: в игре участвует три актора: два игрока и одно игровое поле. Последний актор проверяет ходы игроков и оповещает их о ходах друг друга. Так что можно было бы написать примерно такие интерфейсы:
Реализация Board, очевидно, высылает сообщение moveRejected обратно игроку, если он совершил неправильный ход или сходил не вовремя. Сообщение moved рассылается обоим игрокам. Ходившему — в знак подтверждения хода, оппоненту — как уведомление о ходе противника.
Вот только этот интерфейс сделан очень наивно. Игрок, делающий ход, сам указывает, от чьего имени сделан ход. Злоумышленник может этим коварно воспользоваться. Кроме того, если, по какой-то причине, один из акторов-игроков «упал», то у него нет возможности восстановить состояние игры. Итак, мы пишем следующие интерфейсы, чтобы преодолеть указанные недостатки:
Итак, вот что произошло. Теперь игрок скрывается за интерфейсом PlayerObserver. А игровое поле представлено одним Board и одним Player. Player — это что-то вроде ракурса игрового поля, доступного одному конкретному игроку. Вступая в игру, актор-игрок сообщает игровому полю пароль и передаёт ссылку на себя. От игрового поля он получает подтверждение в виде сообщения authorizationAccepted. Кроме того, подключившись к игре, актор получает состояние игрового поля на данный момент в виде сообщения boardStateChanged.
Обратите внимание, что оба актора Player и актор Board должны разделять состояние между собой. Так оно на самом деле и есть, nop поддерживает это. Можно сказать, что это один актор, который виден с разных ракурсов. Вот как на самом деле выглядит обработка сообщения authorizePlayer:
Здесь player передаётся observer'у во время обработки сообщения board'ом. Т.к. player явно не делался actor'ом, фреймворк автоматически сделает его таковым и при этом объединит с board'ом. На самом деле, чтобы создать новый актор, не имеющий общего состояния с другими, необходимо его явно сделать актором с помощью Actors.wrap.
Полный код акторов Board/Player можно посмотреть тут.
Для части на JavaScript требуется описание интерфейса акторов. Вот как оно выглядит:
Реализация PlayerObserver просто перерисовывает страничку при поступлении сообщений с сервера и отсылает на сервер сообщения, когда игрок двигает фигуру.
Целиком реализация браузерного актора доступна здесь.
Пример с шахматами входит в дистрибутив фреймворка nop в качестве демонстрационного приложения. Код приложения находится в папке /demo/chess. Так же я поднял готовый сервис.
Помимо того, что удалось добиться прозрачного обмена сообщениями, nop.actors умеет ещё и следующие вещи.
Во-первых, фреймворк полностью модульный. Есть реализация механизма акторов внутри процесса. Есть реализация удалённых акторов, сделанная поверх акторов внутри процесса, причём эта реализация может пользоваться любым транспортом.
Во-вторых, асинхронная обработка long-poll запросов с использованием сервлетов версии 3.0. Если к серверу подключились 1000 клиентов, то это вовсе не будет означать, что он создаст 1000 ничего не делающих потоков.
В-третьих, система автоматически выгружает ничего не делающие акторы на диск, освобождая память. При этом время жизни любого актора потенциально бесконечно.
Но у потоков есть альтернативы. Из известных мне — модель акторов (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 ничего не делающих потоков.
В-третьих, система автоматически выгружает ничего не делающие акторы на диск, освобождая память. При этом время жизни любого актора потенциально бесконечно.