Czym jest wątek (thread)? Najważniejsze zasady korzystania z wątków w programowaniu

Czym jest wątek (thread)? Najważniejsze zasady korzystania z wątków w programowaniu
Niemal każda nowoczesna aplikacja robi kilka rzeczy jednocześnie: serwer WWW obsługuje setki żądań, aplikacja mobilna pobiera dane bez zamrażania ekranu, edytor wideo renderuje podgląd, podczas gdy Ty dalej pracujesz. W dużej mierze umożliwiają to wątki (threads). W tym artykule wyjaśnimy, czym jest wątek, czym różni się od procesu, do czego służą wątki i na jakie pułapki natrafiają programiści, pracując z nimi.
Czym jest wątek?
Wątek (thread, wątek wykonania) to najmniejsza jednostka pracy, którą system operacyjny może zaplanować do wykonania na procesorze. Mówiąc prościej, jest to sekwencja instrukcji wykonywana niezależnie od innych takich sekwencji.
Każdy program startuje z jednym wątkiem, tzw. wątkiem głównym (main thread). Następnie program może tworzyć dodatkowe wątki, które będą działać równolegle z głównym.
Proces a wątek: czym się różnią
Aby zrozumieć wątki, porównajmy je z procesami:
- Proces to uruchomiony program z własną, odizolowaną przestrzenią adresową, otwartymi plikami i zasobami systemowymi. Procesy nie widzą nawzajem swojej pamięci (chyba że użyje się specjalnych mechanizmów).
- Wątek żyje wewnątrz procesu. Wszystkie wątki jednego procesu współdzielą pamięć: zmienne globalne, obiekty na stercie, otwarte pliki i połączenia. Każdy wątek ma na własność tylko stos, rejestry i wskaźnik bieżącej instrukcji.
Współdzielona pamięć to jednocześnie największa zaleta i główne źródło problemów przy pracy z wątkami.
Do czego służą wątki?
- Responsywność. Długie operacje wykonują się w wątku w tle, a wątek główny nadal reaguje na działania użytkownika.
- Oczekiwanie na operacje wejścia-wyjścia. Gdy jeden wątek czeka na odpowiedź bazy danych, dysku lub sieci, inne wątki wykonują użyteczną pracę. To typowy scenariusz dla serwerów WWW.
- Wykorzystanie wielu rdzeni procesora. Ciężkie obliczenia (przetwarzanie obrazów, kalkulacje) można podzielić między wątki, które będą działać naprawdę równolegle na różnych rdzeniach.
- Taniej niż procesy. Wątki tworzy się szybciej i zużywają mniej pamięci niż osobne procesy.
Współbieżność a równoległość
Te dwa pojęcia często się myli:
- Współbieżność (concurrency) oznacza, że kilka zadań jest w trakcie wykonywania w tym samym przedziale czasu. Na jednym rdzeniu system przełącza się między nimi tak szybko, że wygląda to na pracę jednoczesną.
- Równoległość (parallelism) oznacza, że zadania dosłownie wykonują się w tej samej chwili na różnych rdzeniach.
Wątki zawsze dają współbieżność, a równoległość tylko wtedy, gdy rdzeni jest kilka i środowisko uruchomieniowe języka na to pozwala (więcej poniżej).
Główne problemy przy pracy z wątkami
1. Wyścig (race condition)
Wyścig występuje, gdy wynik zależy od kolejności wykonania wątków. Klasyczny przykład to zwiększanie wspólnego licznika:
counter = 0
threads = 10.times.map do
Thread.new do
100_000.times { counter += 1 }
end
end
threads.each(&:join)
puts counter # 1_000_000 NIE jest gwarantowane
Operacja counter += 1 w rzeczywistości składa się z trzech kroków: odczytu wartości, dodania jedynki i zapisu. Jeśli dwa wątki jednocześnie odczytają tę samą wartość, jedno ze zwiększeń przepadnie. Takie błędy są szczególnie groźne, bo pojawiają się losowo i trudno je odtworzyć.
2. Zakleszczenie (deadlock)
Zakleszczenie to sytuacja, w której dwa wątki czekają na siebie nawzajem w nieskończoność. Wątek A zajął blokadę 1 i czeka na blokadę 2, a wątek B zajął blokadę 2 i czeka na blokadę 1. Program po prostu się zawiesza.
Najprostszy sposób, by tego uniknąć, to w całym programie zawsze zajmować blokady w tej samej kolejności.
3. Zagłodzenie i livelock
Zagłodzenie (starvation) oznacza, że wątek nigdy nie dostaje dostępu do zasobu, bo stale zajmują go inne. Livelock to sytuacja, w której wątki bez przerwy reagują na siebie i zmieniają stan, ale nie następuje żaden realny postęp.
4. Trudne debugowanie
Błędy wielowątkowe często znikają po dodaniu logowania lub uruchomieniu debugera, bo zmieniają się zależności czasowe. Dlatego bezpieczeństwo wątkowe lepiej zaplanować w architekturze od samego początku, niż później polować na błędy.
Narzędzia synchronizacji
- Muteks (mutex, lock) wpuszcza do sekcji krytycznej tylko jeden wątek naraz.
- Semafor ogranicza liczbę wątków, które jednocześnie mają dostęp do zasobu (np. nie więcej niż 5 połączeń naraz).
- Zmienna warunkowa (condition variable) pozwala wątkowi „uśpić się”, dopóki inny wątek nie da sygnału, że warunek został spełniony.
- Operacje atomowe wykonują proste działania (inkrementację, compare-and-swap) jako jeden niepodzielny krok bez jawnych blokad.
- Kolejki bezpieczne wątkowo to wygodny sposób przekazywania danych między wątkami bez współdzielonego, zmiennego stanu.
Poprawiony przykład z licznikiem z użyciem muteksu:
counter = 0
mutex = Mutex.new
threads = 10.times.map do
Thread.new do
100_000.times do
mutex.synchronize { counter += 1 }
end
end
end
threads.each(&:join)
puts counter # zawsze 1_000_000
Pule wątków (thread pool)
Tworzenie nowego wątku dla każdego drobnego zadania jest marnotrawstwem: każdy wątek kosztuje pamięć i czas utworzenia. Zamiast tego stosuje się pulę wątków: z góry tworzy się stałą liczbę wątków, zadania trafiają do kolejki, a wolne wątki pobierają je do wykonania.
Przykład w Ruby z gemem concurrent-ruby (jest już zależnością Rails), czyli równoległe pobieranie kilku stron:
require "concurrent"
require "net/http"
urls = %w[https://example.com https://example.org https://example.net]
pool = Concurrent::FixedThreadPool.new(5)
futures = urls.map do |url|
Concurrent::Promises.future_on(pool, url) do |u|
[u, Net::HTTP.get(URI(u)).bytesize]
end
end
futures.each do |future|
url, size = future.value!
puts "#{url}: #{size} bajtów"
end
pool.shutdown
pool.wait_for_termination
GVL w Ruby
W Ruby warto rozumieć, jak naprawdę wykonują się wątki:
- W MRI/CRuby, standardowym interpreterze Ruby, istnieje GVL (Global VM Lock), czyli globalna blokada, która pozwala tylko jednemu wątkowi naraz wykonywać kod Ruby.
- JRuby i TruffleRuby nie mają GVL, a ich wątki wykonują się naprawdę równolegle.
Co to oznacza w praktyce? W MRI wątki świetnie sprawdzają się w zadaniach ograniczonych przez wejście-wyjście (sieć, baza danych, pliki), ponieważ podczas oczekiwania blokada jest zwalniana. W zadaniach obliczeniowych (CPU-bound) prawie nie dają przyspieszenia, więc lepiej użyć wielu procesów (forkowanie workerów w Pumie, Unicornie lub Passengerze) lub Ractorów, dostępnych od Ruby 3.0.
Ważne: GVL nie sprawia, że kod jest bezpieczny wątkowo. Wyścigi takie jak w przykładzie z licznikiem wciąż są możliwe, bo przełączenie wątków może nastąpić między dowolnymi dwiema operacjami.
W Javie, C#, Go, Ruście i C++ wątki wykonują się naprawdę równolegle, więc ciężkie obliczenia skalują się na wszystkie rdzenie.
Wątki w aplikacjach webowych (na przykładzie Rails)
W programowaniu webowym wątki są wszędzie, nawet jeśli nie tworzysz ich samodzielnie:
- Puma, domyślny serwer Rails, obsługuje żądania kilkoma procesami (workerami), a w każdym z nich działa kilka wątków. Liczbę wątków ustawia się w
config/puma.rb. - Phusion Passenger w wersji open source korzysta z modelu wieloprocesowego; wielowątkowość jest dostępna w wersji Enterprise.
- Sidekiq wykonuje zadania w tle w wątkach.
Praktyczne wnioski dla programisty Rails:
- Nie przechowuj danych żądania w zmiennych klasowych, zmiennych globalnych ani stałych modyfikowanych w trakcie działania, bo inny wątek może je nadpisać. Do stanu w obrębie żądania używaj
ActiveSupport::CurrentAttributes. - Rozmiar puli połączeń z bazą (
poolwdatabase.yml) powinien być nie mniejszy niż liczba wątków w procesie, w przeciwnym razie wątki będą czekać na wolne połączenie. - Sprawdzaj, czy używane gemy są bezpieczne wątkowo.
# config/puma.rb
max_threads = ENV.fetch("RAILS_MAX_THREADS", 5)
threads max_threads, max_threads
workers ENV.fetch("WEB_CONCURRENCY", 2)
# config/database.yml
production:
pool: <%= ENV.fetch("RAILS_MAX_THREADS", 5) %>
Alternatywy dla klasycznych wątków
- Async/await i pętla zdarzeń (gem
asynci serwer Falcon w Ruby, Node.js): jeden wątek obsługuje tysiące operacji wejścia-wyjścia, przełączając się między zadaniami w punktach oczekiwania. - Lekkie („zielone”) wątki: gorutyny w Go, wątki wirtualne w Javie 21+, Fibery w Ruby. Zarządza nimi środowisko uruchomieniowe, a nie system operacyjny, i są znacznie tańsze, więc można ich tworzyć setki tysięcy.
- Model aktorów (Erlang/Elixir, Akka): żadnej współdzielonej pamięci, tylko wymiana komunikatów między odizolowanymi aktorami.
Dobre praktyki
- Ograniczaj współdzielony, zmienny stan. Im mniej danych dzielą wątki, tym mniej problemów. Preferuj obiekty niemutowalne.
- Utrzymuj krótkie sekcje krytyczne. Trzymaj blokadę dokładnie tak długo, jak trzeba, i nigdy nie wykonuj w niej wolnych operacji wejścia-wyjścia.
- Zajmuj blokady w jednolitej kolejności, aby uniknąć zakleszczeń.
- Korzystaj z pul wątków zamiast ręcznie tworzyć wątek dla każdego zadania.
- Wybieraj narzędzia wysokiego poziomu: kolejki bezpieczne wątkowo, executory, futures i gotowe kolekcje współbieżne.
- Zawsze obsługuj wyjątki w wątkach. Błąd w wątku w tle może zniknąć bez śladu, jeśli nikt go nie sprawdza.
- Dobieraj narzędzie do zadania: wątki lub async dla zadań I/O-bound, procesy lub naprawdę równoległe wątki dla zadań CPU-bound.
- Testuj pod obciążeniem. Błędy wielowątkowe często ujawniają się dopiero przy dużej liczbie jednoczesnych żądań.
Podsumowanie
Wątek to lekka jednostka wykonania wewnątrz procesu, która współdzieli pamięć z innymi wątkami. Wątki sprawiają, że aplikacje są szybsze i bardziej responsywne, ale współdzielona pamięć przynosi wyścigi, zakleszczenia i trudne do wychwycenia błędy. Poznaj sposób działania wątków w swoim języku i środowisku uruchomieniowym, chroń wspólne dane narzędziami synchronizacji, korzystaj z pul wątków i abstrakcji wysokiego poziomu, a wątki staną się niezawodnym narzędziem, a nie źródłem bólu głowy.
Wstecz



