Dlaczego tworzenie Wątku jest drogie?

180

Samouczki Java mówią, że tworzenie wątku jest kosztowne. Ale dlaczego dokładnie jest to drogie? Co dokładnie dzieje się, gdy tworzony jest wątek Java, który powoduje, że jego tworzenie jest kosztowne? Przyjmuję to stwierdzenie za prawdziwe, ale interesuje mnie tylko mechanika tworzenia wątków w JVM.

Narzut cyklu życia nici. Tworzenie wątków i porzucanie nie są bezpłatne. Rzeczywisty narzut różni się w zależności od platformy, ale tworzenie wątków zajmuje dużo czasu, wprowadzając opóźnienia w przetwarzaniu żądań i wymaga pewnej aktywności przetwarzania przez JVM i system operacyjny. Jeśli żądania są częste i lekkie, jak w większości aplikacji serwerowych, utworzenie nowego wątku dla każdego żądania może zużywać znaczne zasoby komputerowe.

Z Java Concurrency in Practice
Brian Goetz, Tim Peierls, Joshua Bloch, Joseph Bowbeer, David Holmes, Doug Lea
Print ISBN-10: 0-321-34960-1

Kachanow
źródło
Nie znam kontekstu, w którym samouczki, które przeczytałeś, mówią tak: czy sugerują, że samo stworzenie jest drogie, czy też „tworzenie wątku” jest drogie. Różnica, którą próbuję pokazać, polega na czystym działaniu polegającym na utworzeniu wątku (nazwijmy go instancją lub coś takiego) lub na tym, że masz wątek (więc używając wątku: oczywiście mając narzut). Który z nich jest przedmiotem roszczenia // o który chcesz zapytać?
Nanne
9
@typoknig - Drogie w porównaniu do NIE tworzenia nowego wątku :)
willcodejavaforfood 30.03.11
możliwy duplikat generowania wątku Java
Paul Draper
1
Nici do wygrania. nie trzeba zawsze tworzyć nowych wątków dla zadań.
Alexander Mills

Odpowiedzi:

149

Tworzenie wątków Java jest kosztowne, ponieważ wymaga sporo pracy:

  • Duży stos pamięci musi zostać przydzielony i zainicjowany dla stosu wątków.
  • Należy wykonać wywołania systemowe, aby utworzyć / zarejestrować natywny wątek w systemie operacyjnym hosta.
  • Deskryptory muszą zostać utworzone, zainicjowane i dodane do wewnętrznych struktur danych JVM.

Jest to również kosztowne w tym sensie, że nić ogranicza zasoby, dopóki żyje; np. stos wątków, dowolne obiekty dostępne ze stosu, deskryptory wątków JVM, natywne deskryptory wątków systemu operacyjnego.

Koszty wszystkich tych rzeczy zależą od platformy, ale nie są tanie na żadnej platformie Java, z którą się kiedykolwiek spotkałem.


Wyszukiwarka Google znalazła mi stary test porównawczy, który zgłasza szybkość tworzenia wątków wynoszącą ~ 4000 na sekundę w Sun Java 1.4.1 na vintage'owym podwójnym procesorze Xeon z 2002 roku z Linuxem z 2002 roku. Bardziej nowoczesna platforma da lepsze liczby ... i nie mogę komentować metodologii ... ale przynajmniej daje szansę na określenie, jak drogie może być tworzenie wątków.

Testy porównawcze Petera Lawreya wskazują, że tworzenie wątków jest obecnie znacznie szybsze w wartościach bezwzględnych, ale nie jest jasne, ile z tego wynika z usprawnień w Javie i / lub systemie operacyjnym ... lub wyższych prędkości procesora. Ale jego liczby wciąż wskazują na ponad 150-krotną poprawę, jeśli używasz puli wątków w porównaniu do tworzenia / rozpoczynania nowego wątku za każdym razem. (I podkreśla, że ​​to wszystko jest względne ...)


(Powyższe zakłada „wątki rodzime” zamiast „zielone wątki”, ale wszystkie współczesne maszyny JVM używają wątków rodzimych ze względów wydajnościowych. Zielone wątki są prawdopodobnie tańsze w tworzeniu, ale płacisz za to w innych obszarach).


