Процессы, потоки и планирование

Что это такое

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

Почему это важно

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

Ключевые идеи

Процесс владеет адресным пространством

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

процесс A                процесс B
  адресное пространство    адресное пространство
  fd 0,1,2 ...             fd 0,1,2 ...
        \                   /
         +--- канал / сокет ---+

Потоки делят адресное пространство

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

counter = 0
поток 1: read 0 -> write 1
поток 2: read 0 -> write 1     # одно приращение потеряно

Планировщик решает, кто выполняется

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

ядро 0: [A 4ms][B 4ms][C 4ms][A 4ms] ...
очередь готовых: B, C, D, E, ...
переключение: сохранить регистры A, загрузить B, продолжить B

Состояния, блокировка и обратное давление

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

Практика

Посмотрите потоки на своей машине

Запустите программу, которая ничего не делает, только спит, затем посмотрите список процессов и посчитайте её потоки. Запустите второй экземпляр и сравните идентификаторы процессов и показатели памяти.

Готово, когда: вы можете указать столбцы, в которых видно отдельные адресные пространства для двух процессов и общую память для потоков внутри одного.

Переподпишитесь намеренно

Запустите фиксированную задачу, которая нагружает процессор, на одном потоке, затем на четырёх, затем на тридцати двух, и замерьте каждый запуск.

Готово, когда: запуск на четырёх потоках явно быстрее, и вы можете объяснить, почему запуск на тридцати двух не становится вчетверо быстрее снова.

Частые ошибки

  • Совместный доступ к изменяемому состоянию без синхронизации — порча проявляется только под нагрузкой и никогда в отладчике; исправить: защитите значение блокировкой или дайте каждому потоку свою копию и объедините в конце.
  • Блокировка на вводе-выводе с удержанием блокировки — каждый поток, которому нужна блокировка, ждёт самый медленный сетевой вызов; исправить: сначала прочитайте данные, а блокировку берите только чтобы обновить общее состояние.
  • Создание потока на каждый запрос без ограничения — машина тратит время на переключения и умирает от памяти, а не от работы; исправить: используйте пул с границей, рассчитанной по ядрам и нагрузке.
  • Предположение, что поток стартует немедленно — планировщик может запустить его намного позже создания; исправить: задайте порядок явно примитивом синхронизации, а не задержкой.
  • Измерение на простаивающей машине и перенос на занятую — конкуренция незаметна, когда больше ничего не работает; исправить: замеряйте с той конкурентностью, которую сервис действительно увидит.

Что читать дальше

  • Структуры данных (01-cs-fundamentals/02-data-structures) — очереди и планировщики — те же структуры данных, только с прикреплённой политикой, поэтому начните отсюда.
  • Память и файловые системы (01-cs-fundamentals/15-operating-systems/02-memory-and-filesystems) — адресное пространство, которое делят эти потоки, и что происходит, когда оно не помещается.
  • Конкурентность и асинхронное программирование (01-cs-fundamentals/05-concurrency-async-programming) — блокировки, гонки и асинхронные модели — изнутри программы.