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

Пример 1 — Тридцать два обработчика, оказавшиеся медленнее четырёх

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

обработчики   общее время
   1             400 с
   4             108 с
   8             104 с
  32             170 с

Решение

1 -> 4  : почти линейно; четыре готовых потока на четырёх аппаратных
4 -> 8  : почти ничего не выиграно; ядра уже насыщены
8 -> 32 : медленнее — потоков больше, чем ядер, значит больше переключений
          контекста и более холодный кэш на единицу работы

Когда на ядро приходится один готовый поток, планировщику почти не из чего выбирать, и работа делится почти идеально. Но как только ядра насыщены, лишние потоки добавляют лишь переключения и давление на кэш, а каждое переключение сохраняет и восстанавливает регистры в холодном кэше — поэтому общее время растёт вместо того, чтобы падать. Ловушка здесь в том, что предел задают число аппаратных потоков и то, чего потоки ждут, а вовсе не размер кучи работы; именно поэтому «добавить ещё обработчиков» выглядит бесплатным.

Пример 2 — Блокировка, удержанная через неверный вызов

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

обработчик A: lock(cache) -> загрузка из внешнего источника (300 мс) -> unlock
обработчик B: lock(cache) -> заблокирован на 300 мс
обработчик C: lock(cache) -> заблокирован на 300 мс

Решение

блокировка удержана во время ожидания ввода-вывода, поэтому каждый поток,
касающийся кэша, ждёт самый медленный сетевой вызов. Блокировка освобождает
ядро, но эти потоки ждут не ядро — они ждут блокировку.

исправление: сначала загрузить, затем брать блокировку только чтобы применить результат
  value = fetch(key)                       # блокировка не удержана
  cache.withLock(() => cache.set(key, value))

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