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

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

Между прочим, когда вы пишете программу на Java, у вас в программе уже по-любому есть потоки. Только он один – и это main thread – главный поток, с которого начинается выполнение программы. Давайте научимся создавать и запускать свои потоки.

Процессы и потоки

Различные процессы и потоки нужны, чтобы выполняться параллельно – одновременно друг с другом.

Процесс совокупность кода и данных, которые объединяются общим виртуальным адресным пространством. Это законченная и независимая единица выполнения. Каждая отдельная программа чаще всего занимает один процесс. Но порой бывает и больше – Google Chrome создаёт каждой вкладке по отдельному процессу. При начале работы программы операционная система формирует процесс, копируя код и данные программы в память. После этого она запускает основной поток этого процесса.

Потоки – это более мелкие крупицы выполнения. В каждом процессе есть как минимум один потокосновной, main thread. В программе на Java мы пишем метод main(), с которого начинается программа. Так запускается главный поток. После этого по нуждам задачи можно прописать запуск и других потоков. Потоки имеют в себе некоторый код, который нужно выполнять. Это может быть как единоразовая задача, так и какой-нибудь цикл while(true), который постоянно крутится, пока его не остановят.

Давайте рассмотрим понятие “параллельно” более подробно. Даже на одноядерном процессоре, где обычно только одна команда может выполняться в каждый момент времени, возможно запускать несколько потоков параллельно. В таком случае процессор периодически переключается между этими потоками, давая каждому из них возможность выполнения. Это называется псевдопараллелизмом. Таким образом, процессор прыгает между потоками, мечась от одного к другому настолько быстро, что вы и заметить не успеваете.

В Java программа завершается после завершения всех её потоков – кроме фоновых задач, так называемых “демонов“. То есть выделяют два типа потоков:

  • Обычные потоки – программа ждёт их завершения, чтобы завершиться самой,
  • Потоки-демоны – это фоновые задачи. Если все остальные недемонические потоки завершились, а потоки-демоны нет – программа всё равно завершится и собственноручно прибьёт потоки-демоны.

Объявить поток демоном или проверить, является ли он демоническим, можно с помощью методов класса Thread, о которых мы поговорим дальше.

Класс Thread

Для запуска задачи можно создать объект Thread и задать ему действие. По существу, мы лямбда-выражением замещаем создание Runnable для конструктора Thread:

Также можно унаследовать свой класс от Thread и записать это действие в переопределении метода run():

Выбирайте на свой вкус и цвет. Но написать создание потока ещё не значит запустить его. У класса Thread есть несколько основных методов для работы:

  • start()запустить выполнение потока.
  • stop()остановить выполнение потока. Однако его не рекомендуют, потому что можно оборвать на полуслове операцию, которая в итоге приведёт к неправильному состоянию объекта, с которым она работала.
  • interrupt() – рекомендуют для остановки потока использовать именно этот метод. При вызове метода interrupt(), устанавливается флаг прерывания потока. Это означает, что если поток находится в режиме ожидания (например, в методе sleep(), wait(), или join()), он будет прерван, вызвав исключение InterruptedException, и флаг прерывания сбросится. Причём когда поток не находится в режиме ожидания, то он должен самостоятельно обрабатывать прерывание, т.е. проверять методом isInterrupted() время от времени, не пора ли ему баиньки.
  • isInterrupted() – как раз метод для того, чтобы узнать, был ли послано потоку сообщение о необходимости прерваться.
  • isAlive() – используется для проверки, выполняется ли ещё поток или уже завершил свою работу. Если поток выполняется, метод возвращает true, если же поток был завершён или ещё не был запущен, то метод возвращает false.
  • isDaemon() – проверка на демона (фоновый ли перед нами поток).
  • getName()/setName() – получить/поменять название потока.
  • getPriority()/setPriority() – установить приоритет потока (int). Чем больше приоритет, тем больше процессорного времени получает поток.
  • getState() – возвращает текущее состояние потока (enum State). Это может быть:
    • NEW (создан). Поток создан, но еще не запущен методом start().
    • RUNNABLE (выполняется). Поток работает.
    • BLOCKED (заблокирован). Поток заблокирован, потому что хочет обратиться к ресурсам, которые уже использует другой поток. Он смиренно ждёт, пока ресурсы освободятся.
    • WAITING (ждёт). Поток находится в состоянии ожидания (через какие-нибудь Thread.sleep(), Thread.join(), Object.wait() и так далее).
    • TIMED_WAITING (ждёт по таймауту). Поток находится в состоянии ожидания с указанным временным интервалом с помощью методов ожидания (как в предыдущем пункте). Но только он закончит ждать, когда истечёт заданное параметром метода время.
    • TERMINATED (завершён). Поток завершил свое выполнение – либо успешно, либо с ошибкой.

Отдельно хочу выделить часто используемый метод Thread.sleep(). Когда вам нужно ожидать события, вы можете в цикле крутить Thread.sleep(500). Тогда этот метод будет ждать 500 миллисекунд – 0.5 секунды. И каждые полсекунды делать проверку, наступило ли событие. Либо же вам в целом нужно подождать какое-то время – и вы можете его передать как параметр в этот метод. Как правило, Thread.sleep() действительно часто пригождается в распространённых задачах.

Пример

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

Здесь операция записи в файл окружена конструкцией try-catch-finally. Она позволяет словить исключение, если возникнет ошибка при работе с файлом.

А второй поток создадим другим способом – через лямбда-выражение с Runnable. Перейдём в класс Main и напишем метод создания потока. Он будет проверять знания пользователя в сложении, генерируя случайные примеры и считывая ввод с клавиатуры:

Теперь давайте запустим два потока вместе! Должно получиться так, что долгая запись в файл не помешает нам взаимодействовать с программой. По сути, пока в файл пишутся кучи строк, мы спокойно решаем примеры.

Теперь будем решать примеры, параллельно видя уведомления о записи строк в файл.

Вывод в консоль от нескольких потоков: первый поток записывает много строк в файл, а второй поток позволяет решать примеры
Содержимое файла file.txt

Вот так! Задачи выполнялись параллельно в силу того, что мы запустили несколько потоков. А даже если и на одном ядре бы выполнялись, для нас бы это выглядело как параллельно.

У файла file.txt вес 17 469 619 КБ

Нормальный такой на 17 гигов файлик получился. Миллиард строчек, как-никак!

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

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