niedziela, 18 kwietnia 2010

Programowanie wielowątkowe w C# cz. 1

Trudno sobie wyobrazić współcześnie pisaną aplikację pozbawioną wątków. W erze, kiedy producenci procesorów dodają kolejne rdzenie do swoich najnowszych modeli, stosowanie podziału obciążenia obliczeniowego na kilka jednostek jest nieuniknione.
Naukę o wątkach należałoby rozpocząć od terminu procesu, który to jak mówi Wikipedia: „jedno z najbardziej podstawowych pojęć w informatyce, definiowane jako egzemplarz wykonywalnego programu”. Tak więc z każdą uruchomioną w naszym komputerze aplikacją możemy skojarzyć przynajmniej jeden proces. Oczywiście nic nie stoi na przeszkodzie, aby aplikacja posiadała więcej niż jeden proces. Zarządzaniem procesami zajmuje się System Operacyjny. Każdy utworzony proces otrzymuje od Systemu operacyjnego czas procesora, pamięć, dostęp do urządzeń I/O oraz dostęp do plików. Dostęp do uruchomionych procesów w systemie Windows uzyskujemy w Menedżerze zadań:


Może nasunąć się pytanie: „Po co więc używać wątków skoro mamy procesy?”. Otóż jeśli spojrzymy na poniższe diagramy z pewnością uchwycimy to, co czyni wątki wydajniejszymi:


Na pierwszym rysunku mamy klasyczny przykład procesu pozbawionego wątków. Drugi zawiera już utworzone dwa wątki. Przejdźmy do odpowiedzi na postawione pytanie. W pierwszym przypadku, aby zwiększyć szybkość wykonywania programu, można by utworzyć kolejny proces. W drugim przypadku tworzymy kolejny wątek w bieżącym procesie. Jak łatwo można zauważyć dzięki wątkom oszczędzamy na czasie i ułatwiamy sobie pracę. Tworząc wątek, posiadamy możliwość korzystania z współdzielonej przestrzeni adresowej, co pozwala na łatwą komunikację między wątkową bez zbędnych potrzeb korzystania z funkcji systemu operacyjnego. Także tworzenie wątków jest dużo szybsze i wymaga mniejszych zasobów. Podsumowując, na korzyść wątków przemawiają szybkość tworzenia, mniejsze użycie zasobów oraz łatwa komunikacja pomiędzy nimi.
Należy zapamiętać, że podczas wykonywania się wątków w systemie operacyjnym, wykonuje się tylko jeden na danym procesorze. Aby podglądnąć ilość wątków w naszych aplikacjach można do menedżera zadań dodać kolumnę wątki:


Jak widać, każdy proces posiada mniej lub więcej uruchomionych wątków.
Proces może mieć kilka stanów:
Nowy – proces został utworzony, otrzymał zasoby oprócz czasu procesora
Gotowy – proces oczekuje na przydział kwantu czasu procesora
Oczekujący – czasami też nazywany uśpiony, proces jest zatrzymany w wyniku niedomiaru zasobów. Stan ten może być też spowodowany oczekiwaniem na odpowiedź od urządzenia I/O
Wykonywany – instrukcje procesu są wykonywane
Zakończony – proces zakończył swoje działanie, oddaje zarezerwowane zasoby; w razie potrzeby informuje inne procesy o zakończeniu swojego działania.

Zarządzaniem procesami, czyli między innymi kiedy któremu przyznać kwant czasu procesora zajmuje się system operacyjny. Dzięki sprawnemu przełączeniu pomiędzy wykonywanymi procesami na maszynach jednoprocesorowych, gdzie może być wykonywany tylko jeden proces jednocześnie, użytkownik nie odczuwa dyskomfortu gdy pracuje z wieloma aplikacjami jednocześnie.

Każdy nowoutworzony wątek w systemie operacyjnym może otrzymać priorytet. Platforma .NET jak i system Windows pozwala nadać następujące priorytety (zaczynając od najwyższego): Highest, AboveNormal, Normal, BelowNormal, Lowest. Należy sobie oczywiście zdać sprawę z tego, że decydującą rolę o ważności wątku ma system operacyjny a nie użytkownik.

