.NET: Çoklu iş parçacığı ve eşzamansızlıkla çalışmaya yönelik araçlar. Bölüm 1

Çevirisi kurumsal sitede yayınlanan Habr makalesinin orijinalini yayınlıyorum блоге.

Bir şeyi şimdi ve burada beklemeden asenkron olarak yapma veya büyük işleri bunu yapan birkaç birim arasında bölme ihtiyacı, bilgisayarların ortaya çıkmasından önce de mevcuttu. Onların gelişiyle bu ihtiyaç çok somut hale geldi. Şimdi, 2019'da, bu makaleyi, yüzden fazla işlemin paralel olarak çalıştığı 8 çekirdekli Intel Core işlemciye ve hatta daha fazla iş parçacığına sahip bir dizüstü bilgisayarda yazıyorum. Yakınlarda, birkaç yıl önce satın alınan, biraz eski püskü bir telefon var, üzerinde 8 çekirdekli bir işlemci var. Tematik kaynaklar, yazarlarının bu yılın 16 çekirdekli işlemcilere sahip amiral gemisi akıllı telefonlarına hayran kaldığı makaleler ve videolarla dolu. MS Azure, 20 çekirdekli işlemciye ve 128 TB RAM'e sahip bir sanal makineyi saatte 2 dolardan daha düşük bir fiyata sunuyor. Ne yazık ki, ipliklerin etkileşimini yönetmeden maksimumu elde etmek ve bu gücü kullanmak mümkün değildir.

terminoloji

İşlem - İşletim sistemi nesnesi, yalıtılmış adres alanı, iş parçacıklarını içerir.
İplik - bir işletim sistemi nesnesi, yürütmenin en küçük birimi, bir sürecin parçası, iş parçacıkları bir süreç içinde hafızayı ve diğer kaynakları kendi aralarında paylaşır.
çoklu görev - İşletim sistemi özelliği, aynı anda birden fazla işlemi çalıştırma yeteneği
Çok çekirdekli - işlemcinin bir özelliği, veri işleme için birkaç çekirdek kullanma yeteneği
Çoklu işlem - bilgisayarın bir özelliği, fiziksel olarak birkaç işlemciyle aynı anda çalışabilme yeteneği
Çoklu kullanım — bir sürecin özelliği, veri işlemeyi birkaç iş parçacığı arasında dağıtma yeteneği.
Paralellik - birim zaman başına birkaç eylemi fiziksel olarak aynı anda gerçekleştirmek
asenkroni - Bir işlemin bu işlemin tamamlanmasını beklemeden yürütülmesi; yürütmenin sonucu daha sonra işlenebilir.

Метафора

Tanımların tümü iyi değildir ve bazılarının ek açıklamaya ihtiyacı vardır, bu nedenle resmi olarak tanıtılan terminolojiye kahvaltı pişirmeyle ilgili bir metafor ekleyeceğim. Bu metaforda kahvaltı hazırlamak bir süreçtir.

