Spis treści

Jeśli nie ThreadLocal to co? ScopedValue!

Co to jest ScopedValue?

ScopedValue to wartość, która może być widoczna i używana w metodach, a która nie została do nich przekazana jako ich parametr. Dotychczas taką rolę w języku Java pełniły zmienne ThreadLocal. Przyjrzyjmy się im odrobinę, żeby odświeżyć swoją wiedzę, a później popatrzymy, co nowego wnoszą ich nowoczesne zastępniki, czyli zmienne ScopedValue.

Co to jest zmienna ThreadLocal?

Zmienne ThreadLocal są deklarowane zazwyczaj jako prywante zmienne globalne (pola statyczne) i dają gwarancję, że wartość, którą z nich odczytujemy (metodą get()) to ta sama wartość, która została w nich zapisana (metodą set()) w tym samym wątku (lub ta, którą zwróciło wywołanie metody protected initialValue() zwracającej wartość inicjującą zmienną ThreaLocal).

W ten sposób możemy na przykład zdefiniować metodę get(), która zwróci identyfikator bieżącego wątku:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
 import java.util.concurrent.atomic.AtomicInteger;

 public class ThreadId {
     // Atomic integer containing the next thread ID to be assigned
     private static final AtomicInteger nextId = new AtomicInteger(0);

     // Thread local variable containing each thread's ID
     private static final ThreadLocal<Integer> threadId =
         new ThreadLocal<Integer>() {
             @Override protected Integer initialValue() {
                 return nextId.getAndIncrement();
         }
     };

     // Returns the current thread's unique ID, assigning it if necessary
     public static int get() {
         return threadId.get();
     }
 }

Źródło: ThreadName.java

Jak działa ThreaLocal w środku?

Byłam przekonana, że ThreadLocal jest zaimplementowana tak, że przechowuje jakąś mapę ConcurrentHashMap<Thread,T> używającą Thread.currentThread() jako klucza i wartości zmiennej ThreadLocal… jako wartości. Sądziłam, że w zależności od tego, w jakim wątku się aktualnie wykonujemy, uzyskujemy (lub ustawiamy w tej mapie) odpowiednią wartość.

Tak, ryzykujemy, że przy mapie będzie dość “tłoczno” (thread contention). Dodatkowo, ponieważ zmienne ThreadLocal są statyczne, taka mapa musiałaby być również zasobem statycznym: byłaby trzymana w pamięci razem z klasą, a więc w zasadzie przez cały czas działania aplikacji. A zakończone wątki - klucze w owej mapie - wraz z ich zasobami - również nie mogłyby być zwolnione przez garbage collector.

Chyba że użylibyśmy WeakHashMap - wówczas kolektor mógłby zgarnąć zakończony wątek (wraz z całą “dżunglą”). Ale wciaż jest problem “zagęszczenia” wokół mapy.

Tymczasem te zmienne wcale tak nie działają!

Zobaczcie:

Każdy wątek przechowuje (i ma wyłączny dostęp, a więc odpada konieczność synchronizacji) mapę, której kluczami są zmienne ThreadLocal, a wartościami - wartości tych zmiennych. Niezależnie od tego, jaką ścieżką przechodzi kod w danym wątku, wszystkie zapisywane i odczytywane przez ten kod zmienne typu ThreadLocal nie są “wyciągane” z pól obiektów, lecz z mapy siedzącej w obiekcie wątku.

1
2
// mapa tworzona dla każdego wątku i aktualizowana przez instancje ThreadLocal 
new WeakHashMap<ThreadLocal,T>()

Tak naprawdę używana jest wewnętrzna implementacja mapy: ThreadLocal.ThreadLocalMap. Optymalizuje ona sposób liczenia hashy dla kluczy tak, aby nie było zbyt wielu kolizji podczas wstawiania wartości do mapy (często przecież tworzone i inicjowane jest na raz wiele zmiennych ThreadLocal z domyślnymi wartościami) i dodatkowo dba o usuwanie entriesów z nullowymi kluczami (weak referencje pomagają w tym, że garbage collector odśmieca obiekty ThreadLocal zakończonych wątków, ale entriesy z nullowymi kluczami wciąż twią w ThreadLcoalMap) głównie podczas kolizji bądź zmiany rozmiaru.