Zrobiłem trochę kopania, aby zobaczyć, jak naprawdę przydzielany jest stos wątku Java. W przypadku OpenJDK 6 w systemie Linux stos wątków jest przydzielany przez wywołanie, pthread_createktóre tworzy wątek macierzysty. (JVM nie przekazuje pthread_createwstępnie przydzielonego stosu.)

Następnie w pthread_createramach stosu jest przydzielane przez wywołanie mmapw następujący sposób:

mmap(0, attr.__stacksize, 
     PROT_READ|PROT_WRITE|PROT_EXEC, 
     MAP_PRIVATE|MAP_ANONYMOUS, -1, 0)

Zgodnie z man mmaptym MAP_ANONYMOUSflaga powoduje, że pamięć jest inicjowana do zera.

Tak więc, chociaż może nie być konieczne zerowanie nowych stosów wątków Java (zgodnie ze specyfikacją JVM), w praktyce (przynajmniej w przypadku OpenJDK 6 w systemie Linux) są zerowane.

Stephen C.
źródło
2
@ Raedwald - jest to część inicjalizacji, która jest droga. Gdzieś coś (np. GC lub system operacyjny) wyzeruje bajty, zanim blok zostanie przekształcony w stos wątków. To wymaga cykli pamięci fizycznej na typowym sprzęcie.
Stephen C
2
„Gdzieś coś (np. GC lub system operacyjny) wyzeruje bajty”. To będzie? System operacyjny zrobi to, jeśli będzie wymagać przydzielenia nowej strony pamięci ze względów bezpieczeństwa. Ale to będzie rzadkie. System operacyjny może przechowywać pamięć podręczną stron już zerowanych (IIRC, Linux to robi). Dlaczego GC miałaby się tym przejmować, biorąc pod uwagę, że JVM uniemożliwi odczytanie treści przez dowolny program Java? Należy pamiętać, że standardowa malloc()funkcja C , z której mogłaby korzystać JVM, nie gwarantuje zerowania przydzielonej pamięci (prawdopodobnie w celu uniknięcia takich problemów z wydajnością).
Raedwald,
1
stackoverflow.com/questions/2117072/… zgadza się, że „Jednym z głównych czynników jest pamięć stosu przydzielona do każdego wątku”.
Raedwald,
2
@ Raedwald - zobacz zaktualizowaną odpowiedź, aby uzyskać informacje na temat faktycznego przydzielania stosu.
Stephen C
2
Możliwe (nawet prawdopodobne), że strony pamięci przydzielone przez mmap()wywołanie są mapowane na stronę kopiowania przy zapisie na stronę zerową, więc ich inicjalizacja odbywa się nie w mmap()sobie, ale po pierwszym zapisaniu stron , a następnie tylko jednej stronie w czas. Oznacza to, że kiedy wątek zaczyna się wykonywać, koszt ponoszony przez utworzony wątek, a nie wątek twórcy.
Raedwald
76

Inni dyskutowali, skąd pochodzą koszty gwintowania. Ta odpowiedź wyjaśnia, dlaczego utworzenie wątku nie jest tak drogie w porównaniu z wieloma operacjami, ale stosunkowo drogie w porównaniu z alternatywnymi metodami wykonywania zadań, które są stosunkowo tańsze.

Najbardziej oczywistą alternatywą dla uruchomienia zadania w innym wątku jest uruchomienie zadania w tym samym wątku. Trudno to pojąć tym, którzy zakładają, że więcej wątków jest zawsze lepszych. Logika jest taka, że ​​jeśli narzut związany z dodaniem zadania do innego wątku jest większy niż zaoszczędzony czas, wykonanie zadania w bieżącym wątku może być szybsze.

Inną alternatywą jest użycie puli wątków. Pula wątków może być bardziej wydajna z dwóch powodów. 1) ponownie wykorzystuje wątki już utworzone. 2) możesz dostroić / kontrolować liczbę wątków, aby zapewnić optymalną wydajność.

Następujący program drukuje ....

Time for a task to complete in a new Thread 71.3 us
Time for a task to complete in a thread pool 0.39 us
Time for a task to complete in the same thread 0.08 us
Time for a task to complete in a new Thread 65.4 us
Time for a task to complete in a thread pool 0.37 us
Time for a task to complete in the same thread 0.08 us
Time for a task to complete in a new Thread 61.4 us
Time for a task to complete in a thread pool 0.38 us
Time for a task to complete in the same thread 0.08 us

