по мотивам «Concurrent Programming in Java»

1 Конкурентное (concurrent) объектно-ориентированное программирование

в Java имеют место три вида абстракций параллелизма:

  • Взаимоисключения (exclusion) - обеспечивают согласованное состояние объектов предотвращая нежелательное взаимодействие конкурентных операций, обычно с использованием synchronized-методов.

  • Обусловленность состоянием (state dependence) - запуск (triggering), препятствование (preventing), отсрочивание (postponing), или возобновление (recovering) действий взависимости от того находится ли объект в состоянии при котором эти действия могут (или могли бы) продолжить выполнение. Обычно реализуется с использованием методов монитора (monitor) таких как Object::wait, Object::notify, и Object::notifyAll.

  • Создание потоков - организация и управление параллелизмом используя объекты класса Thread.

1.1 Использование абстракций параллелизма

Каждый объект класса Object (и его подклассов) имеет блокировку, которая захватывается при входе в synchronized методы, и автоматически освобождается при выходе из них. Конструкция synchronized для блока кода работает точно также за исключением того, что принимает аргумент, который задает объект для получения блокировки. Наиболее распространенным объектом, используемым в качестве параметра является this, означая использование блокировки объекта метод которого и вызывается. При владении блокировкой одним потоком другие потоки должны ждать пока она не будет освобождена. Захват блокировки не оказывает влияния на несинхронизированные методы, которые могут быть выполнены даже если блокировкой владеет другой поток.

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

  1. Используйте блокировку при обновлении полей объекта.
  2. Используйте блокировку при доступе к, возможно, обновляемым полям.
  3. Не используйте блокировку при вызове методов других объектов.

Данные правила имеют множество исключений и уточнений.

Рассмотрим пример:

import java.util.Random;

class Particle {
    protected int x;
    protected int y;
    protected final Random rng = new Random();

    public Particle(int initialX, int initialY) {
        x = initialX;
        y = initialY;
    }

    public synchronized void move() {
        x += rng.nextInt(10) - 5;
        y += rng.nextInt(20) - 10;
    }

    public void draw(Graphics g) {
        int lx, ly;
        synchronized (this) {
            lx = x;
            ly = y;
        }
        g.drawRect(lx, ly, 10, 10);
    }
}

Замечания:

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

  • В методе Particle::draw необходимо получить согласованный снапшот (snapshot) значений x и y. Для этих целей используется синхронизированный блок кода.

  • Метод Particle::draw подчиняется правилу #3 и освобождает блокировку до вызова методов другого объекта (в данном случае g::drawRect). Однако, метод Particle::move, кажется, его нарушает вызывая rng::nextInt. В данном случае это приемлемо, т.к. каждый объект класса Particle обладает собственным экземпляром rng, и, концептуально, rng является частью самого этого объекта, и поэтому не рассматривается как другой объект из правила #3.

Выполняемые внутри нового потока действия должны быть определены в классах реализующих интерфейс java.lang.Runnable. Этот интерфейс имеет единственный метод run без аргументов, возвращаемого значения, и проверяемых исключений.

public interface java.lang.Runnable {
    void run();
}

interface инкапсулирует связанный набор сервисов и атрибутов (в более широком понимании - роль) не связывая эту функциональность с каким-либо объектом или кодом. Интерфейсы более абстрактны, чем классы, т.к. они ничего не говорят о реализации или коде. Они только описывают сигнатуры (имена, аргументы, возвращаемые типы и исключения) открытых (public) операций не накладывая ограничений на классы объектов, которые могут их реализовывать. Классы, реализующие Runnable, как правило, не имеют каких-либо особенностей кроме наличия метода run.

Каждый объект класса Thread поддерживает управляющее состояние (control state) необходимое для исполнения и управления последовательностью вызовов составляющих его действия. Наиболее используемые конструкторы класса Thread принимают объект класса Runnable в качестве аргумента, который используется для вызова метода Runnable::run при старте потока. Часто вместо реализации Runnable более удобно использовать анонимный внутренний класс или лямбда-выражение.

Механизм потоков

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

Каждая программа состоит из, по крайней мере, одного потока - того, который запускает метод main класса переданного в качестве аргумента при старте JVM. Другие внутренние фоновые потоки, также, могут быть запущены при инициализации JVM. Количество и природа таких потоков может различаться в зависимости от реализации JVM. Однако, все потоки пользовательского уровня явно создаются и запускаются либо из главного потока, либо из любого другого созданного потока.

Ниже рассмотрены основные методы и свойства класса Thread, и приведены некоторые замечания по их использованию.

Создание

Конструкторы класса Thread принимают различные комбинации аргументов включающие:

  • Объект Runnable. В этом случае при последующем вызове Thread::start будет вызван метод run переданного объекта. Реализация Thread::run по-умолчанию просто возвращает управление.

  • Объект String, служащий идентификатором для данного Thread. Может быть полезен для отслеживания и отладки, но не играет другой роли.

  • Объект ThreadGroup в которую должен быть помещен новый Thread. В случае отсутствия доступа к данной ThreadGroup будет возбуждено исключение SecurityException.