Tworzenie wątków na platformie .NET jest niezwykle proste. Aby utworzyć nowy wątek należy utworzyć instancję klasy Thread, korzystając z jednego z dostępnych konstruktorów:
    1         //korzystając z ThreadStart
    2         private static void Method1()
    3         {
    4             for (int i = 0; i < 5; i++)
    5             {
    6                 Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
    7             }
    8         }
    9 
   10         //korzystając z ParameterizedThreadStart
   11         private static void Method2(object o)
   12         {
   13             for (int i = 0; i < 5; i++)
   14             {
   15                 Console.WriteLine(o.ToString());
   16             }
   17         }
   18 
   19         static void Main(string[] args)
   20         {
   21             Thread t1 = new Thread(Method1);
   22             Thread t2 = new Thread(Method2);
   23             t1.Start();
   24             t2.Start("Dane dla wątku");
   25         }

Jak widać dane dla wątku są przekazywane w metodzie Start(). Przyjmuje ona zawsze parametr typu object, dlatego też sami musimy zatroszczyć się o to jak będziemy rzutowali dane w kodzie dla wątku. Oczywiście można skorzystać tutaj z różnych dodatków C# takich jak np. wyrażenia lambda czy inne metody tworzenia kodu dla wątku:

            Thread t1 = new Thread(delegate()
            {
                for (int i = 0; i < 5; i++)
                {
                    Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
                }
            });
            Thread t2 = new Thread(delegate(object o)
                {
                    for (int i = 0; i < 5; i++)
                    {
                        Console.WriteLine(o.ToString());
                    }
                });

            //Lambda expressions
            Thread t3 = new Thread(() =>
                {
                    for (int i = 0; i < 5; i++)
                    {
                        Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
                    }
                });

            Thread t4 = new Thread((o) =>
                {
                    for (int i = 0; i < 5; i++)
                    {
                        Console.WriteLine(o.ToString());
                    }
                });

Podczas swojego działania wątki mogą przyjąć różne stany w zależności czy aktualnie się wykonują, są zawieszone czy też wstrzymane. W .NET Framework mamy następujące stany do wykorzystania:
Running - wątek jest wykonywany
StopRequested - prośba o wstrzymanie wątku - tylko do wewnętrznego użytku
SuspendRequested - prośba o zawieszenie wątku
Background - wątek jest wykonywany w tle, uzyskujemy to poprzez ustawienie właściwości Thread.IsBackground na true.
Unstarted - nie wykonano na wątku metody Start.
Stopped - wątek został zatrzymany.
WaitSleepJoin - wątek jest zablokowany, najczęściej jest to spowodowane użyciem metody Sleep lub Join; może też to być spowodowanem zablokowaniem dostępu do danych (lock)
Suspended - wątek został zawieszony
AbortRequested - została wywołana metoda Abort na wątku, jednak jeszcze nie został zakończony
Aborted - wątek został usunięty, jednak stan jeszcze nie został przełączony na Stopped

Metoda Join w kontekście wykorzystania wątków ma szczególne zastosowanie. Dzięki niej, jeśli wykonujące wątki operują na danych które przykładowo modyfikują, należy zagwarantować, że wątki wzajemnie na siebie zaczekają. W innym przypadku operacje były by nie tyle niebezpieczne co bezsensowne. Przyjrzyjmy się następującemu kodu:

    class MyClass
    {
        public int MyProperty { get; set; }
        public MyClass()
        {
            this.MyProperty = 0;
        }
    }

    class Program
    {
        static void Main(string[] args)
        {
            MyClass myClass = new MyClass();

            Thread t1 = new Thread((o) =>
                {
                    myClass.MyProperty *= (int)o;
                });

            Thread t2 = new Thread((o) =>
                {
                    myClass.MyProperty += (int)o;
                });

            object variable = 10;
            t1.Start(variable);
            t2.Start(variable);
            Console.WriteLine(myClass.MyProperty);
        }
    }