Jest to test dla trywialnego zadania, który uwidacznia koszty każdej opcji wątków. (To zadanie testowe jest rodzajem zadania, które najlepiej wykonać w bieżącym wątku.)

final BlockingQueue<Integer> queue = new LinkedBlockingQueue<Integer>();
Runnable task = new Runnable() {
    @Override
    public void run() {
        queue.add(1);
    }
};

for (int t = 0; t < 3; t++) {
    {
        long start = System.nanoTime();
        int runs = 20000;
        for (int i = 0; i < runs; i++)
            new Thread(task).start();
        for (int i = 0; i < runs; i++)
            queue.take();
        long time = System.nanoTime() - start;
        System.out.printf("Time for a task to complete in a new Thread %.1f us%n", time / runs / 1000.0);
    }
    {
        int threads = Runtime.getRuntime().availableProcessors();
        ExecutorService es = Executors.newFixedThreadPool(threads);
        long start = System.nanoTime();
        int runs = 200000;
        for (int i = 0; i < runs; i++)
            es.execute(task);
        for (int i = 0; i < runs; i++)
            queue.take();
        long time = System.nanoTime() - start;
        System.out.printf("Time for a task to complete in a thread pool %.2f us%n", time / runs / 1000.0);
        es.shutdown();
    }
    {
        long start = System.nanoTime();
        int runs = 200000;
        for (int i = 0; i < runs; i++)
            task.run();
        for (int i = 0; i < runs; i++)
            queue.take();
        long time = System.nanoTime() - start;
        System.out.printf("Time for a task to complete in the same thread %.2f us%n", time / runs / 1000.0);
    }
}
}

Jak widać, utworzenie nowego wątku kosztuje tylko ~ 70 µs. Można to uznać za trywialne w wielu, jeśli nie w większości przypadków użycia. Względnie mówiąc, jest on droższy niż alternatywy, aw niektórych sytuacjach lepszym rozwiązaniem jest pula wątków lub w ogóle nieużywanie wątków.

Peter Lawrey
źródło
8
To świetny kawałek kodu. Zwięzłe, do rzeczy i wyraźnie pokazuje swój jist.
Nicholas
Uważam, że w ostatnim bloku wynik jest wypaczony, ponieważ w pierwszych dwóch blokach główny wątek jest usuwany równolegle podczas wkładania wątków roboczych. Jednak w ostatnim bloku akcja wzięcia jest wykonywana szeregowo, więc rozszerza wartość. Prawdopodobnie możesz użyć queue.clear () i zamiast tego użyć CountDownLatch, aby poczekać na zakończenie wątków.
Victor Grazi
@VictorGrazi Zakładam, że chcesz zbierać wyniki centralnie. W każdym przypadku wykonuje tę samą pracę w kolejce. Zatrzask odliczający byłby nieco szybszy.
Peter Lawrey,
W rzeczywistości, dlaczego nie po prostu zrobić coś konsekwentnie szybko, na przykład zwiększając licznik; upuść całą rzecz BlockingQueue. Sprawdź licznik na końcu, aby uniemożliwić kompilatorowi optymalizację operacji inkrementacji
Victor Grazi 10.09.13
@grazi możesz to zrobić w tym przypadku, ale nie zrobiłbyś tego w najbardziej realistycznych przypadkach, ponieważ czekanie na ladzie może być nieefektywne. Gdybyś to zrobił, różnica między przykładami byłaby jeszcze większa.
Peter Lawrey,
31

Teoretycznie zależy to od JVM. W praktyce każdy wątek ma stosunkowo dużą ilość pamięci stosu (myślę, że domyślnie 256 KB). Ponadto wątki są implementowane jako wątki systemu operacyjnego, więc ich utworzenie wymaga wywołania systemu operacyjnego, tj. Przełącznika kontekstu.

