Nasza strona używa cookies. Korzystając ze strony, wyrażasz zgodę na używanie cookies, zgodnie z aktualnymi ustawieniami przeglądarki. Rozumiem

Poprawa rozdzielczości timerów w .NET Core

Dowiedz się, jak skutecznie pokonać ograniczenia timerów w .NET Core.

W artykule tym chcemy przybliżyć problem małej rozdzielczości timerów dostępnych w .NET Core. Jeśli jesteś programistą tworzącym rozwiązania czasu rzeczywistego i chciałbyś wykorzystać C#, będziesz potrzebował timerów o wysokiej rozdzielczości. Jak pokażemy poniżej, istniejące rozwiązanie nie będzie dla Ciebie satysfakcjonujące. 

Podobnie programiści gier, bądź twórcy rozwiązań, w których istotnym elementem są periodyczne pomiary z dużą częstotliwością,  mogą być zawiedzeni tym, co oferuje .NET Core. Naszym celem jest przedstawienie sprawdzonego rozwiązania, które umożliwi Ci pokonanie ograniczeń tych timerów. Na początek jednak przedstawimy projekt, przy którym pracowałem, aby lepiej zrozumieć, w jakich okolicznościach te ograniczenia mogą na nas wpływać.

Pracujemy dla firmy Motorola Solutions, która jest liderem rozwiązań telekomunikacyjnych w obszarze tzw. “bezpieczeństwa publicznego”, dlatego rozwiązania, które dostarczamy, muszą spełniać wymagania wysokiej jakości. Tworzymy więc oprogramowanie wspomagające przeprowadzanie testów wydajnościowych. Nasz projekt miał emulować działanie tysięcy radiotelefonów nadających głos. Wymaganiem było, aby każda ramka głosowa była wysyłana w odpowiednim czasie z dokładnością rzędu milisekund. 

Do tego typu zagadnień idealną technologią jest C++. A może jednak spróbować C#? Nasz projekt był eksperymentem mającym potwierdzić bądź zaprzeczyć użyteczności C# i .NET Core w rozwiązywaniu problemów wydajnościowych.

Aby uzyskać wysoką jakość, zakodowane fragmenty dźwięku muszą być wysyłane w odpowiednich odstępach czasu. W fizycznych urządzeniach jest to rozwiązane sprzętowym zegarem. Na komputerze użyjemy dostarczonego przez .NET Core API timerów. Możemy skorzystać z kilku implementacji, przy czym dla aplikacji bez GUI interesujące są następujące klasy:

  • System.Timers.Timer - wywołuje zdarzenie w regularnych odcinkach czasu
  • System.Threading.Timer - wykonuje metodę wywołania zwrotnego w regularnych odcinkach czasu.


Celem tego artykułu nie jest omawianie szczegółów użycia tych klas, a jedynie skupienie się na ich rozdzielczości. 

Obu klas możemy użyć, podając okres wzbudzania timera w milisekundach. Taka rozdzielczość na pierwszy rzut oka wygląda bardzo dobrze. 

Zróbmy kilka testów. Poniższa metoda demonstruje użycie System.Timers.Timer:

        private static List<long> TestSystemTimersTimer(long interval, int testTime)
        {
            var milliseconds = new List<long>();
            var stopWatch = new Stopwatch();

            Console.WriteLine("System.Timers timer");
            stopWatch.Start();
            var aTimer = new System.Timers.Timer(interval);
            aTimer.Elapsed += (s, e) => milliseconds.Add(stopWatch.ElapsedMilliseconds);
            aTimer.AutoReset = true;
            aTimer.Enabled = true;

            Thread.Sleep(testTime);

            aTimer.Stop();
            aTimer.Dispose();

            stopWatch.Stop();
            return milliseconds;
      }


Przyjmuje ona dwa parametry: pierwszym jest okres wzbudzania timera w milisekundach, a drugim czas trwania testu (ms). Jako rezultat, zwracana jest lista zawierająca czas kolejnych tyknięć zegara od początku testu. W idealnym świecie ten czas pomiędzy tyknięciami powinien być równy pierwszemu podanemu w wywołaniu parametrowi. Jednakże, gdy uruchomimy ten kod na Windowsie i spróbujemy użyć parametru poniżej 15 ms, nasz timer nadal będzie odpalał się co mniej więcej 15 ms. 

Wniosek - rozdzielczość timera System.Timers.Timer na Windows jest nie mniejsza niż 15 ms.

Może pod Linuxem będzie lepiej? Testy wykazują, że dla Linuxa rozdzielczość ta wynosi około 4 ms. Lepiej, ale nie dość dobrze. 