Mamy tutaj do czynienia z klasą posiadającą jedno pole – właściwość typu integer. Po utworzeniu obiektu klasy nadajemy temu polu wartość 0 (właściwie nie musielibyśmy tego robić, gdyż podczas tworzenia obiektu jego pola otrzymują domyślne wartości a dla integera jest to i tak 0). Teraz w metodzie Main tworzymy dwa wątki, korzystając przy tym z wyrażeń lambda. Pierwszy wątek mnoży to co dostanie na wejściu przez aktualną wartość pola, drugi dodaje jakąś wartość do wartości pola. Jeśli uruchomimy ten program kilka razy, może okazać się że za każdym razem otrzymujemy inny wynik. Możemy otrzymać 0, 10 lub 100. Uruchamiając ten program tak naprawdę uruchamiamy nie 2 wątki a co najmniej 3. Ten trzeci wątek – który nie widzimy tutaj tak wprost to nasza aplikacja. Kod który jest wykonywany w metodzie Main działa na oddzielnym wątku. Pozostałe dwa wątki tworzymy już jawnie. W tym wypadku pomiędzy wątkami nie ma żadnej synchronizacji. Ktoś by powiedział: wrzucić metodę Sleep w każdym wątku o odpowiednim czasie i po problemie. Jest to pomysł, jednak niezwykle mało elastyczny. Naszym zamierzeniem jest najpierw dodanie do właściwości wartości znajdującej się w zmiennej variable, a następnie wymnożenie tej wartości przez wartość zmiennej variable. Tutaj właśnie przychodzi nam z pomocą metoda Join. Spójrzmy na nasz kod po dodaniu tej metody:

    class MyClass
    {
        public int MyProperty { get; set; }
        public MyClass()
        {
            this.MyProperty = 0;
        }
    }

    class Program
    {
        private static Thread t1;
        private static Thread t2;
        static void Main(string[] args)
        {
            MyClass myClass = new MyClass();
            t1 = new Thread((o) =>
                {
                    myClass.MyProperty *= (int)o;

                });

            t2 = new Thread((o) =>
                {
                    myClass.MyProperty += (int)o;
                });

            object variable = 10;
            t2.Start(variable);
            t2.Join();
            t1.Start(variable);
            t1.Join();
            Console.WriteLine(myClass.MyProperty);
        }
    }

Dzięki funkcji Join zawsze uzyskamy oczekiwany wynik.

W przypadku wątków mamy możliwość zastosowania także innych funkcji jak: Abort (przerywa działanie wątku), Resume (wznawia pracę wątku), Suspend (zawiesza wątek).
Korzystanie z tych metod powinno być decyzją przemyślaną. Dzieje się tak, gdyż nigdy nie mamy pewności, że zawieszony wątek uda nam się wznowić. Microsoft także zaleca używanie innych rozwiązań w pracy z wątkami jak Mutexy, Semafory czy też monitory.

Synchronizacja wątków nigdy nie była sprawą prostą i oczywistą. Wyobraźmy sobie sytuację, w której mamy pobrać dane, wykonać na nich operacje a następnie zapisać do pliku/bazy danych. Operacja może wydawać się bardzo prosta. Dla potrzeb tego artykułu przyjmijmy, że wszystkie te operacje zabierają więcej niż 2 sekundy. Możemy teraz wyobrazić sobie użytkownika, który obsługuje naszą aplikację. Kiedy rozpoczną się wymienione operacje interfejs użytkownika zamiera. Jeżeli użytkownik nie ma możliwości interakcji z naszą aplikacją, pierwszą myślą jaka mu przychodzi do głowy jest zabicie procesu (naszej aplikacji). Teraz weźmy tą samą aplikację tylko podzieloną na wątki. Dzięki temu aplikacja podczas wykonywania operacji nie zamrozi całego interfejsu. Problemem jednak jest tutaj synchronizacja między wątkami. Czynność, którą nazwaliśmy wykonanie operacji, musi wykonać się po pobraniu danych, a zapisanie do pliku/bazy danych po wykonaniu operacji.
Jak już wspominałem wcześniej, w .NET istnieje kilka klas za pomocą których możemy uzyskać synchronizację naszych wątków. Przyjrzyjmy się ich hierarchii:

System.Threading.WaitHandle
• System.Threading.EventWaitHandle
• System.Threading.Mutex
• System.Threading.Semaphore