Zdaj sobie sprawę, że „kosztowny” w informatyce jest zawsze bardzo względny. Tworzenie wątków jest bardzo kosztowne w porównaniu do tworzenia większości obiektów, ale niezbyt drogie w porównaniu do losowego wyszukiwania na dysku twardym. Nie musisz unikać tworzenia wątków za wszelką cenę, ale tworzenie ich setek na sekundę nie jest mądrym posunięciem. W większości przypadków, jeśli twój projekt wymaga wielu wątków, powinieneś użyć puli wątków o ograniczonym rozmiarze.

Michael Borgwardt
źródło
9
Btw kb = kilo-bit, kB = kilo bajt. Gb = bit giga, GB = bajt giga.
Peter Lawrey,
@PeterLawrey, czy kapitalizujemy „k” w „kb” i „kB”, więc istnieje symetria do „Gb” i „GB”? Te rzeczy mnie denerwują.
Jack
3
@Jack Jest K= 1024 i k= 1000.;) en.wikipedia.org/wiki/Kibibyte
Peter Lawrey
9

Istnieją dwa rodzaje wątków:

  1. Właściwe wątki : są to abstrakcje wokół podstawowych wątków systemu operacyjnego. Tworzenie wątków jest zatem tak samo kosztowne jak w systemie - zawsze jest narzut.

  2. „Zielone” wątki : utworzone i zaplanowane przez JVM, są tańsze, ale nie dochodzi do właściwego paralelizmu. Zachowują się one jak wątki, ale są wykonywane w wątku JVM w systemie operacyjnym. Według mojej wiedzy nie są one często używane.

Największym czynnikiem, jaki mogę wymyślić w narzutach związanych z tworzeniem wątków, jest rozmiar stosu , który zdefiniowałeś dla swoich wątków. Rozmiar stosu wątków można przekazać jako parametr podczas uruchamiania maszyny wirtualnej.

Poza tym tworzenie wątków zależy głównie od systemu operacyjnego, a nawet od implementacji maszyny wirtualnej.

Teraz pozwól mi coś wskazać: tworzenie wątków jest kosztowne, jeśli planujesz wystrzelić 2000 wątków na sekundę, co sekundę swojego środowiska wykonawczego. JVM nie jest do tego przystosowany . Jeśli będziesz miał kilku stabilnych pracowników, którzy nie będą zwalniani i zabijani w kółko, zrelaksuj się.

Slezica
źródło
19
„... kilku stabilnych pracowników, którzy nie zostaną zwolnieni i zabici ...” Dlaczego zacząłem myśleć o warunkach pracy? :-)
Stephen C
6

Tworzenie Threadswymaga przydzielenia sporej ilości pamięci, ponieważ musi to zrobić nie jeden, ale dwa nowe stosy (jeden dla kodu java, drugi dla kodu natywnego). Użycie executorów / pul wątków może uniknąć obciążenia, poprzez ponowne użycie wątków dla wielu zadań dla executora .

Philip JF
źródło
@ Raedwald, jaki jest Jvm, który używa osobnych stosów?
bestsss 30.06.11
1
Philip JP mówi 2 stosy.
Raedwald
O ile mi wiadomo, wszystkie maszyny JVM przydzielają dwa stosy na wątek. Pomocne jest, aby funkcja odśmiecania traktowała kod Java (nawet gdy JITed) inaczej niż c-free-casting.
Philip JF,
@Philip JF Czy możesz to rozwinąć? Co rozumiesz przez 2 stosy jeden dla kodu Java i jeden dla kodu natywnego? Co to robi?
Gurinder
„O ile mi wiadomo, wszystkie maszyny JVM przydzielają dwa stosy na wątek.” - Nigdy nie widziałem żadnych dowodów na poparcie tego. Być może nie rozumiesz prawdziwej natury opstack w specyfikacji JVM. (Jest to sposób na modelowanie zachowania kodów bajtowych, a nie coś, co musi być użyte w środowisku wykonawczym, aby je wykonać.)
Stephen C
1

Oczywiście sednem pytania jest to, co znaczy „drogi”.

Wątek musi utworzyć stos i zainicjować stos na podstawie metody uruchamiania.

Musi skonfigurować struktury statusu kontroli, tj. Jaki stan jest w stanie uruchomić, czeka itp.

Prawdopodobnie istnieje sporo synchronizacji przy konfigurowaniu tych rzeczy.

MeBigFatGuy
źródło