Где модель врёт: планировщик на промисах и замкнутость мира
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Две статьи подряд модель нас не подводила: планировщик с фазами предсказывал порядок, а настоящий node предсказание подтверждал. Но есть места, где она отвечает увереннее, чем вправе, — первое из них прошлая статья уже назвала. Сегодня разберёмся, откуда враньё в моделях берётся вообще и как его замечать с первого взгляда. Для этого соберём соблазнительную «взрослую» версию нашего планировщика — ту, где фазы разбираются через await, а промисы настоящие, — и посмотрим, как она переворачивает порядок и намертво встаёт на одном ждущем обработчике. Отсюда правило, которое пригодится далеко за пределами этой главы: модель обязана быть синхронной и замкнутой.
Нашу модель легко счесть недоделанной. Цикл в ней синхронный, «микрозадачи» — обычный массив, промисов внутри нет ни одного, хотя в языке они есть и делают ровно то, что модель изображает. Напрашивается улучшение, и в чужих статьях и роликах оно встречается постоянно: пусть колбэк вызывается через await, а очередь микрозадач достанется настоящим промисам. Планировщик становится асинхронным — как настоящий цикл — и выглядит куда взрослее нашего.
Соберём такую версию и посмотрим на неё в деле. Она даже короче: перерыва между колбэками у неё нет вовсе, вместо него await, а сроков она не считает — таймер просто ждёт следующего оборота. Всё остальное на месте: три очереди фаз, снимок очереди на входе в фазу, оборот по кругу и номер оборота в каждой строке вывода.
Важных строк в нём две. Первая — объявление fake.run асинхронной функцией: у цикла появился свой промис, и снаружи его теперь можно дождаться. Вторая — await fn() в каждой из трёх фаз: колбэк вызывается с обещанием дождаться, пока он кончится. Обе выглядят как забота о правильности, и в каждой спрятано по ошибке — но увидеть это проще на выводе, чем на словах.
Прогноз к примеру: колбэк фазы проверки заводит цепочку из двух реакций промиса и модельный таймер. В каком порядке напечатаются четыре строки и на каком обороте окажется каждая? Опереться есть на что: в Node цепочка промиса разбирается целиком раньше любого таймера.
Модельный таймер встал между двумя звеньями одной цепочки промиса, а «оборот 2» в последней строке говорит, сколько всего прошло между ними: целый круг цикла с фазой сроков внутри. В настоящем Node такого порядка не бывает ни при каких условиях — цепочка разбирается до дна в перерыве между колбэками, и никакая фаза в неё не влезает. Планировщик, которому мы поверили за то, что он «настоящий асинхронный», соврал в первом же примере.
Пример не подобран нарочно и не сломан: те же четыре строки на честной модели дают другой порядок. Враньё сидит в одной строке самого планировщика, а не в сюжете.
Причин три, и ни одна не про небрежность. Первая: на промисах модель пишется быстрее — своя очередь микрозадач не нужна, её роль исполняет настоящая, и кода выходит вдвое меньше. Вторая: «настоящее лучше изображённого» звучит как здравый смысл, и мысль, что изображение бывает честнее, в голову не приходит. Третья и главная: такую модель показывают, а не проверяют. Она печатает правдоподобный список строк, автор объясняет его словами — и всё сходится.
Поэтому проверка у модели ровно одна: взять её сюжет, снять тот же сюжет настоящим node и сравнить построчно. Сегодня мы так и сделаем, а заодно научимся видеть враньё раньше — по одному признаку в коде самого планировщика, не запуская его вовсе.
Причина одна, и она в await. Он ставит на паузу функцию, в которой написан, и отдаёт управление наружу; а продолжение после паузы возвращается не само по себе, а микрозадачей — в ту самую очередь, где уже стоят реакции промисов из примера. Значит, после каждого колбэка fake.run встаёт в общую очередь и ждёт там наравне со всеми. Фаза модели перестала быть фазой: теперь это просто очередная микрозадача.
Дальше всё решает арифметика очереди, и её стоит выписать по шагам. Каждый колбэк обходится циклу ровно в одну микрозадачу — и звено цепочки промиса стоит ровно одну. Значит, здесь они будут чередоваться, чем бы ни были заняты.
| Что выполняется | Очередь микрозадач после этого | Что напечатано |
|---|---|---|
Колбэк проверки; await ставит в очередь продолжение цикла | шаг 1, продолжение цикла | оборот 1: колбэк проверки |
| Шаг 1 цепочки: он заводит второе звено | продолжение цикла, шаг 2 | оборот 1: промис, шаг 1 |
| Продолжение цикла: новый оборот, фаза сроков | шаг 2, продолжение цикла | оборот 2: модельный таймер |
| Шаг 2 цепочки | продолжение цикла | оборот 2: промис, шаг 2 |
Никакой мистики: планировщик не «обгоняет» цепочку и не важнее её. Он просто встал в ту же очередь и получил ход по-честному, в порядке живой очереди. Беда в том, что очередь эта — предмет урока. Модель, которая объясняет очередь промисов, устроена на очереди промисов и в первом же примере показывает её поведение вместо своего.
А теперь самое неприятное свойство такой модели: врёт она не всегда. Уберём из цепочки второе звено — сюжет тот же, работ в очереди стало на одну меньше. Прогноз: изменится ли порядок и совпадёт ли он на этот раз с настоящим Node?
Порядок правильный: реакция промиса, потом таймер — именно так ответил бы и node. Но правильный он по случайности. В очереди оказалось по одной работе с каждой стороны, чередование дало верный ответ, а поменяйся число звеньев — и он снова станет неверным. Модель, которая права через раз, опаснее той, что врёт всегда: проверить её одним примером нельзя, а два примера подряд никто не пробует.
На честной модели ничего подобного не случается, и не потому, что она умнее. Она вся — один синхронный кусок кода: while, вызовы колбэков, осушение очередей. Пока loop.run() не вернулся, ни одна микрозадача среды не выполнится: вклиниться в середину оборота нечему. Тот же сюжет с цепочкой, только обе микрозадачи модельные:
Порядок совпал с тем, что даёт Node: оба шага цепочки в первом обороте, таймер — во втором. И совпал он не случайно, а потому, что так написано в call: микрозадача в честной модели — это loop.microtask, то есть запись функции в массив, и второе звено цепочки — тоже запись в массив, сделанная по ходу осушения. Модель изображает механизм, а не пользуется им. Ровно это и позволяет ей не врать: у изображения нет своих сроков и своей очереди, оно делает только то, что написано в тридцати строках рядом.
Признак вранья виден раньше вывода: если в самом планировщике есть await, then, setTimeout или queueMicrotask, его порядок задаёт среда, а не тридцать строк рядом. Читать при этом весь код не нужно — хватит поиска по четырём словам.
Утверждение «фаза не может влезть в середину цепочки» стоит проверить, а не объявить: без эталона спор двух моделей остаётся спором двух мнений. Колбэк таймера заводит цепочку из двух реакций, тик и ещё один таймер на ноль. Прогноз: где окажется второй таймер — между звеньями цепочки, как в нечестной модели, или после неё?
// Эталон честности: что Node делает с цепочкой промиса, тиком и вторым таймером, // заведёнными внутри колбэка первого таймера. С этим порядком и сверяется модель. setTimeout(() => { console.log('таймер: колбэк начался'); // цепочка из двух реакций: вторая появится только когда выполнится первая Promise.resolve() .then(() => console.log('таймер: промис, шаг 1')) .then(() => console.log('таймер: промис, шаг 2')); process.nextTick(() => console.log('таймер: тик')); setTimeout(() => console.log('следующий оборот: второй таймер'), 0); console.log('таймер: колбэк кончился'); }, 0);
таймер: колбэк начался таймер: колбэк кончился таймер: тик таймер: промис, шаг 1 таймер: промис, шаг 2 следующий оборот: второй таймер
Порядок такой: тик, оба звена цепочки и только потом второй таймер. Второе звено появилось во время перерыва между колбэками — и разобралось в том же перерыве, потому что перерыв кончается не по числу работ, а по пустоте обеих очередей. Цикл получит ход, когда разбирать станет нечего, и ни мгновением раньше.
Тик в этом выводе идёт первым из отложенного, и стоит он здесь не для полноты картины. Перерыв между колбэками состоит из двух очередей, и обе разбираются до дна прежде, чем цикл сдвинется хоть на шаг: сначала тики, потом реакции промисов вместе со всем, что те по дороге завели. Нечестный планировщик ломает и это: у него между работами перерыва проходят целые фазы — потому что перерыва у него нет вовсе.
Вот с этим выводом и сверяется любая модель цикла событий. Наша ему не противоречит: в ней цепочка тоже разобралась целиком до фазы сроков. Нечестная противоречит прямо: у неё между звеньями прошла целая фаза. Значит, спор кончен — дело не во вкусе и не в стиле, модель разошлась с проверяемым фактом, и любое другое её утверждение о порядке теперь не стоит ничего.
Заметьте, чем именно кончился спор. Не ссылкой на документацию, не авторитетом и не рассуждением — снятым выводом, который можно снять заново на своей машине и получить то же самое. Поэтому в этой части рядом с моделями и стоят записанные прогоны: модель показывает механизм, а прогон отвечает за то, что механизм тот самый.
Сделать модель асинхронной можно, но заплатить придётся полную цену: у неё должна быть своя очередь микрозадач, своё «сейчас» и свой способ отдавать ход, а настоящих промисов внутри не должно остаться ни одного — включая те, что заводит пример читателя. То есть смоделировать придётся уже не цикл, а всю среду вокруг него.
И главное — ради чего? Асинхронность нужна настоящему циклу, чтобы не занимать поток ожиданием байтов. Модели ждать нечего: очередь опроса наполняем мы сами, и «ожидание» в ней — строка в массиве. Синхронная модель не беднее асинхронной, она ровно такая же, только её вывод предсказывается чтением кода. А это и есть весь смысл модели.
У синхронности есть цена, и знать её надо заранее. Раз loop.run() — один синхронный кусок, то для среды весь наш цикл целиком — часть одной задачи: микроочередь она разберёт не раньше, чем этот кусок кончится. Проверим, что это значит на практике: положим в колбэк модели настоящий Promise рядом с модельной микрозадачей. Прогноз: какой по счёту напечатается строка про настоящий промис и какой номер оборота в ней окажется?
Последней — позже даже той строки, что стоит за loop.run(). А счётчик оборотов в ней замер на двойке: цикл давно кончился, оборотов больше не будет, и число в строке говорит не «когда выполнилось», а «чем всё закончилось». Модель здесь не соврала, она просто ни при чём: настоящий промис её миру не принадлежит. Соврал бы тот, кто по этому выводу стал бы рассуждать о порядке в Node.
Отсюда правило пользования, которое строже, чем кажется: внутри модели пользуются только её примитивами. Появился в примере настоящий Promise, await или setTimeout — примером управляют два планировщика сразу, и предсказать вывод чтением кода больше нельзя, даже если кода в сумме десять строк. В примерах двух прошлых статей настоящей асинхронности нет ни строчки — и это не аккуратность автора, а условие, при котором прогноз вообще имеет смысл.
Работает правило и в обратную сторону, как приём чтения. Открыли чужую модель, увидели в её примерах настоящий промис рядом с модельными вызовами — значит, вывод рассказывает про среду, а про модель не доказывает ничего. Проверять дальше не обязательно: одного этого хватает, чтобы не верить выводам.
Теперь понятно, почему в части VI у примеров две породы и почему они не смешиваются. Модель живёт в песочнице: она синхронна, замкнута, и её вывод целиком определён кодом, который виден на экране, — поэтому прогноз перед запуском имеет смысл, а эталон вывода снимается раз и навсегда.
Всё, где участвует настоящая среда — таймеры, файлы, сокеты, процессы, — уходит в записанный прогон: там вывод снят настоящим node и подписан датой и версией. Смешать их в одном примере значило бы получить вывод, который не объясняется ни моделью, ни Node. Ровно это и делает нечестный планировщик — только не нарочно.
Второе враньё нечестного планировщика опаснее первого, потому что выглядит как забота о порядке. await fn() читается как «дождаться, пока колбэк закончит»: с синхронным колбэком разницы не видно — он и так кончается сразу. А если колбэк асинхронный, то есть такой, какими пишут обработчики запросов?
Соберём сервер в миниатюре на том же планировщике. В очереди фазы опроса три работы: обработчик /отчёт, которому нужен ответ базы, сосед /пинг, которому не нужно ничего, и разбор пришедших от базы байтов — он-то ответ и приносит. Прогноз: сколько строк из пяти возможных напечатает пример и в каком порядке?
Две. Цикл встал на первом же обработчике: тот ждёт ответ базы, а ответ базы стоит в очереди той же фазы опроса, до которой ход уже не дойдёт. /пинг не обслужен, байты не разобраны, процесс жив и не делает ничего — при том что работа лежит прямо перед ним. Заодно видна и вторая половина беды: строка за fake.run() напечаталась сразу. Асинхронный run вернул промис, а не результат, и мир модели перестал быть замкнутым — снаружи цикла теперь видно его середину.
В Node такая программа работает, и это не мелочь, а смысл всей платформы: ждущее соединение не занимает ни потока, ни цикла. Повторим сюжет настоящим node: обработчик setImmediate с await внутри, сосед по фазе проверки и таймер, который приносит ответ. Прогноз: где в выводе окажется вторая строка ждущего обработчика — сразу за первой или в самом конце?
// Обработчик ждёт ответ базы, а ответ приносит таймер. Где в выводе окажется // сосед по фазе проверки и где — продолжение ждущего обработчика? let ответить; const ответБазы = new Promise((resolve) => { ответить = resolve; }); setImmediate(async () => { console.log('/отчёт: жду ответ базы'); await ответБазы; console.log('/отчёт: отчёт готов'); }); setImmediate(() => console.log('/пинг: отвечаю сразу')); setTimeout(() => { console.log('фаза сроков: ответ базы разобран'); ответить(); }, 10);
/отчёт: жду ответ базы /пинг: отвечаю сразу фаза сроков: ответ базы разобран /отчёт: отчёт готов
Сосед обслужен сразу, ответ разобран в фазе сроков, а продолжение ждущего обработчика пришло перерывом после того колбэка, который его разбудил. Цикл не ждал никого ни разу. Отсюда правило, которое честная модель соблюдает по построению: колбэк для цикла атомарен — цикл его зовёт, и на этом их разговор окончен. Всё, что колбэк оставил на потом, вернётся к циклу очередью, а не через возвращённое значение. Планировщик, который дожидается колбэка, — это уже не цикл событий, а очередь задач с блокировкой: сервер на нём обслуживал бы ровно одного клиента.
Строка эта, кстати, губит не только модели. await в цикле по списку — самый частый способ превратить десяток параллельных ожиданий в очередь, где каждое ждёт предыдущего: десять запросов к базе по сто миллисекунд занимают секунду вместо ста миллисекунд. В обработчике такой цикл не останавливает сервер, как здесь, но сам ответ готовится ровно так же — по одному шагу за раз.
У атомарности есть обратная сторона, и на сервере она стоит дорого. Цикл зовёт колбэк и забывает про него — а значит, не смотрит и на то, что колбэк вернул. Асинхронный колбэк возвращает промис, и если тот отклонён, перехватывать отклонение некому: ни цикл его не видит, ни try..catch вокруг строки с назначением — она давно выполнилась.
Прогноз: успеет ли напечататься строка соседа по фазе проверки и с каким кодом выйдет процесс?
// Цикл не смотрит на то, что вернул колбэк, — а вернул он отклонённый промис. // Что из этого выйдет для соседа по фазе проверки и для кода выхода? setImmediate(async () => { console.log('/отчёт: начал, жду базу'); throw new Error('база не ответила'); }); setImmediate(() => console.log('/пинг: отвечаю сразу'));
/отчёт: начал, жду базу
file://<пример>/throw.mjs:6
throw new Error('база не ответила');
^
Error: база не ответила
at Immediate.<anonymous> (file://<пример>/throw.mjs:6:9)
at <внутренние кадры node>
Node.js v<версия>Сосед не напечатался вовсе. Непойманное отклонение промиса Node считает непойманной ошибкой и кончает процесс с кодом 1 — ещё до того, как фаза проверки доберётся до второго колбэка. Отсюда правило для обработчиков: асинхронный колбэк ловит свои ошибки сам, внутри себя. Отдать их наружу он не может — снаружи его никто не слушает.
Признак тот же, что и у первого вранья, и ищется тем же поиском: await внутри самого планировщика. В первый раз он переставил порядок, во второй — остановил цикл. Строка одна и та же.
Из этих двух ошибок выводятся три вопроса, которые стоит задавать любой модели — чужой в статье, своей в черновике. На первые два отвечает чтение кода за минуту.
| Вопрос | Признак вранья |
|---|---|
| Синхронна ли она? | В планировщике есть await, .then, setTimeout или queueMicrotask. Порядок в такой модели задаёт среда, а не код модели |
| Замкнут ли её мир? | В примерах рядом с модельными вызовами стоит настоящая асинхронность. Выводом управляют два планировщика, и чтением кода он не предсказывается |
| Признаёт ли она, чего не умеет? | Модель отвечает на любой вопрос и нигде не говорит «здесь я молчу». Упрощения есть у всякой модели; названные — не враньё, умолчанные — да |
На первые два вопроса наша модель отвечает «да» по построению. Третий — наш долг, и вот он целиком, чтобы к нему больше не возвращаться.
| О чём модель молчит | Как на самом деле |
|---|---|
| Старт процесса | Тик, назначенный до loop.run(), пролежит до первого колбэка. В настоящем главном модуле очереди разбираются ещё до первого оборота, и порядок зависит от того, как загружен файл |
| Длительность оборота | В модели оборот стоит один тик, в жизни — столько, сколько шли колбэки. Поэтому модельные сроки ровные, а настоящие плывут под нагрузкой |
| Три фазы из шести | Две служебные заняты внутренними делами libuv, фаза закрытия в порядке остальных ничего не меняет. Упрощение названо, но оно есть |
| Фаза опроса | Очередь опроса наполняем мы сами вызовом loop.io. Настоящая фаза спит на системном вызове и просыпается от пришедших байтов — или по будильнику ближайшего срока |
| Порядок на старте | immediate раньше таймера модель обещает твёрдо, а Node не обещает вовсе: до первого оборота это жребий, и выпадает он в обе стороны |
| Падение колбэка | В модели исключение вылетает из loop.run() и уносит весь цикл вместе с очередями. В Node оно тоже кончает процесс, но идёт своим путём и выглядит иначе |
Ни один из этих долгов не мешает модели отвечать на вопрос, ради которого она собрана: что за чем идёт внутри колбэка. Дальше её придётся поставить рядом с настоящим libuv и посмотреть, где совпадение кончается, — это статья «Настоящий цикл, libuv и пул потоков». А перед ней мы вынесем из цикла то, чему в нём не место вовсе: «Заблокировать цикл и вынести счёт в отдельный поток».
Три вопроса пригодятся и дальше по части: впереди свой список слушателей, свои потоки, свой разборщик HTTP, свой контейнер зависимостей. Каждая из этих миниатюр — такая же модель, и у каждой те же три уязвимых места. Привычка задавать вопросы до того, как поверил выводу, стоит дешевле, чем разбор чужой ошибки в своём коде.
И последнее, ради чего всё это затевалось. Модель полезна не тем, что похожа, а тем, что её можно проверить — прогнозом перед запуском и сверкой с настоящим node после. Похожесть проверить нечем, и именно она подводит: нечестный планировщик был похож на настоящий цикл гораздо больше нашего — у него и промисы настоящие, и асинхронность честная на вид. Просто отвечал он не на тот вопрос.
Хорошая модель отвечает не на все вопросы, а на один — и прямо говорит, на какой. Та, что отвечает на все, просто не сообщила, где перестала знать.
- Планировщик, который разбирает фазы через
await, ставит свой оборот в общую очередь микрозадач: модельный таймер влезает между двумя звеньями цепочки промиса, чего в Node не бывает. - Врёт он не всегда: на сюжете покороче порядок случайно совпадает с настоящим. Модель, которая права через раз, одним примером не проверяется.
- Он же перестаёт быть циклом событий:
await fn()заставляет цикл дожидаться асинхронного колбэка, и один ждущий обработчик останавливает всё — при том что ради обратного Node и сделан. - Честная модель синхронна: пока
loop.run()не вернулся, среда не выполняет ни одной микрозадачи, и порядок целиком задан кодом модели. - Честная модель замкнута: она изображает механизм своими примитивами, а не пользуется им. Настоящий
Promiseвнутри неё выполнится после всего цикла и расскажет про среду, а не про модель. - Отсюда три вопроса к любой модели: синхронна ли она, замкнут ли её мир и названы ли упрощения. Умолчанное упрощение и есть враньё — наша модель молчит про старт процесса, длительность оборота, три недостающие фазы и настоящую фазу опроса.
1/4Планировщик разбирает фазы через await fn(). В колбэке заведены цепочка из двух реакций промиса и модельный таймер. Что окажется в выводе между первым и вторым звеньями цепочки?
2/4Почему честная синхронная модель не может перепутать порядок реакций настоящих промисов?
3/4В колбэк честной модели попал настоящий Promise.resolve().then(…). Когда выполнится реакция?
4/4Планировщик зовёт колбэки через await fn(). Обработчик ждёт ответ базы, а ответ базы стоит в очереди той же фазы опроса. Что произойдёт?
Пример, который доказывает обратное
В чужой статье про цикл событий стоит пример с подписью «реакция промиса обгоняет таймер — смотрите сами». Утверждение верное, а пример печатает ровно обратное: сначала таймер, потом реакция. Автор ничего не подделывал — он просто смешал в одном примере два мира. Найдите строку, из-за которой пример доказывает не то, и почините её так, чтобы вывод подтверждал подпись. Модель и таймер трогать нельзя.
Настоящий Promise в модельный мир не входит: его реакция ждёт конца всей задачи среды, а задача эта — loop.run() целиком. Поэтому она и печатается после таймера, хотя заведена раньше. Микрозадача должна быть модельной:
Вывод:
Теперь пример говорит про модель, и говорит правду: микрозадача разобралась в перерыве сразу после колбэка, а таймеру достался следующий оборот. Разница в одном слове, а разница в выводе — противоположная.
Ловушка живучая, потому что смешанный пример почти всегда печатает что-то правдоподобное, и никто не проверяет, про что именно этот вывод. Признак — настоящая асинхронность рядом с модельными вызовами. Увидели такое в чужой статье, считайте, что про модель там не доказано ничего.
Ожидание, которое не выпадает из модели
База в модели отвечает не сразу — через два тика. Взять её ответ можно двумя способами, оба готовы: промисом и колбэком. Сейчас обработчик /отчёт берёт ответ промисом, и отчёт оказывается готов позже сторожа, хотя база ответила раньше. Поменяйте одну строку так, чтобы отчёт напечатался там, где база и вправду ответила. Сторож, сосед и сама база — чужой код.
Ответ берётся колбэком — единственным способом, который модель понимает:
Вывод:
База отвечает на втором обороте, и отчёт теперь готов там же. Через промис он уезжал в самый конец: resolve модельный таймер вызывает вовремя, но реакция на него — настоящая микрозадача, а она ждёт конца всего loop.run(). Сторож на пятом тике успевал раньше, и это не медленная база, а выпадение из модели.
В настоящем Node оба способа равноценны, и промис там даже удобнее. Разница появляется только в модели и появляется ровно потому, что модель синхронна. Цена замкнутого мира вот такая: то, чем удобно пользоваться снаружи, внутрь не проносят.
Планировщик, который никого не дожидается
Тот самый нечестный планировщик, ужатый до одной фазы опроса: он встаёт на первом же обработчике, и из пяти строк печатаются две. Почините цикл, поменяв одну строку. Сюжет — обработчики, база и строка после запуска — чужой код, трогать его нельзя.
Подсказка: цикл событий колбэка не дожидается. А после починки посмотрите, где оказалась последняя строка вывода, — это остаток вранья, который одной строкой не лечится.
Цикл не должен ждать колбэк, поэтому await уходит:
Вывод:
Мёртвая хватка исчезла: все три работы фазы опроса обслужены, сосед не пострадал, байты разобраны. Цикл стал синхронным — асинхронная функция без единого await внутри выполняется до конца сразу и возвращает уже готовый промис, который никто не смотрит. Так и надо: возвращённое значение колбэка циклу не нужно.
А вот последняя строка осталась не на месте: отчёт готов позже строки, стоящей за fake.run(), то есть после конца всего цикла. Это второе враньё, и одной строкой оно не лечится: обработчик написан на await, а продолжение после await — настоящая микрозадача, которая ждёт конца задачи среды. Синхронная модель не умеет продолжать асинхронный колбэк — и не должна уметь. Ожидание в ней выражают колбэком, как в предыдущей задаче.