Optimierung für den WebServer

Auf der Suche nach Speicheroptimierung z.B. für MP3 wird nicht abgespielt. NeoPixel leuchtet Rot und bei Tests für das demnächst erscheinende Arduino 3 Framework bin ich auf diesen offiziellen Fork des ESPAsyncWebserver gestoßen:

Ich sehe bei der neuen Bibliothek diese Vorteile:

  • 1:1 Ersetzung des bestehenden Webservers, keine Codeänderungen
  • Offiziell gelistet in PlatformIO-Registry
  • Bekannte Bugs gefixt, ESP32AsyncWebserver wird seit Jahren nicht mehr gepflegt/weiterentwickelt
  • Speicheroptimiertes Ausliefern von Websocket JSON-Nachrichten
  • Websocket Nachrichtenschlange lässt sich in der Größe konfigurieren WS_MAX_QUEUED_MESSAGES
  • ArduinoJSON 7 kompatibel
  • Kompiliert mit dem Arduino 3 Framework (erscheint in einigen Wochen)

So könnt Ihr es ausprobieren, in platform.ini diese Zeilen ändern:

lib_deps =
...
;	https://github.com/me-no-dev/ESPAsyncWebServer.git#1d46269
;	https://github.com/me-no-dev/AsyncTCP.git#ca8ac5f
    https://github.com/mathieucarbou/ESPAsyncWebServer.git
...

Die Abhängigkeit zur AsyncTCP Bibliothek wird dann automatisch dazugeladen.

Die Speicheroptimierung verstehe ich so das der JSON Puffer nur einmal allokiert werden muss (jetzt 2-3 Mal, JSON-Objekt, JSON-Serialisierung + Puffer für Auslieferung).

auto buffer = std::make_shared<std::vector<uint8_t>>(len);

@laszloh Das ist doch Dein Fachgebiet, evt. kannst Du es besser erklären :wink:

Allerdings bin ich mit dem Ergebnis noch nicht ganz glücklich. Ich bekomme bei sehr hektischen Lautstärkeänderung über die Web-UI ab und an die Meldung „Websocket: Cannot send data (Too many messages queued)!“. Auch eine Speicherreduzierung konnte ich nicht wirklich feststellen.

Meinungen gern hier, ein Test ist ja schnell gemacht…

2 „Gefällt mir“

Bezüglich JSON Antworten müsste der Code noch umgebaut werden, wie es aktuell gemacht wird ist wie schon erwähnt recht ineffizient.

Nach diesem Muster „ArduinoJson Advanced Response“ dürfte der Speicherverbrauch reduziert werden.

1 „Gefällt mir“

Wir verwenden „ArduinoJson Advanced Response“ teilweise schon z.B. für /savedSSIDs.

Habe es mal testweise für den Endpunkt /info umgesetzt bekomme dann aber einen Fehler (verursacht durch Chunked-Response?):

grafik

„ArduinoJson Advanced Response“ wäre evt. ein Kandidat für /settings & /explorer Endpunkte, diese werden ja beim Laden der Web-UI aufgerufen. Aber geht dann nicht die Allokierung via PSRAM flöten? Und wie sieht’s dann aus mit Heap-Fragmentierung?

#ifdef BOARD_HAS_PSRAM
	SpiRamJsonDocument jsonBuffer(65636);
#else
	StaticJsonDocument<8192> jsonBuffer;
#endif

Erstmal sorry, ich hatte leider in letzter Zeit wenig Zeit & Muse für meine privaten Projekte. Das Sommerloch naht und sowohl Arbeit als auch Familie halten mich auf trabb :wink:

Mehr oder weniger. Ich denke nicht, dass die Anzahl an Allokierungen sich groß unterscheiden wird, aber was mit an dem Fork von mathieucarbou sofort gefällt ist, dass er STL nutzt. Das ist bei dem original Code (vielleicht aufgrund des alters des Codes) nicht so. So ist der AsyncWebSocketMessageBuffer nichts anderes als ein vector + ein sehr rudimentärer smart pointer.
Was mathieucarbou bzw. yubox-node-org dann auch darauf umgeschrieben hat :smile:. Das erspart halt einiges an eigenem Code, da man auf eine existierende Basis aufbaut (und ja, Bugs im STL gibt es aber sicherlich weiger als im eigenem Code :wink: ).

void send(JsonDocument& doc) {
  const size_t len = measureJson(doc);

  // this fork (originally from yubox-node-org), uses another API with shared pointer that better support concurrent use cases then the original project
  auto buffer = std::make_shared<std::vector<uint8_t>>(len);
  assert(buffer); // up to you to keep or remove this
  serializeJson(doc, buffer->data(), len);
  _ws->textAll(std::move(buffer));
}

