Базовый CS с нуля
Блокирующий и неблокирующий вызов
Предыдущий урок сказал, что async означает: CPU делает другую полезную работу, пока медленное устройство отвечает. Это цель. Но он оставил без ответа острый вопрос: как программа вообще может продолжить дальше строки, которая читает файл, когда файла ещё нет?
В модели из Блока 08 программа выполняет одну строку, затем следующую, затем следующую. Если строка 3 читает файл с диска, а строка 4 использует этот файл, то строка 4 явно не может выполниться, пока строка 3 не получит данные. Программа как будто вынуждена ждать.
Выход — в том, как написано чтение в строке 3. Есть два вида вызова, которые программа может сделать к медленному устройству, и они ведут себя совершенно по-разному именно в этой точке. Один вынуждает ждать; другой — нет. Этот урок противопоставляет два вида — блокирующий и неблокирующий — на одной и той же задаче.
После этого урока ты сможешь дать определение блокирующего вызова как такого, что не возвращается, пока его работа не сделана, дать определение неблокирующего вызова как такого, что возвращается сразу, объяснить, что такое колбэк и как он доставляет отложенный результат, и трассировать, что делает стек вызовов в каждом случае.
Блокирующий вызов: он не возвращается, пока работа не сделана. Вспомни из Блока 08, что вызов функции кладёт кадр стека, а функция возвращается, когда её работа завершена — в этот момент её кадр снимается, и вызывающая сторона продолжает со следующей строки.
Блокирующий вызов — это вызов, который следует этому правилу даже когда работа медленная. Вызов для чтения файла с диска не возвращается, пока байты файла не окажутся в руках. Кадр стека функции остаётся на стеке всё время, пока работает диск — десять миллисекунд, десять миллионов циклов — ничего не делая, просто занимая стек.
Это «блокирующий» в буквальном смысле: вызов блокирует строку под собой от выполнения. Строка 4 не может начаться, потому что строка 3 не вернулась. Вся программа заморожена на этом кадре, пока устройство не ответит. Это ровно то синхронное поведение «простаивать» из урока 01 — теперь увиденное на уровне одного вызова функции.
Неблокирующий вызов: он возвращается сразу. Неблокирующий вызов — это вызов к медленному устройству, который не ждёт завершения работы. Он делает только быструю часть — передаёт запрос устройству — а затем возвращается тут же, за горстку циклов, задолго до того, как устройство выдаст какой-либо ответ.
Так что кадр стека функции кладётся и почти сразу снимается. Вызов возвращается, вызывающая сторона продолжает со следующей строки, и программа продолжает выполняться. Чтение с диска теперь происходит в фоне, обрабатываемое самим устройством, пока CPU идёт дальше.
Но это создаёт очевидный пробел. Вызов вернулся до того, как данные появились — так что вызов не мог отдать обратно данные так, как обычная функция возвращает значение. Результат появится только позже, после того как устройство закончит. Программе нужен какой-то способ получить этот результат, когда бы он в итоге ни появился. Этот механизм — колбэк.
Колбэк: функция, переданная, чтобы запустить её позже. Вспомни из Блока 08, что функция — это именованный, повторно используемый блок инструкций, и что функцию можно передавать как значение — отдавать другому коду по имени.
Колбэк — это ровно такое: функция, которую твоя программа пишет, а затем передаёт неблокирующему вызову с указанием «запусти это для меня, когда результат будет готов». Программа не вызывает колбэк сама. Она отдаёт колбэк и идёт дальше.
Когда медленное устройство наконец заканчивает — спустя миллисекунды — результат доставляется вызовом колбэка, при этом результат передаётся как аргумент (Блок 08: аргументы — это значения, передаваемые функции при её вызове). Тело колбэка затем выполняется, с наконец доступными данными. Так что структура разделена надвое: неблокирующий вызов запускает работу и возвращается; колбэк содержит код, использующий результат, и выполняется отдельно, позже. Строка «использовать файл» больше не стоит прямо под строкой «прочитать файл» — она живёт внутри колбэка.
Почему это работает
Зачем передавать функцию — почему просто не вернуть данные? Потому что данных ещё нет, когда вызов возвращается. Обычный возврат отдаёт значение, готовое сейчас. У неблокирующего вызова нет ничего готового сейчас — только обещание чего-то позже. Нельзя вернуть значение, которого не существует. Поэтому вместо возврата данных программа поставляет код: колбэк. Это программа говорит заранее: «вот что делать, когда данные появятся». Передача кода — единственный способ перекинуть мост к результату, приходящему в будущем.
Одна и та же задача, два способа. Возьми одну конкретную задачу: прочитать имя пользователя с диска, затем поприветствовать его.
Блокирующий. Строка 1: name = readFileBlocking("user.txt"). Этот вызов блокирует —
его кадр сидит на стеке все полные 10 мс. Только когда байты приходят, он возвращается,
помещая их в name. Строка 2: print("Hello, " + name) выполняется следующей. Просто для
чтения, но CPU был заморожен на 10 мс между двумя строками.
Неблокирующий. Строка 1: readFileNonBlocking("user.txt", greet). Этот вызов передаёт
запрос диску и передаёт greet как колбэк, затем возвращается сразу. Строка 2 — какой
бы она ни была — выполняется тут же; CPU не заморожен. Примерно через 10 мс диск
заканчивает, и greet вызывается с именем как аргументом; тело greet делает print.
Одна задача, один диск, те же 10 мс времени устройства. Разница в том, провёл ли CPU эти 10 мс замороженным на застрявшем кадре (блокирующий) или выполняя другие строки, пока колбэк ждал своей очереди (неблокирующий).
Трассировка стека вызовов для обеих версий.
Предположим, чтение с диска занимает 10 мс. CPU выполняет 1 000 000 циклов за мс.
Блокирующая версия — name = readFileBlocking("user.txt"):
- Вызов кладёт кадр стека для
readFileBlocking. - Кадр отправляет запрос диску — затем остаётся на стеке, ожидая.
- Следующие 10 мс (10 000 000 циклов) этот кадр просто сидит там. Вызов не вернулся, поэтому ничего под ним не выполняется. CPU заморожен.
- Диск заканчивает.
readFileBlockingнаконец возвращает байты; его кадр снимается. - Теперь выполняется следующая строка. Всего работы CPU за время ожидания: 0 инструкций.
Неблокирующая версия — readFileNonBlocking("user.txt", greet):
- Вызов кладёт кадр стека для
readFileNonBlocking. - Кадр передаёт запрос диску и сохраняет
greetкак колбэк для запуска, когда диск закончит. Это всё быстрая работа. - Вызов возвращается сразу — его кадр снимается после всего лишь горстки циклов.
- Следующая строка выполняется тут же. CPU продолжает работать все 10 мс.
- Примерно через 10 мс диск заканчивает;
greetвызывается с именем. Всего работы CPU за время ожидания: до 10 000 000 инструкций другого кода.
Диск занял 10 мс в обоих случаях. Только неблокирующая версия держала CPU занятым — и сделала это, разделив задачу на неблокирующий вызов и колбэк.
Частая ошибка
Частая ошибка — думать, что неблокирующий вызов уже произвёл результат к моменту своего
возврата. Это не так. Когда readFileNonBlocking возвращается, диск едва начал — байтов
не будет ещё 10 мс. Всё, что нуждается в результате, должно идти внутри колбэка, а не на
строке сразу после вызова. Код, поставленный сразу после неблокирующего вызова, выполняется,
пока результата ещё нет.
Сделан блокирующий вызов чтения с диска. Диск занимает 10 мс. Сколько миллисекунд кадр стека этого вызова остаётся на стеке, прежде чем вызов вернётся?
Сделан неблокирующий вызов чтения с диска. Быстрая часть (передача запроса диску) занимает горстку циклов. Примерно через сколько миллисекунд вызов вернётся? Введи 0 для «фактически сразу».
Программа делает неблокирующий вызов и передаёт колбэк. Кто вызывает этот колбэк, когда устройство заканчивает — введи 1 за собственную следующую строку программы, 2 за механизм, доставляющий результат?
Само чтение с диска занимает 10 мс. Используя неблокирующий вызов вместо блокирующего, сколько миллисекунд занимает собственная работа диска?
В неблокирующей версии код, использующий содержимое файла, живёт где — введи 1 за строку сразу после вызова, 2 за внутри колбэка?
В чём разница между блокирующим и неблокирующим вызовом, и для чего нужен колбэк?
Блокирующий вызов не возвращается, пока его работа не завершена. Когда эта работа — медленное устройство, кадр стека вызова остаётся застрявшим на стеке всё время ожидания, и строка под ним не может выполниться — программа заморожена. Неблокирующий вызов делает противоположное: он передаёт запрос устройству и возвращается сразу, так что программа продолжает выполняться, пока устройство работает в фоне. Поскольку вызов возвращается до того, как результат появился, результат нельзя вернуть обычным способом. Вместо этого программа поставляет колбэк — функцию (Блок 08), переданную неблокирующему вызову с указанием «запусти это, когда результат будет готов». Когда устройство заканчивает, результат доставляется вызовом этого колбэка с результатом как аргументом. Блокирующий держит весь код в одной прямой строке, но замораживает CPU; неблокирующий разделяет задачу на вызов и колбэк, но держит CPU свободным. Следующий урок показывает механизм, который на самом деле запускает эти колбэки.