JEP 429: Scoped Values (Incubator)
Scoped Values (значения с ограниченной областью видимости), версия Incubator (инкубационный модуль)
| Authors | Andrew Haley, Andrew Dinn |
| Ответственный | Andrew Haley |
| Тип | Feature |
| Область | JDK |
| Статус | Closed / Delivered |
| Выпуск | 20 |
| Компонент | core-libs |
| Обсуждение | loom dash dev at openjdk dot java dot net |
| Связан с | JEP 446: Scoped Values (Preview) |
| Рецензенты | Alan Bateman, Alex Buckley |
| Одобрен | John Rose |
| Создан | 2021/03/04 11:03 |
| Обновлён | 2023/11/29 14:40 |
| Задача | 8263012 |
Аннотация
Вводятся Scoped Values, которые позволяют совместно использовать неизменяемые данные внутри потока и между потоками. Они предпочтительнее локальных переменных потока, особенно при использовании большого количества потоков Virtual Threads (виртуальные потоки). Это API в статусе Incubator.
Цели
-
Простота использования — предоставить модель программирования для совместного использования данных как внутри потока, так и с дочерними потоками, чтобы упростить понимание того, как движутся данные.
-
Понятность — сделать время жизни общих данных видимым из синтаксической структуры кода.
-
Надёжность — гарантировать, что данные, переданные вызывающим кодом, могут быть получены только легитимными вызываемыми методами.
-
Производительность — рассматривать общие данные как неизменяемые, чтобы их могло совместно использовать большое число потоков и чтобы были возможны оптимизации во время выполнения.
Что не является целью
-
Изменение языка программирования Java не является целью.
-
Целью не является требовать отказа от локальных переменных потока или переводить существующий API
ThreadLocalв статус Deprecated (устаревший).
Мотивация
Большие программы на Java обычно состоят из отдельных взаимодополняющих компонентов, которым нужно обмениваться данными. Например, веб-фреймворк может включать серверный компонент, реализованный в стиле «поток на запрос», и компонент доступа к данным, отвечающий за их сохранение. Во всём фреймворке аутентификация и авторизация пользователей опирается на объект Principal, общий для компонентов. Серверный компонент создаёт Principal для каждого потока, обрабатывающего запрос, а компонент доступа к данным обращается к Principal потока, чтобы управлять доступом к базе данных.
На диаграмме ниже показано, как фреймворк обрабатывает два запроса, каждый в своём потоке. Обработка запроса идёт снизу вверх: от серверного компонента (Server.serve(...)) к пользовательскому коду (Application.handle(...)) и далее к компоненту доступа к данным (DBAccess.open()). Компонент доступа к данным определяет, разрешён ли потоку доступ к базе данных, следующим образом:
-
В потоке Thread 1 principal
ADMIN, созданный серверным компонентом, разрешает доступ к базе данных. Пунктирная линия показывает, что principal должен быть передан компоненту доступа к данным, который проверяет его и затем вызываетDBAccess.newConnection(). -
В потоке Thread 2 principal
GUEST, созданный серверным компонентом, не разрешает доступ к базе данных. Компонент доступа к данным проверяет principal, определяет, что пользовательский код не должен продолжать работу, и выбрасываетInvalidPrincipalException.
Thread 1 Thread 2
-------- --------
8. DBAccess.newConnection() 8. throw new InvalidPrincipalException()
7. DBAccess.open() <----------+ 7. DBAccess.open() <----------+
... | ... |
... Principal(ADMIN) ... Principal(GUEST)
2. Application.handle(..) | 2. Application.handle(..) |
1. Server.serve(..) ----------+ 1. Server.serve(..) ----------+
Обычно данные передаются от вызывающего кода вызываемому в виде аргументов методов, но для Principal, общего для серверного компонента и компонента доступа к данным, это не подходит, потому что серверный компонент сначала вызывает недоверенный пользовательский код. Нам нужен лучший способ передать данные от серверного компонента компоненту доступа к данным, чем протаскивать их через каскад вызовов недоверенных методов.
Локальные переменные потока для совместного использования данных
Традиционно разработчики использовали локальные переменные потока, появившиеся в Java 1.2, чтобы компоненты могли обмениваться данными, не прибегая к аргументам методов. Локальная переменная потока — это переменная типа ThreadLocal. Хотя она выглядит как обычная переменная, у локальной переменной потока есть несколько копий, по одной на каждый поток; какая именно копия используется, зависит от того, какой поток вызывает её методы get() или set(...), чтобы прочитать или записать значение. Код в одном потоке автоматически читает и записывает свою копию, а код в другом потоке автоматически читает и записывает свою собственную, отдельную копию. Обычно локальную переменную потока объявляют как поле final static, чтобы к ней было легко обратиться из многих компонентов.
Вот пример того, как серверный компонент и компонент доступа к данным, работающие в одном и том же потоке обработки запроса, могут использовать локальную переменную потока для совместного использования Principal. Сначала серверный компонент объявляет локальную переменную потока PRINCIPAL (1). Когда Server.serve(...) выполняется в потоке обработки запроса, он записывает подходящий Principal в локальную переменную потока (2), а затем вызывает пользовательский код. Если пользовательский код вызовет DBAccess.open(), компонент доступа к данным прочитает локальную переменную потока (3), чтобы получить Principal потока обработки запроса. Доступ к базе данных разрешается, только если Principal указывает на достаточные права (4).
class Server {
final static ThreadLocal<Principal> PRINCIPAL = new ThreadLocal<>(); // (1)
void serve(Request request, Response response) {
var level = (request.isAuthorized() ? ADMIN : GUEST);
var principal = new Principal(level);
PRINCIPAL.set(principal); // (2)
Application.handle(request, response);
}
}
class DBAccess {
DBConnection open() {
var principal = Server.PRINCIPAL.get(); // (3)
if (!principal.canOpen()) throw new InvalidPrincipalException();
return newConnection(...); // (4)
}
}
Благодаря локальной переменной потока не нужно передавать Principal как аргумент метода, когда серверный компонент вызывает пользовательский код и когда пользовательский код вызывает компонент доступа к данным. Локальная переменная потока служит своего рода скрытым аргументом метода: поток, который вызывает PRINCIPAL.set(...) в Server.serve(...), а затем PRINCIPAL.get() в DBAccess.open(), автоматически увидит собственную копию переменной PRINCIPAL. По сути, поле ThreadLocal служит ключом, по которому ищется значение Principal для текущего потока.
Проблемы локальных переменных потока
К сожалению, у локальных переменных потока есть многочисленные недостатки конструкции, которых невозможно избежать:
-
Неограниченная изменяемость — каждая локальная переменная потока изменяема: любой код, который может вызвать метод
get()локальной переменной потока, может в любой момент вызвать методset(...)этой переменной. APIThreadLocalдопускает это, чтобы поддерживать полностью универсальную модель взаимодействия, в которой данные могут передаваться между компонентами в любом направлении. Однако это может привести к запутанному, как спагетти, движению данных и к программам, в которых трудно понять, какой компонент обновляет общее состояние и в каком порядке. Более распространённая потребность, показанная в примере выше, — простая односторонняя передача данных от одного компонента другим. -
Неограниченное время жизни — после того как копия локальной переменной потока записана методом
set(...), она сохраняется на всё время жизни потока или до тех пор, пока код в потоке не вызовет методremove(). К сожалению, разработчики часто забывают вызватьremove(), поэтому данные потока часто хранятся дольше, чем нужно. Кроме того, в программах, которые полагаются на неограниченную изменяемость локальных переменных потока, может не быть чёткого момента, когда потоку безопасно вызватьremove(); это может привести к долговременной утечке памяти, поскольку данные потока не будут собраны сборщиком мусора, пока поток не завершится. Было бы лучше, если бы запись и чтение данных потока происходили в ограниченный период выполнения потока, что исключило бы возможность утечек. -
Дорогое наследование — накладные расходы локальных переменных потока могут быть ещё больше при использовании большого количества потоков, потому что локальные переменные родительского потока могут наследоваться дочерними потоками. (На самом деле локальная переменная потока не является локальной для одного потока.) Когда разработчик создаёт дочерний поток, наследующий локальные переменные потока, дочерний поток должен выделить память для каждой локальной переменной потока, ранее записанной в родительском потоке. Это может существенно увеличить потребление памяти. Дочерние потоки не могут совместно использовать память родительского потока, потому что локальные переменные потока изменяемы, а API
ThreadLocalтребует, чтобы изменение в одном потоке не было видно в других потоках. Это досадно, потому что на практике дочерние потоки редко вызывают методset(...)для унаследованных локальных переменных потока.
К легковесному совместному использованию данных
Проблемы локальных переменных потока стали острее с появлением потоков Virtual Threads (JEP 425). Virtual Threads — это легковесные потоки, реализованные в JDK. Множество потоков Virtual Threads используют один и тот же поток операционной системы, поэтому их может быть очень много. Потоки Virtual Threads не только многочисленны, но и настолько дёшевы, что могут представлять любую конкурентную единицу работы. Это значит, что веб-фреймворк может выделить новый поток Virtual Threads под обработку каждого запроса и при этом обрабатывать тысячи или миллионы запросов одновременно. В нашем примере методы Server.serve(...), Application.handle(...) и DBAccess.open() выполнялись бы в новом потоке Virtual Threads для каждого входящего запроса.
Очевидно, было бы полезно, чтобы эти методы могли совместно использовать данные независимо от того, выполняются ли они в потоках Virtual Threads или в традиционных платформенных потоках. Поскольку потоки Virtual Threads являются экземплярами Thread, у них могут быть локальные переменные потока; более того, короткое время жизни потоков Virtual Threads и то, что они не используют пул, делают упомянутую выше проблему долговременных утечек памяти менее острой. (Вызывать метод remove() локальной переменной потока не нужно, если поток быстро завершается, поскольку при завершении его локальные переменные потока удаляются автоматически.) Однако если у каждого из миллиона потоков Virtual Threads есть изменяемые локальные переменные потока, потребление памяти может быть значительным.
Итак, локальные переменные потока сложнее, чем обычно требуется для совместного использования данных, и влекут значительные издержки, которых невозможно избежать. Платформа Java должна предоставить способ хранить неизменяемые и наследуемые данные потока для тысяч или миллионов потоков Virtual Threads. Поскольку такие переменные потока были бы неизменяемыми, дочерние потоки могли бы эффективно совместно использовать их данные. Кроме того, время жизни таких переменных потока должно быть ограничено: любые данные, переданные через переменную потока, должны становиться недоступными, как только завершится метод, который изначально предоставил эти данные.
Описание
Scoped-значение позволяет безопасно и эффективно передавать данные между компонентами большой программы, не прибегая к аргументам методов. Это переменная типа ScopedValue. Обычно её объявляют как поле final static, чтобы к ней было легко обратиться из многих компонентов.
Как и у локальной переменной потока, у scoped-значения есть несколько копий, по одной на каждый поток. Какая именно копия используется, зависит от того, какой поток вызывает его методы. В отличие от локальной переменной потока, scoped-значение записывается один раз и затем неизменяемо, и оно доступно только в течение ограниченного периода выполнения потока.
Scoped-значение используется так, как показано ниже. Некоторый код вызывает ScopedValue.where(...), передавая scoped-значение и объект, к которому его нужно привязать. Вызов run(...) привязывает scoped-значение, создавая копию, специфичную для текущего потока, а затем выполняет лямбда-выражение, переданное в качестве аргумента. В течение времени жизни вызова run(...) лямбда-выражение или любой метод, вызванный из него прямо или косвенно, может прочитать scoped-значение с помощью его метода get(). После завершения метода run(...) привязка уничтожается.
final static ScopedValue<...> V = ScopedValue.newInstance();
// In some method
ScopedValue.where(V, <value>)
.run(() -> { ... V.get() ... call methods ... });
// In a method called directly or indirectly from the lambda expression
... V.get() ...
Синтаксическая структура кода очерчивает период, в течение которого поток может читать свою копию scoped-значения. Такое ограниченное время жизни в сочетании с неизменяемостью значительно упрощает рассуждения о поведении потоков. Односторонняя передача данных от вызывающего кода к вызываемым методам — как прямым, так и косвенным — видна с первого взгляда. Нет метода set(...), который позволил бы удалённому коду изменить scoped-значение в любой момент. Неизменяемость также помогает производительности: чтение scoped-значения с помощью get() часто выполняется так же быстро, как чтение локальной переменной, независимо от расстояния в стеке между вызывающим и вызываемым кодом.
Значение слова «scoped»
Область видимости (scope) чего-либо — это пространство, в котором оно существует, — пределы или диапазон, в которых его можно использовать. Например, в языке программирования Java область видимости объявления переменной — это часть текста программы, в которой допустимо обращаться к переменной по простому имени (JLS 6.3). Такую область видимости точнее называть лексической областью видимости или статической областью видимости, поскольку пространство, в котором переменная видима, можно определить статически, найдя символы { и } в тексте программы.
Другой вид области видимости называется динамической областью видимости. Динамическая область видимости чего-либо — это те части программы, которые могут использовать это во время её выполнения. Именно на это понятие опирается термин scoped value: привязка scoped-значения V в методе run(...) создаёт копию V, которую могут использовать определённые части программы во время выполнения, а именно методы, вызываемые прямо или косвенно из run(...). Разворачивающееся выполнение этих методов определяет динамическую область видимости; копия находится в области видимости во время выполнения этих методов и нигде больше.
Пример веб-фреймворка со scoped-значениями
Показанный ранее код фреймворка легко переписать так, чтобы вместо thread-local переменной использовалось scoped-значение. В точке (1) серверный компонент объявляет scoped-значение вместо thread-local переменной. В точке (2) серверный компонент вызывает ScopedValue.where(...) и run(...) вместо метода set(...) thread-local переменной.
class Server {
final static ScopedValue<Principal> PRINCIPAL = ScopedValue.newInstance(); // (1)
void serve(Request request, Response response) {
var level = (request.isAdmin() ? ADMIN : GUEST);
var principal = new Principal(level);
ScopedValue.where(PRINCIPAL, principal) // (2)
.run(() -> Application.handle(request, response));
}
}
class DBAccess {
DBConnection open() {
var principal = Server.PRINCIPAL.get(); // (3)
if (!principal.canOpen()) throw new InvalidPrincipalException();
return newConnection(...);
}
}
Вместе where(...) и run(...) обеспечивают одностороннюю передачу данных от серверного компонента к компоненту доступа к данным. Scoped-значение, переданное в where(...), привязано к соответствующему объекту на время вызова run(...), поэтому PRINCIPAL.get() в любом методе, вызванном из run(...), прочитает это значение. Соответственно, когда Server.serve(...) вызывает пользовательский код, а пользовательский код вызывает DBAccess.open(), значение, прочитанное из scoped-значения (3), — это значение, записанное Server.serve(...) ранее в этом потоке.
Привязка, установленная run(...), доступна только в коде, вызванном из run(...). Если бы PRINCIPAL.get() встречался в Server.serve(...) после вызова run(...), было бы выброшено исключение, потому что PRINCIPAL больше не привязан в этом потоке.
Повторная привязка scoped-значений
Неизменяемость scoped-значений означает, что вызывающий код может с помощью scoped-значения надёжно передать константное значение вызываемым методам в том же потоке. Однако бывают случаи, когда одному из вызываемых методов нужно с помощью того же scoped-значения передать другое значение своим собственным вызываемым методам в этом потоке. API ScopedValue позволяет установить новую привязку для вложенных вызовов.
В качестве примера рассмотрим третий компонент веб-фреймворка: компонент журналирования с методом void log(Supplier<String> formatter). Пользовательский код передаёт лямбда-выражение в метод log(...); если журналирование включено, метод вызывает formatter.get(), чтобы вычислить лямбда-выражение, а затем выводит результат. Хотя у пользовательского кода может быть разрешение на доступ к базе данных, у лямбда-выражения его быть не должно, поскольку ему нужно только отформатировать текст. Поэтому scoped-значение, изначально привязанное в Server.serve(...), должно быть повторно привязано к гостевому Principal на время выполнения formatter.get():
8. InvalidPrincipalException()
7. DBAccess.open() <--------------------------+ X---------+
... | |
... Principal(GUEST) |
4. Supplier.get() | |
3. Logger.log(() -> { DBAccess.open(); }) ----+ Principal(ADMIN)
2. Application.handle(..) |
1. Server.serve(..) ---------------------------------------+
Ниже приведён код log(...) с повторной привязкой. Он получает гостевой Principal (1) и передаёт его как новую привязку для scoped-значения PRINCIPAL (2). На время вызова call (3) PRINCIPAL.get() будет читать это новое значение. Таким образом, если пользовательский код передаст в log(...) вредоносное лямбда-выражение, выполняющее DBAccess.open(), проверка в DBAccess.open() прочитает гостевой Principal из PRINCIPAL и выбросит InvalidPrincipalException.
class Logger {
void log(Supplier<String> formatter) {
if (loggingEnabled) {
var guest = Principal.createGuest(); // (1)
var message = ScopedValue.where(Server.PRINCIPAL, guest) // (2)
.call(() -> formatter.get()); // (3)
write(logFile, "%s %s".format(timeStamp(), message));
}
}
}
(Здесь мы используем call(...) вместо run(...) для вызова форматировщика, потому что нужен результат лямбда-выражения.) Синтаксическая структура where(...) и call(...) означает, что повторная привязка видна только во вложенной динамической области видимости, которую создаёт call(...). Тело log(...) не может изменить привязку, которую видит сам этот метод, но может изменить привязку, которую видят вызываемые им методы, например метод formatter.get(...). Это гарантирует ограниченное время жизни для передачи нового значения.
Наследование scoped-значений
В примере веб-фреймворка каждому запросу выделяется отдельный поток для его обработки, поэтому один и тот же поток может выполнять код фреймворка из серверного компонента, затем пользовательский код разработчика приложения, затем снова код фреймворка из компонента доступа к данным. Однако пользовательский код может воспользоваться легковесностью Virtual Threads, создавая собственные потоки Virtual Threads и выполняя в них свой код. Эти потоки Virtual Threads будут дочерними потоками потока, обрабатывающего запрос.
Данные, которые передаёт компонент, работающий в потоке обработки запроса, должны быть доступны компонентам, работающим в дочерних потоках. Иначе, когда пользовательский код, работающий в дочернем потоке, вызовет компонент доступа к данным, этот компонент — теперь тоже работающий в дочернем потоке — не сможет проверить Principal, переданный серверным компонентом, работающим в потоке обработки запроса. Чтобы данные можно было передавать между потоками, scoped-значения могут наследоваться дочерними потоками.
Предпочтительный механизм создания потоков Virtual Threads в пользовательском коде — API Structured Concurrency (структурированная конкурентность; JEP 428), а именно класс StructuredTaskScope. Scoped-значения родительского потока автоматически наследуются дочерними потоками, созданными с помощью StructuredTaskScope. Код в дочернем потоке может использовать привязки, установленные для scoped-значения в родительском потоке, с минимальными накладными расходами. В отличие от thread-local переменных, привязки scoped-значений родительского потока не копируются в дочерний поток.
Ниже приведён пример наследования scoped-значений, которое происходит незаметно в пользовательском коде, в варианте метода Application.handle(...), вызываемого из Server.serve(...). Пользовательский код вызывает StructuredTaskScope.fork(...) (1, 2), чтобы выполнить методы findUser() и fetchOrder() конкурентно, каждый в своём потоке Virtual Threads. Каждый метод вызывает компонент доступа к данным (3), который, как и раньше, обращается к scoped-значению PRINCIPAL (4). Остальные детали пользовательского кода здесь не обсуждаются; подробнее см. JEP 428.
class Application {
Response handle() throws ExecutionException, InterruptedException {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<String> user = scope.fork(() -> findUser()); // (1)
Future<Integer> order = scope.fork(() -> fetchOrder()); // (2)
scope.join().throwIfFailed(); // Wait for both forks
return new Response(user.resultNow(), order.resultNow());
}
}
String findUser() {
... DBAccess.open() ... // (3)
}
}
class DBAccess {
DBConnection open() {
var principal = Server.PRINCIPAL.get(); // (4)
if (!principal.canOpen()) throw new InvalidPrincipalException();
return newConnection(...);
}
}
StructuredTaskScope.fork(...) гарантирует, что привязка scoped-значения PRINCIPAL, сделанная в потоке обработки запроса — когда Server.serve(...) вызвал ScopedValue.where(...), — автоматически видна PRINCIPAL.get() в дочернем потоке. На следующей диаграмме показано, как динамическая область видимости привязки распространяется на все методы, выполняемые в дочернем потоке:
Thread 1 Thread 2
-------- --------
8. DBAccess.newConnection()
7. DBAccess.open() <----------+
... |
... Principal(ADMIN)
4. Application.findUser() |
3. StructuredTaskScope.fork(..) |
2. Application.handle(..) |
1. Server.serve(..) ---------------------------------------------+
Модель fork/join, которую предоставляет StructuredTaskScope, означает, что динамическая область видимости привязки по-прежнему ограничена временем вызова ScopedValue.where(...).run(...). Principal остаётся в области видимости, пока работает дочерний поток, а scope.join() гарантирует, что дочерние потоки завершатся до того, как run(...) сможет вернуть управление и уничтожить привязку. Так удаётся избежать проблемы неограниченного времени жизни, которая возникает при использовании thread-local переменных.
Переход на scoped-значения
Scoped-значения, скорее всего, окажутся полезными и предпочтительными во многих сценариях, где сегодня используются thread-local переменные. Помимо роли скрытых аргументов методов, scoped-значения могут помочь в следующих случаях:
-
Реентерабельный код — иногда нужно обнаруживать рекурсию, например потому, что фреймворк не реентерабелен или потому, что рекурсию необходимо как-то ограничить. Scoped-значение позволяет это сделать: настройте его как обычно, с помощью
ScopedValue.where(...)иrun(...), а затем глубоко в стеке вызовов вызовитеScopedValue.isBound(), чтобы проверить, есть ли у него привязка для текущего потока. В более сложном варианте scoped-значение может моделировать счётчик рекурсии за счёт многократной повторной привязки. -
Вложенные транзакции — обнаружение рекурсии может быть полезно и в случае плоских транзакций: любая транзакция, начатая во время выполнения другой транзакции, становится частью самой внешней транзакции.
-
Графические контексты — ещё один пример встречается в графике, где часто есть контекст рисования, который нужно разделять между частями программы. Scoped-значения благодаря автоматической очистке и реентерабельности подходят для этого лучше, чем thread-local переменные.
В целом мы рекомендуем переходить на scoped-значения, когда назначение thread-local переменной совпадает с целью scoped-значения: односторонней передачей неизменяемых данных. Если в кодовой базе thread-local переменные используются для двусторонней передачи — когда вызываемый метод глубоко в стеке вызовов передаёт данные удалённому вызывающему коду через ThreadLocal.set(...), — или совершенно неструктурированно, то переход невозможен.
Есть несколько сценариев, в которых предпочтительнее thread-local переменные. Например, кэширование объектов, которые дорого создавать и использовать, таких как экземпляры java.text.DateFormat. Общеизвестно, что объект DateFormat изменяемый, поэтому его нельзя разделять между потоками без синхронизации. Выдать каждому потоку собственный объект DateFormat через thread-local переменную, которая существует в течение всего времени жизни потока, часто бывает практичным подходом.
Альтернативы
Многие возможности scoped-значений можно эмулировать с помощью thread-local переменных, хотя и ценой некоторых потерь в объёме занимаемой памяти, безопасности и производительности.
Мы экспериментировали с изменённой версией ThreadLocal, которая поддерживает некоторые свойства scoped-значений. Однако необходимость нести дополнительный груз thread-local переменных приводит к чрезмерно громоздкой реализации, или к API, который возвращает UnsupportedOperationException для значительной части основной функциональности, или к тому и другому одновременно. Поэтому лучше не изменять ThreadLocal, а ввести scoped-значения как совершенно отдельное понятие.
Scoped-значения созданы под влиянием того, как многие диалекты Lisp поддерживают свободные переменные с динамической областью видимости; в частности, того, как такие переменные ведут себя в многопоточной среде выполнения с глубоким связыванием (deep binding), такой как Interlisp-D. Scoped-значения улучшают свободные переменные Lisp, добавляя типобезопасность, неизменяемость, инкапсуляцию и эффективный доступ как внутри потоков, так и между ними.