Класс Thread сам по себе реализует Runnable, и, поэтому, вместо представления требуемого для запуска кода в виде объекта Runnable и его передачи конструктору, возможно создание наследника класса Thread и соответствующее переопределение метода run. Однако, более предпочтительным подходом является определение Runnable в качестве отдельного класса и передача его экземпляра в конструктор Thread. Изоляция кода внутри отдельного класса освобождает от проблем связанных с потенциальным взаимодействием synchronized методов и блоков, используемых в реализции Runnable, с методами используемыми классом Thread. В более общем смысле, такое разделение обеспечивает независимый контроль над источником действий и контекстом в котором они выполняются - один и тот же Runnable может передаваться в потоки, созданные различными способами, так же как и другим легковесным исполнителям (executors). Также, стоит отметить, что наследование от Thread исключает возможность расширения другого класса.

Объекты класса Thread, также, имеют атрибут обозначающий статус потока-демона (daemon status), который не может быть задан через конструкторы, но может быть установлен до запуска потока. Метод Thread::setDaemon обозначает, что процесс JVM может завершиться резко прервав поток в том случае, если все не демон-потоки завершили своё выполнение. Метод Thread::isDaemon возвращает статус потока-демона. Использование демонов довольно ограниченно т.к., обычно, даже фоновым потокам часто требуется выполнить какие-то действия при завершении программы.

Запуск потоков

Результатом вызова метода start у объекта класса Thread является запуск метода run как независимого действия. Вновь созданный поток не владеет блокировками синхронизации захваченными вызывающим потоком.

Поток завершается при выходе из метода run как обычным возвратом, так и из-за возбуждения непроверяемого исключения (т.е. RuntimeException, Error или одного из наследников). Потоки не являются перезапускаемыми (restartable) даже после их завершения. Вызов Thread::start более одного раза приведет к возбуждению исключения InvalidThreadStateException.

Метод Thread::isAlive вернет true в случае если поток был запущен, но не завершен. Также, он вернет true в случае когда поток просто блокирован каким-то образом. Различные реализации JVM отличаются тем когда именно isAlive возвращает false для отмененных потоков. Нет методов для определения того запускался ли поток, метод isAlive которого возвращает false. Также, поток не может прямо определить запустивший его поток, хотя он может определить идентификаторы других потоков в его ThreadGroup.

Приоритеты

Для возможности реализации JVM под различные аппаратные платформы и операционные системы язык программирования Java не дает каких-либо гарантий о диспетчеризации (scheduling) или честном выборе потоков (fairness), и даже не гарантирует, что потоки будут делать прогресс при выполнении (forward progress). Но, потоки поддерживают методы работы с приоритетом, который может повлиять на работу планировщиков:

  • Каждый поток имеет приоритет в пределах от Thread.MIN_PRIORITY до Thread.MAX_PRIORITY (1 и 10 соответственно).
  • По-умолчанию новому потоку задается приоритет равный приоритету создавшего его потока. Начальный поток, ассоциированный с main, по-умолчанию имеет приоритет равный Thread.NORM_PRIORITY (5).
  • Текущий приоритет потока можно получить с помощью метода Thread::getPriority.
  • Приоритет потока может быть динамически изменен с помощью метода Thread::setPriority. Максимально возможный приоритет потока ограничается в его ThreadGroup.

При наличии большего числа готовых к работе потоков, чем доступных ЦПУ планировщик, в общем случае, склонен к выбору для запуска потоков с бОльшим приоритетом. Точная политика может и будет отличаться для различных платформ. Например, некоторые реализации JVM всегда выбирают поток с наибольшим текущим приоритетом. Некоторые реализации отображают десять возможных приоритетов потоков на меньшее число, поддерживаемых системой, категорий так, что потоки с различными приоритетами будут учитываться одинаково. А некоторые смешивают заданный приоритет со схемой учета возраста потока, или другой политикой планирования, для обеспечения того, что даже низкоприоритетные потоки, в итоге, получат шанс быть выполнеными. Также, задание приоритетов может, но не обязательно, повлиять на диспетчеризацию в отношении других программ, запущенных на той же компьютерной системе.

Приоритеты не имеют какого-либо другого влияния на семантику или корректность. В особенности, приоритет не может использоваться в качестве замены использованию блокировок. Приоритеты могут использоваться только для отражения относительной важности или срочности различных потоков там где это было бы уместно принять к учету при интенсивном соперничестве между потоками за шанс быть выполнеными. Но, программы должны проектироваться так, чтобы работать корректно (хотя, возможно, и не так отзывчиво) даже если Thread::setPriority реализован просто как заглушка (no-op). То же самое относится и к Thread::yield.

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

