Дачный термостат я довёл до ума на WebServer.h из прошлого урока: маршруты аккуратные, /status отдаёт JSON. А потом поставил коробку на кухню и открыл страницу с двух телефонов одновременно. Первый браузер грузился, второй висел, а главное — табло на экране, которое рисовалось по millis-расписанию раз в секунду, начало заикаться: раз в несколько секунд пауза в полсекунды. Виновник нашёлся быстро: пока WebServer.h отдавал страницу, весь мой loop() стоял — вместе с расписанием, экраном и реле.
Это не баг, а устройство синхронного сервера: обработчик выполняется в общем loop(), и пока он не вернётся, ничего другого не происходит. Для одного браузера — незаметно, для двух — уже заметно, для десяти — увы. Библиотека ESPAsyncWebServer меняет раскладку: обработчики регистрируются колбэками и выполняются в сетевой задаче чипа, а loop() остаётся при своих делах. Десять открытых страниц перестают быть событием: каждая обслуживается параллельно, и millis-расписание датчиков не срывается.
Как устроен async: колбэки вместо очереди
Синтаксис выглядит непривычно, если не писали на функциональных языках: маршруту передаётся безымянная функция — лямбда. server.on("/led", HTTP_GET, [](AsyncWebServerRequest *request) { ... }); читается так: на GET-запрос к /led вызвать вот эту функцию, у которой на входе — объект запроса. Внутри — те же действия, что в обычном обработчике: достать аргумент, переключить светодиод, отправить ответ request->send(...). Аргументы лежат в самом запросе: request->hasParam("state") проверяет наличие, request->getParam("state")->value() возвращает значение — это async-родня server.hasArg и server.arg из WebServer.h.
Фильтр HTTP_GET в середине — не украшение: он говорит, что маршрут отвечает только на GET, и POST мимо него уйдёт в 404. Так же регистрируется HTTP_POST для форм — и это аккуратнее, чем ловить всё подряд и проверять метод внутри. Ответ отправляется тем же request->send(код, тип, тело), и вот важное: send() не тянет соединение за собой — библиотека сама закроет передачу, когда байты уйдут в сеть.
Про лямбды ещё два уточнения, чтобы синтаксис не пугал. Квадратные скобки перед телом — это список захвата: пустые [] значат «своих переменных не беру», [&] разрешает пользоваться внешними переменными как ledOn, которую вы видите в скетче ниже. Состояние устройства я держу в глобальных переменных, а в лямбду передаю только request — так меньше шансов напутать с временем жизни объектов. Если страница большая, её не собирают в String внутри обработчика: её хранят константным массивом в PROGMEM и отдают send_P — сетевой задаче достаётся только ссылка.
Установка — первый барьер, о который спотыкаются. ESPAsyncWebServer не приходит одна: ей нужен напарник AsyncTCP — низкоуровневый транспорт для ESP32 (для ESP8266 есть свой ESPAsyncTCP). Библиотеки берите парой из одного источника — сейчас актуален форк ESP32Async, а старые копии из случайных репозиториев не собираются с новыми версиями ядра: башня из десятков красных сообщений о типах и указателях означает именно несовпадение версий, а не вашу ошибку в коде. Ставятся они ZIP-архивом через менеджер библиотек, и после установки пример из документации собирается с первого раза.
#include <WiFi.h>#include <ESPAsyncWebServer.h> // + её напарник AsyncTCP AsyncWebServer server(80);const int ledPin = 2;bool ledOn = false; void setup() { Serial.begin(115200); pinMode(ledPin, OUTPUT); WiFi.begin("DachaNet", "superParol123"); while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); } Serial.println(WiFi.localIP()); // обработчик — лямбда прямо в регистрации маршрута server.on("/led", HTTP_GET, [](AsyncWebServerRequest *request) { if (request->hasParam("state")) { ledOn = (request->getParam("state")->value() == "on"); digitalWrite(ledPin, ledOn); } request->send(200, "text/plain", ledOn ? "led=on" : "led=off"); }); server.on("/status", HTTP_GET, [](AsyncWebServerRequest *request) { String json = String("{\"led\":\"") + (ledOn ? "on" : "off") + "\"}"; request->send(200, "application/json", json); }); server.begin(); // loop() остаётся свободным} void loop() { // здесь пусто: датчики, экран, реконнект живут по своему расписанию, // а запросы обслуживает сетевая задача чипа}Сравните loop() с версией на WebServer.h: там был server.handleClient() на каждом обороте, здесь — пусто. Сетевая задача живёт на втором ядре ESP32 и не спрашивает разрешения у вашего цикла. Отсюда и число, которое обычно удивляет: восемь-десять открытых страниц async-сервер обслуживает так, что вы этого не заметите по датчикам, — при том что sync-сервер на том же железе уже при двух-трёх клиентах начинал подлагивать. Плата за это платит памятью: под каждое соединение держится буфер, и на сотню одновременных клиентов запаса кучи не хватит, но домашний проект в эти рамки попадает с запасом.
Отдельно про память, потому что за параллельность платят байтами. Под каждое открытое соединение async-сервер держит буферы — по паре килобайт на клиента, и ещё место нужно под сам TCP-стек. После старта WiFi у ESP32 свободно порядка двухсот килобайт кучи, поэтому восемь-десять браузеров она держит смеясь; но String-конкатенация больших страниц в каждом обработчике качает кучу туда-сюда, и через сутки фрагментация сделает своё дело. Мои правила простые: страницы — из PROGMEM или массива байтов, ответы — короткие, и раз в час в Serial печатать ESP.getFreeHeap() — цифра на глазах покажет, течёт ли память.
Правило короткого обработчика становится строже. В WebServer.h долгий обработчик тормозил ваш loop(); в async-версии наоборот — долгий обработчик тормозит сетевую задачу, то есть весь WiFi. delay(500) внутри лямбды — это полсекунды, когда все браузеры и сам WiFi-стек ждут. Поэтому в обработчике только трогаем пин и шлём ответ; любые паузы и длинные сборки страниц выносим в loop() на millis-расписании — приёмы из урока про millis вместо delay и урока про кооперативный цикл ложатся сюда один в один.
| Сервер | loop() во время запроса | Клиентов комфортно | Параметры запроса |
|---|---|---|---|
| WiFiServer (база) | Занят чтением и сборкой ответа | Один | readStringUntil + indexOf |
| WebServer.h | Стоит на время обработчика | Один-три | server.arg("имя") |
| ESPAsyncWebServer | Свободен: обслуживает сетевая задача | Восемь и больше | request->getParam("имя")->value() |
Замер в симуляторе: сколько тиков съедает сервер
Сам ESPAsyncWebServer движок симулятора не знает — зато честно умеет другое: delay, счётчики и digitalWrite. Поэтому эксперимент устроен как контрольный замер, а не пересказ кода. Проход цикла здесь стоит 100 мс виртуального времени; каждые пять проходов расписание делает тик — переключает светодиод, как переключались бы датчики и экран; клиент приходит каждые десять проходов. Первые три клиента обслуживает синхронный сервер: обработка заявки держит цикл семь проходов, как длинный обработчик в loop(). Следующие три достаются асинхронному: заявка обслуживается мгновенно, цикл не прерывается. В конце каждой фазы замер печатает три числа: сколько проходов съели клиенты и сколько тиков из положенного успело расписание. Смотрите на светодиод: в sync-фазе он мигает вразнобой — расписание догоняет после каждого клиента, в async-фазе мигает метрономом. И на зазор между числами za_prohodov: обслуживание блокирующим кодом удлиняет фазу на те самые семь проходов с клиента, растянутые по расписанию. После прогона уместен вопрос к себе: что в моём проекте играет роль тика — экран, датчик, реконнект из урока про автовосстановление связи? Всё это sync-сервер ставит на паузу, а async-сервер не трогает.
- 1. Где выполняется обработчик маршрута в ESPAsyncWebServer?
- 2. Какая библиотека нужна ESPAsyncWebServer в пару на ESP32?
- 3. Почему delay(500) внутри лямбды-обработчика хуже, чем в loop()?
- 4. Как достать state из запроса /led?state=on в async-обработчике?
- 5. Пять браузеров открыли страницу sync-сервера. Что станет с millis-расписанием датчиков в loop()?
- ESPAsyncWebServer ставится парой с AsyncTCP из одного источника; башня красных сообщений = несовпадение версий, не ваш код
- Маршрут — это лямбда: server.on("/led", HTTP_GET, [](AsyncWebServerRequest *r){ r->send(...); })
- Параметры: request->hasParam("state") и request->getParam("state")->value() — async-родня server.arg
- Обработчик живёт в сетевой задаче: delay и долгие циклы в нём тормозят весь WiFi; в loop() — только millis-расписания
- Восемь-десять клиентов для async — рутина; sync-сервер уже на двух-трёх начинает подлагивать
Честности ради — async нужен не всем. Если платой управляет один человек с одного телефона, страницы крошечные, а запросы редкие, WebServer.h из прошлого урока справится, и лишняя зависимость вам не понадобится: меньше библиотек — меньше мест, где можно словить несовпадение версий. Моя граница простая: появился второй потребитель данных — второй телефон, скрипт-табло, голосовой ассистент, который ходит по /status, — или у loop() появилось расписание, которое нельзя ставить на паузу, я переношу сервер на async. Между этими берегами живёт большинство проектов, и обе библиотеки стоят в одном разделе не случайно.
Асинхронный сервер — потолок классической схемы «браузер спрашивает, плата отвечает»: клиент всегда инициирует запрос сам. Но табло с живой температурой хочется наоборот: плата сама шлёт данные, как только они поменялись, без перезагрузок и без жадных заголовков на каждый опрос. Для этого у той же библиотеки есть WebSocket — постоянный канал поверх одного рукопожатия. Этому посвящён следующий урок про живые данные. А если устройств станет много и каждому нужен свой канал команд — посмотрите урок про публикацию показаний по MQTT: там плата вообще перестаёт быть сервером.
Частые вопросы по теме урока
- Где выполняется обработчик маршрута в ESPAsyncWebServer?
- Обработчики-колбэки живут в сетевой задаче. Поэтому loop() остаётся свободным: millis-расписание датчиков не срывается, сколько бы браузеров ни открылось. В сетевой задаче чипа — loop() не участвует
- Какая библиотека нужна ESPAsyncWebServer в пару на ESP32?
- Пара обязательна: ESPAsyncWebServer поверх AsyncTCP (на ESP8266 — ESPAsyncTCP). Библиотеки берут из одного источника, иначе не совпадут с версией ядра. AsyncTCP — низкоуровневый транспорт
- Почему delay(500) внутри лямбды-обработчика хуже, чем в loop()?
- Лямбда выполняется в сетевой задаче. Пауза в ней останавливает обслуживание всех соединений и сам WiFi-стек. В обработчике — только пин и send(). Тормозит сетевую задачу — весь WiFi для всех клиентов
- Как достать state из запроса /led?state=on в async-обработчике?
- У async-запроса свой API: hasParam() и getParam("имя")->value(). server.arg() — это метод синхронного WebServer.h, к async-запросу он не относится. request->getParam("state")->value(), проверка — request->hasParam()
- Пять браузеров открыли страницу sync-сервера. Что станет с millis-расписанием датчиков в loop()?
- Синхронный сервер обслуживает клиентов по очереди внутри loop(). Каждый запрос — пауза для всего остального кода. Async-сервер этот конфликт снимает. Начнёт срываться: loop() занят раздачей страниц по очереди