Problem: çatışmayan resurs thread-dir

Tipik Spring MVC servisi hər sorğunu ayrıca platforma thread-ində emal edir. Vaxtın çoxunda həmin thread hesablama aparmır; verilənlər bazasını, başqa servisə HTTP çağırışını və ya faylı gözləyir. Gözləyərkən də thread əməliyyat sisteminin thread-ini, stack yaddaşını və planlaşdırma xərcini tutur. Bir neçə yüzlük Tomcat pool-u CPU demək olar ki, boş olsa belə, paralelliyi bir neçə yüz eyni vaxtlı sorğu ilə məhdudlaşdırır. Pool ölçüsünü artırmaq yalnız yaddaş və kontekst keçidi zərər verənə qədər kömək edir.

Alternativlər reaktiv proqramlaşdırma və ya callback idi; onlar yaxşı miqyaslanır, amma kodun yazılma, debug və oxunma qaydasını dəyişir. Java 21-də (JEP 444) yekunlaşan virtual thread-lər başqa yol seçir: sadə bloklayan üslubu saxla və thread-i hər tapşırığa bir dənə düşəcək qədər ucuz et.

Virtual thread nədir

Virtual thread birbaşa əməliyyat sistemi tərəfindən yox, JVM tərəfindən idarə olunan java.lang.Thread-dir. Çoxlu virtual thread carrier thread adlanan kiçik platforma thread-ləri pool-una planlaşdırılır. Virtual thread-də işləyən kod JDK-nın idarə etməyi bildiyi bloklayan əməliyyatı (socket I/O, Thread.sleep, java.util.concurrent kilidləri, növbələr) çağıranda virtual thread unmount olunur: stack-i heap-də saxlanır, carrier thread isə başqa virtual thread işlətməkdə sərbəst qalır. Əməliyyat davam edə biləndə virtual thread yenidən mount olunur, ola bilsin başqa carrier üzərində.

 virtual thread-lər  (minlərlə, ucuz, stack-lər heap-də)
   VT1  VT2  VT3  VT4  VT5  VT6 ...
     \   |    |    /     |   /
      v  v    v   v      v  v
   +-----------------------------+
   | carrier thread-lər (~#CPU)  |   <- real OS thread-ləri
   +-----------------------------+

Yaratmaq bir sətirdir, içindəki kod isə adi bloklayan koddur:

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {    List<Future<Quote>> quotes = providers.stream()        .map(p -> executor.submit(() -> p.fetchQuote(request)))        .toList();    // hər tapşırıq OS thread-i israf etmədən şəbəkə I/O-da bloklana bilər}

Pool-lardan fərqə diqqət edin: virtual thread-ləri pool-lamırsınız. Hər tapşırığa bir dənə yaradın və bitməyə qoyun. Onları pool-lamaq məqsədi puç edir.

Nə yaxşılaşır, nə yox

Virtual thread-lər vaxtının çoxunu gözləməyə sərf edən çoxlu paralel tapşırığı olan yüklərdə ötürmə qabiliyyətini yaxşılaşdırır: verilənlər bazası və digər servisləri çağıran sorğu handler-ləri, fan-out çağırışlar, element başına I/O olan batch işlər. Kod ardıcıl qalır, stack trace-lər mənalı qalır, debugger və profiler-lər hələ də tapşırıq başına bir thread görür.

Onlar CPU-ya bağlı kodu sürətləndirmir. Nüvə lazım olan hesablama yenə nüvə tələb edir və nüvədən çox virtual thread olduqda onlar sadəcə növbə ilə işləyir. Tək sorğunun gecikməsini də azaltmırlar; sadəcə daha çox sorğunun eyni anda gözləməsinə imkan verirlər.

Spring Boot ayarı

Java 21 və ya yeni versiyada işləyən Spring Boot 3.2 və sonrakılarda bir property sorğu emalını və bir sıra digər executor-ları virtual thread-lərə keçirir:

spring:  threads:    virtual:      enabled: true

Bu property ilə daxili Tomcat və ya Jetty sorğular üçün virtual thread-lər işlədir, Spring-in default tapşırıq icrası və planlaşdırma infrastrukturu da buna uyğunlaşır. Təsirə məruz qalan komponentlərin dəqiq siyahısı üçün framework versiyanızın sənədinə baxın və nəticə çıxarmazdan əvvəl thread dump-da və ya log çıxışında sorğu thread-lərinin virtual olduğunu yoxlayın.

Pinning

Bəzən virtual thread unmount ola bilmir və bloklanarkən carrier thread-ə pinned qalır, bu da carrier-i israf edir. JDK 21-dən 23-ə qədər iki əsas səbəb var idi:

  • synchronized blok və ya metod daxilində bloklama (o cümlədən Object.wait), çünki monitor carrier thread-ə bağlıdır.
  • Native metod və ya foreign function çağırışı daxilində bloklama.

Çoxlu virtual thread pinned halda bloklansa, kiçik carrier thread dəsti tükənə və ötürmə qabiliyyəti çökə bilər. JDK 24 (JEP 491, "Synchronize Virtual Threads without Pinning") implementasiyanı elə dəyişdi ki, tipik hallarda virtual thread-lər carrier-i pin etmədən monitoru ala, saxlaya və buraxa bilir. Native frame-lərdən pinning hələ də mövcuddur. Çox komandanın hələ də uzunmüddətli dəstək bazası kimi işlətdiyi JDK 21-dəsinizsə, bloklayan çağırışlar ətrafında synchronized-ı risk sayın.

Praktik addımlar:

  • Kilid bloklayan çağırış boyunca tutulan yerdə ReentrantLock (və ya digər java.util.concurrent kilidləri) üstün tutun. Onlar JDK 21-də virtual thread-i düzgün park edir.
  • synchronized bölmələrini qısa və I/O-suz saxlayın. Bu, virtual thread-lərdən əvvəl də yaxşı məsləhət idi və qalır.
  • JDK 21-dən 23-ə qədər olan dövrdə pinning-i tapmaq üçün -Djdk.tracePinnedThreads=full sistem property-si thread pinned halda bloklananda stack çap edir; Java Flight Recorder jdk.VirtualThreadPinned hadisəsini qeyd edir. Bu property bütün JDK versiyalarında mövcud olmaya bilər, ona görə işlətdiyiniz JDK-nın release notes sənədini yoxlayın.
  • Asılı olduğunuz kitabxanalar I/O ətrafında synchronized ehtiva edə bilər (köhnə JDBC drayverləri məlum nümunədir). Onları yeniləyin və yük altında test edin.

Thread-local-lar

Virtual thread-lər ThreadLocal-ı dəstəkləyir, ona görə mövcud kod işləyir. Amma virtual thread qısaömürlüdür və onların sayı çox ola bilər, ona görə bahalı obyekt (böyük buffer və ya SimpleDateFormat tipli keş) saxlayan thread-local artıq ucuz thread başına keş deyil: hər tapşırıq öz nüsxəsini alır. Sorğu boyu paylaşılmalı data açıq ötürülməli və ya bunun üçün nəzərdə tutulmuş strukturda saxlanmalıdır.

Scoped value-lar (ScopedValue) çağırış ağacında immutable kontekst ötürməyin nəzərdə tutulan əvəzidir. Java 21-də preview API idi, ona görə ondan asılı olmazdan əvvəl JDK versiyanızda statusunu yoxlayın. Konteksti (security context, MDC log dəyərləri, tracing) thread-local ilə ötürən framework-lər işləməyə davam edir, amma düzgünlüyü thread-in təkrar istifadəsinə söykənən koda diqqət edin.

Connection pool və digər limitlər hələ də qüvvədədir

Ucuz thread-lər sonsuz resurs demək deyil. On min virtual thread verilənlər bazası bağlantısı istəyir, pool-da isə iyirmi var, demək olar hamısı bağlantı gözləyir. İndi pool darboğazdır; bu, çox vaxt istədiyinizdir, çünki verilənlər bazasını qoruyur. Amma bu təsirlərə diqqət edin:

  • Paralel sorğuların sayını artırmaq növbələnməni web serverdən connection pool-a köçürür; ağlabatan acquire timeout-ları verin ki, gözləyən sorğular asılı qalmaq əvəzinə uğursuz olsun.
  • Aşağı axın servisləri, rate limit-lər və verilənlər bazası sürətlənmir. Paralel çağırışların qəfil artması onları basa bilər. Paylanmış sistemlərdə uğursuzluq idarəsində bəhs olunan kimi, bulkhead və ya Semaphore ilə asılılığa paralelliyi məhdudlaşdırın.
  • Thread başına yaddaş yüngüldür, amma pulsuz deyil. Park olunmuş virtual thread-in stack-i heap-də yaşayır, ona görə dərin çağırış stack-ləri çox sayda thread ilə vurulanda toplanır; yaddaş və garbage collection məqaləsinə baxın.
// Kövrək asılılığa paralel çağırışları məhdudlaşdırprivate final Semaphore paymentGatewayLimit = new Semaphore(50);Result charge(Payment p) throws InterruptedException {    paymentGatewayLimit.acquire();    try {        return gateway.charge(p);    } finally {        paymentGatewayLimit.release();    }}

Virtual thread-ləri nə vaxt işlətməmək

  • CPU-ya bağlı iş: nüvə sayına uyğun pool və ya fork/join framework işlədin.
  • Thread pool-u paralellik limiti kimi istifadə edən kod. Gizli limiti açıq semaphore və ya rate limiter ilə əvəz edin.
  • Asılılıqları virtual thread-lərdə hələ yoxlanmamış tətbiqlər (pinning-ə meylli drayverlər və ya native kitabxanalar).
  • Tətbiq artıq reaktivdir və yaxşı işləyir; yenidən yazmaq az qazandırır.
  • Paralelliyiniz aşağıdırsa (onlarla eyni vaxtlı sorğu), fayda kiçikdir və miqrasiya riski dəyməz.

Tez-tez edilən səhvlər

  • Virtual thread-ləri sabit ölçülü executor-da pool-lamaq.
  • Virtual thread-lərin thread təhlükəsizliyi ehtiyacını aradan qaldırdığını fərz etmək. Paylaşılan dəyişən vəziyyət hələ də concurrency əsaslarındakı alətləri tələb edir.
  • Spring property-sini yandırıb nə əvvəl, nə sonra heç nə ölçməmək.
  • Konteyner ölçüsünü unutmaq: konteynerdəki CPU limitləri carrier thread sayına təsir edir; Java üçün Docker məqaləsinə baxın.
  • Pool limitlərini dəyişməz saxlayıb növbələrin başqa yerə köçməsinə təəccüblənmək.

Güzəştlər

Virtual thread-lər yüksək paralellikli sadə, oxunaqlı, bloklayan kodu yeni uğursuzluq rejimləri (köhnə JDK-larda pinning, gizli pool darboğazları, tapşırıq başına thread-local) və reaktiv axınların verdiyindən daha az təbii backpressure bahasına verir. Reaktiv kod mürəkkəblik bahasına açıq axın nəzarəti verir. Verilənlər bazası və HTTP çağırışları edən əksər thread-per-request servislər üçün virtual thread-lər yüksək paralelliyə daha az səylə gedən yoldur, şərti ki, ölçəsiniz.

Yoxlama siyahısı

  • Java 21 və ya sonrası; property üçün Spring Boot 3.2 və ya sonrası.
  • Dəyişiklikdən əvvəl baza ölçmə götürülüb.
  • JDK 21-də bloklayan çağırışlar ətrafında synchronized blokları yoxdur (yeni JDK-larda yenidən yoxlayın).
  • Connection pool ölçüsü və timeout-lar şüurlu nəzərdən keçirilib.
  • Kövrək asılılıqlara paralellik limitləri qoyulub.
  • Thread-local-lar ağır obyektlərə görə yoxlanılıb.
  • Ayar açıq və bağlı halda yük testi aparılıb.

Növbəti addım

Yaddaş modeli hissələri tanış deyildisə, concurrency əsasları ilə başlayın, sonra yaddaş və garbage collection məqaləsini oxuyun. Bu mövzunu öyrənmə yolunuzda yerləşdirmək üçün senior backend developer yol xəritəsinə baxın.