Programowanie wielo-UI-wątkowe
05.04.2012 12:48
Możliwość tworzenia aplikacji wykorzystujących wątki robocze (ang. Worker Threads) w .NET to żadna nowość. Istnieje wiele dobrze udokumentowanych sposobów radzenia sobie z tak zdefiniowanym zagadnieniem wielowątkowości: Threading in C# by Joseph Albahari
Jednakże prawie wszystkie dostępne w sieci artykuły poruszają (i opisują) zagadnienie wielowątkowości wyłącznie w kontekście wątków wykonujących żmudne (czyt. długie) obliczenia w tle bądź operacje wejścia/wyjścia o nieprzewidywalnym czasie ukończenia. Mówiąc inaczej, metody te opisują sytuacje, w których wątek główny programu (często ten, na którym funkcjonuje graficzny interfejs użytkownika) powołuje do życia wątki obliczeniowe po to, aby nie utracić responsywności (popularne "Brak odpowiedzi...") w trakcie wykonywania tychże obliczeń. Brak jest natomiast prostych odpowiedzi na pytania jak tworzyć i komunikować się pomiędzy dwoma, trzema itd. niezależnymi wątkami, które same pełnią rolę "wątków głównych" (ang. UI Threads) w obrębie jednej domeny dotnetowej aplikacji (ang. Application Domain). No dobra, odpowiedzi są, ale najczęściej w stylu "po co Tobie drugi wątek UI?", "uważam, że źle zaprojektowałeś aplikację" lub "myślę, że chodzi Tobie o InvokeRequired". Jak widać niewiele mają one wspólnego z tematem...
Wiadomo (na przykładzie Windows Forms), że dostęp do i modyfikacja elementu interfejsu użytkownika może nastąpić wyłącznie z wątku, który ten element utworzył. Inaczej bowiem każdego nieroztropnego programistę czeka przykra niespodzianka w postaci wyjątku InvalidOperationException i nieunikniony zawał serca. Nie można ot tak sobie zmieniać stanu "kontrolek" z poziomu dowolnego miejsca w wielowątkowym programie. A szkoda :)
Application.Run + SynchronizationContext
Podniesienie rangi wątku do rangi "głównego" (w ogromnym uproszczeniu!) w typowej aplikacji Windows Forms, ale także i każdej okienkowej aplikacji, dzieje się z chwilą uruchomienia pętli przetwarzającej zdarzenia pochodzące od użytkownika oraz wiadomości systemowe. Przetwarzanie takowej pętli zdarzeń rozpoczyna się z chwilą wywołania w danym wątku statycznej metody Application.Run (patrz -> Program.cs). Konsekwencją takiego modelu będą wszelkie nieudane próby tworzenia i zarządzania interfejsem graficznym w wątkach (implementowanych np. za pomocą klasy System.Threading.Thread) pozbawionych takiej pętli. Dodatkowo, i tu bardzo ważne!, wątki, w których tworzone są jakiekolwiek elementy pełniące rolę interfejsu użytkownika i dziedziczące po klasie System.Windows.Forms.Control "automagicznie" otrzymują tzw. kontekst umożliwiający synchronizację obiektów należących do danego wątku (ang. WindowsFormsSynchronizationContext). Dzięki temu, wykorzystując unikatowy obiekt SynchronizationContext (zwracany przez System.Threading.SynchronizationContext.Current) możemy sprawnie wywoływać dowolne metody, z dowolnego miejsca, na właściwym wątku. W dużym skrócie, wystarczy metodom Send bądź Post, udostępnianym przez SynchronizationContext, przekazać wskaźnik do interesującej nas funkcji (delegat, yuk :P) a jej rychłe wywołanie zostanie zakolejkowane na przynależnym wątku. Przy czym druga ze wspomnianych metod jest metodą asynchroniczną. Zainteresowanych tematem odsyłam (jak zwykle) do źródeł z pierwszej ręki: Parallel Computing - It's All About the SynchronizationContext
Na nieszczęście wszystkich programistów dokumentacja .NET Framework nie jest już pod tym względem szczególnie bogata, dlatego też dla wielu - czasem doświadczonych programistów - temat ten mógł stanowić wielką niewiadomą. Było minęło, prawda?
Przykład
Kończąc powyższe przemyślenia zaprezentuję drobny przykład (pisany na kolanie), który - tu zaznaczę - pod żadnym pozorem NIE może być brany za kompletny czy nawet książkowo poprawny. Z pewnych oczywistych względów taki nie jest. Z drugiej strony, na pewno odnajdziecie w nim ciekawe rozwiązania, które z pewnością podsuną Wam rozwiązania problemów programowania wielo-UI-wątkowego. Bo programowanie wielo-UI-wątkowe to coś więcej niż InvokeRequired...
using System; using System.Collections.Generic; using System.Threading; using System.Text; using System.Windows.Forms; using System.Drawing; namespace MultipleUIThreads { class Program { // Referencje do obiektów klasy Test static Test test1; static Test test2; static Test test3; static void Main(string[] args) { Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); // null -> brak nadrzędnego wątku test1 = new Test(null, "Okno 1"); test1.Show(); test1.DispatchWork<string>(new string[] { "raz", "dwa", "trzy" }); Thread thread2 = new Thread((obj2) => { // obj2 -> Id nadrzędnego wątku test2 = new Test(obj2, "Okno 2"); test2.Show(); test2.DispatchWork<int>(new int[] { 1, 2, 3, 4 }); Thread thread3 = new Thread((obj3) => { // obj3 -> Id nadrzędnego wątku, w tym wypadku thread2 test3 = new Test(obj3, "Okno 3"); test3.Show(); Application.Run(); }); thread3.Start(Thread.CurrentThread.ManagedThreadId); Application.Run(); }); thread2.Start(Thread.CurrentThread.ManagedThreadId); Application.DoEvents(); Thread.Sleep(3000); // Wywołanie metody w innym wątku niż obecny test2.DispatchChangeSizeAndBackColor(new Size(500, 240), Color.Yellow); Thread.Sleep(3000); // Wywołanie metody w innym wątku niż obecny test3.DispatchWork<int>(new int[] { 22, 222, -23, -56 }); Thread.Sleep(3000); // Wywołanie metody w innym wątku niż obecny test3.DispatchAnything<Point, Color, Size>(test3.ChangeLocationColorSize, new Point(200, 300), Color.Blue, new Size(350, 390)); // Z zasady operacje na oknie nie powinny się odbywać przed uruchomieniem pętli // przetwarzającej wiadomości systemowe oraz zdarzenia użytkownika! Application.Run(); } } class Test { private Form form; SynchronizationContext context; public SynchronizationContext Context { get { return this.context; } private set { this.context = value; } } // ManagedThreadId nadrzędnego wątku private int parentThreadId; public Test(object obj, string name) { form = CreateForm(name); if (obj is int) parentThreadId = (int)obj; else parentThreadId = -1; // Zapisz aktualny kontekst umożliwiający synchronizację // SynchronizationContext.Current == null w przypadku gdy wcześniej // nie utworzono obiektu : System.Windows.Forms.Control context = SynchronizationContext.Current; } private Form CreateForm(string name) { Form form = new Form(); form.Text = name; form.Size = new Size(480, 140); form.Paint += new PaintEventHandler(Paint); return form; } private void Paint(object sender, PaintEventArgs e) { Graphics g = e.Graphics; StringFormat format = new StringFormat() { Alignment = StringAlignment.Center, LineAlignment = StringAlignment.Center }; g.DrawString(String.Format( "\"ParentThread\".ManagedThreadId = {0}\n"+ "CurrentThread.ManagedThreadId = {1}", parentThreadId, Thread.CurrentThread.ManagedThreadId), new Font("Arial", 18), Brushes.Black, ((Form)sender).DisplayRectangle, format); } public void DispatchWork<T>(params T[] args) { Console.WriteLine("Metoda DispatchWork wywołana z wątku: " + Thread.CurrentThread.ManagedThreadId.ToString()); if (SynchronizationContext.Current != this.Context) { Console.WriteLine("Wywołanie metody nastąpiło z innego wątku. Nastąpi \"przekierowanie\" do poprawnego wątku"); // Metoda asynchroniczna SynchronizationContext.Post natychmiast zwraca sterowanie // Obsługa wyjątków w wątku, do którego należy metoda Work(), dlatego też // try { Post } catch { } nie przechwyci wyjątku! To jest generalna // zasada przy projektowaniu aplikacji wykorzystujących metody asynchroniczne this.Context.Post(delegate { Work<T>(args); }, null); return; } Console.WriteLine("Prawidłowy SynchronizationContext"); Work<T>(args); } public void Work<T>(params T[] args) { Console.WriteLine("Metoda Work wywołana z wątku: " + Thread.CurrentThread.ManagedThreadId); Console.WriteLine("args.Length = {0}, typeof(T) = {1}", args.Length, typeof(T)); int i = 0; foreach (T t in args) Console.WriteLine("args[{0}] = {1}", i++, t.ToString()); if (typeof(T).Equals(typeof(int))) { int sum = 0; for (int j = 0; j < args.Length; j++) sum += Convert.ToInt32(args[j]); Console.WriteLine("Suma = {0}", sum); } Console.WriteLine(new string('-', 50)); } public void Show() { form.Show(); } public void DispatchChangeSizeAndBackColor(Size size, Color color) { Console.WriteLine("Metoda DispatchChangeSizeAndBackColor wywołana z wątku: " + Thread.CurrentThread.ManagedThreadId.ToString()); if (SynchronizationContext.Current != this.Context) { Console.WriteLine("Wywołanie metody nastąpiło z innego wątku. Nastąpi \"przekierowanie\" do poprawnego wątku"); this.Context.Post(delegate { ChangeSizeAndBackColor(size, color); }, null); return; } Console.WriteLine("Prawidłowy SynchronizationContext"); ChangeSizeAndBackColor(size, color); } public void ChangeSizeAndBackColor(Size size, Color color) { Console.WriteLine("Metoda ChangeBackgroundColor wywołana z wątku: " + Thread.CurrentThread.ManagedThreadId); form.Size = size; form.BackColor = color; Console.WriteLine(new string('-', 50)); } // Tu już mamy prawdziwy zawrót głowy :) public void DispatchAnything<T, T2, T3>(Action<T, T2, T3> function, T param1, T2 param2, T3 param3) { Console.WriteLine("Metoda DispatchAnything wywołana z wątku: " + Thread.CurrentThread.ManagedThreadId.ToString()); if (SynchronizationContext.Current != this.Context) { Console.WriteLine("Wywołanie metody nastąpiło z innego wątku. Nastąpi \"przekierowanie\" do poprawnego wątku"); this.Context.Post(delegate { function(param1, param2, param3); }, null); return; } Console.WriteLine("Prawidłowy SynchronizationContext"); function(param1, param2, param3); } public void ChangeLocationColorSize(Point point, Color color, Size size) { Console.WriteLine("Metoda ChangeLocationColorSize wywołana z wątku: " + Thread.CurrentThread.ManagedThreadId); form.SetDesktopLocation(point.X, point.Y); form.BackColor = color; form.Size = size; Console.WriteLine(new string('-', 50)); } } }
I jeszcze mały zrzut treści wyświetlanych w konsoli. Kolorowe okienka WinForms pominę milczeniem :)
Powodzenia!