Klasa WaitHandle jest klasą bazową dla trzech pozostałych klas. Należy tutaj zaznaczyć, że klasa ta implementuje interfejs IDisposable, co sugeruje nam, że po skończeniu jej użytkowania należy zwolnić zasoby zarezerwowane przez nią. Świetne wyobrażenie o synchronizacji wątków przedstawił Sacha Barber w swoim artykule (link można znaleźć w źródłach):
Przedstawił on synchronizację jako przejazd kolejowy. Przed przejazdem czeka kolejka samochodów (wątków). Zapora podnosi się w zależności od potrzeb i pozwala na przejazd tylko jednego samochodu.

        static void Main(string[] args)
        {

            Thread pobranie = new Thread(() =>
                {
                    for (int i = 0; i < 5; i++)
                    {
                        Thread.Sleep(500);
                        Console.WriteLine("Pobieranie danych");
                    }
                });

            Thread operacje = new Thread(() =>
                {
                    for (int i = 0; i < 4; i++)
                    {
                        Thread.Sleep(250);
                        Console.WriteLine("Przetwarzanie danych");
                    }
                });

            Thread zapis = new Thread(() =>
                {
                    for (int i = 0; i < 8; i++)
                    {
                        Thread.Sleep(150);
                        Console.WriteLine("Zapis danych");
                    }
                });
            pobranie.Start();
            operacje.Start();
            zapis.Start();
        }

Jeśli uruchomimy powyższy kod otrzymamy taki sam (bądź bardzo podobny) wynik:


Z pewnością taki wynik nas nie zadawala, a co więcej w realnej aplikacji może zagrozić danym wprowadzonym przez użytkownika (o ile w ogóle zadziała). Spróbujmy skorzystać z wymienionych poprzednio klas do uzyskania synchronicznych wywołań. Na początek może kod i zrzut ekranu aplikacji po zastosowaniu tej klasy, a później parę słów komentarza:

    class Program
    {
        private static EventWaitHandle ewhOperacje = new AutoResetEvent(false); // 0.
        private static EventWaitHandle ewhZapis = new AutoResetEvent(false); // 0.

        static void Main(string[] args)
        {

            Thread pobranie = new Thread(() =>
                {
                    for (int i = 0; i < 5; i++)
                    {
                        Thread.Sleep(500);
                        Console.WriteLine("Pobieranie danych");
                    }
                    ewhOperacje.Set(); //1.
                });

            Thread operacje = new Thread(() =>
                {
                    ewhOperacje.WaitOne(); //2.
                    for (int i = 0; i < 4; i++)
                    {
                        Thread.Sleep(250);
                        Console.WriteLine("Przetwarzanie danych");
                    }
                    ewhZapis.Set(); // 1.
                });

            Thread zapis = new Thread(() =>
                {
                    ewhZapis.WaitOne(); // 2.
                    for (int i = 0; i < 8; i++)
                    {
                        Thread.Sleep(150);
                        Console.WriteLine("Zapis danych");
                    }
                });
            pobranie.Start();
            operacje.Start();
            zapis.Start();
        }
    }


W porównaniu do poprzedniego przykładu, ten kod działa tak jakbyśmy tego chcieli. Przejdźmy do objaśnienia zaznaczonych fragmentów kodu:
0 – Jest to utworzenie obiektów klasy AutoResetEvent. Klasa ta umożliwia wątkom komunikowanie się między sobą poprzez wysyłanie sygnałów. Wątek oczekuje na sygnał wywołując metodę WaitOne – 2na obiekcie klasy AutoResetEvent. Jeżeli obiekt jest w stanie nie sygnalizowanym (false), wtedy wątek jest blokowany, aż do momentu kiedy blokujący wątek wywoła metodę Set – 1. Klasa AutoResetEvent posiada tą szczególną właściwość, że po wywołaniu metody Set i odblokowaniu wątku, od razu przechodzi w stan braku sygnału (false).
Istnieje także klasa ManualResetEvent, która pozwala uzyskać takie samo zachowanie jak AutoResetEvent, ale także umożliwia „manualne” zmienianie sygnału. Po wywołaniu Set i zmianie sygnału nie zostanie on zmieniony na brak sygnału automatycznie. W zależności od potrzeb można korzystać z jednej lub drugiej wersji klasy.

Przejdźmy teraz do Mutexów. Mutex w swoim działaniu jest bardzo podobny do lock (o nim trochę później w tym artykule). W odróżnieniu jednak od locka pozwala na działanie w obrębie „komputera” a nie tylko aplikacji. Należy też zaznaczyć, że lock jest dużo szybszy od Mutexu. Jest jednak jedno szczególne zastosowanie Mutexa, które znalazło szerokie zastosowanie w programowaniu. Dzięki temu, że Mutex nie jest zależny tylko od aplikacji, można dzięki niemu zaimplementować możliwość uruchomienia tylko jednej instancji naszej aplikacji. Zobaczmy na przykład:
    class Program
    {
        private static Mutex mutex = new Mutex(false, "test");
        static void Main(string[] args)
        {
            if (!mutex.WaitOne(1000))
            {
                Console.WriteLine("Mutex został zainicjowany! Wychodziny");
                return;
            }
            try
            {
                Console.WriteLine("Stworzono Mutex");
                Console.ReadLine();
            }
            finally
            {
                mutex.ReleaseMutex();
            }
        }
    }