Was man mit der neuen API auch gut sieht, ist, wer am Ende des Tages für den allokierten Speicher verantwortlich ist. Mit std::move gitb man aktiv alle rechte an dem Buffer ab, was auch mit der alten API überein stimmt, nur halt, implizit (aka hoffen, bzw im Code von AsyncWebServer schauen).

Wie hast du AsyncJsonResponse initialisiert? Wenn du nur 1 Parameter übergibst, wird der default Speicher von 1024 an DynamicJsonDocument übergeben. Es kann sein, dass das für den Info-Endpunkt nciht ausreicht.
Wenn du keinen 2. Parameter übergeben hast, kannst du mal mit 4096 probieren?

SPI-RAM Allokierung dürfte nicht weg gehen, weil wir haben CONFIG_SPIRAM_USE_MALLOC mit CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL=256 aktiv. Damit verwendet malloc für alle Speicheranfrage > 256 byte per default den SPI-RAM.

Was aber definitiv verloren geht, ist das StaticJsonDocument, sobald wir kein SPI RAM haben, womit danach alle ArduinoJson Objekte auf dem Heap landen. Ob und welche Auswirkung das hat, müsste man mal ausprobieren (mit AsyncJsonResponse sparen wir auf jeden fall mindestens 2 malloc/free Zyklen bei der Übergabe vom Json-String an den Webserver).

1 „Gefällt mir“

Danke für Eure Antworten! Es scheint genauso zu passen.

Habe testweise die Endpunkte /settings, /explorer, /info auf „ArduinoJson Advanced Response“ umgebaut. Dazu kann man die Größe des JSON Buffer angeben. Beispiel /settings mit 2KB:

// handle get settings
void handleGetSettings(AsyncWebServerRequest *request) {

	// param to get a single settings section
	String section = "";
	if (request->hasParam("section")) {
		section = request->getParam("section")->value();
	}

	AsyncJsonResponse *response = new AsyncJsonResponse(false, 2048);
	JsonObject settingsObj = response->getRoot();
	settingsToJSON(settingsObj, section);
#if defined(ASYNCWEBSERVER_FORK_mathieucarbou)
	if (response->overflowed()) {
		// JSON buffer too small for data
		Log_Println(jsonbufferOverflow, LOGLEVEL_ERROR);
	}
#endif
	response->setLength();
	request->send(response);
}

Im Fork gibt es auch die Möglichkeit der Überlaufprüfung. Das finde ich wichtig weil man sonst abgeschnittenes JSON bekommen kann und keiner weiß wo der Fehler liegt :wink: .

SPI-RAM Allokierung dürfte nicht weg gehen, weil wir haben CONFIG_SPIRAM_USE_MALLOC mit CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL=256 aktiv. Damit verwendet malloc für alle Speicheranfrage > 256 byte per default den SPI-RAM.

Gerade getestet, passt!
Für den /explorer Endpunkt werden 8KB (ohne PSRAM) bzw. 64KB (im PSRAM) angefordert. Bei einem Lolin D32 (ohne PSRAM) werden dann die 8KB vom Heap genommen. Gleicher Code mit Lolin D32 Pro (mit PSRAM) lässt den Heap tatsächlich nahezu unverändert. Es funktioniert also tatsächlich ohne weitere Änderungen.

Für die Websocket Optimierung könnte der Code so aussehen:

#if defined(ASYNCWEBSERVER_FORK_mathieucarbou)
	if (client == 0) {
		// serializing a Json document in a more optimized way using a shared buffer
		const size_t len = measureJson(doc);
		auto buffer = std::make_shared<std::vector<uint8_t>>(len);
		serializeJson(doc, buffer->data(), len);
		ws.textAll(std::move(buffer));
		return;
	}
#endif

Gibt es noch weitere Überlegungen? Weil dann würde ich mal einen PR erstellen…

2 „Gefällt mir“

Schaut doch mal drüber:

Wo kommt das ASYNCWEBSERVER_FORK_mathieucarbou her?

ASYNCWEBSERVER_FORK_mathieucarbou ist im Fork hier definiert & ermöglicht die Umschaltung von alter/neuer Webserver. Wenn dann keine Probleme mehr auftauchen können die #Defines natürlich rausfliegen.

1 „Gefällt mir“

