Докуметация Cтарт Статьи Форум Лента Вход
Не официальное русскоязычное сообщество
Главная
    Документация jMonkeyEngine
        jMonkeyEngine Уроки и Документация
            Документация для продвинутых пользователей
                Оптимизация многопоточности

Оптимизация многопоточности

Опубликованно: 06.05.2017, 20:20
Последняя редакция, Andry: 13.06.2017 13:58

Модель нарезки jME3

JME3 подобен Swing тем, что для скорости и эффективности все изменения в графе сцены должны быть сделаны в одном потоке обновления. Если вы вносите изменения только в Control.update(), AppState.update() или SimpleApplication.simpleUpdate(), это произойдет автоматически. Однако, если вы передаете работу другому потоку, вам может потребоваться передать результаты в главный поток jME3, чтобы там могли происходить изменения в графе сцены.

public void rotateGeometry(final Geometry geo, final Quaternion rot) {
    mainApp.enqueue(new Callable<Spatial>() {
        public Spatial call() throws Exception {
            return geo.rotate(rot);
        }
    });
}
Этот пример не возвращает полученное значение, вызвав get() для объекта Future, возвращаемого функцией enqueue(). Это означает, что пример метода rotateGeometry() вернется немедленно и не будет ждать, пока вращение будет обработано перед продолжением.

Если обрабатывающему потоку нужно ждать или требуется возвращаемое значение, тогда можно использовать метод get() или другие методы в возвращаемом объекте Future, такие как isDone().

Во-первых, убедитесь, что знаете, что такое Application States и пользовательские элементы Control.

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

Часто многопоточность означает, что отдельные логически логически обособленные циклы выполняются параллельно, и обмениваются информацией о своем состоянии. (Например, один поток для AI, один Sound, один Graphics). Однако мы рекомендуем использовать глобальный цикл обновления для игровой логики и выполнять многопоточность внутри этого цикла, когда это уместно. Такой подход лучше масштабируется для нескольких ядер и не разрушает вашу логику кода.

Фактически, каждый цикл for в основном цикле обновления может быть шансом для многопоточности, если вы можете разбить его на автономные задачи.

Многопоточность Java

Пакет java.util.concurrent обеспечивает хорошую основу для многопоточности и разделения труда на задачи, которые могут выполняться одновременно (отсюда и название). Три основных компонента: Executor (контролирует потоки), Callable объекты (задачи) и Future объекты (результат). Подробнее о параллельном пакете можно прочитать здесь, я приведу лишь краткое введение.

  • Callable является одним из классов, который выполняется в потоке Executor. Объект представляет одну из нескольких параллельных задач (например, задачу поиска пути одного NPC). Каждый Callable запускается из updateloop, вызывая метод с именем call().
  • Executor является одним центральным объектом, который управляет всеми вашими Callables. Каждый раз, когда вы назначаете Callable в Executor, Executor возвращает для него объект Future.
  • Future — это объект, который используется для проверки состояния отдельной Callable задачи. Future также дает вам возвращаемое значение в случае, если один возвращается.

Многопоточность в jME3

Итак, как мы реализуем многопоточность в jME3?

Давайте рассмотрим пример элемента управления, который управляет Spatial NPC. NPC Control должен вычислить длительную операцию поиска пути для каждого NPC. Если бы мы выполняли операции непосредственно в цикле simpleUpdate(), они блокировали бы игру каждый раз, когда NPC хочет перейти от A к B. Даже если мы переместим это поведение в метод update() выделенного элемента управления NPC, мы Все равно будем иметь раздражающие стоп-кадры, потому что он по-прежнему работает в том же потоке цикла обновления.

Чтобы избежать замедления, мы решили сохранить операции поиска пути в Control NPC, но выполнить его в другом потоке.

Executor

Вы создаете объект Executor в глобальном AppState (или методе initSimpleApp()), в любом случае в месте высокого уровня, где к нему могут обращаться несколько элементов управления.

/* Этот конструктор создает новый Executor, размер Pool которого равен 4. */
ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor(4);

Размер пула означает, что исполнитель будет держать четыре потока в живых в любое время. Наличие большего количества потоков в пуле означает, что одновременно может выполняться больше задач. Но больший пул приводит только к увеличению скорости, если ПК может справиться с этим! Выделение пула, который бесполезно велик, просто отнимает память, поэтому вам нужно найти хороший компромисс: примерно то же самое, как удвоить количество ядер в компьютере, имеет смысл.

Executor должен быть отключен, когда приложение заканчивается, чтобы процесс был корректным. В вашем простом приложении вы можете переопределить метод destroy и завершить работу executor:
    @Override
    public void destroy() {
        super.destroy();
        executor.shutdown();
    }

Поля класса Control

В NPC Control мы создаем отдельные объекты, которыми манипулирует поток. В нашем примере (Control поиска пути) задача связана с местоположениями и массивами путей, поэтому нам нужны следующие переменные:

//Вектор для хранения желаемого местоположения в:
Vector3f desiredLocation = new Vector3f();
//Объект MyWayList, содержащий  список ориентиров в качестве результата:
MyWayList wayList = null;
//Future, который используется для проверки состояния выполнения:
Future future = null;

Здесь мы также создали переменную Future для отслеживания состояния этой задачи.

Метод Update() в Control

Далее давайте посмотрим на вызов update() в Control, в котором начинается трудоемкая задача. В нашем примере задачей является findWay Callable (который содержит процесс поиска пути). Поэтому вместо того, чтобы описывать процесс поиска пути в цикле update() в Control, мы начинаем процесс через future = executor.submit(findWay);.

public void update(float tpf) {
    try{
        //Если у нас нет waylist и еще не запущен callable, сделайте это!
        if(wayList == null && future == null){
            //Задайте желаемый вектор расположения, после этого мы больше не будем его изменять
            //потому что к нему обращаются в другом потоке!
            desiredLocation.set(getGoodNextLocation());
            //запустить вызываемый executor
            future = executor.submit(findWay);    //  Thread начинается!
        }
        //Если мы уже начали вызов, мы проверяем статус
        else if(future != null){
            //Получить waylist, когда его сделали
            if(future.isDone()){
                wayList = future.get();
                future = null;
            }
            else if(future.isCancelled()){
                // Установим для future значение null. Может быть, нам удастся в следующий раз...
                future = null;
            }
        }
    }
    catch(Exception e){
      Exceptions.printStackTrace(e);
    }
    if(wayList != null){
        //.... Успех! Давайте обработаем wayList и перемещения NPC...
    }
}

Обратите внимание, как эта логика принимает решение на основе объекта Future.

Помните, что не следует возиться с полями классов после запуска потока, потому что к ним обращаются и изменяются в новом потоке. В более очевидных терминах: Вы не можете изменить «желаемое местоположение NPC, пока искатель пути вычисляет другой путь. Вы должны сначала отменить текущее Future.

Callable

Следующий пример кода показывает Callable, который предназначен для выполнения долгосрочной задачи (здесь, wayfinding). Это задача, использование которой блокировало своевременное выполнение остальной части приложения, и теперь она выполняется в отдельном потоке. Вы реализуете задачу в Callable всегда во внутреннем методе call().

Код задачи в Callable должен быть автономным! Он не должен писать или считывать данные данных объектов, которые управляются графиком сцены или потоком OpenGL напрямую. Даже чтение мест Spatials может быть проблематичным! Поэтому в идеале все данные, необходимые для процесса маршрутизации, должны быть доступны новому потоку, когда он уже запущен, возможно, в клонированной виде, что бы одновременного доступа к данным не происходило.

На самом деле вам может потребоваться доступ к состоянию игры. Если вам необходимо прочитать или записать текущее состояние из графа сцены, вы должны иметь клон данных в своем потоке. Есть только два пути:

  • Используйте очередь выполнения application.enqueue(), чтобы создать подпоток, который клонирует информацию. Недостатком является то, что он может быть медленнее.
    Пример ниже получает расположение Vector3f от объекта сцены mySpatial, используя этот способ.
  • Создание отдельного класса World, который обеспечивает безопасный доступ к его данным с помощью синхронизированных методов для доступа к графику сцены. В качестве альтернативы он может также использовать application.enqueue().
    Следующий пример получает объект Data data = myWorld.getData(); Используя этот способ.

Эти два способа потокобезопасны, они не дезорганизуют логику игры и оставляют код Callable удобочитаемым.

// Автономная трудоемкая задача:
private Callable<MyWayList> findWay = new Callable<MyWayList>(){
    public MyWayList call() throws Exception {

        //Чтение или запись данных из графа сцены -- через очередь выполнения:
        Vector3f location = application.enqueue(new Callable<Vector3f>() {
            public Vector3f call() throws Exception {
       //Мы клонируем местоположение, чтобы мы могли безопасно использовать переменную в нашей цепочке
                return mySpatial.getLocalTranslation().clone();
            }
        }).get();

        // Этот мировой класс обеспечивает безопасный доступ через синхронизированные методы
        Data data = myWorld.getData();

        //... Теперь обрабатываем данные и находим путь ...

        return wayList;
    }
};

Полезные ссылки

Описание высокого уровня, описывающее управление состоянием игры и рендеринга в разных потоках:
Многопоточность и ваш игровой цикл.
Пример C++ можно найти по:
Многопоточность рендеринга в игровом движке с реализацией Double buffer.

Вывод

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


Переведено для jmonkeyengine.ru, оригинал
Автор перевода: Andry

Добавить комментарий

jMonkeyEngine.ru © 2017. Все права сохранены.