Po uruchomieniu 2 instancji naszej aplikacji zobaczymy:

    class Program
    {
        private static Mutex mutex = new Mutex(false, "test");
        static void Main(string[] args)
        {
            if (!mutex.WaitOne(1000))
            {
                Console.WriteLine("Mutex został zainicjowany! Wychodziny");
                return;
            }
            try
            {
                Console.WriteLine("Stworzono Mutex");
                Console.ReadLine();
            }
            finally
            {
                mutex.ReleaseMutex();
            }
        }
    }

Po uruchomieniu 2 instancji naszej aplikacji zobaczymy:


Ważne jest tutaj wspomnieć, że w przypadku awarii naszej aplikacji Mutex zostanie automatycznie zwolniony.

Przejdźmy teraz do Semaforów. W Internecie można znaleźć takie porównanie: „Semafor jest jak nocny klub – ma określoną pojemność pilnowaną przez bramkarza. Kiedy klub jest pełny, nikt więcej nie wejdzie dopóki ktoś nie wyjdzie. Podczas tworzenia obiektu tej klasy, konstruktor wymaga dwóch parametrów – pojemności klubu oraz ilość wolnych miejsc.
Spójrzmy na następujący kod:

    class Program
    {
        private static Semaphore s = new Semaphore(2, 2);
        static void Main(string[] args)
        {
            for (int i = 0; i < 10; i++)
            {
                Thread t = new Thread(Run);
                t.Name = i.ToString();
                t.Start();
            }
        }

        public static void Run()
        {
            s.WaitOne();
            Console.WriteLine(Thread.CurrentThread.Name);
            Thread.Sleep(1000);
            s.Release();
        }
    }

Tworzymy tutaj obiekt klasy Semaphore o pojemności 2 wątków i z wolnymi dwoma miejscami na wątki. Następnie tworzymy 10 wątków i przekazujemy im do wykonania metodę Run. Metoda ta może dzięki Semaphorze być obsługiwana tylko przez dwa wątki na raz. Gdzie może się nam coś takiego przydać w praktyce? Przekładowo do zarządzania połączeniami do bazy danych. Możemy z góry ustalić że będzie maksymalnie 10 połączeń a Semaphora zajmie się zarządzaniem ich.

Lock
Przy omawianiu Mutexów wspomniałem o locku. Lock podobnie jak Mutex pozwala na ochronę sekcji kodu, aby tylko jeden wątek mógł mieć do niego dostęp. Jak wcześniej wspominałem lock jest znacznie szybszy od mutexa i zaleca się jego stosowanie.
Może się tutaj nasunąć pytanie: po co stosować lock? Otóż spójrzmy na poniższy kod, który chyba najlepiej obrazuje zastosowanie locka:

    class Program
    {
        private static int a = 62;
        private static int b = 20;

        static void Main(string[] args)
        {
            for (int i = 0; i < 100; i++)
            {
                new Thread(Divide).Start();
            }
        }

        private static void Divide()
        {
            b = 23;
            if (b != 0)
            {
                Console.WriteLine(a / b);
            }
            b = 0;
        }
    }

Uruchamiając powyższy kod raz, dwa, sto razy możemy odnieść wrażenie, że wszystko działa poprawnie. Jednak tak nie jest. Praca z wątkami często prowadzi do pułapek. Kod który podano powyżej nie jest bezpieczny. Można sobie wyobrazić sytuację, kiedy jeden wątek jest w momencie przypisywania do zmiennej b wartości 0 a drugi wątek jest w momencie wypisywania na konsoli wartości wyrażenia. Aby zapobiec takim zdarzeniom, należy zablokować możliwość zmiany wartości krytycznej sekcji naszego programu.
Poprawimy kod korzystając z lock:

    class Program
    {
        private static int a = 62;
        private static int b = 20;
        private static object o = new object();

        static void Main(string[] args)
        {
            for (int i = 0; i < 100; i++)
            {
                new Thread(Divide).Start();
            }
        }

        private static void Divide()
        {
            lock (o)
            {
                b = 23;
                if (b != 0)
                {
                    Console.WriteLine(a / b);
                }
                b = 0;
            }
        }
    }

