Що таке Thread? Особливості використання потоків у розробці

Ruby / Ruby on Rails
Oleksandr Vykhor
93
25-09-2026 15:00:00


Що таке Thread? Особливості використання потоків у розробці

Майже будь-який сучасний застосунок робить кілька речей одночасно: вебсервер обробляє сотні запитів, мобільний застосунок завантажує дані, не заморожуючи екран, відеоредактор рендерить прев'ю, поки ви продовжуєте працювати. Значною мірою це можливо завдяки потокам (threads). У цій статті розберемо, що таке потік, чим він відрізняється від процесу, навіщо потрібні потоки та з якими підводними каменями стикаються розробники під час роботи з ними.

Що таке потік (thread)?

Потік (thread, потік виконання) — це найменша одиниця роботи, яку операційна система може запланувати на виконання процесором. Простіше кажучи, це послідовність інструкцій, яка виконується незалежно від інших таких послідовностей.

Кожна програма запускається з одним потоком — головним (main thread). Далі програма може створювати додаткові потоки, які працюватимуть паралельно з головним.

Процес і потік: у чому різниця

Щоб зрозуміти потоки, порівняймо їх із процесами:

  • Процес — це запущена програма з власним ізольованим адресним простором, відкритими файлами та системними ресурсами. Процеси не бачать пам'ять один одного (якщо не використовувати спеціальні механізми).
  • Потік живе всередині процесу. Усі потоки одного процесу мають спільну пам'ять: глобальні змінні, об'єкти в купі, відкриті файли та з'єднання. У кожного потоку власні лише стек, регістри та вказівник поточної інструкції.
  Процес Потік Пам'ять Ізольована Спільна з іншими потоками процесу Вартість створення Вища Нижча Обмін даними IPC: пайпи, сокети, спільна пам'ять Напряму через спільні змінні Ізоляція збоїв Падіння зачіпає лише цей процес Збій одного потоку може покласти весь процес

Спільна пам'ять — водночас головна перевага і головне джерело проблем під час роботи з потоками.

Навіщо потрібні потоки?

  • Чуйність інтерфейсу. Довгі операції виконуються у фоновому потоці, а головний потік продовжує реагувати на дії користувача.
  • Очікування введення-виведення. Поки один потік чекає відповіді від бази даних, диска чи мережі, інші потоки виконують корисну роботу. Це типовий сценарій для вебсерверів.
  • Використання кількох ядер процесора. Важкі обчислення (обробку зображень, розрахунки) можна розділити між потоками, які працюватимуть справді паралельно на різних ядрах.
  • Дешевше за процеси. Потоки створюються швидше і споживають менше пам'яті, ніж окремі процеси.

Конкурентність і паралелізм

Ці два терміни часто плутають:

  • Конкурентність (concurrency) — кілька задач перебувають у процесі виконання в один і той самий проміжок часу. На одному ядрі ОС перемикається між ними так швидко, що це виглядає як одночасна робота.
  • Паралелізм (parallelism) — задачі буквально виконуються в один і той самий момент на різних ядрах.

Потоки завжди дають конкурентність, а паралелізм — лише якщо ядер кілька і середовище виконання мови це дозволяє (детальніше нижче).

Головні проблеми під час роботи з потоками

1. Стан гонитви (race condition)

Гонитва виникає, коли результат залежить від порядку виконання потоків. Класичний приклад — збільшення спільного лічильника:

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 НЕ гарантовано

Операція counter += 1 насправді складається з трьох кроків: прочитати значення, додати одиницю, записати назад. Якщо два потоки одночасно прочитають одне й те саме значення, одне зі збільшень загубиться. Такі помилки особливо небезпечні, бо проявляються випадково і погано відтворюються.

2. Взаємне блокування (deadlock)

Дедлок — ситуація, коли два потоки нескінченно чекають один на одного. Потік A захопив блокування 1 і чекає на блокування 2, а потік B захопив блокування 2 і чекає на блокування 1. Програма просто зависає.

Найпростіший спосіб цього уникнути — в усій програмі захоплювати блокування завжди в однаковому порядку.

3. Голодування та livelock

Голодування (starvation) — потік ніколи не отримує доступу до ресурсу, бо його постійно займають інші. Livelock — потоки безперервно реагують один на одного та змінюють стан, але реального просування немає.

4. Складне налагодження

Багатопотокові помилки часто зникають, щойно додати логування або запустити дебагер, бо змінюються таймінги. Тому потокобезпечність краще закладати в архітектуру від самого початку, ніж потім полювати на баги.

