czwartek, 22 kwietnia 2010

Programowanie wielowątkowe w .NET cz. 2

Programowanie aplikacji z graficznym interfejsem jest obecnie podstawową techniką każdego programisty. Środowiska takie jak Visual Studio oferują bogate możliwości tworzenia graficznego interfejsu jak np. WindowsForms czy też Windows Presentation Fundation (WPF).
Często zdarza się, że aplikacja wykonując skomplikowane zadanie zamiera. Należy ograniczyć takie zjawisko. Jednym z rozwiązań jest skorzystanie z wątków. Poprzednia część artykułu, ukazuje podstawy pracy z wątkami. Tworzenie, Anulowanie oraz synchronizację pomiędzy nimi. W tej części skupimy się na wykorzystaniu wątków w UI takim jak WindowsForms czy też WPF.

Zacznijmy od krótkiego przykładu:
Stworzymy prostą aplikację WindowsForms. Na formatkę kładziemy Button i wprowadzamy dla niego

        private void button1_Click(object sender, EventArgs e)
        {
            string s = "";
            for (int i = 0; i < 100000; i++)
            {
                s += "a";
            }
            Text = "done";
        }

Po uruchomieniu tej aplikacji i naciśnięciu przycisku, aplikacja zamiera. Użytkownik najprawdopodobniej przerwałby aplikację. Co więc zrobić aby interfejs nie zamarł? Tworzymy wątek:

    public partial class Form1 : Form
    {
        Thread t1;
        public Form1()
        {
            InitializeComponent();
        }

        private void button1_Click(object sender, EventArgs e)
        {
            t1 = new Thread(Operation);
            t1.Start();
        }

        private void Operation()
        {
            string s = "";
            for (int i = 0; i < 100000; i++)
            {
                s += "a";
            }
            Text = "done";
        }
    }

Po uruchomieniu aplikacji i kliknięciu na button otrzymamy następujący błąd:


Dla wielu taki błąd może wydać się mało zrozumiały i w pierwszym momencie całkowicie irracjonalny. W wolnym tłumaczeniu, można by powyższy komunikat przetłumaczyć jako: Niedozwolona operacja między wątkowa: uzyskanie dostępu do Form1 z innego wątku niż została utworzona. Otóż błąd nie jest nowością i znają go wszyscy, którzy zaczynali kiedyś swoją przygodę z programowaniem wielowątkowym. Otóż wszystko co dzieje się na formie działa na jednym wątku. Jak wiemy już z wcześniejszej części operacje między wątkowe które modyfikują dane są niebezpieczne. Dlatego też otrzymujemy owy błąd. Aby się go pozbyć, należy uaktualnić formę z wątka w którym została stworzona.
Operacja która nam to umożliwia jest powtarzalna i w większości przypadków będzie za każdym razem w taki sam sposób przebiegała:
1. Tworzymy metodę odpowiedzialną za uaktualnienie interfejsu użytkownika
2. Tworzymy delegatę typu takiego, jak metoda do uaktualnienia interfejsu
3. Sprawdzamy za pomocą właściwości InvokeRequired czy jesteśmy w wątku Formy
4. Jeżeli nie wywołujemy metodę Invoke
5. W przeciwnym razie uaktualniamy interfejs

Poprawny kod programu:

    public partial class Form1 : Form
    {
        Thread t1;
        private delegate void Update(string s);
        public Form1()
        {
            InitializeComponent();
        }

        private void button1_Click(object sender, EventArgs e)
        {
            t1 = new Thread(Operation);
            t1.Start();
        }

        private void Operation()
        {
            string s = "";
            for (int i = 0; i < 100000; i++)
            {
                s += "a";
            }
            UpdateUI("done");
        }

        private void UpdateUI(string text)
        {
            if (this.InvokeRequired)
            {
                Invoke(new Update(UpdateUI), new object[] { text });
            }
            else
            {
                this.Text = text;
            }
        }
    }

Korzystając z platformy .NET mamy jeszcze możliwość użycia SynchronizationContext (.NET 2.0) oraz wyrażeń lambda (.NET 3.0).

