Экспресс-курс · No. 16
Программа, что делает одно за раз, проста и часто слишком медленна. Так что мы заставляем софт жонглировать многими задачами или гонять их на многих ядрах разом. Отсюда берётся скорость — и отсюда же берётся целое семейство странных багов, все укоренённые в одном: два куска работы трогают одни данные в один момент.
Только суть · Один образ на идею · Выучи слова
Два слова используют так, будто они значат одно, а это не так. Одно про жонглирование; другое про много рук. Разложить их по местам — фундамент для всего остального.
Concurrency — это жонглирование; parallelism — это много рук
Один повар ведёт четыре блюда — режет, потом мешает, потом проверяет духовку, быстро переключаясь, — против четырёх поваров, каждый делает одно блюдо в тот же миг.
Concurrency (конкурентность) — это управление многими задачами через переключение между ними: один работник продвигает несколько дел, никогда по-настоящему в один миг. Parallelism (параллелизм) — это много задач буквально работают в один миг, на нескольких работниках. Один повар, жонглирующий четырьмя блюдами, — конкурентен; четыре повара — параллельны. Они часто сочетаются, но это разные идеи: конкурентность — способ структурировать работу; параллелизм — способ её исполнять.
Одно ядро может быть конкурентным; много ядер — параллельны
Один кассир-билетёр может обслужить всю очередь, ведя быстрые шаги каждого по очереди; ряд кассиров обслуживает нескольких в одну и ту же секунду.
Ядро процессора делает одно за раз, но может переключаться между задачами так быстро, что кажется, будто делает многое, — это конкурентность на одном ядре. Настоящему параллелизму нужно несколько ядер, реально работающих разом. У современных машин много ядер, так что реальный софт обычно делает оба: несколько задач в полёте (конкурентно), часть по-настоящему вместе (параллельно). Знать, что у тебя, объясняет, какие ускорения вообще возможны.
Зачем вообще заморачиваться
Официант, что принял полный заказ одного стола, отнёс его и лишь потом подошёл к следующему, оставил бы зал людей ждать впустую.
Делать одно за раз тратит огромные количества ожидания. Пока одна задача ждёт — сетевого ответа, диска, пользователя, — работник мог бы продвигать другую. Конкурентность отвоёвывает это простаивающее время; параллелизм добавляет сырую мощь под тяжёлую работу. Вместе они — вот почему приложение остаётся отзывчивым при загрузке и почему большое вычисление может задействовать каждое ядро. Цена — сложность, о которой весь остаток курса.
Concurrency — это жонглирование многими задачами через переключение; parallelism — запуск их в один миг на многих ядрах. Разные идеи: одна структурирует работу, другая её исполняет.
Чтобы гонять вещи конкурентно, ты делишь работу на отдельные линии исполнения. Есть два главных вида, и разница между ними в основном про одно: делят ли они память.
Процесс — это изолированная программа
Две отдельные кухни в двух отдельных зданиях — у каждой свои ингредиенты, свои столы, и они не могут тронуть запасы другой без звонка между ними.
Процесс — это работающая программа со своей приватной памятью, изолированная от других процессов. Два процесса не могут случайно испортить данные друг друга, потому что не делят ничего, — чтобы общаться, они должны намеренно передавать сообщения. Эта изоляция делает процессы безопасными и крепкими, но тяжелее: запуск одного стоит больше, а разговор между ними медленнее. Думай о процессах как об отдельных, огороженных стеной программах.
Поток делит память с собратьями
Несколько поваров на одной кухне, делящих те же столы и ингредиенты, — быстро координировать, но они могут потянуться за тем же ножом в тот же миг.
Поток (thread) — это линия исполнения внутри процесса, и потоки одного процесса делят ту же память. Это делает их лёгкими и быстрыми в координации — но это же и источник почти каждого бага конкурентности, потому что два потока могут тронуть одни данные в одно время. Общая память — это мощь и опасность: дешёвое общение, опасные столкновения. Бо́льшая часть курса об этой опасности.
У переключения между задачами есть цена
Работник, жонглирующий делами, должен отложить одно, вспомнить точно, где он был, и взять другое, — и всё это откладывание и подхватывание берёт реальное время.
Когда одно ядро переключается с задачи на задачу, оно делает переключение контекста (context switch): сохраняет, где было, и грузит состояние следующей задачи. Это быстро, но не бесплатно, и делать это слишком часто — тысячи крошечных задач, молотящих, — тратит время в чистых накладных. Поэтому «просто сделай больше потоков» не автоматически быстрее: за некой точкой само жонглирование стоит больше, чем экономит.
Процессы изолированы, с приватной памятью и безопасным-но-тяжёлым обменом сообщениями. Потоки делят память — лёгкие и быстрые, и корень большинства багов конкурентности.
Бо́льшую часть времени программа не вычисляет — она ждёт. Как ты обращаешься с этим ожиданием, блокирующе или асинхронно, решает, может ли один работник оставаться занятым или сидит без дела.
Блокирующий вызов останавливается и ждёт
Стоять у стойки, пока клерк ушёл в подсобку за твоим заказом, не делая ничего до его возвращения, — ты не можешь помочь никому другому, пока стоишь там.
Блокирующий (blocking) вызов — это когда работник останавливается и ждёт результата, прежде чем делать что-либо ещё, — спросил базу и просто стоит, пока она не ответит. Пока заблокирован, тот работник не достигает ничего. Для одного быстрого шага это нормально; но если блокируешься на медленном — сеть, диск, другие сервисы, — ты тратишь ровно то время, что мог бы потратить на другую работу.
Async позволяет одному работнику оставаться занятым
Взять номерок в людной кулинарии: вместо того чтобы стоять замороженным, ты начинаешь другие дела и возвращаешься, когда твой номер выкликнут.
Асинхронная (неблокирующая) работа это переворачивает: ты начинаешь медленную операцию и вместо ожидания идёшь делать что-то ещё, подхватывая результат, когда он готов. Ключевые слова async и await во многих языках выражают ровно это — «начни это и дай мне продолжить; вернись, когда закончится». Один работник может иметь сотни медленных операций в полёте разом, оставаясь занятым, а не праздным. Так один поток обслуживает тысячи ждущих соединений.
Цикл событий жонглирует ожиданием
Единственный, очень организованный администратор, что принимает каждый звонок, запускает медленные поиски и берётся за того, кто готов следующим, — никогда не заблокированный ни на одном.
Async обычно работает на цикле событий (event loop): один работник, что начинает задачи, и всякий раз, когда медленное завершается, выполняет маленький кусок кода, что его ждал, и идёт дальше. Ощущается, будто много всего происходит разом, но это один работник, быстро переключающийся между готовыми задачами. Это модель за средами вроде JavaScript и Node — поразительно эффективными в жонглировании ожиданием, на одном потоке.
I/O-bound любит async; CPU-bound нужен параллелизм
Работе, что в основном ждёт доставок, помогает жонглёр получше; работе, что в основном тяжёлый подъём, нужно больше мускулов, а не лучшее расписание.
Правильный инструмент зависит от того, чего твоя работа ждёт. I/O-bound работа — в основном ждёт сети, диска или других сервисов — идеальна для async: один работник жонглирует всем ожиданием. CPU-bound работа — тяжёлое вычисление, что держит ядро полностью занятым, — не получает помощи от async (нет простаивающего времени отвоёвывать) и вместо этого нуждается в параллелизме по ядрам. Неверно оценить, что у тебя, — вот почему некоторые «ускорения» не делают ничего.
Блокирующий останавливается и ждёт; async начинает медленное и остаётся занятым. Async выигрывает для ожидания (I/O-bound); настоящий параллелизм — для тяжёлых вычислений (CPU-bound).
Вот сердце того, почему конкурентность сложна. В тот миг, когда две вещи могут тронуть одни данные в одно время, ты можешь получить баг, что прерывист, невидим в тестах и бесит при попытке воспроизвести.
Состояние гонки: две руки, один предмет
Два человека тянутся за последним печеньем в один миг — оба видят, что оно там, оба хватают, и что будет дальше, зависит от точного тайминга в доли секунды.
Состояние гонки (race condition) — это когда результат зависит от точного тайминга конкурентных операций, трогающих общие данные. Два потока оба читают счётчик как 5, оба прибавляют один, оба пишут 6 — и единица тихо теряется, потому что верный ответ был 7. Никто не сделал ошибки в логике; шаги просто переплелись плохо. Это определяющий баг конкурентности, и идёт он прямо из общего состояния плюс одновременного доступа.
Ужас в том, что он прерывист
Дребезг в машине, что исчезает в тот миг, как ты привозишь её к механику, — реальный, прерывистый и невозможный показать по требованию.
Состояния гонки мерзки, потому что недетерминированы: они зависят от тайминга, так что появляются раз в тысячу прогонов, никогда в твоих тестах и только под реальной нагрузкой в проде. Один и тот же ввод может сработать или упасть в зависимости от микроскопического расписания. Поэтому у багов конкурентности грозная репутация — их нельзя надёжно воспроизвести, и «у меня на машине работает» — ровно то, чего ждёшь, даже когда оно сломано.
Опасность — это общее, изменяемое состояние
Столкновения случаются только над тем, что обоим позволено хватать и менять, — запри общий шкаф или дай каждому свой, и драки прекратятся.
Заметь точный источник: данные, что одновременно общие (больше одной задачи может дотянуться) и изменяемые (их можно поменять). Убери любое свойство — и гонка исчезает: данные, что трогает лишь одна задача, безопасны, а данные, что никто не меняет, безопасно делить свободно. Это ключевая мысль, на которой строится остаток курса: каждое лекарство — это на деле способ управлять, ограничить или избежать общего изменяемого состояния.
Состояние гонки — это две задачи, трогающие общие, изменяемые данные разом, с результатом, висящим на тайминге. Оно прерывисто, невидимо в тестах — и определяющий баг конкурентности.
Классическое лекарство от гонки — сделать так, чтобы лишь одна задача трогала общие данные за раз. Оно работает — но инструмент, которым ты это насаждаешь, несёт собственную знаменитую опасность.
Блокировка даёт одной задаче исключительный доступ
Единственный ключ от туалета на одного: кто держит — заходит; все остальные ждут своей очереди снаружи. Двух человек внутри разом — никогда.
Блокировка (lock, или мьютекс — от «mutual exclusion», взаимное исключение) гарантирует, что лишь одна задача входит в участок кода за раз. Прежде чем тронуть общие данные, задача должна захватить блокировку; другие, желающие её, ждут, пока она не освободится. Защищённый участок — это критическая секция. Сделанное верно, это превращает хаотичную свалку над общими данными в упорядоченную очередь по-одному, и состояние гонки исчезает.
Deadlock: все ждут вечно
Два человека у узкой двери, каждый отказывается отступить, пока не отступит другой, — оба застряли, вежливо, навсегда.
Блокировки несут свой классический сбой: взаимоблокировку (deadlock). Задача A держит блокировку 1 и хочет блокировку 2; задача B держит блокировку 2 и хочет блокировку 1. Каждая ждёт, пока другая отпустит, и ни одна не отпускает — обе замёрзли навсегда. Это идёт от захвата нескольких блокировок в разном порядке. Частое лекарство — дисциплина: всегда захватывай блокировки в одном согласованном порядке, чтобы круговое ожидание не смогло сложиться.
Блокировки верны, но дороги
Один ключ от туалета держит порядок, но если он постоянно всем нужен, образуется очередь, и весь офис замедляется до скорости той единственной двери.
Блокировки чинят гонки, но у них есть цена: пока одна задача держит блокировку, другие ждут, так что тяжёлая блокировка может стереть скорость, ради которой ты вообще брал конкурентность. Чрезмерная блокировка создаёт состязание (contention) — все стоят в очереди на одну блокировку. Так что держи блокировки как можно короче, защищай как можно меньше и предпочитай конструкции, что требуют меньше блокировок. Верная программа, сериализованная на одной блокировке, немногим лучше однопоточной.
Блокировка даёт одной задаче за раз исключительный доступ, убивая гонку. Но блокировки могут уйти в deadlock, а чрезмерная блокировка сериализует всё — верно, но медленно.
Блокировки борются с симптомами общего изменяемого состояния. Более глубокий ход — спроектировать так, чтобы его было меньше с чем бороться, — и пара паттернов делает целые классы багов конкурентности попросту невозможными.
Не дели память — передавай сообщения
Вместо многих поваров, дерущихся за один общий стол, каждый работает на своей станции и передаёт готовые тарелки по раздаче — двое никогда не тянутся за одним.
Мощная альтернатива общей памяти — передача сообщений (message passing): задачи не трогают одни данные; они шлют друг другу сообщения через очередь. Лишь одна задача владеет данным куском, а другие просят изменений, посылая сообщение. Есть знаменитый лозунг: «не общайтесь, деля память; делите память, общаясь». Убери общий доступ — и убираешь гонку в корне, без всяких блокировок.
Неизменяемые данные нельзя загонять
Вручить каждому свою ксерокопию вместо одного общего оригинала — они все могут читать разом, и никто не может черкнуть на том, что читает другой.
Если данные никогда не меняются после создания — если они неизменяемы (immutable), — то любое число задач может читать их в одно время с нулевым риском, потому что опасность была не в делении, а в делении и изменении. Вместо мутации значения на месте ты делаешь новое. Неизменяемость убирает одну половину условия «общее и изменяемое», а с ней — целую категорию багов. Поэтому функциональные стили так сильно на неё опираются.
Атомарные операции неделимы
Турникет, что считает каждого проходящего одним неделимым щелчком, — нет полусосчитанного мига, в который двое могут проскользнуть.
Некоторые операции атомарны (atomic): они случаются как один неделимый шаг, что нельзя прервать на полпути, так что две задачи не могут застать друг друга в середине обновления. Языки и базы дают атомарные счётчики, атомарные обмены и транзакции ровно для тех маленьких общих обновлений, что любят гонки. Когда тебе правда надо делить меняющееся значение, атомарная операция часто дешевле и безопаснее полной блокировки — обновление просто нельзя расщепить.
Лучший баг конкурентности — сделанный невозможным: передавай сообщения вместо деления, делай данные неизменяемыми или используй атомарные операции — и гонке негде жить.
Конкурентность — это мощь, что стоит добавлять нарочно, а не рефлексом. Навык — тянуться к ней, только когда задача требует, и выбирать простейшую модель, что делает дело.
Подбери инструмент под узкое место
Ты не нанимаешь больше грузчиков на работу, что вся ожидание, и не берёшь лучшего диспетчера на работу, что вся тяжёлый подъём, — ты подбираешь помощь туда, куда реально уходит время.
Прежде чем добавлять конкурентность, знай своё узкое место (bottleneck). Если работа I/O-bound — в основном ожидание — async на одном потоке часто решает это просто. Если она CPU-bound — в основном вычисление — нужен настоящий параллелизм по ядрам. Если ни то ни другое — быстро и просто — добавление конкурентности просто покупает баги без выигрыша. Самая частая ошибка — тянуться к потокам, когда обычный последовательный код был уже достаточно быстр.
Держи общую поверхность крошечной
Мастерская, где каждый в основном работает один, с одной маленькой, ясно помеченной общей полкой инструментов, — чем меньше общего, тем меньше может пойти не так.
Раз каждый баг конкурентности идёт от общего изменяемого состояния, главный ход — минимизировать деление. Держи бо́льшую часть данных во владении одной задачи, делай что можешь неизменяемым и ограничь по-настоящему общие, меняющиеся части маленькой, тщательно охраняемой поверхностью. Конструкцию с крошечной общей поверхностью можно реально осмыслить; ту, где всё общее и изменяемое, — это дом с привидениями. Простота тут не опциональна — это вся защита.
- Какое узкое место — I/O-bound (async), CPU-bound (параллелизм) или ни то ни другое (не надо)? - Какие данные общие и изменяемые, и могут ли две задачи тронуть их разом? - Могу ли я избежать деления — передачей сообщений, неизменяемостью или одной атомарной операцией? - Если надо блокировать, мала ли критическая секция и согласован ли порядок блокировок? - Может ли уйти в deadlock, и исключил ли я круговое ожидание? - Стоит ли добавленная сложность того, или последовательный код был уже достаточно быстр?
- concurrency / parallelism — жонглирование переключением против запуска в один миг. - процесс / поток — изолирован с приватной памятью против деления памяти (и риска). - blocking / async / await / event loop — стоп-и-жди против старт-и-продолжай. - I/O-bound / CPU-bound — работа, тяжёлая ожиданием, против тяжёлой вычислением. - состояние гонки (race condition) — зависящий от тайминга баг над общим, изменяемым состоянием. - lock / mutex / критическая секция / deadlock / contention — исключительный доступ и его опасности. - передача сообщений / immutable / atomic — паттерны, что убирают гонку в корне.
- Ты тянешься к async для ожидания и к параллелизму для вычислений — не рефлексом. - Можешь указать ровно, какие данные общие и изменяемые, и их мало. - Ты предпочитаешь передачу сообщений и неизменяемость разбрасыванию блокировок повсюду. - Твои блокировки короткие, упорядоченные, и ты порассуждал о deadlock. - Ты не добавляешь конкурентность там, где обычный последовательный код был уже достаточно быстр.
Конкурентность — намеренная мощь: async для ожидания, параллелизм для вычислений и крошечная общая поверхность, охраняемая нарочно. Каждый баг восходит к общему изменяемому состоянию — так дели как можно меньше.