Інструменти синхронізації

  • М'ютекс (mutex, lock) пропускає до критичної секції лише один потік за раз.
  • Семафор обмежує кількість потоків, які одночасно мають доступ до ресурсу (наприклад, не більше 5 з'єднань одночасно).
  • Умовна змінна (condition variable) дозволяє потоку «заснути», доки інший потік не повідомить, що умову виконано.
  • Атомарні операції виконують прості дії (інкремент, compare-and-swap) як один неподільний крок без явних блокувань.
  • Потокобезпечні черги — зручний спосіб передавати дані між потоками без спільного змінюваного стану.

Виправлений приклад із лічильником з використанням м'ютекса:

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 # завжди 1_000_000

Пули потоків (thread pool)

Створювати новий потік під кожну дрібну задачу марнотратно: кожен потік коштує пам'яті та часу на створення. Натомість використовують пул потоків: заздалегідь створюється фіксована кількість потоків, задачі кладуться в чергу, а вільні потоки забирають їх на виконання.

Приклад на Ruby з гемом concurrent-ruby (він уже входить до залежностей Rails) — паралельне завантаження кількох сторінок:

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} байтів"
end

pool.shutdown
pool.wait_for_termination

GVL у Ruby

У Ruby важливо розуміти, як насправді виконуються потоки:

  • У MRI/CRuby, стандартному інтерпретаторі Ruby, є GVL (Global VM Lock) — глобальне блокування, яке дозволяє лише одному потоку за раз виконувати Ruby-код.
  • У JRuby і TruffleRuby GVL немає, і потоки виконуються справді паралельно.

Що це означає на практиці? У MRI потоки чудово підходять для задач, обмежених введенням-виведенням (мережа, база даних, файли), бо під час очікування блокування звільняється. Для обчислювальних (CPU-bound) задач вони майже не дають прискорення — краще використовувати кілька процесів (форк воркерів у Puma, Unicorn або Passenger) або Ractor, доступні починаючи з Ruby 3.0.

Важливо: GVL не робить ваш код потокобезпечним. Гонитви на кшталт прикладу з лічильником однаково можливі, бо перемикання потоків може статися між будь-якими двома операціями.

У Java, C#, Go, Rust і C++ потоки виконуються справді паралельно, тому важкі обчислення масштабуються на всі ядра.

Потоки у вебзастосунках (на прикладі Rails)

У веброзробці потоки всюди, навіть якщо ви не створюєте їх самі:

  • Puma, типовий сервер Rails, обробляє запити кількома процесами (воркерами), у кожному з яких працюють кілька потоків. Кількість потоків задається в config/puma.rb.
  • Phusion Passenger в open-source версії використовує багатопроцесну модель; багатопотоковість доступна в Enterprise-версії.
  • Sidekiq виконує фонові задачі в потоках.

Практичні висновки для Rails-розробника:

  • Не зберігайте дані запиту в змінних класу, глобальних змінних або константах, що змінюються під час роботи, — інший потік може їх перезаписати. Для стану в межах запиту використовуйте ActiveSupport::CurrentAttributes.
  • Розмір пулу з'єднань із базою (pool у database.yml) має бути не меншим за кількість потоків у процесі, інакше потоки чекатимуть на вільне з'єднання.
  • Перевіряйте, що геми, які ви використовуєте, потокобезпечні.
# 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) %>

Альтернативи класичним потокам

  • Async/await і цикл подій (гем async і сервер Falcon у Ruby, Node.js): один потік обслуговує тисячі операцій введення-виведення, перемикаючись між задачами в точках очікування.
  • Легкі («зелені») потоки: горутини в Go, віртуальні потоки в Java 21+, Fiber у Ruby. Ними керує середовище виконання, а не ОС, і вони набагато дешевші — їх можна створювати сотнями тисяч.
  • Модель акторів (Erlang/Elixir, Akka): жодної спільної пам'яті, лише обмін повідомленнями між ізольованими акторами.

Найкращі практики

  1. Мінімізуйте спільний змінюваний стан. Що менше даних поділяють потоки, то менше проблем. Надавайте перевагу незмінним об'єктам.
  2. Робіть критичні секції короткими. Тримайте блокування рівно стільки, скільки потрібно, і ніколи не виконуйте всередині нього повільне введення-виведення.
  3. Захоплюйте блокування в єдиному порядку, щоб уникнути дедлоків.
  4. Використовуйте пули потоків замість ручного створення потоку під кожну задачу.
  5. Надавайте перевагу високорівневим інструментам: потокобезпечним чергам, executors, futures і готовим конкурентним колекціям.
  6. Завжди обробляйте винятки в потоках. Помилка у фоновому потоці може безслідно зникнути, якщо її ніхто не перевіряє.
  7. Обирайте інструмент під задачу: потоки або async для I/O-bound, процеси або справді паралельні потоки для CPU-bound.
  8. Тестуйте під навантаженням. Багатопотокові помилки часто проявляються лише за великої кількості одночасних запитів.

Висновок

Потік — це легка одиниця виконання всередині процесу, яка поділяє пам'ять з іншими потоками. Потоки роблять застосунки швидшими та чуйнішими, але спільна пам'ять приносить гонитви, дедлоки та важковловимі помилки. Розберіться, як працюють потоки у вашій мові та середовищі виконання, захищайте спільні дані засобами синхронізації, використовуйте пули потоків і високорівневі абстракції — і потоки стануть надійним інструментом, а не джерелом головного болю.


Назад