[obrazek thread local map i wątki]

Do czego używa się zmiennych ThreadLocal?

sesje

Zmienne TL (ThreadLocal) mogą być używane do przechowywania informacji specyficznych dla użytkownika, na przykład danych sesji obecnie zalogowanego użytkownika aplikacji w wielowątkowej aplikacji webowej

połączenie do bazy danych

W aplikacjach wielowątkowych każdy wątek może mieć własne połączenie do bazy danych, dzięki czemu unika się “stłoczenia” wątków podczas dostępu do bazy i poprawia się ogólną wydajność apliakcji

identyfikacja żądania

Można śledzić żądania - wątek może przechowywać w zmiennej TL identyfiakator żądania - po to, by móc skorelować logi należące do tego żądania podczas przejścia przez całą ścieżkę obsługi tego żądania w aplikacji

zarządzanie transakcjami

Zmienne TL przechowują identyfiakator transakcji czy sam obiekt reprezentujący transację/jej stan Biblioteki i frameworki które pomagają w pisaniu kodu transakcyjnego (Spring?) zwykle używają zmiennych ThreadLocal do przechowywania stanu transakcji (dlatego właśnie próba zakomitowania transakcji w innym wątu niż ten, w którym transakcja została utworzona prowadzi do błędów). Ta właściwość pozwala na zaimplementowanie transakcyjności w sposób dość “przezroczysty” dla programistów piszących kod transakcyjny, np. na zaoferowanie “declarative transactions” w bibliotece Spring.

kontekst

W zmiennych TL można przechowywać informacje kontekstowe takie jak poziom logowania bądź docelowe miejsce logowania (plik/konsola/socket)

locale

W wielowątkowych aplikacjach webowych w zmiennych TL przechowywać można informacje o preferowanym przez użytkownika (i wysłanym przez przeglądarkę w nagłówku żądania HTTP) języku i “locale” (patrz: Accept-Language)

cache

Instancje cache-a mogą być przechowywane w zmiennej TL (cache per thread) aby uniknąć “stłoczenia” wątków podczas dostępu do globalnej instancji cache-a

logowanie

Mogą być również wykorzystywane do przechowywania informacji o

  • kontekście przetwarzania (rejestrowania, jakie metody biznesowe zostały wywołane)
  • czasie wykonania (czas rozpoczęcia/zakończenia przetwarzania)
  • identyfikacji żądania (w modelu: jeden wątek obsługuje jedno żądanie)

Pamiętam, że pierwszy raz spotkałam zmienne ThreadLocal, gdy wgryzałam się w sposób logowania żądań i związanych z nimi transakcji w dużej aplikacji typu “enterprise”. Nie wiedziałam o tym, że istnieje coś takiego jak MDC (Mapped Diagnotic Context) i jaki problem rozwiązuje (spoiler: pozwala skonfigurować logowanie tak, aby automatycznie dodawały się informacji o kontekście - jaki request, jaka sesja, jaka transakcja). Implementacje MDC w popularnych bibliotekach są oparte właśnie o zmienne ThreadLocal, na przykład:

/posts/2023-07-16_threadlocal/scoped.jpg

Scoped Values - szczegóły

Po tej przydługiej dygresji popatrzmy na to, czym jest ScopedValue. Według opisu w JavaDoc:

A value that may be safely and efficiently shared to methods without using method parameters. 

W zasadzie to samo można powiedzieć o ThreadLocal! Jednak kiedy wczytamy się w opis, zauważymy, że wartości nie są “przypisywane” czy “ustawiane” (tak jak w zmiennych TL), lecz “związane” (“bound”).