Sabah kahvaltısını hazırlarken (işlemci) Mutfağa geliyorum (bilgisayar). 2 elim var (Çekirdekler). Mutfakta çok sayıda cihaz var (IO): fırın, kettle, ekmek kızartma makinesi, buzdolabı. Gazı açıyorum, üzerine bir tava koyuyorum ve ısınmasını beklemeden içine yağ döküyorum (eşzamansız olarak, Engellemeyen-IO-Bekle), Yumurtaları buzdolabından çıkarıp bir tabağa kırıyorum ve ardından tek elimle çırpıyorum (Konu#1), ve ikinci (Konu#2) plakayı (Paylaşılan Kaynak) tutarak. Şimdi su ısıtıcısını açmak istiyorum ama yeterli elim yok (Konu Açlığı) Bu süre zarfında, içine çırptığım şeyi döktüğüm kızartma tavası ısınır (Sonucun işlenmesi). Çaydanlığa uzanıp çalıştırıyorum ve aptalca içindeki suyun kaynamasını izliyorum (Engelleme-IO-Bekle), ancak bu süre zarfında omleti çırptığı tabağı yıkayabilirdi.

Sadece 2 elimi kullanarak omlet pişirdim ve daha fazlası yok ama aynı zamanda omleti çırptığım anda 3 işlem aynı anda gerçekleşti: omleti çırpmak, tabağı tutmak, kızartma tavasını ısıtmak CPU bilgisayarın en hızlı kısmıdır, IO çoğu zaman her şeyin yavaşladığı şeydir, bu nedenle çoğu zaman etkili bir çözüm, IO'dan veri alırken CPU'yu bir şeyle meşgul etmektir.

Metafora devam ediyorum:

  • Omlet hazırlama sürecinde kıyafetlerimi de değiştirmeye çalışsaydım, bu çoklu görev örneği olurdu. Önemli bir nüans: bilgisayarlar bu konuda insanlardan çok daha iyidir.
  • Örneğin bir restoranda birkaç şefin bulunduğu bir mutfak - çok çekirdekli bir bilgisayar.
  • Bir alışveriş merkezindeki yemek alanında birçok restoran - veri merkezi

.NET Araçları

.NET, diğer birçok şeyde olduğu gibi iş parçacıklarıyla çalışma konusunda iyidir. Her yeni sürümle birlikte, onlarla çalışmak için giderek daha fazla yeni araç, işletim sistemi iş parçacıkları üzerinde yeni soyutlama katmanları tanıtılıyor. Soyutlamaların oluşturulmasıyla çalışırken, çerçeve geliştiricileri, yüksek seviyeli bir soyutlama kullanıldığında bir veya daha fazla seviye aşağıya inme fırsatını bırakan bir yaklaşım kullanır. Çoğu zaman bu gerekli değildir, aslında pompalı tüfekle ayağınıza ateş etmenin kapısını açar, ancak bazen, nadir durumlarda, mevcut soyutlama düzeyinde çözülmeyen bir sorunu çözmenin tek yolu olabilir. .

Araçlar derken, hem çerçeve hem de üçüncü taraf paketler tarafından sağlanan uygulama programlama arayüzlerini (API'ler) ve ayrıca çok iş parçacıklı kodla ilgili herhangi bir sorunun aranmasını kolaylaştıran tüm yazılım çözümlerini kastediyorum.

Bir konu başlatmak

Thread sınıfı, .NET'te iş parçacıklarıyla çalışmak için en temel sınıftır. Yapıcı iki delegeden birini kabul eder:

  • ThreadStart — Parametre yok
  • ParametrizedThreadStart - nesne türünde bir parametreyle.

Temsilci, Start yöntemi çağrıldıktan sonra yeni oluşturulan iş parçacığında yürütülecektir. ParametrizedThreadStart türünde bir temsilci yapıcıya geçirildiyse, Start yöntemine bir nesnenin geçirilmesi gerekir. Bu mekanizma, herhangi bir yerel bilginin akışa aktarılması için gereklidir. Bir iş parçacığı oluşturmanın pahalı bir işlem olduğunu ve iş parçacığının kendisinin ağır bir nesne olduğunu, en azından yığına 1 MB bellek ayırdığını ve OS API ile etkileşim gerektirdiğini belirtmekte fayda var.

new Thread(...).Start(...);

ThreadPool sınıfı havuz kavramını temsil eder. .NET'te iş parçacığı havuzu bir mühendislik parçasıdır ve Microsoft'taki geliştiriciler bunun çok çeşitli senaryolarda en iyi şekilde çalışmasını sağlamak için çok çaba harcadılar.

Genel kavram:

Uygulama başladığı andan itibaren arka planda yedekte birkaç thread oluşturup bunların kullanıma alınmasını sağlar. Konu dizileri sık sık ve çok sayıda kullanılıyorsa havuz arayanın ihtiyaçlarını karşılayacak şekilde genişler. Havuzda doğru zamanda boş iş parçacığı kalmadığında, iş parçacıklarından birinin geri dönmesini bekleyecek veya yeni bir iş parçacığı oluşturacaktır. Bundan, iş parçacığı havuzunun bazı kısa vadeli eylemler için mükemmel olduğu ve uygulamanın tüm çalışması boyunca hizmet olarak çalışan işlemler için pek uygun olmadığı sonucu çıkıyor.

Havuzdan bir iş parçacığı kullanmak için, ParametrizedThreadStart ile aynı imzaya sahip olan WaitCallback türünde bir temsilciyi kabul eden bir QueueUserWorkItem yöntemi vardır ve ona iletilen parametre aynı işlevi gerçekleştirir.

ThreadPool.QueueUserWorkItem(...);

Daha az bilinen iş parçacığı havuzu yöntemi RegisterWaitForSingleObject, engellemeyen GÇ işlemlerini düzenlemek için kullanılır. Bu yönteme iletilen temsilci, yönteme iletilen WaitHandle "Released" olduğunda çağrılacaktır.

ThreadPool.RegisterWaitForSingleObject(...)

.NET'in bir iş parçacığı zamanlayıcısı vardır ve işleyicisinin havuzdan alınan bir iş parçacığında çağrılması açısından WinForms/WPF zamanlayıcılarından farklıdır.

System.Threading.Timer

Ayrıca havuzdaki bir iş parçacığına yürütülmek üzere bir temsilci göndermenin oldukça egzotik bir yolu da var: BeginInvoke yöntemi.

DelegateInstance.BeginInvoke

Yukarıdaki yöntemlerin birçoğunun çağrılabileceği fonksiyon üzerinde kısaca durmak istiyorum - Kernel32.dll Win32 API'sinden CreateThread. Harici yöntemlerin mekanizması sayesinde bu işlevi çağırmanın bir yolu vardır. Böyle bir çağrıyı yalnızca bir kez korkunç bir eski kod örneğinde gördüm ve tam olarak bunu yapan yazarın motivasyonu benim için hala bir sır olarak kalıyor.

Kernel32.dll CreateThread

Konuları Görüntüleme ve Hata Ayıklama

Sizin tarafınızdan oluşturulan iş parçacıkları, tüm üçüncü taraf bileşenler ve .NET havuzu, Visual Studio'nun İş Parçacığı penceresinde görüntülenebilir. Bu pencere yalnızca uygulama hata ayıklama altında ve Ara modundayken iş parçacığı bilgilerini görüntüler. Burada her iş parçacığının yığın adlarını ve önceliklerini rahatlıkla görüntüleyebilir ve hata ayıklamayı belirli bir iş parçacığına değiştirebilirsiniz. Thread sınıfının Priority özelliğini kullanarak, işlemci süresini iş parçacıkları arasında bölüştürürken OC ve CLR'nin öneri olarak algılayacağı bir iş parçacığının önceliğini ayarlayabilirsiniz.

.NET: Çoklu iş parçacığı ve eşzamansızlıkla çalışmaya yönelik araçlar. Bölüm 1

Görev Paralel Kitaplığı

Görev Paralel Kitaplığı (TPL) .NET 4.0'da kullanıma sunuldu. Artık eşzamansız çalışma için standart ve ana araçtır. Daha eski bir yaklaşımı kullanan herhangi bir kod eski olarak kabul edilir. TPL'nin temel birimi System.Threading.Tasks ad alanındaki Task sınıfıdır. Görev, bir iş parçacığı üzerindeki soyutlamadır. C# dilinin yeni sürümüyle, Görevler (async/await operatörleri) ile çalışmanın zarif bir yolunu bulduk. Bu kavramlar, asenkron kodun sanki basit ve senkronizeymiş gibi yazılmasını mümkün kıldı; bu, iş parçacıklarının iç işleyişini çok az anlayan kişilerin bile bunları kullanan, uzun işlemler gerçekleştirirken donmayan uygulamalar yazmasını mümkün kıldı. Eşzamansız/beklemede kullanımı bir veya birkaç makalenin konusu olsa da, birkaç cümleyle konunun özünü anlamaya çalışacağım:

  • async, Görev veya geçersizliği döndüren bir yöntemin değiştiricisidir
  • ve wait, engellemeyen bir Görev bekleme operatörüdür.

Bir kez daha: wait operatörü, genel durumda (istisnalar vardır), mevcut yürütme iş parçacığını daha da serbest bırakacak ve Görev yürütmeyi tamamladığında ve iş parçacığını (aslında bağlamı söylemek daha doğru olacaktır) , ancak daha sonra buna daha fazla değineceğim) yöntemi daha da uygulamaya devam edeceğiz. .NET'te bu mekanizma, yazılı yöntemin bir durum makinesi olan ve bu durumlara bağlı olarak ayrı parçalar halinde yürütülebilen bütün bir sınıfa dönüştüğünde, verim dönüşüyle ​​aynı şekilde uygulanır. İlgilenen herkes aynс/await kullanarak herhangi bir basit kodu yazabilir, Derleyicinin Oluşturduğu Kod etkinken JetBrains dotPeek'i kullanarak derlemeyi derleyebilir ve görüntüleyebilir.

Görevi başlatma ve kullanma seçeneklerine bakalım. Aşağıdaki kod örneğinde hiçbir işe yaramayan yeni bir görev oluşturuyoruz (Thread.Sleep(10000)), ancak gerçek hayatta bu, karmaşık CPU yoğun bir çalışma olmalıdır.

using TCO = System.Threading.Tasks.TaskCreationOptions;

public static async void VoidAsyncMethod() {
    var cancellationSource = new CancellationTokenSource();

    await Task.Factory.StartNew(
        // Code of action will be executed on other context
        () => Thread.Sleep(10000),
        cancellationSource.Token,
        TCO.LongRunning | TCO.AttachedToParent | TCO.PreferFairness,
        scheduler
    );

    //  Code after await will be executed on captured context
}

Bir dizi seçenekle bir Görev oluşturulur:

  • LongRunning, görevin hızlı bir şekilde tamamlanmayacağına dair bir ipucudur; bu, havuzdan bir iş parçacığı almayıp, başkalarına zarar vermemek için bu Görev için ayrı bir iş parçacığı oluşturmayı düşünmenin faydalı olabileceği anlamına gelir.
  • EkliToParent - Görevler hiyerarşik olarak düzenlenebilir. Bu seçenek kullanıldıysa Görev kendisinin tamamlandığı ve alt öğelerinin yürütülmesini beklediği bir durumda olabilir.
  • PreferFairness - yürütülmek üzere gönderilen Görevleri daha sonra gönderilenlerden önce yürütmenin daha iyi olacağı anlamına gelir. Ancak bu sadece bir öneridir ve sonuçlar garanti edilmez.

Yönteme iletilen ikinci parametre CancellationToken'dır. Bir işlemin başladıktan sonra iptalini doğru bir şekilde gerçekleştirmek için yürütülen kodun CancellationToken durumu kontrolleriyle doldurulması gerekir. Hiçbir denetim yoksa CancellationTokenSource nesnesinde çağrılan Cancel yöntemi, Görevin yürütülmesini yalnızca başlamadan önce durdurabilir.

Son parametre TaskScheduler türünde bir zamanlayıcı nesnesidir. Bu sınıf ve onun soyundan gelenler, Görevleri iş parçacıkları arasında dağıtmaya yönelik stratejileri kontrol etmek için tasarlanmıştır; varsayılan olarak Görev, havuzdaki rastgele bir iş parçacığında yürütülür.

Bekleme operatörü oluşturulan Göreve uygulanır; bu, eğer varsa ondan sonra yazılan kodun, beklemeden önceki kodla aynı bağlamda (genellikle bu aynı iş parçacığında anlamına gelir) yürütüleceği anlamına gelir.

Yöntem, eşzamansız geçersiz olarak işaretlenmiştir; bu, bekleme operatörünü kullanabileceği ancak çağıran kodun yürütmeyi bekleyemeyeceği anlamına gelir. Böyle bir özellik gerekliyse, yöntemin Görev'i döndürmesi gerekir. Zaman uyumsuz geçersiz olarak işaretlenen yöntemler oldukça yaygındır: Kural olarak bunlar olay işleyicileri veya ateşle ve unut ilkesine göre çalışan diğer yöntemlerdir. Yalnızca yürütmenin sonuna kadar bekleme fırsatını vermekle kalmayıp aynı zamanda sonucu da döndürmeniz gerekiyorsa, Görev'i kullanmanız gerekir.

StartNew yönteminin döndürdüğü Görevde ve diğerlerinde, ChangeAwait yöntemini false parametresiyle çağırabilirsiniz, ardından beklemeden sonraki yürütme, yakalanan bağlamda değil, isteğe bağlı olarak devam edecektir. Bu, beklemeden sonraki kod için yürütme bağlamının önemli olmadığı durumlarda her zaman yapılmalıdır. Bu aynı zamanda bir kütüphanede paketlenmiş olarak teslim edilecek kodu yazarken MS'in bir önerisidir.

Bir Görevin tamamlanmasını nasıl bekleyebileceğiniz üzerinde biraz daha duralım. Aşağıda, beklentinin ne zaman koşullu olarak iyi yapıldığı ve ne zaman koşullu olarak kötü yapıldığına ilişkin yorumların yer aldığı bir kod örneği verilmiştir.

public static async void AnotherMethod() {

    int result = await AsyncMethod(); // good

    result = AsyncMethod().Result; // bad

    AsyncMethod().Wait(); // bad

    IEnumerable<Task> tasks = new Task[] {
        AsyncMethod(), OtherAsyncMethod()
    };

    await Task.WhenAll(tasks); // good
    await Task.WhenAny(tasks); // good

    Task.WaitAll(tasks.ToArray()); // bad
}

İlk örnekte, çağıran iş parçacığını engellemeden Görevin tamamlanmasını bekleriz; sonucu yalnızca zaten orada olduğunda işlemeye geri döneriz; o zamana kadar çağıran iş parçacığı kendi cihazlarına bırakılır.

İkinci seçenekte yöntemin sonucu hesaplanana kadar çağıran thread'i bloke ediyoruz. Bu sadece programın böylesine değerli bir kaynağı olan bir iş parçacığını basit bir boşta kalma ile işgal ettiğimiz için değil, aynı zamanda çağırdığımız yöntemin kodunun beklemeyi içermesi ve senkronizasyon bağlamının daha sonra çağıran iş parçacığına geri dönmeyi gerektirmesi nedeniyle de kötüdür. bekliyor, o zaman bir kilitlenmeyle karşılaşacağız: Çağıran iş parçacığı, eşzamansız yöntemin sonucunun hesaplanmasını bekler, eşzamansız yöntem, çağıran iş parçacığında yürütülmesine devam etmek için boşuna çabalar.

Bu yaklaşımın diğer bir dezavantajı karmaşık hata yönetimidir. Gerçek şu ki, eşzamansız/beklemede kullanıldığında eşzamansız koddaki hataların üstesinden gelmek çok kolaydır; kod eşzamanlıymış gibi davranırlar. Bir Göreve eşzamanlı bekleme şeytan çıkarma işlemini uygularsak, orijinal istisna bir AggregateException'a dönüşür, yani. İstisnayı ele almak için, InnerException türünü incelemeniz ve bir catch bloğunun içine kendiniz bir if zinciri yazmanız veya C# dünyasında daha tanıdık olan catch blokları zinciri yerine catch When yapısını kullanmanız gerekecektir.

Üçüncü ve son örnekler de aynı nedenden dolayı kötü olarak işaretlenmiştir ve aynı sorunları içermektedir.

WhenAny ve WhenAll yöntemleri, bir Görev grubunu beklemek için son derece kullanışlıdır; bir Görev grubunu tek bir Görev grubuna sararlar; bu, ya gruptan bir Görev ilk tetiklendiğinde ya da tümü yürütmeyi tamamladığında tetiklenir.

Konuları durdurma

Çeşitli nedenlerden dolayı akışın başladıktan sonra durdurulması gerekebilir. Bunu yapmanın çeşitli yolları var. Thread sınıfının uygun şekilde adlandırılmış iki yöntemi vardır: düşük и Kesmek. İlkinin kullanılması kesinlikle tavsiye edilmez, çünkü Herhangi bir rastgele anda onu çağırdıktan sonra, herhangi bir talimatın işlenmesi sırasında bir istisna atılacaktır ThreadAbortedException. Herhangi bir tamsayı değişkeni artırılırken böyle bir istisnanın oluşmasını beklemiyorsunuz, değil mi? Ve bu yöntemi kullanırken bu çok gerçek bir durumdur. CLR'nin kodun belirli bir bölümünde böyle bir istisna oluşturmasını engellemeniz gerekiyorsa, bunu çağrılara sarabilirsiniz. Thread.BeginCriticalRegion, Thread.EndCriticalRegion. Nihayet bloğunda yazılan herhangi bir kod bu tür çağrılara sarılır. Bu nedenle, çerçeve kodunun derinliklerinde boş try'lı bloklar bulabilirsiniz, fakat sonunda boş değil. Microsoft bu yöntemi o kadar önermiyor ki .net çekirdeğine dahil etmedi.

Interrupt yöntemi daha öngörülebilir şekilde çalışır. Bir istisna dışında iş parçacığını kesebilir ThreadInterruptedException yalnızca iş parçacığının bekleme durumunda olduğu anlarda. WaitHandle, lock beklerken veya Thread.Sleep çağrıldıktan sonra askıda kalırken bu duruma girer.

Yukarıda açıklanan her iki seçenek de öngörülemezlikleri nedeniyle kötüdür. Çözüm bir yapı kullanmaktır İptal Simgesi ve sınıf İptalToken Kaynağı. Önemli olan şudur: CancellationTokenSource sınıfının bir örneği oluşturulur ve yalnızca ona sahip olan kişi, yöntemi çağırarak işlemi durdurabilir. İptal etmek. İşlemin kendisine yalnızca CancellationToken iletilir. CancellationToken sahipleri işlemi kendileri iptal edemezler ancak yalnızca işlemin iptal edilip edilmediğini kontrol edebilirler. Bunun için bir Boole özelliği var İptal Talep Edildi mi ve yöntem ThrowIfCancelTalep Edildi. İkincisi bir istisna atacak Görevİptal Edildiİstisna Tekrarlanan CancellationToken örneğinde Cancel yöntemi çağrıldıysa. Ve bu benim kullanmanızı önerdiğim yöntemdir. Bu, bir istisna işleminin hangi noktada iptal edilebileceği konusunda tam kontrol elde ederek önceki seçeneklere göre bir gelişmedir.

Bir iş parçacığını durdurmanın en acımasız seçeneği Win32 API TerminateThread işlevini çağırmaktır. Bu işlevi çağırdıktan sonra CLR'nin davranışı önceden tahmin edilemeyebilir. MSDN'de bu fonksiyon hakkında aşağıdakiler yazılmıştır: “TerminateThread yalnızca en uç durumlarda kullanılması gereken tehlikeli bir işlevdir. “

FromAsync yöntemini kullanarak eski API'yi Görev Tabanlıya dönüştürme

Görevler tanıtıldıktan sonra başlatılan ve çoğu geliştirici için korku yaratmayı bırakan bir proje üzerinde çalışacak kadar şanslıysanız, hem üçüncü taraf hem de ekibinizinki olan çok sayıda eski API ile uğraşmak zorunda kalmayacaksınız. geçmişte işkence gördü. Neyse ki .NET Framework ekibi bizimle ilgilendi, ancak belki de amaç kendi başımızın çaresine bakmaktı. Her ne olursa olsun, .NET eski asenkron programlama yaklaşımlarıyla yazılmış kodları yenisine sorunsuz bir şekilde dönüştürmek için bir dizi araca sahiptir. Bunlardan biri TaskFactory'nin FromAsync yöntemidir. Aşağıdaki kod örneğinde WebRequest sınıfının eski async yöntemlerini bu yöntemi kullanarak bir Task içerisine sarıyorum.

object state = null;
WebRequest wr = WebRequest.CreateHttp("http://github.com");
await Task.Factory.FromAsync(
    wr.BeginGetResponse,
    we.EndGetResponse
);

Bu yalnızca bir örnektir ve bunu yerleşik türlerle yapmak zorunda kalmanız pek olası değildir, ancak herhangi bir eski proje, IAsyncResult'u döndüren BeginDoSomething yöntemleriyle ve onu alan EndDoSomething yöntemleriyle doludur.

TaskCompletionSource sınıfını kullanarak eski API'yi Görev Tabanlıya dönüştürün

Dikkate alınması gereken bir diğer önemli araç ise sınıftır. Görev TamamlamaKaynak. Fonksiyonları, amacı ve çalışma prensibi açısından yukarıda yazdığım ThreadPool sınıfının RegisterWaitForSingleObject metodunu biraz anımsatıyor olabilir. Bu sınıfı kullanarak eski eşzamansız API'leri Görevler'e kolayca ve rahatlıkla sarabilirsiniz.

TaskFactory sınıfının bu amaçlara yönelik FromAsync yönteminden daha önce bahsetmiştim diyeceksiniz. Burada, Microsoft'un son 15 yılda sunduğu .net'teki eşzamansız modellerin gelişiminin tüm geçmişini hatırlamamız gerekecek: Görev Tabanlı Eşzamansız Modelden (TAP) önce, Eşzamansız Programlama Modeli (APP) vardı. yöntemler hakkındaydı BaşlamakGeri dönen bir şey yap IAsyncSonuç ve yöntemler SonBunu kabul eden DoSomething ve bu yılların mirası için FromAsync yöntemi mükemmeldir, ancak zamanla yerini Olay Tabanlı Eşzamansız Model (EAP), bu, eşzamansız işlem tamamlandığında bir olayın ortaya çıkacağını varsaydı.

TaskCompletionSource, olay modeli etrafında oluşturulan Görevleri ve eski API'leri sarmak için mükemmeldir. Çalışmasının özü şu şekildedir: Bu sınıfın bir nesnesi, durumu TaskCompletionSource sınıfının SetResult, SetException vb. yöntemleri aracılığıyla kontrol edilebilen Task türünde ortak bir özelliğe sahiptir. Bu Göreve bekleme operatörünün uygulandığı yerlerde, TaskCompletionSource'a uygulanan yönteme bağlı olarak bir istisna dışında yürütülecek veya başarısız olacaktır. Hala net değilse, bazı eski EAP API'lerinin TaskCompletionSource kullanılarak bir Görev'e sarıldığı bu kod örneğine bakalım: olay tetiklendiğinde, Görev Tamamlandı durumuna ve wait operatörünü uygulayan yönteme yerleştirilecektir. bu Görev, nesneyi aldıktan sonra yürütülmesine devam edecek sonuç.

public static Task<Result> DoAsync(this SomeApiInstance someApiObj) {

    var completionSource = new TaskCompletionSource<Result>();
    someApiObj.Done += 
        result => completionSource.SetResult(result);
    someApiObj.Do();

    result completionSource.Task;
}

Görev TamamlamaKaynak İpuçları ve Püf Noktaları

Eski API'leri sarmalamak, TaskCompletionSource kullanılarak yapılabilecek tek şey değildir. Bu sınıfın kullanılması, iş parçacıklarını işgal etmeyen Görevler üzerinde çeşitli API'ler tasarlamanın ilginç bir olasılığını açar. Ve hatırladığımız gibi akış pahalı bir kaynaktır ve sayıları sınırlıdır (esas olarak RAM miktarına göre). Bu sınırlama, örneğin karmaşık iş mantığına sahip yüklü bir web uygulaması geliştirilerek kolaylıkla başarılabilir. Long-Polling gibi bir hileyi uygularken bahsettiğim olasılıkları düşünelim.

Kısacası hilenin özü şudur: API'den kendi tarafında meydana gelen bazı olaylar hakkında bilgi almanız gerekirken, API bazı nedenlerden dolayı olayı rapor edemez, yalnızca durumu döndürebilir. Bunların bir örneği, WebSocket zamanlarından önce veya herhangi bir nedenle bu teknolojiyi kullanmanın imkansız olduğu zamanlarda HTTP üzerine inşa edilen tüm API'lerdir. İstemci HTTP sunucusuna sorabilir. HTTP sunucusu istemciyle iletişimi kendisi başlatamaz. Basit bir çözüm, sunucuyu bir zamanlayıcı kullanarak yoklamaktır, ancak bu, sunucuda ek bir yüke ve ortalama TimerInterval / 2'de ek bir gecikmeye neden olur. Bunu aşmak için, Uzun Yoklama adı verilen ve sunucunun yanıtını geciktirmeyi içeren bir hile icat edildi. Zaman Aşımı sona erene veya bir olay meydana gelene kadar sunucu. Eğer olay gerçekleşmişse işleme alınır, gerçekleşmediyse istek tekrar gönderilir.

while(!eventOccures && !timeoutExceeded)  {

  CheckTimout();
  CheckEvent();
  Thread.Sleep(1);
}

Ancak etkinliği bekleyen müşterilerin sayısı arttıkça böyle bir çözümün berbat olacağı ortaya çıkacak, çünkü... Bu tür istemcilerin her biri, bir olayı bekleyen bir iş parçacığının tamamını kaplar. Evet ve olay tetiklendiğinde ilave 1 ms'lik bir gecikmeyle karşılaşıyoruz; çoğu zaman bu önemli değildir, ancak yazılımı neden olabileceğinden daha kötü hale getirelim? Thread.Sleep(1)'i kaldırırsak, bir işlemci çekirdeğini %100 boşta, işe yaramaz bir döngüde dönerek boşuna yükleyeceğiz. TaskCompletionSource'u kullanarak bu kodu kolayca yeniden oluşturabilir ve yukarıda tanımlanan tüm sorunları çözebilirsiniz:

class LongPollingApi {

    private Dictionary<int, TaskCompletionSource<Msg>> tasks;

    public async Task<Msg> AcceptMessageAsync(int userId, int duration) {

        var cs = new TaskCompletionSource<Msg>();
        tasks[userId] = cs;
        await Task.WhenAny(Task.Delay(duration), cs.Task);
        return cs.Task.IsCompleted ? cs.Task.Result : null;
    }

    public void SendMessage(int userId, Msg m) {

        if (tasks.TryGetValue(userId, out var completionSource))
            completionSource.SetResult(m);
    }
}

Bu kod üretime hazır değil, yalnızca bir demo. Bunu gerçek durumlarda kullanmak için, en azından bir mesajın kimsenin beklemediği bir zamanda gelmesi durumuyla da ilgilenmeniz gerekir: bu durumda AsseptMessageAsync yönteminin zaten tamamlanmış bir Görev döndürmesi gerekir. Bu en yaygın durumsa ValueTask'ı kullanmayı düşünebilirsiniz.

Bir mesaj için istek aldığımızda, bir TaskCompletionSource oluşturup sözlüğe yerleştiririz ve ardından ilk olarak ne olacağını bekleriz: belirtilen zaman aralığının sona ermesi veya bir mesajın alınması.

ValueTask: neden ve nasıl

Eşzamansız/beklemede operatörler, verim dönüş operatörü gibi, yöntemden bir durum makinesi oluşturur ve bu, neredeyse her zaman önemli olmayan, ancak nadir durumlarda bir sorun yaratabilen yeni bir nesnenin oluşturulmasıdır. Bu durum gerçekten çok sık çağrılan bir yöntem olabilir, saniyede onlarca, yüzbinlerce çağrıdan bahsediyoruz. Böyle bir yöntem, çoğu durumda tüm bekleme yöntemlerini atlayarak bir sonuç döndürecek şekilde yazılırsa, .NET bunu optimize etmek için bir araç sağlar; ValueTask yapısı. Daha açık hale getirmek için kullanımına bir örnek verelim: Çok sık gittiğimiz bir önbellek var. İçinde bazı değerler var ve sonra onları basitçe döndürüyoruz; değilse, onları almak için yavaş bir IO'ya gidiyoruz. İkincisini eşzamansız olarak yapmak istiyorum, bu da tüm yöntemin eşzamansız olduğu anlamına gelir. Dolayısıyla, yöntemi yazmanın bariz yolu aşağıdaki gibidir:

public async Task<string> GetById(int id) {

    if (cache.TryGetValue(id, out string val))
        return val;
    return await RequestById(id);
}

Biraz optimize etme isteği ve Roslyn'in bu kodu derlerken ne üreteceğine dair hafif bir korku nedeniyle, bu örneği aşağıdaki gibi yeniden yazabilirsiniz:

public Task<string> GetById(int id) {

    if (cache.TryGetValue(id, out string val))
        return Task.FromResult(val);
    return RequestById(id);
}

Gerçekten de, bu durumda en uygun çözüm, sıcak yolu optimize etmek, yani herhangi bir gereksiz tahsis olmadan ve GC'ye yüklenmeden sözlükten bir değer elde etmek olacaktır; bu sırada veri için hala IO'ya gitmemiz gereken nadir durumlarda , her şey eskisi gibi artı/eksi olarak kalacak:

public ValueTask<string> GetById(int id) {

    if (cache.TryGetValue(id, out string val))
        return new ValueTask<string>(val);
    return new ValueTask<string>(RequestById(id));
}

Bu kod parçasına daha yakından bakalım: Önbellekte bir değer varsa bir yapı oluştururuz, aksi takdirde asıl görev anlamlı bir yapıya sarılır. Çağıran kod, bu kodun hangi yolda yürütüldüğünü umursamaz: C# sözdizimi açısından ValueTask, bu durumda normal bir Görev ile aynı şekilde davranacaktır.

TaskSchedulers: görev başlatma stratejilerini yönetme

Göz önünde bulundurmak istediğim bir sonraki API, sınıftır. görev Zamanlayıcı ve türevleri. Yukarıda TPL'nin Görevleri iş parçacıkları arasında dağıtmaya yönelik stratejileri yönetme yeteneğine sahip olduğundan bahsetmiştim. Bu tür stratejiler TaskScheduler sınıfının alt öğelerinde tanımlanır. İhtiyaç duyabileceğiniz hemen hemen her stratejiyi kütüphanede bulabilirsiniz. ParalelUzantılarEkstralarMicrosoft tarafından geliştirilen ancak .NET'in parçası olmayan ancak bir Nuget paketi olarak sağlanan . Bunlardan bazılarına kısaca bakalım:

  • CurrentThreadTaskScheduler — geçerli iş parçacığında Görevleri yürütür
  • Sınırlı EşzamanlılıkSeviyesiGörev Zamanlayıcı — yapıcıda kabul edilen N parametresi tarafından aynı anda yürütülen Görevlerin sayısını sınırlar
  • SıralıGörevZamanlayıcı — LimitedConcurrencyLevelTaskScheduler(1) olarak tanımlanır, dolayısıyla görevler sırayla yürütülür.
  • İş ÇalmaGörev Zamanlayıcı - uygular iş hırsızlığı Görev dağıtımına yaklaşım. Esasen ayrı bir ThreadPool'dur. .NET ThreadPool'un tüm uygulamalar için bir statik sınıf olması sorununu çözer; bu, programın bir bölümünde aşırı yüklenmesinin veya yanlış kullanımının diğerinde yan etkilere yol açabileceği anlamına gelir. Üstelik bu tür kusurların nedenini anlamak son derece zordur. O. Programın ThreadPool kullanımının agresif ve öngörülemez olabileceği kısımlarında ayrı WorkStealingTaskSchedulers kullanımına ihtiyaç duyulabilir.
  • Sıraya AlınmışGörev Zamanlayıcı — görevleri öncelik sırası kurallarına göre gerçekleştirmenize olanak tanır
  • Konu Başına Görev Zamanlayıcı - üzerinde yürütülen her Görev için ayrı bir iş parçacığı oluşturur. Tamamlanması tahmin edilemeyecek kadar uzun süren görevler için yararlı olabilir.

güzel bir detay var makale Microsoft blogunda TaskSchedulers hakkında.

Görevler ile ilgili her şeyin kolayca hata ayıklanması için Visual Studio'da bir Görevler penceresi bulunur. Bu pencerede görevin mevcut durumunu görebilir ve o anda yürütülen kod satırına atlayabilirsiniz.

.NET: Çoklu iş parçacığı ve eşzamansızlıkla çalışmaya yönelik araçlar. Bölüm 1

PLinq ve Paralel sınıf

Görevler ve onlar hakkında söylenen her şeye ek olarak, .NET'te iki ilginç araç daha vardır: PLinq (Linq2Parallel) ve Parallel sınıfı. İlki, tüm Linq işlemlerinin birden fazla iş parçacığında paralel olarak yürütülmesini vaat ediyor. İş parçacığı sayısı WithDegreeOfParallelism uzatma yöntemi kullanılarak yapılandırılabilir. Ne yazık ki çoğu zaman varsayılan modundaki PLinq, önemli bir hız artışı sağlamak için veri kaynağınızın dahili bileşenleri hakkında yeterli bilgiye sahip değildir; diğer yandan deneme maliyeti çok düşüktür: önce AsParallel yöntemini çağırmanız yeterlidir. Linq yöntemleri zincirini kullanın ve performans testlerini çalıştırın. Ayrıca Partitions mekanizmasını kullanarak veri kaynağınızın doğası hakkında ek bilgileri PLinq'e iletmeniz mümkündür. Daha fazlasını okuyabilirsiniz burada и burada.

Parallel statik sınıfı, bir Foreach koleksiyonunu paralel olarak yinelemek, bir For döngüsü yürütmek ve paralel Invoke'ta birden fazla temsilci yürütmek için yöntemler sağlar. Hesaplamalar tamamlanana kadar geçerli iş parçacığının yürütülmesi durdurulacak. İş parçacığı sayısı, son argüman olarak ParallelOptions iletilerek yapılandırılabilir. Seçenekleri kullanarak TaskScheduler ve CancellationToken'ı da belirtebilirsiniz.

Bulgular

Raporumun materyallerinden ve sonrasındaki çalışmam sırasında topladığım bilgilerden yola çıkarak bu yazıyı yazmaya başladığımda bu kadar çok şey olacağını tahmin etmemiştim. Şimdi bu yazıyı yazdığım metin editörü sitemle 15. sayfanın bittiğini söylediğinde ara sonuçları özetleyeceğim. Diğer püf noktaları, API'ler, görsel araçlar ve tuzaklar bir sonraki makalede ele alınacaktır.

Sonuç:

  • Modern bilgisayarların kaynaklarını kullanabilmek için iş parçacığı, eşzamansızlık ve paralellik ile çalışma araçlarını bilmeniz gerekir.
  • .NET'in bu amaçlara yönelik birçok farklı aracı vardır
  • Hepsi aynı anda görünmediğinden eski API'leri sıklıkla bulabilirsiniz, ancak eski API'leri fazla çaba harcamadan dönüştürmenin yolları vardır.
  • .NET'te iş parçacıklarıyla çalışmak Thread ve ThreadPool sınıfları tarafından temsil edilir
  • Thread.Abort, Thread.Interrupt ve Win32 API TerminateThread yöntemleri tehlikelidir ve kullanılması önerilmez. Bunun yerine CancellationToken mekanizmasını kullanmak daha iyidir
  • Akış değerli bir kaynaktır ve arzı sınırlıdır. İş parçacıklarının olayları beklemekle meşgul olduğu durumlardan kaçınılmalıdır. Bunun için TaskCompletionSource sınıfını kullanmak uygundur
  • Paralellik ve eşzamansızlıkla çalışmaya yönelik en güçlü ve gelişmiş .NET araçları Görevler'dir.
  • C# async/await operatörleri engellemesiz bekleme kavramını uygular
  • TaskScheduler'dan türetilmiş sınıfları kullanarak Görevlerin iş parçacıkları arasındaki dağıtımını kontrol edebilirsiniz.
  • ValueTask yapısı, etkin yolları ve bellek trafiğini optimize etmede yararlı olabilir
  • Visual Studio'nun Görevler ve Konular pencereleri, çok iş parçacıklı veya eşzamansız kodda hata ayıklamak için yararlı birçok bilgi sağlar
  • PLinq harika bir araçtır ancak veri kaynağınız hakkında yeterli bilgiye sahip olmayabilir, ancak bu bölümleme mekanizması kullanılarak düzeltilebilir
  • Devam edecek ...

Kaynak: habr.com

Yorum ekle