Debounce w .NET – jak zatrzymać lawinę zdarzeń

Wprowadzenie
Czasem w systemie zaczyna pojawiać się zbyt dużo zdarzeń. Mogą być one powtarzalne, nadmiernie szczegółowe albo po prostu nieistotne w dużej liczbie. Taka lawina informacji przeciąża nasze rozwiązanie. Potrzebujemy mechanizmu, który ograniczy częstość ich występowania. Jest nim debounce.

Skąd pochodzi debounce
Termin „debounce” pochodzi z elektroniki. Mechaniczne przyciski po naciśnięciu potrafią kilkukrotnie „odbijać” styki, generując serię krótkich impulsów zamiast jednego sygnału. Aby zapobiec błędnemu odczytowi wielu kliknięć, stosuje się układ debouncing, który czeka, aż sygnał się ustabilizuje. Ta sama koncepcja została zaadaptowana w programowaniu zdarzeń.
Redukcja powtarzalnych zdarzeń przy monitorowaniu zmian w plikach
Wyobraźmy sobie proces monitorowania zmian na dysku za pomocą FileSystemWatcher. Z jednej operacji potrafi pojawić się kilka różnych zdarzeń dotyczących tego samego zasobu. Sygnały mogą dotyczyć modyfikacji, zmiany rozmiaru, pojawienia się pliku itd. Wszystkie występują w bliskich odstępach czasowych. Reakcja na każde z nich osobno oznaczałaby kilkukrotne przeprocesowanie tych samych informacji. Zastosowanie mechanizmu debounce rozwiązuje ten problem.
Implementacja
Pełną wersję przykładu wraz z innymi algorytmami asynchronicznymi znajdziesz w repozytorium:
https://github.com/L3mur1/useful-async-algorithms
Tworząc debounce określamy tzw. debouncing window – przedział czasu, w którym kolejne zdarzenia uznajemy za duplikaty.
/// <summary>
/// Events with the same path within this time span are ignored.
/// </summary>,
private readonly TimeSpan debounceWindow;
Do podstawowej implementacji wystarczy pamiętać ostatni czas obsłużonego zdarzenia i porównać go z aktualnym. W przypadku plików musimy dodatkowo wziąć pod uwagę ścieżkę, ponieważ każde zdarzenie dotyczy innego zasobu. Dlatego używamy słownika ConcurrentDictionary, w którym trzymamy ostatni czas publikacji dla każdego zasobu osobno.
// Stores the last event time for each file path to support debouncing.
private readonly ConcurrentDictionary<string, DateTime> lastEventTimes = new();
Gdy pojawiają się kolejne sygnały, porównujemy ich czas wystąpienia z ostatnim zarejestrowanym dla danej ścieżki. Pomijamy zdarzenia, które wystąpiły wewnątrz deboucing window dla tego samego zasobu. Jeśli zdarzenie występuje poza oknem czasowym, uzupełniamy słownik i publikujemy.
/// <summary>
/// Handles incoming events and applies debouncing based on the window and path.
/// </summary>
private void OnNext(FileEvent fileEvent)
{
if (lastEventTimes.TryGetValue(fileEvent.Path, out var lastTime))
{
// Check if the event is within the debounce window
if (fileEvent.PublishTime - lastTime < debounceWindow)
{
// Ignore event within deboucing window
return;
}
}
// Publish event and update last event time for path
lastEventTimes[fileEvent.Path] = fileEvent.PublishTime;
subject.OnNext(fileEvent);
}
Warto pamiętać, że w przypadku monitorowania wielu ścieżek, np. całych folderów, słownik może rosnąć w nieskończoność i niepotrzebnie zabierać pamięć systemu. Warto wprowadzić mechanizm okresowego czyszczenia zasobów.
/// <summary>
/// lastEventTimes dictionary should be periodically cleaned up
/// to avoid memory leaks from paths that are no longer active.
/// this is example clean up
/// </summary>
private void CleanUp(long obj)
{
var threshold = DateTime.UtcNow - debounceWindow;
foreach (var kvp in lastEventTimes)
{
// Remove entries older than the threshold
if (kvp.Value < threshold)
{
lastEventTimes.TryRemove(kvp.Key, out _);
}
}
}
Dzięki zastosowaniu debounce zamiast lawiny zdarzeń dostajemy tylko jedno – reprezentatywne dla konkretnej ścieżki – w danym oknie czasowym.
Podsumowanie
Debounce to algorytm redukujący liczbę przetwarzanych zdarzeń w systemie. Jego istotą jest powiązanie różnych sygnałów w krótkich odstępach czasu. Ma to szczególne znaczenie w przetwarzaniu zdarzeń w architekturach event-driven, które z natury są asynchroniczne i nie dają gwarancji spójności. Stosując ten wzorzec tworzymy systemy mniej hałaśliwe i bardziej oszczędne w zasobach.
Ten artykuł jest częścią serii. Przykładową implementację debounce oraz inne użyteczne algorytmy przy przetwarzaniu zdarzeń znajdziesz w repozytorium: useful-async-algorithms




