Что такое Thread? Какие особенности использования тредов в разработке?

Что такое Thread? Особенности использования потоков в разработке
Почти любое современное приложение делает несколько вещей одновременно: веб-сервер обрабатывает сотни запросов, мобильное приложение загружает данные, не замораживая экран, видеоредактор рендерит превью, пока вы продолжаете работать. Во многом это возможно благодаря потокам (threads). В этой статье разберём, что такое поток, чем он отличается от процесса, зачем нужны потоки и с какими подводными камнями сталкиваются разработчики при работе с ними.
Что такое поток (thread)?
Поток (thread, поток выполнения) — это наименьшая единица работы, которую операционная система может запланировать на выполнение процессором. Проще говоря, это последовательность инструкций, которая выполняется независимо от других таких последовательностей.
Каждая программа запускается с одним потоком — главным (main thread). Затем программа может создавать дополнительные потоки, которые будут работать параллельно с главным.
Процесс и поток: в чём разница
Чтобы понять потоки, сравним их с процессами:
- Процесс — это запущенная программа с собственным изолированным адресным пространством, открытыми файлами и системными ресурсами. Процессы не видят память друг друга (если не использовать специальные механизмы).
- Поток живёт внутри процесса. Все потоки одного процесса разделяют общую память: глобальные переменные, объекты в куче, открытые файлы и соединения. У каждого потока свои только стек, регистры и указатель текущей инструкции.
Общая память — одновременно главное преимущество и главный источник проблем при работе с потоками.
Зачем нужны потоки?
- Отзывчивость. Долгие операции выполняются в фоновом потоке, а главный поток продолжает реагировать на действия пользователя.
- Ожидание ввода-вывода. Пока один поток ждёт ответа от базы данных, диска или сети, другие потоки выполняют полезную работу. Это типичный сценарий для веб-серверов.
- Использование нескольких ядер процессора. Тяжёлые вычисления (обработку изображений, расчёты) можно разделить между потоками, которые будут работать по-настоящему параллельно на разных ядрах.
- Дешевле процессов. Потоки создаются быстрее и потребляют меньше памяти, чем отдельные процессы.
Конкурентность и параллелизм
Эти два термина часто путают:
- Конкурентность (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): никакой общей памяти, только обмен сообщениями между изолированными акторами.
Лучшие практики
- Минимизируйте общее изменяемое состояние. Чем меньше данных разделяют потоки, тем меньше проблем. Отдавайте предпочтение неизменяемым объектам.
- Делайте критические секции короткими. Держите блокировку ровно столько, сколько нужно, и никогда не выполняйте внутри неё медленный ввод-вывод.
- Захватывайте блокировки в едином порядке, чтобы избежать дедлоков.
- Используйте пулы потоков вместо ручного создания потока под каждую задачу.
- Предпочитайте высокоуровневые инструменты: потокобезопасные очереди, executors, futures и готовые конкурентные коллекции.
- Всегда обрабатывайте исключения в потоках. Ошибка в фоновом потоке может бесследно исчезнуть, если её никто не проверяет.
- Выбирайте инструмент под задачу: потоки или async для I/O-bound, процессы или по-настоящему параллельные потоки для CPU-bound.
- Тестируйте под нагрузкой. Многопоточные ошибки часто проявляются только при большом количестве одновременных запросов.
Заключение
Поток — это лёгкая единица выполнения внутри процесса, которая разделяет память с другими потоками. Потоки делают приложения быстрее и отзывчивее, но общая память приносит гонки, дедлоки и трудноуловимые ошибки. Разберитесь, как работают потоки в вашем языке и среде выполнения, защищайте общие данные средствами синхронизации, используйте пулы потоков и высокоуровневые абстракции — и потоки станут надёжным инструментом, а не источником головной боли.
Назад