API ScopedValue działa przez wywołanie metody w której obiekt ScopedValue jest “związany” (“bounded”) z pewna wartością przez “ograniczony” (“scoped”) okres, w którym ta metoda jest wykonywana (…a to “związanie” znika po wyjściu z “zakresu”).

Kontekst dynamiczny

To “związanie” wartości z pewnym ograniczonym czasem działania (a właściwie - z leksykalnym zakresem kodu, w którym związanie jest “ważne”) nazywane jest “kontekstem dynamicznym”: poza tym kontekstem zmienna nie jest związana z żadną wartością.

Można o tych zmiennych myśleć tak:

  • potrzebujemy zmiennej (nazwijmy ją VAL) typu ScopedValue (V to typ przechowywanej w ScopedValue wartości))
  • potrzebujemy, aby posiadała pewną wartość v typu V
  • chcemy związać zmienną VAL z wartością v podczas wykonywania pewnego runnable: () -> process()

Wówczas te wymagania można zakodować tak:

1
2
3
4
5
record V(String v){}

private static final ScopedValue<V> VAL = ScopedValue.newInstance();

ScopedValue.runWhere(VAL, V("Answer is: 42"), () -> doSomething());

Źródło: Scoped.java

Związanie

Ponowne związanie

Zmienne ScopedValue powinny być deklarowane jako static final i rozumiane jako “klucze” do uzyskania wartości w kontekście, w jakim są zdefiniowane.

“Związanie” zawsze dokonuje się w kontekście bieżącego wątku - jednak może być ono “zawiązane ponownie” (“rebound”) na potrzeby wywoływanej metody: zagnieżdzamy wówczas nowy zakrez dynamiczny (utworzony po pownym związaniu) wewnątrz starego kontekstu.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import java.util.concurrent.StructuredTaskScope;
import java.lang.Thread;

public class ScopedThread {
  private record V(String v){}
  
  private static final ScopedValue<V> VAL = ScopedValue.newInstance();

  public void printMyVal(){
    System.out.println(VAL.get());
  }

  public void test(String ...values) {
    for (String s : values) {
      ScopedValue.runWhere(VAL, new V(s), () ->{
          try (var scope = new StructuredTaskScope<String>()) {            
            scope.fork(() -> childTask(1));
            scope.fork(() -> childTask(2));
            try {
              scope.join();
            } catch(InterruptedException ex) {
              Thread.currentThread().interrupt();
            }
        }});
      }    
  }
  
  String childTask(int id) {
    // set name of thread - where do I execure?
    Thread.currentThread().setName("thread-" + id);    
    // modify "implicit parameter" from VAL by adding id
    ScopedValue.runWhere(VAL, new V(VAL.get().v + id), () ->{
      var val = "Task_%s: VAL=%s thread=%s".formatted(id, VAL.get(), Thread.currentThread().getName());
      System.out.println(val);
    });
    return "";
  }
  
  public static void main(String[] args) {
    var s = new ScopedThread();
    s.test("one", "two", "three");
  }
}

W powyższym programiku:

  • dla każdego napisu z wiersza poleceń tworzony jest nowy zakres (scope) dla zmiennej VAL, która jest związana (binding) z wartością typu V przechowującą napis
  • w zakresie tworzony jest zakres strukturalny StructuredTaskScope (napiszę o nim więce w kolejnym wpisie) w którym submitowane są do wykonania (jako wątki wirtualne) dwa runnable
  • w funkji childTask(int id)
    • ustawiam nazwę bieżącego wątku
    • tworzę nowy zakres w którym wiążę VAR z watością V, do której dopisuję wartość parametru id
    • wyousuję wartość w VAL oraz nazwę bieżącego wątku

Wiem, ten program kompletnie nie ma sensu. Ale moim celem było sprawdzenie, czy mam w głowie właściwy model tego, co się będzie działo :)

Źródło: ScopedThread.java

Garść linków