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