Spróbujmy System.Threading.Timer :

        private static List<long> TestSystemThreadingTimer(long interval, int testTime)
        {
            var milliseconds = new List<long>();
            var stopWatch = new Stopwatch();

            Console.WriteLine("System.Threading timer");
            stopWatch.Start();
            var stateTimer = new System.Threading.Timer(_ => 
 milliseconds.Add(stopWatch.ElapsedMilliseconds),
                null, interval, interval);

            Thread.Sleep(testTime);

            stateTimer.Dispose();
            stopWatch.Stop();
            return milliseconds;
        }


Z wykonania powyższego kodu wynika, że jego rozdzielczość ma te same ograniczenia: 15 ms Windows i 4 ms Linux.

Podobne testy rozdzielczości timerów wykonywaliśmy, wysyłając pakiety UDP i sprawdzając wyniki za pomocą narzędzia Wireshark. Dzięki temu nie polegaliśmy wyłącznie na klasie Stopwatch w pomiarach rozdzielczości. Wyniki były takie same, a więc wniosek jest jeden - rozdzielczość timerów w .NET Core nie jest w stanie osiągnąć 1 ms.

Czy to oznacza, że musimy porzucić plany napisania aplikacji dużej wydajności w .Net Core? Niekoniecznie. Zauważmy, że poza .Net Core istnieje również niezależna implementacja .NET Mono. Tutaj możesz zerknąć na implementację Mono timerów System.Threading. Nie chcemy rezygnować z .Net Core, ale warto sprawdzić implementację Mono pod kątem rozdzielczości timerów, czy można jej użyć w naszym projekcie. 

Pobieżna analiza kodu pokazuje, że przed bezpośrednim użyciem tej implementacji powstrzymuje nas głównie użycie parametru “CLSCompliant(false)”, który możemy wykomentować, jeśli nie piszemy biblioteki dla innych języków oraz istnienie wywołań metod natywnych:

  • static extern void SetTimeout (int timeout, int id);
  • static extern long GetTimeMonotonic ();


Pierwsza z nich jest wewnątrz bloku kompilacji warunkowej dla WebAssembly, czyli w naszym przypadku nie jest kompilowana. Druga metoda jest natomiast sercem implementacji Mono. Z uwagi na to, że zależy nam na użyciu czystego kodu C# bez natywnych dodatków, spróbujmy napisać jej implementację w czystym C#, używając jako źródła czasu klasy Stopwatch:

private const long TICKS_MULTIPLIER = 1000 * TimeSpan.TicksPerMillisecond;
private static readonly double StopwatchTimestampDiffMultiplier = (double)TICKS_MULTIPLIER / Stopwatch.Frequency;
private static readonly long StartTime = Stopwatch.GetTimestamp();

        private static long GetTimeMonotonic()
        {
            return (long)((Stopwatch.GetTimestamp() - StartTime) * StopwatchTimestampDiffMultiplier);
        }


Po skopiowaniu implementacji timerów z Mono oraz podmianie metody natywnej i innych drobnych poprawkach otrzymujemy własną implementację timerów bazującą na Mono. Pod tym linkiem znajduje się zmieniony kod. 

Po wykonaniu podobnych testów, jak dla implementacji z .NET Core dostajemy wyniki, że zarówno na Windowsie jak i na Linuxie nasza implementacja pracuje z rozdzielczością 1 ms. Kod projektu testującego omówione timery wraz z implementacją Mono jest dostępny pod linkiem.

Zastosowanie implementacji timera Mono pozwoliło nam na użycie 1000 aktywnych timerów w ramach jednego procesu. Dzięki skalowalności nasz produkt emulował 10000 aktywnych radiotelefonów.

Z tego artykułu wynika, że standardowa implementacja timerów w .NET Core, która bazuje na czasie systemowym, nie pozwala osiągnąć rozdzielczości rzędu 1 milisekundy. Dotyczy to zarówno .NET Core 2, 3 jak i nowego .NET 5. 


Podsumowanie

Przedstawiliśmy sposób na zwiększenie dokładności timerów w projektach .NET Core. Użyliśmy implementacji timerów Mono bazującej na metodzie GetTimestamp klasy Stopwatch, dzięki czemu osiągnęliśmy 1 milisekundową rozdzielczość. Powyższe podejście może być szczególnie pomocne dla programistów pracujących w projektach wymagających rozwiązań w czasie rzeczywistym. 

Trzeba zaznaczyć, że na dokładność działania timerów mają również wpływ inne czynniki. Szczególnie uciążliwym elementem .NET Core jest Garbage Collector, który podczas swojego działania wstrzymuje pracę wszystkich wątków - w tym również timerów. Projektując aplikacje (prawie) czasu rzeczywistego, musimy zwrócić baczną uwagę na alokację pamięci oraz opcje Garbage Collectora tak, aby zminimalizować jego wpływ na timery. Jak tego dokonać, to już temat na odrębny artykuł.

Rozpocznij dyskusję
Zobacz kogo teraz szukają