Do kodu wprowadziliśmy dwie konstrukcje. Po pierwsze do klasy dołożyliśmy pole o typie object, a dwa zastosowaliśmy blok kodu lock. Blokowanie musi odbyć się na typie referencyjnym. Często zdarza się, że programiści stosują tutaj konstrukcję lock(this) czy też lock(typof(MyClass)). Należy unikać takich konstrukcji. Dobre nawyki programowania dla lock mówią, że obiekt blokowany powinien być prywatnym polem klasy typu referencyjnego.

ThreadPool
Pula wątków umożliwia w elegancki sposób zarządzać wieloma małymi zadaniami. Tworzenie obiektów Thread powoduje zużycie zasobów, jednak daje możliwość zarządzania wątkiem (wstrzymywanie, wznawianie, przerwanie). ThreadPool zmniejsza zużycie zasobów, jednak po uruchomieniu wątku nie możemy nim jakkolwiek zarządzać. Innym problemem jest to, że wątki w ThreadPool są uruchomiane jako wątki w tle. Dla nas oznacza to, że w momencie zakończenia głównego wątka, niewykonane wątki z puli zostaną przerwane.
Zobaczmy na przykład:

    class Program
    {
        static void Main(string[] args)
        {
            ThreadPool.QueueUserWorkItem(Method);
            Console.ReadLine();
        }

        private static void Method(object o)
        {
            Console.Write("Hello from ThreadPool");
        }
    }

Tyle o podstawach wielowątkowości w .NET. W następnej części przyjrzymy się w jaki sposób obchodzić się z wątkami w WindowsForms oraz WPF. W ostatniej części zobaczymy nowości, które wprowadzono do .NET 4.0.

Źródło:
http://aragorn.pb.bialystok.pl/~wkwedlo/OS1-3.pdf
http://msdn.microsoft.com/en-us/library/ms686749%28v=VS.85%29.aspx
http://en.wikipedia.org/wiki/Process_%28computing%29
http://www.codeproject.com/KB/threads/ThreadingDotNet.aspx
http://wazniak.mimuw.edu.pl/images/5/54/Sop_02_wyk_bw_1.1.pdf
http://www.albahari.com/threading/part2.aspx

6 komentarzy:

  1. Fajny artykuł. Powoli zaczynam rozumieć wielowątkowość... :o)
    Przy testowaniu przykładu, w którym omawiasz lock nasunął mi się pomysł na drobną poprawkę w metodzie Divide() kodu bez locka, która sprawi, że program rzeczywiście wysypie się na dzieleniu przez 0.

    private static void Divide()
    {
    b = 23;
    for (int i = 0; i < 100000; i++) { }
    Console.WriteLine(a / b);
    b = 0;
    }

    Pętla for da innemu wątkowi czas na podstawienie b = 0.

    OdpowiedzUsuń
  2. Faktycznie fajny ale czy do pełni szczęścia mógłbyś wyjasnić ( w stylu nocnego klubu i bramkarzy :-) to do mnie trafiło najbardziej) jak działa metoda lock(o) ponieważ o samej metodzie w opisie jest niewiele

    OdpowiedzUsuń
    Odpowiedzi
    1. To działa jak kibel w klubie...chcesz załatwić potrzebę to wchodzisz do niego i zamykasz się na jakis czas dopóki nie załatwisz swojej potrzeby, a inni do tego czasu musza czekać. Kolejny gość wejdzie dopiero jak zwolnisz kabinę.

      Usuń
  3. Dzięki, właśnie tego szukałem :D

    OdpowiedzUsuń
  4. Python to język, który czyni programowanie prostym i przyjemnym. Jego czytelność i wszechstronność sprawiają, że nawet najbardziej skomplikowane zadania stają się łatwe do zrealizowania. Idealny dla każdego programisty. https://coderslab.pl/pl/python-developer

    OdpowiedzUsuń
  5. Osiągnij sukces w świecie mobilnym dzięki naszym aplikacjom! Profesjonalne tworzenie, nowatorskie rozwiązania i wsparcie na każdym etapie - z nami Twoja aplikacja będzie wyjątkowa i przyciągnie uwagę użytkowników. https://aexol.com/pl/

    OdpowiedzUsuń