Диапазон приоритетов Использование
10 Управление критическими ситуациями
7-9 Интерактивная, событийно-ориентированная система
4-6 IO-операции
2-3 Фоновые вычисления
1 Запуск только в том случае, если больше некому

Управляющие методы

Для организации взаимодействия между потоками доступны несколько методов:

  • Каждый объект Thread имеет ассоциированный с ним булевый статус прерванности (interruption status). Вызов t.interrupt для некоторого потока t устанавливает его статус прерванности в true, за исключением случаев когда этот поток вовлечен в Object::wait, Thread::sleep, или Thread::join. В этом случае interrupt приведет к тому, что эти действия (в t) вызовут исключение InterruptedException, а статус прерванности t установится в false.

  • Состояние прерванности любого потока может быть получено с помощью метода isInterrupted. Этот метод вернет true если поток был прерван вызовом метода interrupt, и статус, после этого, не был сброшен ни потоком через вызов Thread.interrupted, ни возбуждением исключения в ходе wait, sleep, или join.

  • Вызов t.join() для потока t переводит вызывающий поток (caller) в состояние ожидания завершения целевого потока t - вызов t.join() вернет управление когда t.isAlive() возвратит false. Версия с аргументом таймаута в миллисекундах вернет управление даже если поток не завершился в указанный период времени. Из-за того как определен isAlive не имеет смысла вызывать join у не запущенного потока. По схожим причинам не стоит вызывать join у потока созданного кем-то другим.

Изначально класс Thread поддерживал дополнительные управляющие методы: suspend, resume, stop, и destroy. Методы suspend, resume, и stop были объявлены устаревшими. Метод destroy не имел реализации и, вероятно, никогда не будет. Эффект вызова методов suspend и resume может быть получен более безопасным и надежным способом используя техники ожидания (waiting) и уведомления (notification).

Статические методы

Некоторые методы класса Thread могут быть применены только к текущему потоку (т.е. к потоку в котором происходит вызов методов Thread). Для обеспечения данного условия такие методы объявлены статическими (static).

  • Метод Thread::currentThread возвращает ссылку на текущий объект Thread. Эта ссылка может использоваться для вызова других (не статических) методов. Например, Thread.currentThread().getPriority() вернет приоритет вызвавшего этот метод потока.

  • Вызов Thread::interrupted сбрасывает статус отмены текущего потока и возвращает предыдущий статус. Таким образом, статус отмены потока не может быть сброшен из других потоков.

  • Вызов Thread::sleep(msecs: long) приостановит выполнение текущего потока на, по крайней мере, msec миллисекунд.

  • Thread::yield исключительно вспомогательный метод дающий JVM подсказку о том, что в случае если есть какие-либо ожидающие запуска потока, то вместо данного потока планировщик может запустить один или более из них. JVM может интерпретировать эту подсказку любым образом.

Несмотря на отсутствие гарантий yield может быть эффективен на некоторых реализациях JVM для однопроцессорных систем, которые не используют вытесняющую многозадачность с квантованием времени (time-sliced pre-emptive scheduling). В этом случае потоки перепланируются (rescheduled) только при блокировании одного из них (например, из-за I/O операций или вызова sleep). На таких системах потоки выполняющие длительные неблокирующие операции могут занимать процессор на относительно большой промежуток времени ухудшая, тем самым, отзывчивость приложения. В качестве способа защиты методы, выполняющие неблокирующие операции время которых может превысить допустимый предел отзывчивости для потоков, которые обрабатывают события, или других реактивных потоков, могут вставлять в код yield (или даже sleep), и, когда уместно, иметь меньший приоритет. Для минимизации нежелательного воздействия вызов yield можно делать лишь иногда. Например, в цикле можно укзать следующее:

if (Math.random() < 0.01) Thread.yield();

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

ThreadGroups

Каждый поток создается как элемент группы потоков ThreadGroup (по-умолчанию группы потока вызвавшего конструктор). ThreadGroup имеют древовидную вложенность. Когда поток создает новую ThreadGroup она становится дочерней для его группы. Метод Thread::getThreadGroup возвращает группу потока. Класс ThreadGroup, в свою очередь, поддерживает такие методы как enumerate, которые дают возможность узнать какие потоки находятся в группе.

Одной из целей класса ThreadGroup является поддержка политик безопасности, которые динамически ограничивают доступ к операциям класса Thread. Например, запрет вызова interrupt для потока другой группы. Для группы потоков, также, можно задать предельное значение приоритета её потоков.

Обычно ThreadGroup не используются явно в многопоточных программах. Для целей большинства приложений обычные коллекции являются более предпочтительными для работы с множествами объектов Thread.

ThreadGroup::uncaughtException является одним из часто используемых методов класса ThreadGroup. Этот метод вызывается когда поток группы завершается из-за неперехваченного непроверяемого исключения (например, NullPointerException). Результатом вызова, обычно, является печать стек-трейса (stack trace).