SynchronizationContext
Możliwość komunikacji za pomocą tej metody została wprowadzona w .NET 2.0. Jeżeli mamy dwa wątki, przykładowo t1 i t2, to jeśli chcielibyśmy w pewnym momencie z wątka t1 wykonać jakiś kod w wątku t2 możemy skorzystać z metody SynchronizationContext.Send(). Jest tu jedno ale: nie każdy wątek posiada SynchronizationContext. Na nasze szczęście każdy UI posiada taki obiekt, dlatego też bez obaw możemy za jego pomocą aktualizować interfejs użytkownika. Może nasunąć się pytanie: skąd bierze się w wątku owy tajemniczy obiekt SynchronizationContext? Odpowiedź nie jest trudna i nie ma tu żadnej magii i setek linijek kodu. SynchronizationContext tworzy pierwsza utworzona kontrolka. Spójrzmy na poniższy kod:

    public partial class Form1 : Form
    {
        Thread t;
        public Form1()
        {
            InitializeComponent();
        }

        private void button1_Click(object sender, EventArgs e)
        {
            t = new Thread(DoSomething);
            t.Start(SynchronizationContext.Current);
        }

        private void DoSomething(object o)
        {
            for (int i = 0; i < 10000; ++i)
            {
                SynchronizationContext cntx = o as SynchronizationContext;
                cntx.Send(UpdateUI, i);
            }
        }

        private void UpdateUI(object i)
        {
            listBox1.Items.Add(i);
            label1.Text = listBox1.Items.Count.ToString();
        }
    }

Na formatce znajduje się label, button oraz listbox. Po naciśnięciu przycisku tworzymy nowy wątek i przekazujemy mu obiekt SynchronizationContext. Jako że klasa jest tworzona w wątku w którym działa forma, przekażemy wątek UI, czyli właściwy dla nas. Następnie do pracy rusza metoda DoSomething, która w pętli dodaje 10000 elementów do listboxa. Za pomocą metody Send wysyłamy zmiany do naszych kontrolek. Można by zadać pytanie: czy jest jakaś różnica pomiędzy tym sposobem uaktualniania interfejsu a przedstawionym poprzednio? Jednym z dużych plusów tego sposobu jest to, że nie sprawdzamy dwukrotnie tego, czy jesteśmy w wątku UI. Oprócz metody Send jest jeszcze metoda Post. W jej przypadku problemem jest jednak to, że nie mamy możliwości łapania błędów w uruchomionym wątku, a jakikolwiek błąd spowoduje wywołanie błędu w wątku UI.

Wyrażenia Lambda
Zamiast używać anonimowych delegat, moglibyśmy użyć wyrażenia lambda. Wyrażenia lambda wprowadzono w .NET 3.0. Pozwalają one na zminimalizowanie kodu w wielu miejscach długiego programu. Są też i wady tego rozwiązania: często czynią kod mniej czytelnym. Zobaczmy nasz przykład z wyrażeniem lambda:

    public partial class Form1 : Form
    {
        Thread t;
        public Form1()
        {
            InitializeComponent();
        }

        private void button1_Click(object sender, EventArgs e)
        {
            t = new Thread(DoSomething);
            t.Start(SynchronizationContext.Current);
        }

        private void DoSomething(object o)
        {
            for (int i = 0; i < 10000; ++i)
            {
                SynchronizationContext cntx = o as SynchronizationContext;
                Random r = new Random();
                cntx.Send( (x) =>
                    {
                        listBox1.Items.Add(i);
                        label1.Text = listBox1.Items.Count.ToString();
                    }, i);
            }
        }
    }