Schickt die lib automatisch einen HTTP Fehler bei overflow?
Ansosnten dürfte der code weiterhin nur den overflow loggen, aber weiterhin mit unvollständigen JSON antworten. Würde das dann nicht nur loggen sondern nen richtigen HTTP Fehler zurück schicken wenn die lib das nicht automatisch macht.
Unvollständiges JSON dürfte sowieso client seitig dann ein parser Fehler triggern.

Bei websockets hast du jetzt nur den Sonderfall ASYNCWEBSERVER_FORK_mathieucarbou + client == 0 ergänzt, da finde ich das Ergebniss jeztzt schwer zu lesen. Würde das da feiner in den aktuellen code integrieren (der make_sahred buffer sollte für client != 0 genauso funktionieren).

Würde das dann nicht nur loggen sondern nen richtigen HTTP Fehler zurück schicken wenn die lib das nicht automatisch macht.

Das geht glaube ich nicht automatisch. Welchen Fehlercode schlägst Du vor? 500?

der make_sahred buffer sollte für client != 0 genauso funktioniere

Für ws.text() sehe ich da keine Funktion mit passenden Parameter, es gibt sie nur ws.textAll() ?

Du hast recht, keine Ahnung warum die fehlt, aber ein ws.text(client, buffer.data()); sollte funktionieren.

Es gibt sie, aber in dem AsyncWebSocketClient. Das wäre auch besser diesen direkt an den Clienten zu senden. Der implementiert void text(std::shared_ptr<std::vector<uint8_t>> buffer);.

Weil der Aufruf von void AsyncWebSocket::text(uint32_t id, const char *message, size_t len); erzeugt eine Kopie von den übergebenen Daten (weil bei einem Pointer keiner sagen kann, wo die Daten eigentlich her kommen) in einem neuen Shared Buffer (womit wir wieder ein Copy auslösen):

void AsyncWebSocket::text(uint32_t id, const uint8_t *message, size_t len)
{
    if (AsyncWebSocketClient * c = client(id))
        c->text(makeSharedBuffer(message, len));
}

Der Aufruf von AsyncWebSocketClient::text würde so aussehen: ws.client(client)->text(shared_buffer);

Ja seit gestern weil ich ein Issue dazu aufgemacht hatte. Der Autor hat es sofort eingebaut. Schaut nochmal auf den aktuellen Stand web.cpp ab hier:

Der Autor schreibt dazu noch (obwohl sein Beispiel obigen Weg vorschlägt):

Note that I strongly avise you to use the AsyncWebSocketMessageBuffer class instead, like in the spirit of the original fork. std::shared_ptr<std::vector<uint8_t>> was introduced in yubox-node-org fork as an internal way of fixing the pointers scope / references, but this should be kept internal and not be dealt with. So I added back the AsyncWebSocketMessageBuffer which wraps that without overhead.

Klingt irgendwie logisch & besser. Also wer für diese Zeilen einen besseren (funktionierenden) Vorschlag hat, gern!

Ansonsten war mir noch aufgefallen das mit dem neuen Webserver beim Volllaufen der Nachrichten-Queue das Websocket geschlossen wurde. Das ist derzeit nicht so sondern die Nachrichten werden verworfen. Ausprobieren kann man es durch sehr schnelle Lautstärkewechsel, dann kommt die Meldung „too many messages“. Das jetzige Verhalten habe ich mit diesen Zeilen kompatibel gemacht:

// discard message on queue full, socket should not be closed
client->setCloseClientOnQueueFull(false);

Edit:
Der Autor/Maintainer hat die Dokumentation angepasst und der aktualisierte PR verwendet jetzt AsyncWebSocketMessageBuffer, das ist dann etwas mehr „high-level“.

Hm, AsyncWebSocketMessageBuffer ist jetzt nur noch ein wrapper um std::shared_ptr<std::vector<uint8_t>>, wie auch der Autor schreibt, overhead wird es keine geben. Wenn wir den verwenden möchten, können wir es schon machen.

#if defined(ASYNCWEBSERVER_FORK_mathieucarbou)
	// serialize JSON in a more optimized way using a shared buffer
	const size_t len = measureJson(doc);
	AsyncWebSocketMessageBuffer *buffer = ws.makeBuffer(len);
	if(!buffer) {
		// memory allocation of vector failed, we can not use the AsyncWebSocketMessageBuffer 
		// some error handling here
		return;
	}
	serializeJson(doc, buffer->get(), len);
	if (client == 0) {
		ws.textAll(buffer);
	} else {
		ws.text(client, buffer);
	}
	return;
#else
3 „Gefällt mir“

@laszloh Perfekt, die Fehlerbehandlung für Speicherzuweisung habe ich noch hinzugefügt.