Введение в многопоточность Java
Что такое потоки, зачем их использовать в Java и компромиссы параллельного программирования.
Введение в многопоточность Java
Каждая программа Java, которую вы писали до сих пор, имела один поток выполнения — один курсор, шагающий по байт-коду, одну переменную на стеке, по одному вызову метода за раз. Это поток «main», который JVM запускает для вас. Многопоточность — это когда JVM запускает несколько таких курсоров одновременно, разделяющих одну и ту же кучу. Два потока могут в один и тот же момент находиться в двух разных методах на двух разных объектах — и в этом одновременно и сила, и опасность.
У современных процессоров много ядер. Однопоточная программа оставляет все, кроме одного, простаивать. Веб-сервер, обрабатывающий по одному запросу за раз, не может использовать 16-ядерную машину лучше, чем 1-ядерную. Вся причина, по которой существует многопоточность, — заставить эти ядра работать и сохранять отзывчивость программы, когда одна её часть чего-то ждёт (диск, сеть, пользователя).
Что такое поток на самом деле
Поток Java — это две склеенные вместе вещи:
- Поток уровня ОС, который операционная система планирует на ядро ЦП. У него есть счётчик команд, набор регистров и нативный стек. ОС выделяет ему кванты времени, чередуя с каждым другим готовым к выполнению потоком на машине.
- Объект Java типа
java.lang.Thread. Он несёт имя, приоритет, флаг демона и — что важнее всего — ссылку наRunnable, чей методrun()он будет выполнять.
Когда вы вызываете thread.start(), JVM просит ОС создать новый нативный поток, который при первом планировании вызовет ваш метод run(). Исходный поток немедленно продолжается; теперь оба выполняются параллельно.
public static void main(String[] args) {
System.out.println("main: hello from " + Thread.currentThread().getName());
Thread t = new Thread(() -> {
System.out.println("worker: hello from " + Thread.currentThread().getName());
}, "worker-1");
t.start(); // worker выполняется параллельно с main
System.out.println("main: continuing");
}Чередование вывода не детерминировано — ОС решает, какой поток выполнится первым, и это решение меняется от запуска к запуску. Этот недетерминизм — центральный факт параллельного программирования.
Зачем использовать потоки
Два разных мотива, которые часто смешивают:
- Пропускная способность. У вас есть работа, ограниченная ЦП — изменение размера изображений, разбор, сжатие. Один поток использует одно ядро; восемь потоков используют восемь ядер и завершают примерно в восемь раз быстрее. Это параллелизм.
- Отзывчивость. У вас есть поток, который иначе бы блокировался — в ожидании сетевого ответа, ответа базы данных, клика пользователя. Помещение этой работы в отдельный поток позволяет остальной части программы продолжать делать полезное, пока он ждёт. Это конкурентность.
Большинству реальных программ нужно и то, и другое. Веб-сервер использует много потоков, чтобы один медленный запрос не застопорил остальные (конкурентность) и чтобы множество быстрых запросов можно было обрабатывать параллельно по ядрам (параллелизм).
Почему потоки трудны
Потоки разделяют память. Одну и ту же HashMap, ArrayList или int counter++ могут затронуть два потока в один и тот же момент — а JVM, кэшам ЦП и компилятору всем дозволено переупорядочивать операции способами, которые вас удивят. Три проблемы, на которые многопоточный код натыкается постоянно:
- Состояния гонки. Два потока читают-изменяют-записывают одну и ту же переменную; одно из обновлений теряется.
counter++не атомарен — это прочитать counter, прибавить один, записать counter. Два потока могут оба прочитать одно и то же значение и оба записать обратноvalue + 1, и вы теряете один шаг. - Видимость. Поток записывает поле; другой поток читает его и видит старое значение, потому что у каждого потока свой кэш ЦП, и нет правила, заставляющего запись распространиться без барьера памяти. Вот почему существуют
volatile,synchronizedиjava.util.concurrent. - Взаимная блокировка (deadlock). Поток A держит блокировку X и ждёт блокировку Y; поток B держит блокировку Y и ждёт блокировку X. Ни один никогда не продвигается. Программа зависает без исключения и без строки в логе.
Остаток этой части книги в основном о предотвращении этих трёх сбоев при сохранении выигрыша в пропускной способности.
Словарь, который вы будете встречать
Несколько терминов, которые встречаются повсюду и которые предполагают остальные главы:
| Термин | Что он означает |
|---|---|
| Конкурентность | Несколько задач, делающих прогресс в течение одного и того же периода времени. Они могут буквально выполняться в один момент, а могут и нет. |
| Параллелизм | Несколько задач, буквально выполняющихся в один момент на разных ядрах. Подмножество конкурентности. |
| Взаимное исключение | В критической секции одновременно допускается только один поток. Блокировки и synchronized его обеспечивают. |
| Модель памяти | Правила, говорящие, когда один поток гарантированно увидит запись другого потока. Определена JLS, уточнена JSR-133. |
| Атомарная | Операция, которую нельзя наблюдать сделанной наполовину. Либо она произошла, либо нет — никакого промежуточного состояния, видимого другим потокам. |
| Потокобезопасный | Класс, чей публичный API можно вызывать из нескольких потоков без внешней синхронизации и при этом вести себя корректно. |
Потоки-демоны и правило выхода JVM
Одна вещь, удивляющая новичков: JVM завершается, когда заканчивается последний не-демонический поток. Поток main — не демон. Потоки, которые вы создаёте через new Thread(...), по умолчанию не демоны — так что порождение рабочего потока удерживает JVM живой, пока этот рабочий не вернётся.
Вы можете пометить поток как демон с помощью t.setDaemon(true) до start(). Потоки-демоны не удерживают JVM живой; когда все не-демонические потоки заканчиваются, JVM выдёргивает их из-под них. Используйте демоны для фоновой работы, которая должна умереть вместе с программой (таймер, опрашивающий что-то, сбрасыватель метрик) — никогда для работы, завершение которой вам действительно нужно (записи в файл, коммиты транзакций).
Потоки против виртуальных потоков
Java 21 ввела виртуальные потоки, которые на уровне API выглядят идентично, но планируются JVM поверх небольшого пула потоков ОС. Ментальная модель в этой главе — один Java-Thread равен одному потоку ОС — описывает «платформенные потоки», то есть то, что вы получаете с простым конструктором new Thread(...). Платформенные потоки дороги: каждый занимает около 1 МБ нативного стека, и ОС ограничивает, сколько их может быть у процесса, так что вы создаёте их с осторожностью. Виртуальные потоки дёшевы — миллионы это нормально — и они снова делают блокирующий ввод-вывод бесплатным. Мы рассмотрим их в конце этой части книги; до тех пор «поток» означает «платформенный поток».
Проработанный пример: последовательно против параллельно
Программа ниже суммирует кусок работы ЦП двумя способами — один раз последовательно в потоке main, один раз разделив на четыре потока — и печатает реальное время для каждого. Числа варьируются от машины к машине, но форма везде одна и та же: больше потоков — меньше реального времени, пока не закончатся ядра.
Что вынести из запуска:
- Последовательный запуск использовал одно ядро; параллельный — четыре. Ускорение сублинейно (ближе к 3x, чем к 4x), потому что ОС, GC и другие потоки JVM тоже хотят процессорного времени. Закон Амдала в действии — небольшая последовательная доля (финальный цикл суммы частичных значений, разогрев цикла) ограничивает ускорение.
- Каждый рабочий записывал в свой собственный слот в
partials[]. Никакие два потока никогда не касались одного индекса, поэтому синхронизация не потребовалась. Это простейшая форма параллелизма — разбить данные и дать каждому потоку владеть своим разделом. t.join()— это то, какmainждёт завершенияworker-3. Без join-ов цикл прочитал быpartialsдо того, как рабочие записали бы, иparallelSumбыл бы неверен.join— единственный элемент координации потоков, который использует эта программа; следующие главы введут много других.- Поток-демон внизу не удержал JVM живой. Он собирался спать 60 секунд, но
mainвернулся, и JVM завершилась, убив демона посреди сна, не выполнив его инструкцию печати. Таков контракт демона. Thread.currentThread().getName()и явное имя, переданное конструкторуThread, — это то, как вы различаете потоки в логах, в профилировщиках и в дампах потоков. Всегда давайте потокам имена —Thread-3бесполезен, когда вы пытаетесь выяснить, какой из них застрял.
Что дальше
Следующая глава, Класс Java Thread, крупным планом рассматривает сам объект Thread — его конструкторы, разницу между расширением Thread и передачей ему Runnable, а также API для именования, приоритета, статуса демона и прерывания.
Практика
Два потока каждый выполняют `counter++` 100 000 раз на разделяемом `int counter`, который начинается с 0. После `join()` обоих какое значение `counter` наиболее вероятно?