Feuer und nicht Ganz und gar nicht Vergessen - Vergängliche Hinrichtung ohne Staat
Die meisten async Systeme erinnern sich entweder an zu viel (Logs, Warteschlangen, beharrlicher Schrott, den man nie wollte)... oder sie erinnern sich an gar nichts (Feuer-und-vergessene schwarze Löcher, die verschwinden, sobald etwas schief geht).
Dieser Artikel führt etwas anderes ein:
Ein Muster, bei dem jede async Operation zu einem winzigen rückverfolgbar, inspizierbar, Wiederaufladbar, Selbstreinigungs-Task-Einheit -und das ganze System bleibt privat, begrenzt, schnell und deterministisch.
Es ist der Begleiter zu meinem früheren Artikel, LRU-Lernen -Wenn die Kapazität übersteigt macht Ihr System besser. Dieser untersuchte, wie begrenzter Speicher + gleitender Ablauf zu einem Überlebensmechanismus wird.
Diese erkundet die andere Hälfte: gebundene, ephemere Hinrichtung -wie ein winziger rollender Puffer aktiver Aufgaben zu einem Debugger, einem Ereignisprotokoll und einer Replay-Engine wird, ohne dass dabei Benutzerdaten gespeichert werden.
Das Beispiel stammt aus dem Übersetzungs-Widget meines Blogs - eine kleine Benutzeroberfläche zum Übersetzen von Markdown-Inhalten on-the-fly. Es sieht trivial aus. Unter der Haube implementiert es Muster, die Sie für jeden async-Workflow stehlen können.
Das ist Teil 1 einer zweiteiligen Reihe:
- Teil 1 (dieser Artikel): Die Theorie, das Muster und ein reales Beispiel
- Teil 2: Aufbau einer wiederverwendbaren Ephemeral Execution Library: Die vollständige Umsetzung mit DI-Integration
NUGET!!!
Das Problem -Async Workflows erstellen versteckten Zustand
ASP.NET-Entwickler neigen dazu, aus drei schlechten Optionen wählen:
Feuervergessen
Sie Task.Run() Ich hoffe, es endet, und wenn es explodiert, bekommt man nichts. Keine Korrelation, keine Rekonstruktion, keine Ahnung, was versagt hat.
Feuer-und-Warte
Sie blockieren Threads, die Sie nicht blockieren sollten. Durchsatz stirbt. Latenz explodiert. Ihre Benutzer hassen Sie.
Warteschlangen/Kanäle überall
Jetzt haben Sie:
- ungebundene Warteschlangen
- Beharrlicher Zustand, den du nicht behalten wolltest
- PII versehentlich irgendwo gespeichert
- ein verteiltes System, wo Sie nur eine einfache Bedienung wollten
Verteilte Rückverfolgung
Nützlich... aber extern. Auch: es kann nichts wieder abspielen und oft undicht Daten, die Sie nie beabsichtigt, zu speichern.
Was wir eigentlich wollen, ist:
- kurzlebig
- privat
- debuggingfähig
- inspizierbar
- gebunden
- bei Bedarf wiederholbar
- und dann gegangen
Eine "nur lange genug" Erinnerung an das, was das System tut - und nichts mehr.
Das Muster - Feuer und Nicht ganz. Vergessen
Hier ist die Idee in einem Satz:
Starten Sie sofort eine async-Operation, verfolgen Sie diese explizit über eine TaskCompletionSource, reinigen Sie sie deterministisch und halten Sie einen winzigen Rollpuffer der letzten Aufgaben, damit Sie sie überprüfen oder abrufen können - ohne jegliche Benutzerinhalte zu behalten.
Es liegt auf halbem Weg zwischen:
- Event-Sourcing
- Rückverfolgung
- Stellen Warteschlangen
- Termingeschäfte/Versprechen
- ephemere Sitzungen
...ohne sie zu werden.
flowchart TB
subgraph Client["Client Request"]
R[Start Translation]
end
subgraph API["API Layer"]
A[Create TaskId] --> B[Create TaskCompletionSource]
B --> C[Queue to Channel]
C --> D[Return TaskId Immediately]
end
subgraph Cache["Ephemeral Cache (Bounded)"]
E[Store TranslateTask]
F[Max 5 per user]
G[6hr absolute / 1hr sliding expiry]
end
subgraph Worker["Background Worker"]
H[Read from Channel]
H --> I[Execute Translation]
I --> J[Complete TCS]
end
R --> A
B --> E
E --> F --> G
C --> H
style Cache fill:none,stroke:#10b981,stroke-width:2px
style Worker fill:none,stroke:#6366f1,stroke-width:2px
Anatomie einer ephemeren Aufgabe
Die Kerndatenstruktur ist ein TranslateTask - eine winzige Atom-Exekutionseinheit, die von einem Task<TaskCompletion>:
// From TranslateTask.cs
public class TranslateTask(
string taskId,
DateTime startTime,
string language,
Task<TaskCompletion>? task)
: TranslateResultTask(taskId, startTime, language)
{
public Task<TaskCompletion>? Task { get; init; } = task;
}
public record TaskCompletion(
string? TranslatedMarkdown,
string OriginalMarkdown,
string Language,
bool Complete,
DateTime? EndTime);
Sie enthält:
- Eine eindeutige ID (korreliert über den gesamten Fluss)
- Ein Zeitstempel (als es anfing)
- Metadaten (Sprache -nicht Benutzerinhalt!)
- Ein Verweis auf die tatsächliche
Task<TaskCompletion> - Dauer (berechnet auf den Zugang)
- Fehlerzustand (abgeleitet vom Aufgabenzustand)
- Endergebnis (nur bei erfolgreichem Abschluss)
Nichts blieb bestehen, nichts auf Diskette geschrieben, nichts gespeichert über ein rollendes Fenster hinaus.
AWS hat Step-Funktionen. Azure hat dauerhafte Funktionen. Sie haben... 30 Zeilen Code, die keine Cloud-Bill erfordern.
Das TaskCompletionSource-Muster -Bridging Fire-and-Forget to Fire-and-Track
Die Magie geschieht in der BackgroundTranslateService. Statt Feuer-und-vergessen, verwenden wir eine TaskCompletionSource - ein Versprechen-ähnliches Konstrukt, das uns sofort zurückkehren lässt, während die Arbeit im Hintergrund geschieht.
// From BackgroundTranslateService.cs
private readonly Channel<(PageTranslationModel, TaskCompletionSource<TaskCompletion>)>
_translations = Channel.CreateUnbounded<(PageTranslationModel, TaskCompletionSource<TaskCompletion>)>();
private async Task<Task<TaskCompletion>> Translate(PageTranslationModel message)
{
// Create a TaskCompletionSource that will eventually hold the result
var tcs = new TaskCompletionSource<TaskCompletion>();
// Send the translation request along with the TCS to be processed
await _translations.Writer.WriteAsync((message, tcs));
// Return the Task immediately -caller can await it or check status later
return tcs.Task;
}
Was ist eine TaskCompletionSource?
Wenn Sie nicht verwendet haben TaskCompletionSource<T> vor, betrachten Sie es als ein Versprich, dass du manuell kontrollierst. Im Gegensatz zu einem regulären Task das, wenn seine Arbeit beendet ist, ein TCS vollendet, wenn Sie Anruf SetResult(), SetException(), oder SetCanceled().
So können Sie:
- Zurückgeben a
Tasksofort zum Anrufer - Machen Sie die eigentliche Arbeit irgendwo anders (verschiedene Threads, Hintergrund-Service, etc.)
- Vervollständigen Sie die Aufgabe, wenn Sie Entscheiden Sie, dass es erledigt ist
Es ist die Brücke zwischen "Feuer-vergessen" und "Feuer-vergessen".
Die API-Ebene -Beginn einer Übersetzung
Wenn ein Benutzer auf "Übersetzen" klickt, ruft das Frontend die API auf:
// From translations.js
fetch('/api/translate/start-translation', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
Language: shortCode,
OriginalMarkdown: markdown
})
})
.then(response => response.json())
.then(taskId => {
// Got a taskId immediately -translation is running in background
console.log("Task ID:", taskId);
// Poll for updates via HTMX
htmx.ajax('get', "/editor/get-translations", {
target: '#translations',
swap: 'innerHTML'
});
});
Die API kehrt sofort mit nur einer Task-ID zurück:
// From TranslateAPI.cs
[HttpPost("start-translation")]
public async Task<Results<Ok<string>, BadRequest<string>>> StartTranslation(
[FromBody] MarkdownTranslationModel model)
{
if (!backgroundTranslateService.TranslationServiceUp)
return TypedResults.BadRequest("Translation service is down");
// Create a unique identifier for this translation task
var taskId = Guid.NewGuid().ToString("N");
var userId = Request.GetUserId(Response);
// Trigger translation -returns Task<TaskCompletion> immediately
var translationTask = await backgroundTranslateService.Translate(model);
// Wrap it in our trackable TranslateTask
var translateTask = new TranslateTask(taskId, DateTime.Now, model.Language, translationTask);
// Store in the ephemeral cache (bounded, self-cleaning)
translateCacheService.AddTask(userId, translateTask);
// Return the task ID to the client -they can poll for status
return TypedResults.Ok(taskId);
}
Die Antwort ist sofort. Die eigentliche Übersetzung läuft im Hintergrund. Der Benutzer kann die UI-Aktualisierung abfragen oder einfach nur ansehen.
Das Rolling Operation Window -Ein selbstreinigender Puffer
Die wichtigsten Erkenntnisse aus der LRU-Artikel gilt auch hier:
Behalten Sie gerade genug Geschichte, um zu debuggen und Vernunft über das System... und lassen Sie natürlichen Verfall löschen alles andere.
// From TranslateCacheService.cs
public class TranslateCacheService(IMemoryCache memoryCache)
{
public void AddTask(string userId, TranslateTask task)
{
if (memoryCache.TryGetValue(userId, out CachedTasks? tasks))
{
var currentTasks = tasks?.Tasks ?? new List<TranslateTask>();
currentTasks = currentTasks.OrderByDescending(x => x.StartTime).ToList();
// Keep only the 5 most recent tasks -bounded window
if (currentTasks.Count >= 5)
{
var lastTask = currentTasks.Last();
currentTasks.Remove(lastTask);
}
currentTasks.Add(task);
currentTasks = currentTasks.OrderByDescending(x => x.StartTime).ToList();
tasks!.Tasks = currentTasks;
memoryCache.Set(userId, tasks, new MemoryCacheEntryOptions
{
AbsoluteExpiration = tasks.AbsoluteExpiration,
SlidingExpiration = TimeSpan.FromHours(1)
});
}
else
{
// First task for this user
var cachedTasks = new CachedTasks
{
Tasks = new List<TranslateTask> { task },
AbsoluteExpiration = DateTime.Now.AddHours(6)
};
memoryCache.Set(userId, cachedTasks, new MemoryCacheEntryOptions
{
AbsoluteExpiration = cachedTasks.AbsoluteExpiration,
SlidingExpiration = TimeSpan.FromHours(1)
});
}
}
public List<TranslateTask> GetTasks(string userId)
{
if (memoryCache.TryGetValue(userId, out CachedTasks? tasks))
return tasks?.Tasks ?? new List<TranslateTask>();
return new List<TranslateTask>();
}
private class CachedTasks
{
public List<TranslateTask> Tasks { get; set; } = new();
public DateTime AbsoluteExpiration { get; set; }
}
}
Jede neue Aufgabe:
- Wird dem Rollfenster des Benutzers hinzugefügt
- Schiebt den ältesten heraus, wenn wir in der Kapazität sind (5 max)
- Verdunstung vollständig nach 6 Stunden (absolut) oder 1 Stunde Inaktivität (Schieben)
flowchart LR
subgraph Window["Per-User Task Window (Max 5)"]
T1[Oldest Task] --- T2[Older] --- T3[Recent] --- T4[Newer] --- T5[Newest]
end
New[New Task] -->|Enqueue| Window
T1 -->|Evicted| Gone[(Expired)]
style Window fill:none,stroke:#10b981,stroke-width:2px
style Gone fill:none,stroke:#ef4444,stroke-width:2px
Kein Retentionsrisiko. Kein PII im Cache (nur Task-IDs, Zeitstempel und Sprachcodes).
Gebundene Übereinstimmung - Die Gouverneursschleife
Der Hintergrunddienst implementiert eine feste Übereinstimmung Schleife mit einem Kanalleser und Task.WhenAny:
// From BackgroundTranslateService.cs
private async Task TranslateFilesAsync(CancellationToken cancellationToken)
{
var processingTasks = new List<Task>();
while (!cancellationToken.IsCancellationRequested)
{
// Fill up to IPCount concurrent tasks (e.g., 4 parallel translations)
while (processingTasks.Count < markdownTranslatorService.IPCount &&
!cancellationToken.IsCancellationRequested)
{
var item = await _translations.Reader.ReadAsync(cancellationToken);
var translateModel = item.Item1;
var tcs = item.Item2;
// Start the task and add it to the list
var task = TranslateTask(cancellationToken, translateModel, item, tcs);
processingTasks.Add(task);
}
// Wait for ANY of the tasks to complete
var completedTask = await Task.WhenAny(processingTasks);
processingTasks.Remove(completedTask);
// Handle exceptions if needed
try
{
await completedTask;
}
catch (Exception ex)
{
logger.LogError(ex, "Error translating markdown");
}
}
}
Das ist wie ein Gouverneur auf einer Dampfmaschine: mehr Belastung → Rückendruck baut → natürliche Drosselung → Stabilität.
Das Muster gibt Ihnen:
- Keine Überlastspirale - kann nicht laichen unbegrenzte Aufgaben
- Weiches Echtzeit-Verhalten -gebundene Latenz
- Natürliche Glättung von Bursts -Kanal-Puffer die Spikes
- Stabilität unter Last - degradiert anmutig
Vervollständigung der TaskCompletionSource
Wenn die eigentliche Übersetzung beendet ist, vervollständigen wir das TCS:
// From BackgroundTranslateService.cs
private async Task TranslateTask(
CancellationToken cancellationToken,
PageTranslationModel translateModel,
(PageTranslationModel, TaskCompletionSource<TaskCompletion>) item,
TaskCompletionSource<TaskCompletion> tcs)
{
try
{
await retryPolicy.ExecuteAsync(async () =>
{
// Do the actual translation work
var translatedMarkdown = await markdownTranslatorService.TranslateMarkdown(
translateModel.OriginalMarkdown,
translateModel.Language,
cancellationToken);
// SUCCESS: Complete the TCS with the result
tcs.SetResult(new TaskCompletion(
translatedMarkdown,
translateModel.OriginalMarkdown,
translateModel.Language,
true,
DateTime.Now));
});
}
catch (TranslateException e)
{
// FAILURE: Complete the TCS with an exception
tcs.SetException(new Exception($"Translation failed after 3 retries: {e.Message}"));
}
catch (Exception e)
{
// UNEXPECTED: Complete the TCS with the exception
tcs.SetException(e);
}
}
Der Anrufer ist Task<TaskCompletion> - die sie sofort bekamen, als sie anriefen Translate() - Jetzt fertig. Sie können:
awaites, wenn sie blockieren wollen- Kontrolle
IsCompletedzur Umfrage - Kontrolle
IsFaultedum zu sehen, ob es fehlgeschlagen ist - Kontrolle
Resultum den übersetzten Inhalt zu erhalten
Die Statusprojektion - Ableiten des Staates aus dem Task-State
Bei der Anzeige von Aufgaben an den Benutzer projizieren wir den Live-Task-Status in ein View-Modell:
// From TranslateTask.cs
public TranslateResultTask(TranslateTask task, bool includeMarkdown = false)
{
TaskId = task.TaskId;
StartTime = task.StartTime;
Language = task.Language;
// Check for faulted state first -a faulted task is also "completed" in .NET terms
if (task.Task?.IsFaulted == true)
{
Failed = true;
Completed = false;
TotalMilliseconds = (int)(DateTime.Now - task.StartTime).TotalMilliseconds;
}
else if (task.Task?.IsCompletedSuccessfully == true)
{
Completed = true;
Failed = false;
var endTime = task.Task.Result.EndTime;
TotalMilliseconds = (int)((endTime - task.StartTime)!).Value.TotalMilliseconds;
EndTime = endTime;
}
else
{
// Still in progress
Completed = false;
Failed = false;
TotalMilliseconds = (int)(DateTime.Now - task.StartTime).TotalMilliseconds;
}
if (Completed && includeMarkdown)
{
var result = task.Task?.Result;
if (result == null) return;
OriginalMarkdown = result.OriginalMarkdown;
TranslatedMarkdown = result.TranslatedMarkdown;
}
}
Eine Anmerkung zum Task-State
.NET's Task hat einige skurrile Zustandskombinationen:
IsCompletedist wahr für beides Erfolgreicher Abschluss UND fehlerhafte AufgabenIsCompletedSuccessfullyist nur für den Erfolg wahrIsFaultedbedeutet, es warf eine AusnahmeIsCanceledbedeutet, dass es annulliert wurde
Also müssen Sie überprüfen, IsFaulted vor der Prüfung IsCompletedOder Sie behandeln Misserfolge als Erfolge.
Das UI -HTMX Polling
Die Ansicht Umfragen für Updates mit HTMX hx-trigger:
@* From _GetTranslations.cshtml *@
@{
var allCompleted = Model.All(x => x.Completed);
var trigger = allCompleted ? "none" : "every 5s";
}
<div class="translationpoller"
hx-get="/editor/get-translations"
hx-swap="outerHTML"
hx-trigger="@trigger">
<table class="table">
@foreach (var item in Model)
{
<tr>
<td>
@if (item.Completed)
{
<a href="#" x-on:click.prevent="viewTranslation('@item.TaskId')">View</a>
}
else if (item.Failed)
{
<text>Failed</text>
}
else
{
<text>Processing</text>
}
</td>
<td>
@if (item.Completed)
{
<i class='bx bx-check text-green'></i>
}
else if (item.Failed)
{
<i class='bx bx-x text-red'></i>
}
else
{
<img src="~/img/3-dots-bounce.svg" />
}
</td>
<td>@item.Language</td>
<td>@TimeSpan.FromMilliseconds(item.TotalMilliseconds).Humanize()</td>
</tr>
}
</table>
</div>
Das clevere Stück: hx-trigger="@trigger" Änderungen auf der Grundlage des Zustands:
- Wenn die Aufgaben noch laufen: Umfrage alle 5 Sekunden
- Wenn alle Aufgaben erledigt sind: Beenden Sie die Umfrage (
"none")
Das ist Selbstregulierende Umfragen - es hört automatisch auf, wenn es nichts zu sehen gibt.
Das Ergebnis abrufen
Wenn der Benutzer auf "Ansicht" klickt, holen wir die fertige Übersetzung:
// From TranslateAPI.cs
[HttpGet("get-translation/{taskId}")]
public async Task<Results<JsonHttpResult<TranslateResultTask>, BadRequest<string>>> GetTranslation(
string taskId)
{
var userId = Request.GetUserId(Response);
var tasks = translateCacheService.GetTasks(userId);
var translationTask = tasks.FirstOrDefault(t => t.TaskId == taskId);
if (translationTask == null)
return TypedResults.BadRequest("Task not found");
// Include the markdown content in the response
var result = new TranslateResultTask(translationTask, includeMarkdown: true);
return TypedResults.Json(result);
}
Das JavaScript bevölkert dann den Editor:
// From translations.js
export function viewTranslation(taskId) {
fetch(`/api/translate/get-translation/${taskId}`)
.then(response => response.json())
.then(data => {
// Show the translated content area
document.getElementById("translatedcontent").classList.remove("hidden");
// Populate the editors
var originalMde = window.mostlylucid.simplemde.getinstance('translatedcontentarea');
originalMde.value(data.originalMarkdown);
var mde = window.mostlylucid.simplemde.getinstance('markdowneditor');
mde.value(data.translatedMarkdown);
});
}
Warum dies Datenschutz-sicher ist
Weil:
- Im Cache werden keine Benutzerinhalte gespeichert -nur Aufgaben-Metadaten (ID, Zeitstempel, Sprache)
- Inhalt existiert nur im Ergebnis der Aufgabe -die sich im Gedächtnis befindet, an die Aufgabe angehängt
- Das Fenster ist begrenzt -max 5 Aufgaben pro Benutzer, max 6 Stunden Retention
- Alles verfällt natürlich -Sliding Ablauf reinigt inaktive Benutzer
- Nichts berührt Scheibe -Keine Protokolle, keine Warteschlangen, keine Datenbank
- Sie können PII nicht versehentlich behalten. - Es gibt keinen Ausweg.
Das ist die Architektur Ich wünschte, Browser und Frontend-Frameworks hätten für Session-Operationen verwendet.
Das neu entstehende Verhalten
Was man bekommt, ohne zu hart zu denken:
Nutzen Wie? |---------|-----| | Stabilität Begrenzte Übereinstimmung über Kanal + Semaphormuster | Negative Rückkopplungsschleife Weitere Belastung → Rückendruck → gebundener Speicher → konsistentes Verhalten | Debug-Sichtbarkeit Die genauen Operationen sind gerade lange genug vorhanden, um zu inspizieren | Abrufbarkeit Abgeschlossene Ergebnisse sind bis zum Ablauf zugänglich | Null Datenspeicherung Ausschließlich Privatsphäre und Einfachheit für einmal ausgerichtet | Selbstoptimierende Ausführung Alte Aufgaben fallen weg, relevante bleiben
Genau wie der LRU-Cache den Verhaltensspeicher geschärft hat, schärft das rollende Operationsfenster die Ausführungsansicht.
Wann dieses Muster verwendet werden soll
Verwenden Sie es, wenn:
- Benutzerdaten dürfen nicht bestehen bleiben
- Operationen sind kurzlebig (Sekunden bis Minuten)
- Debugging-Angelegenheiten
- Fragen des Datenschutzes
- Laststabilität ist wichtig
- Sie wollen sofortige Antwort + Hintergrundverarbeitung
Verwenden Sie es nicht, wenn:
- Sie brauchen wahre Haltbarkeit (verwenden Sie eine echte Warteschlange)
- Der Betrieb dauert Stunden (Verwendung von Hangfire oder ähnlichem)
- Sie brauchen genau einmal Semantik (verwenden Sie eine verteilte Transaktion)
- Mehrere Server müssen koordinieren (verwenden Sie Redis oder einen Nachrichten-Broker)
Fazit - Ephemere Ausführung als Design-Philosophie
Wenn die LRU-Artikel war ungefähr Lernen durch Vergessen, hier geht es um Ausführung durch Verdunstung.
Das System erinnert sich genau genug, um hilfreich zu sein - und nichts mehr.
Es ist:
- gebunden
- Deterministisch
- privat
- debuggingfähig
- wiederholbar
- Selbstreinigung
- robust
- und Architektur-erste
Das Beste? Sie wissen bereits, wie man es umsetzt: ein TaskCompletionSource, , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , Channel, eine IMemoryCache mit gleitendem Ablauf und einem Hintergrundarbeiter.
Feuer... und vergessen Sie es nicht.
Was kommt als Nächstes?
Im **Teil 2: Aufbau einer wiederverwendbaren Ephemeral Execution Library**Wir werden dieses Muster in einen Drop-In-Helfer verwandeln:
EphemeralForEachAsync<T>- wieParallel.ForEachAsyncaber mit Operationsverfolgung- Geschlüsselte Pipelines für per-entity sequential execution
EphemeralWorkCoordinator<T>- eine langlebige, beobachtbare Warteschlange- benannte/typisierte Koordinatoren mit
AddEphemeralWorkCoordinator<TCoordinator>(wieAddHttpClient) - Volle DI-Integration mit scoped und Singleton-Lebensdauern
- Vergleich mit anderen Ansätzen (TPL Dataflow, Channels, Background Services)
Verknüpfungen
- LRU-Lernen -Wenn die Kapazität übersteigt macht Ihr System besser -der Begleitartikel über das gebundene Gedächtnis
- Teil 2: Aufbau einer wiederverwendbaren Ephemeral Execution Library -die vollständige Umsetzung
- TaskCompletionSource-Dokumentation -Microsoft's docs über das TCS-Muster
- System.Threading.Channels - die Erzeuger-Verbraucher primitive wir verwenden
- Dokumentation von IMemoryCache -ASP.NETs eingebauter Cache