26/02/2011

Jak zagłodzić Timer?

Home

Okazuje się, że bardzo prosto, ale zacznijmy od początku. Niedawno zakończyłem pracę nad serwerem zajmującym się wykonywaniem tzw. zadań wsadowych. Definicje zadań do wykonania pobierane są z bazy danych, a w danym momencie może działać wiele serwerów. Każdy serwer rezerwuje sobie swoje zadania na pewien kwant czasu. Po upływie tego czasu inne serwery mają prawo przejąć to zadanie. Może się tak zdarzyć na przykład jeśli jakiś serwer ulegnie awarii. Jeśli wykonanie danego zadania zajmuje więcej czasu niż czas rezerwacji to serwer musi przedłużyć dzierżawę.

W tym celu stworzyłem komponent, który monitoruje zadania przetwarzane przez serwer i kiedy zbliża się czas wygaśnięcia dzierżawy, przedłuża ją. Komponent ten korzysta z klasy Timer z przestrzeni nazw System.Timers, która co określony kwant czasu generuje zdarzenie. W metodzie obsługującej to zdarzenia nie robię nic innego jak po prostu aktualizuję czas wygaśnięcia dzierżawy. Tyle w telegraficznym skrócie.

W czasie testowania stworzonego rozwiązania zauważyłem, że w niektórych przypadkach czas wygaśnięcia dzierżawy nie jest aktualizowany. Wyglądało na to jakby klasa Timer nie generowała zdarzeń albo generowała je z dużym opóźnieniem! Wbrew pozorom rozwiązanie zagadki okazało się bardzo proste. Otóż klasa Timer generuje zdarzenie Elapse w wątku pochodzącym z puli wątków ThreadPool. Jeśli dodamy do tego fakt, że zadania wsadowe również są wykonywane przez wątki z puli to wszystko staje się oczywiste. Jeśli serwer umieści w puli odpowiednio dużo zadań wsadowych to może okazać sie, że brakuje wątków dla klasy Timer.

Poniżej prosty kod prezentujący ten efekt. Na początku ograniczam liczbę wątków do 10 i zlecam wykonanie 10 zadań. Zanim wszystkie zadania zostaną uruchomione na ekran zostanie wypisanych kilka napisów Hello World from System.Timers.Timer!. Następnie kiedy wszystkie 10 zadań zostanie uruchomionych napis przestanie się pokazywać. Zobaczymy go ponownie kiedy przynajmniej jedno zadanie zostanie zakończone i tym samym zwolni sie jeden wątek.
class Program
{
  static void Main(string[] args)
  {
    ThreadPool.SetMaxThreads(10, 10);
    
    System.Timers.Timer t = new System.Timers.Timer();
    t.Interval = 1000;
    t.Elapsed += new System.Timers.ElapsedEventHandler(t_Elapsed);
    t.Start();

    for (int i = 0; i < 10; ++i)
        ThreadPool.QueueUserWorkItem((o) =>
            {
              Console.WriteLine("Start " + o);
              Thread.Sleep(10000);
              Console.WriteLine("End " + o);
            }, i);

    Console.ReadLine();
  }

  static void t_Elapsed(object sender, System.Timers.ElapsedEventArgs e)
  {
    Console.WriteLine("Hello World from System.Timers.Timer!");
  }
}
Taki sam efekt otrzymamy jeśli użyjemy klasy System.Threading.Timer zamiast System.Timers.Timer. Jak można poradzić sobie z tym problemem. Uważam, że są trzy rozwiązania:
  • Zliczać liczbę zadań umieszczanych w puli wątków i pilnować aby zawsze, przynajmniej jeden wątek z puli był wolny i mógł być użyty przez klasę Timer. Maksymalną liczbę wątków możemy pobrać przy pomocy metody GetMaxThreads. Jest to jednak podejście sprzeczne z ideą puli wątków, do której wrzucamy zadania do wykonania i nie zastanawiamy się kiedy dokładnie zostanie uruchomione, przez jaki wątek itd.
  • Zrezygnować z klasy ThreadPool i samemu zarządzać wątkami.
  • Użyć (napisać) zegar odpalający zdarzenia Elapse poza pulą wątków.
W opisie problemu pominąłem Task Parallel Library ponieważ w omawianym przypadku użycie .NET 4.0 nie było możliwe. Szybki test pokazał jednak, że jeśli mógłbym użyć Task Parallel Library to problem nie wystąpiłby. Z tego co wiem Task Parallel Library nie używa klasy ThreadPool, a więc przyczyna problemu nie występuje.

4 comments:

Maciej Aniserowicz said...

Do takich rzeczy polecam bibliotekę Quartz.net.

Tomasz Wójcik said...

Z TPL zagłodzenie też by wystąpiło - pod spodem TPL też wykorzystuje wątki z puli wątków (ThreadPool) do fizycznego wykonywania tasków, łatwo to spreparować - zamiast for użyj Parallel.For, ale z większym ograniczeniem - np. 100 zamiast 10 (żeby taski z popartycjonowanymi danymi zapchały ThreadPool) lub Task.Factory.StartNew zamiast ThreadPool.QueueUserWorkItem i uzyskasz ten sam efekt. Dodatkowo możesz w t_Elapsed wypisywać ilość dostępnych wątków w ThreadPool (ThreadPool.GetAvailableThreads) zamiast "Hello World" i będzie bardzo ładnie widać, jak ich ubywa, kiedy taski się wykonują. TPL tworzy osobne wątki dla tasków tylko wtedy, kiedy wskażemy mu, że task będzie długo trwał (np. możemy stworzyć task z opcją TaskCreationOptions.LongRunning)
Moim zdaniem pomysł rozwiązania opisywanego problemu z wykorzystaniem do tego zarówno TPL, jaki ThreadPool i jeszcze Timera jest bardzo nietrafiony - lepiej wykorzystać Quartz.NET (jak to już napisał Procent). Tych trzech rozwiązań też lepiej nawet nie brać pod uwagę - nie tam tkwi błąd.

Michał Komorowski said...

@Tomasz Wójcik

Co do Task Parallel Library to oczywiście masz rację. Mój błąd. W swoim teście ustawiłem maksymalną liczbę wątków na 10 i uruchomiłem 10 zadań ale zapomniałem o tym, że TPL potrafi dodatkowo wykorzystać główny wątek aplikacji i stąd mój błędy wniosek. A więc zagłodzenia timera nastąpi już przy uruchomieniu 11 zadań, a nie 100!

Michał Komorowski said...

Po pierwszym przyjrzeniu się biblioteka Quartz.NET wygląda na ciekawą ale nie zgodzę się z tym, że rozwiązuje problem. Na podstawie kodu z tutoriala przeprowadziłem szybki test. Stworzyłem triggera odpalającego joba co sekundę (odpowiednik Timera) oraz kilka innych triggerów odpalających długotrwające zadania. Zagłodzenie wystąpiło tak samo jak przy użyciu TPL czy ThreadPool.

Uważam, że istota problemu tkwi w użyciu jakiejś puli wątków, a nie w tym czy jest to pola z biblioteki Quarz.NET czy ThreadPool. Jeśli mamy ograniczoną liczbę zasobów (w tym wypadku wątków) to zawsze może dojść do zagłodzenia. Aby się przed tym zabezpieczyć krytyczne czynności powinny korzystać z innych wątków niż te z puli albo na odwrót.

Post a Comment