Что такое 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. Тестируйте под нагрузкой. Многопоточные ошибки часто проявляются только при большом количестве одновременных запросов.

Заключение

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


Назад