BackgroundWorker
Jest to jedno z udogodnień wprowadzonych w .NET 2.0. Komponent pozwala na uruchomienie czynności w oddzielnym wątku. Rozwiązanie to korzysta z omówionego wcześniej SynchronizationContext, który pobiera z aktywnej formy. Oprócz tego oferuje takie udogodnienia jak raportowanie postępu operacji, przerwanie prowadzonych obliczeń.
Przykład:


    public partial class Form1 : Form

    {

        public Form1()

        {

            InitializeComponent();

        }

 

        private void backgroundWorker1_DoWork(object sender, DoWorkEventArgs e)

        {

            for (int i = 0; i < 101; i++)

            {

                if (backgroundWorker1.CancellationPending)

                {

                    e.Cancel = true;

                    return;

                }

                backgroundWorker1.ReportProgress(i);

                Thread.Sleep(100);

            }

            e.Result = "done";

        }

 

        private void backgroundWorker1_ProgressChanged(object sender, ProgressChangedEventArgs e)

        {

            progressBar1.Value = e.ProgressPercentage;

        }

 

        private void backgroundWorker1_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e)

        {

            if (e.Error == null && e.Cancelled == true)

            {

                return;

            }

            Text = e.Result.ToString();

        }

 

        private void button1_Click(object sender, EventArgs e)

        {

            backgroundWorker1.RunWorkerAsync();

        }

 

        private void button2_Click(object sender, EventArgs e)

        {

            backgroundWorker1.CancelAsync();

        }

    }


Przykład umożliwia uruchomienie wątku, który wykonuje fikcyjne obliczenia. Wątek można przerwać za pomocą przycisku stop. Wykorzystanie BackgroundWorkera jest proste i intuicyjne. Microsoft tworząc go miał właśnie prostotę na uwadze. Programowanie wielowątkowe przez wielu jest uważane za jedno z trudniejszych w kwestii implementacji jak i debugowania.


WPF
W WPF tak samo jak i WindowsForms obowiązuje zasada, że dostęp do kontrolek jest dozwolony tylko z wątku w którym zostały utworzone.
W WPF możemy skorzystać tak jak poprzednio z BackgroundWorkera:
Kod:

<Window x:Class="WpfApplication2.MainWindow"

       xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"

       xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"

       Title="MainWindow" Height="350" Width="525">

    <Grid>

        <Grid.RowDefinitions>

            <RowDefinition Height="30"/>

            <RowDefinition Height="50"/>

            <RowDefinition Height="30"/>

            <RowDefinition Height="*"/>

 

        </Grid.RowDefinitions>

 

        <Grid.ColumnDefinitions>

            <ColumnDefinition Width="80"/>

            <ColumnDefinition Width="300" />

        </Grid.ColumnDefinitions>

 

        <Button Name="button1" Grid.Column="0" Grid.Row="0" Content="Button" Click="button1_Click"/>

        <ProgressBar Name="progressBar" Minimum="0" Maximum="100" Value="0" Grid.Column="1" Grid.Row="2"/>

 

    </Grid>

</Window>

 


    public partial class MainWindow : Window

    {

        private BackgroundWorker bw;

        public MainWindow()

        {

            InitializeComponent();

            bw = new BackgroundWorker();

            bw.DoWork += new DoWorkEventHandler(bw_DoWork);

            bw.ProgressChanged += new ProgressChangedEventHandler(bw_ProgressChanged);

            bw.WorkerReportsProgress = true;

        }

 

        void bw_ProgressChanged(object sender, ProgressChangedEventArgs e)

        {

            progressBar.Value = e.ProgressPercentage;

        }

 

        void bw_DoWork(object sender, DoWorkEventArgs e)

        {

            for (int i = 0; i < 101; i++)

            {

                Thread.Sleep(100);

                bw.ReportProgress(i);

            }

        }

 

        private void button1_Click(object sender, RoutedEventArgs e)

        {

            bw.RunWorkerAsync();

        }

    }


Oprócz użycia BackgroundWorkera WPF posiada bardzo podobny mechanizm jaki był zaimplementowany w początkowych wersjach .NET. Jest to jednak bardziej kompleksowe rozwiązanie. Należy zdać sobie sprawę, że wszystkie obiekty WPF, które należy traktować z uwagą podczas pracy z wątkami dziedziczą po klasie DispatcherObject. Dzięki temu wiemy, że Button czy Brush podczas operacji między wątkowych muszą być traktowane w specjalny sposób a klasa Color już nie, gdyż nie dziedziczy po DispatcherObject. Obiekt DispatcherObject zawiera kilka metod które pozwalają określić czy znajdujemy się we właściwym wątku. CheckAccess oraz VerifyAccess – te dwie metody pozwalają na określenie czy znajdujemy się w dobrym wątku. Pierwsza metoda, jest podobna do właściwości znanej z WindowsForms InvokeRequired. Drga z metod pozwala na upewnienie, czy po przejściu na właściwy w naszym mniemaniu wątek, przebiegło pomyślnie. Wywoływanie VerifyAccess nie jest konieczne za każdym razem, a większość kontrolek samo za nas dba o kontrolę podczas ich używania. Dzieje się tak dlatego, gdyż wywoływanie tej metody obniża wydajność aplikacji.
Jeśli już wiemy jak sprawdzić jak sprawdzić czy znajdujemy się w odpowiednim wątku, warto wiedzieć jak skorzystać z niego. Klasa DispatcherObject oferuje właściwość o nazwie Dispatcher. Za pomocą niej możemy użyć takich metod jak Invoke oraz BeginInvoke. Różnica pomiędzy tymi metodami jest taka, że po wywołaniu metody Invoke, że wyjście z metody wykonywanej następuje dopiero po jej zakończeniu a dla metody BeginInvoke tworzona jest kolejka zadań (jednym słowem po wywołaniu metody, BeginInvoke nie czeka na zakończenie wywoływanej metody).
Spójrzmy na poprzedni przykład, tylko tym razem wykorzystamy metodę BeginInvoke:

    public partial class MainWindow : Window

    {

        private Thread T1;

        public MainWindow()

        {

            InitializeComponent();

        }

 

        private void button1_Click(object sender, RoutedEventArgs e)

        {

            T1 = new Thread(DoSomething);

            T1.IsBackground = true;

            T1.Start();

        }

 

        private void DoSomething()

        {

            for (int i = 0; i < 101; i++)

            {

                Thread.Sleep(100);

                progressBar.Dispatcher.BeginInvoke((Action)(() =>

                    {

                        progressBar.Value = i;

                    }), null);

            }

        }

    }


Należy pamiętać, że nie ma sensu wykonywać obliczeń w metodzie przekazywanej do BeginInvoke. Spójrzmy:

        private void DoSomething()

        {

            for (int i = 0; i < 101; i++)

            {

                progressBar.Dispatcher.BeginInvoke((Action)(() =>

                    {

                        Thread.Sleep(100);

 

                        progressBar.Value = i;

                    }), null);

            }

        }


W tym przypadku nic nie zyskujemy. BeginInvoke wywołuje się na wątku na którym działa ProgressBar, co za tym idzie interfejs użytkownika. Wykonywane obliczenia powodują zamrożenie interfejsu.
Wspomniałem wcześniej, że Invoke wykonując metodę nie kolejkuje zadań, a kończy swoje działanie dopiero w momencie zakończenia wywoływanej metody. Taka cecha może być przydatna gdy np. w wątku wyświetlamy okienko z odpowiedziami TAK/NIE i w zależności od odpowiedzi wykonujemy bądź kończymy metodę wykonywaną w wątku.


Tyle o wykorzystaniu wątków w aplikacjach z wizualnym interfejsem. Ostatnia część cyklu będzie dotyczyła nowości jaką wprowadził Microsoft do .NET 4.0 czyli ParallelExtensions

Źródło:
Pro WPF in C# 2008 Matthew MacDonald
Programming WPF Chris Sells and Ian Griffiths
http://www.codeproject.com/KB/threads/mtguide_1.aspx
http://www.codeproject.com/KB/threads/SynchronizationContext.aspx
http://www.codeproject.com/KB/cpp/BackgroundWorker_Threads.aspx
http://www.codeproject.com/KB/threads/ThreadingDotNet5.aspx
http://www.codeproject.com/KB/threads/mtguide_1.aspx
http://msdn.microsoft.com/en-us/library/ms741870.aspx

Brak komentarzy:

Prześlij komentarz