فصل ۲۲: برنامهنویسی موازی؛ مبانی PFX و PLINQ
فروش یا انتشار این ترجمه منوط به داشتن مجوز لازم از صاحب حقوق اثر است.
نشان تصویری آغاز فصل در منبع
فصل ۲۲: برنامهنویسی موازی (Parallel Programming)
در این فصل، APIها و سازههای چندریسمانی را بررسی میکنیم که برای بهرهگیری از پردازندههای چندهستهای طراحی شدهاند:
- LINQ موازی یا
PLINQ - کلاس
Parallel - سازههای موازیسازی مبتنی بر Task
- مجموعههای همزمان (concurrent collections)
این سازهها در مجموع ــ با قدری تساهل در نامگذاری ــ «چارچوب موازی» (Parallel Framework یا PFX) نامیده میشوند. کلاس Parallel همراه با سازههای موازیسازی Task، «کتابخانهٔ موازی Task» (Task Parallel Library یا TPL) نام دارد.
پیش از خواندن این فصل باید با مبانی فصل ۱۴ راحت باشید؛ بهویژه قفلگذاری (locking)، ایمنی رشته (thread safety) و کلاس Task.
چرا PFX؟
در ۱۵ سال گذشته، سازندگان CPU از پردازندههای تکهستهای به چندهستهای مهاجرت کردهاند. این موضوع برای برنامهنویسان مسئلهساز است، زیرا کد تکریسمانی صرفاً بهدلیل وجود هستههای اضافی، خودبهخود سریعتر اجرا نمیشود.
بهرهگیری از چند هسته در بسیاری از برنامههای سروری آسان است، چون هر thread میتواند مستقل از دیگری یک درخواست client جداگانه را پردازش کند. اما در برنامههای desktop دشوارتر است، زیرا معمولاً لازم است کد محاسباتی سنگین را به این شکل سامان دهید:
- آن را به بخشهای کوچک تقسیم کنید.
- این بخشها را با multithreading بهطور موازی اجرا کنید.
- نتایج را هنگام آمادهشدن، بهشکلی thread-safe و پربازده با هم ترکیب کنید.
هرچند میتوان همهٔ این کارها را با سازههای کلاسیک multithreading انجام داد، این کار ــ بهخصوص مرحلههای partitioning و collating ــ دستوپاگیر است. مشکل دیگر آن است که راهبرد معمولِ قفلگذاری برای thread safety، وقتی threadهای زیادی همزمان روی یک دادهٔ مشترک کار میکنند، contention زیادی ایجاد میکند.
کتابخانههای PFX دقیقاً برای کمک در چنین سناریوهایی طراحی شدهاند.
مفاهیم PFX
برای تقسیم کار بین threadها دو راهبرد وجود دارد: «موازیسازی داده» (data parallelism) و «موازیسازی وظیفه» (task parallelism).
وقتی مجموعهای از کارها باید روی تعداد زیادی مقدار داده انجام شود، میتوانیم کار را موازی کنیم به این صورت که هر thread همان مجموعه کارها را روی زیرمجموعهای از دادهها انجام دهد. این روش data parallelism نام دارد، چون داده را میان threadها تقسیم میکنیم. در مقابل، در task parallelism خود وظیفهها را تقسیم میکنیم؛ یعنی هر thread کار متفاوتی انجام میدهد.
بهطور کلی data parallelism سادهتر است و روی سختافزارهای بسیار موازی بهتر scale میشود، زیرا دادهٔ مشترک را کاهش میدهد یا حذف میکند و در نتیجه contention و مسائل thread safety کمتر میشوند. همچنین اغلب تعداد مقادیر داده از تعداد taskهای مجزا بیشتر است و بنابراین ظرفیت موازیسازی افزایش مییابد.
Data parallelism با «موازیسازی ساختیافته» (structured parallelism) نیز سازگار است؛ یعنی واحدهای کار موازی در یک نقطه از برنامه آغاز و در همان محدوده پایان مییابند. در مقابل، task parallelism غالباً بدون ساختار (unstructured) است و واحدهای کار ممکن است در نقاط پراکندهای از برنامه شروع و تمام شوند. structured parallelism سادهتر و کمخطاتر است و اجازه میدهد کار دشوارِ partitioning، هماهنگی threadها و حتی collating نتایج به کتابخانه سپرده شود.
اجزای PFX
PFX همانطور که در شکل 22-1 دیده میشود دو لایهٔ عملکرد دارد. لایهٔ بالاتر شامل دو API ساختیافته برای data parallelism است: PLINQ و کلاس Parallel. لایهٔ پایینتر شامل کلاسهای task parallelism و مجموعهای از سازههای کمکی دیگر برای برنامهنویسی موازی است.
شکل 22-1 ـ اجزای PFX. لایهٔ بالایی شامل Parallel و PLINQ برای structured data parallelism است؛ در لایههای پایینتر task parallelism، concurrent collections، spinning primitives، سازههای slim signaling، lazy initialization، thread pool CLR و threadها قرار دارند.
PLINQ غنیترین قابلیتها را ارائه میکند: تمام مراحل موازیسازی را خودکار میکند، از تقسیم کار به taskها و اجرای آنها روی threadها گرفته تا collating نتایج در یک دنبالهٔ خروجی واحد. این مدل declarative نامیده میشود، زیرا شما صرفاً اعلام میکنید که میخواهید کار را موازی کنید ــ در قالب یک LINQ query ــ و جزئیات پیادهسازی را به runtime میسپارید.
در مقابل، رویکردهای دیگر imperative هستند؛ یعنی باید صریحاً کد partitioning یا collating را بنویسید. خلاصهٔ تفاوتها چنین است:
تقسیم کار و ترکیب نتیجه در اجزای PFX| سازه | کار را تقسیم میکند؟ | نتایج را ترکیب میکند؟ |
|---|
| PLINQ | بله | بله |
| کلاس Parallel | بله | خیر |
| Task parallelism در PFX | خیر | خیر |
Concurrent collectionها و spinning primitiveها در کارهای سطح پایینتر برنامهنویسی موازی کمک میکنند. اهمیت آنها از اینجاست که PFX نه فقط برای سختافزار امروز، بلکه برای نسلهای آینده با تعداد هسته بسیار بیشتر طراحی شده است. اگر ۳۲ کارگر بخواهند یک توده هیزم را جابهجا کنند، چالش اصلی این است که مزاحم کار یکدیگر نشوند. تقسیم یک الگوریتم بین ۳۲ هسته نیز مشابه است: اگر منابع مشترک با lockهای معمولی محافظت شوند، blocking ممکن است باعث شود در هر لحظه فقط بخشی از هستهها واقعاً مشغول باشند. Concurrent collectionها برای دسترسی بسیار همزمان بهینه شدهاند و هدفشان کمینهکردن یا حذف blocking است. خود PLINQ و کلاس Parallel نیز برای مدیریت کار بهینه به concurrent collectionها و spinning primitiveها تکیه میکنند.
کاربردهای دیگر PFX
- Concurrent collectionها زمانی مفیدند که به queue، stack یا dictionary ایمن در برابر thread نیاز دارید.
BlockingCollection راهی ساده برای پیادهسازی ساختار producer/consumer و همچنین محدودکردن concurrency فراهم میکند.- Taskها همانگونه که در فصل ۱۴ دیدیم، پایهٔ برنامهنویسی ناهمگام هستند.
چه زمانی از PFX استفاده کنیم؟
کاربرد اصلی PFX برنامهنویسی موازی است: استفاده از پردازندههای چندهستهای برای سرعتدادن به کدهای محاسباتی سنگین.
یکی از چالشها «قانون آمدال» (Amdahl’s law) است. این قانون میگوید بیشینهٔ بهبود عملکرد حاصل از موازیسازی، به بخشی از کد محدود میشود که ناچار است بهصورت ترتیبی اجرا شود. برای مثال اگر فقط دو سوم زمان اجرای الگوریتم قابل موازیسازی باشد، حتی با تعداد نامحدود هسته نیز هرگز نمیتوانید بیش از سه برابر افزایش سرعت به دست آورید.
بنابراین پیش از ادامه، ارزش دارد بررسی کنید که گلوگاه واقعاً در کدی است که امکان موازیسازی دارد. همچنین باید پرسید آیا کد واقعاً لازم است اینقدر محاسباتی باشد یا نه؛ بهینهسازی اغلب سادهترین و مؤثرترین راه است. البته برخی روشهای optimization ممکن است موازیسازی را دشوارتر کنند.
سادهترین سودها در مسائل موسوم به embarrassingly parallel به دست میآیند؛ یعنی کار بهراحتی به taskهایی تقسیم میشود که مستقل و کارآمد اجرا میشوند. structured parallelism برای چنین مسائلی بسیار مناسب است. نمونهها شامل بسیاری از عملیات image processing، ray tracing و روشهای brute-force در ریاضیات یا cryptography است. در مقابل، پیادهسازی نسخهٔ بهینهٔ quicksort نمونهای از مسئلهای است که به این آسانی موازی نمیشود و ممکن است نیازمند unstructured parallelism باشد.
PLINQ
PLINQ queryهای محلی LINQ را بهصورت خودکار موازی میکند. مزیت اصلی آن سادگی است، زیرا بار partitioning کار و collating نتیجه را به .NET میسپارد.
برای استفاده از PLINQ کافی است روی دنبالهٔ ورودی AsParallel() را فراخوانی کنید و سپس LINQ query را مثل همیشه ادامه دهید. مثال زیر با یک الگوریتم ساده و بهینهنشده، اعداد اول بین 3 و 100,000 را محاسبه میکند و از تمام هستههای ماشین استفاده میکند:
// Calculate prime numbers using a simple (unoptimized) algorithm.
IEnumerable<int> numbers = Enumerable.Range (3, 100000-3);
var parallelQuery =
from n in numbers.AsParallel()
where Enumerable.Range (2, (int) Math.Sqrt (n)).All (i => n % i > 0)
select n;
int[] primes = parallelQuery.ToArray();
AsParallel یک extension method در System.Linq.ParallelEnumerable است. این متد ورودی را در دنبالهای مبتنی بر ParallelQuery<TSource> میپیچد؛ در نتیجه operatorهای LINQ بعدی به مجموعهٔ جایگزین extension methodهایی که در ParallelEnumerable تعریف شدهاند bind میشوند. این متدها نسخههای موازی operatorهای استاندارد query را فراهم میکنند. در اصل، دنبالهٔ ورودی را به chunkهایی تقسیم میکنند که روی threadهای مختلف اجرا میشوند و سپس نتایج را دوباره در یک دنبالهٔ خروجی واحد جمع میکنند.
شکل 22-2 ـ مدل اجرای PLINQ؛ دنباله ورودی پس از AsParallel() بین چند thread تقسیم و خروجی پردازششده دوباره در یک دنباله جمع میشود.
فراخوانی AsSequential() یک ParallelQuery را از حالت موازی خارج میکند تا operatorهای بعدی به operatorهای استاندارد bind شوند و ترتیبی اجرا شوند. این کار پیش از فراخوانی متدهایی که side effect دارند یا thread-safe نیستند لازم است.
برای operatorهایی که دو دنبالهٔ ورودی میپذیرند ــ Join، GroupJoin، Concat، Union، Intersect، Except و Zip ــ باید AsParallel() را روی هر دو ورودی اعمال کنید؛ در غیر این صورت exception رخ میدهد. اما لازم نیست در طول query مرتباً AsParallel را تکرار کنید، زیرا operatorهای PLINQ خودشان ParallelQuery برمیگردانند. تکرار AsParallel حتی ناکارآمد است، چون query را مجبور به merge و repartition میکند:
mySequence.AsParallel() // Wraps sequence in ParallelQuery<int>
.Where (n => n > 100) // Outputs another ParallelQuery<int>
.AsParallel() // Unnecessary - and inefficient!
.Select (n => n * n)
همهٔ operatorهای query را نمیتوان بهطور مؤثر موازی کرد. برای operatorهایی که این امکان را ندارند، PLINQ آن بخش را ترتیبی اجرا میکند. همچنین اگر PLINQ تشخیص دهد هزینهٔ موازیسازی از سود آن بیشتر است، ممکن است کل query را ترتیبی اجرا کند.
PLINQ فقط برای collectionهای محلی است. برای مثال با Entity Framework کار نمیکند، زیرا LINQ در آن حالت به SQL ترجمه و روی database server اجرا میشود. بااینحال میتوانید روی result setهایی که از database query گرفتهاید، پردازش محلی اضافی را با PLINQ انجام دهید.
چرا AsParallel حالت پیشفرض نیست؟
با توجه به اینکه AsParallel میتواند queryهای LINQ را شفاف موازی کند، این سؤال مطرح میشود که چرا Microsoft operatorهای استاندارد query را از ابتدا موازی نکرده است. دلیل نخست آن است که برای سودمندبودن PLINQ باید مقدار معقولی کار محاسباتی سنگین وجود داشته باشد تا بین worker threadها توزیع شود. بیشتر queryهای LINQ-to-Objects بسیار سریع اجرا میشوند؛ بنابراین نهفقط موازیسازی لازم نیست، بلکه هزینهٔ partitioning، collating و هماهنگی threadهای اضافی ممکن است query را کندتر کند.
- خروجی PLINQ بهطور پیشفرض ممکن است از نظر ترتیب عناصر با LINQ معمولی تفاوت داشته باشد.
- PLINQ exceptionها را برای پشتیبانی از چند exception احتمالی در
AggregateException میپیچد. - اگر query متدهای thread-unsafe را فراخوانی کند، PLINQ نتایج غیرقابلاعتماد تولید میکند.
افزون بر این، PLINQ hookهای متعددی برای tuning و tweaking ارائه میدهد و تحمیل همهٔ این ظرافتها بر API استاندارد LINQ-to-Objects باعث شلوغی آن میشد.
رفتار اجرای موازی
مانند LINQ معمولی، queryهای PLINQ بهصورت lazy ارزیابی میشوند؛ یعنی اجرا زمانی آغاز میشود که مصرف نتیجه را شروع کنید، معمولاً با foreach، هرچند operator تبدیل مانند ToArray یا operatorی که یک مقدار واحد برمیگرداند نیز اجرا را آغاز میکند.
با این حال، هنگام enumeration رفتار با query ترتیبی متفاوت است. query ترتیبی کاملاً به شیوهٔ pull توسط مصرفکننده هدایت میشود و هر عنصر دقیقاً وقتی از ورودی گرفته میشود که مصرفکننده به آن نیاز دارد. query موازی معمولاً از threadهای مستقل استفاده میکند تا عناصر را اندکی زودتر از نیاز مصرفکننده از ورودی بگیرند، آنها را در طول زنجیرهٔ query موازی پردازش کنند و نتایج را در یک buffer کوچک نگه دارند تا بهمحض درخواست آماده باشند. اگر مصرفکننده مکث کند یا enumeration را زود متوقف کند، پردازندهٔ query نیز مکث یا توقف میکند تا CPU و حافظه بیهوده مصرف نشوند.
PLINQ و ترتیب
یکی از پیامدهای موازیسازی operatorها این است که هنگام collating، نتیجه الزاماً به همان ترتیبی که ارسال شده بود برنمیگردد. بنابراین تضمین معمول LINQ دربارهٔ حفظ ترتیب sequence دیگر برقرار نیست.
اگر حفظ ترتیب لازم است، پس از AsParallel()، متد AsOrdered() را فراخوانی کنید:
myCollection.AsParallel().AsOrdered()...
AsOrdered برای تعداد زیاد عناصر هزینهٔ performance دارد، زیرا PLINQ باید موقعیت اصلی هر عنصر را دنبال کند. میتوان بعداً با AsUnordered این الزام را حذف کرد؛ از آن نقطه query آزاد است کارآمدتر اجرا شود:
inputSequence.AsParallel().AsOrdered()
.QueryOperator1()
.QueryOperator2()
.AsUnordered() // From here on, ordering doesn’t matter
.QueryOperator3()
...
محدودیتهای PLINQ
برای آنچه PLINQ میتواند موازی کند محدودیتهای عملی وجود دارد. نسخههای indexed از Select، SelectMany و ElementAt بهطور پیشفرض مانع موازیسازی میشوند، مگر آنکه عناصر منبع در موقعیت index اصلی خود باشند. بسیاری از operatorها ــ از جمله operatorهای حذفکننده مثل Where ــ موقعیت index را تغییر میدهند، پس اگر قصد استفاده از نسخههای indexed دارید معمولاً باید در ابتدای query قرار گیرند.
operatorهای Join، GroupBy، GroupJoin، Distinct، Union، Intersect و Except قابل موازیسازیاند، اما از راهبرد partitioning پرهزینهای استفاده میکنند که گاهی از پردازش ترتیبی کندتر است. overloadهای seeded از Aggregate در شکل استاندارد خود موازیپذیر نیستند و PLINQ overloadهای ویژهای برای آنها دارد.
سایر operatorها قابل موازیسازیاند، اما استفاده از آنها تضمین نمیکند query واقعاً موازی اجرا شود. اگر PLINQ احتمال دهد overhead موازیسازی query را کند میکند، ممکن است اجرای ترتیبی را انتخاب کند. میتوان این رفتار را پس از AsParallel() با دستور زیر override کرد:
.WithExecutionMode (ParallelExecutionMode.ForceParallelism)
مثال: Spellchecker موازی
فرض کنید میخواهیم spellcheckerای بنویسیم که برای سندهای بسیار بزرگ با استفاده از تمام هستههای موجود سریع اجرا شود. اگر الگوریتم را بهصورت LINQ query بنویسیم، موازیکردن آن بسیار ساده است. ابتدا dictionaryای از واژههای انگلیسی را برای lookup سریع داخل HashSet بارگذاری میکنیم:
if (!File.Exists ("WordLookup.txt") // Contains about 150,000 words
File.WriteAllText ("WordLookup.txt",
await new HttpClient().GetStringAsync (
"http://www.albahari.com/ispell/allwords.txt"));
var wordLookup = new HashSet<string> (
File.ReadAllLines ("WordLookup.txt"),
StringComparer.InvariantCultureIgnoreCase);
سپس یک «سند» آزمایشی میسازیم که آرایهای از یک میلیون واژهٔ تصادفی است و دو غلط املایی به آن وارد میکنیم:
var random = new Random();
string[] wordList = wordLookup.ToArray();
string[] wordsToTest = Enumerable.Range (0, 1000000)
.Select (i => wordList [random.Next (0, wordList.Length)])
.ToArray();
wordsToTest [12345] = "woozsh"; // Introduce a couple
wordsToTest [23456] = "wubsie"; // of spelling mistakes.
اکنون میتوانیم wordsToTest را در برابر wordLookup بررسی کنیم:
var query = wordsToTest
.AsParallel()
.Select ((word, index) => (word, index))
.Where (iword => !wordLookup.Contains (iword.word))
.OrderBy (iword => iword.index);
foreach (var mistake in query)
Console.WriteLine (mistake.word + " - index = " + mistake.index);
// OUTPUT:
// woozsh - index = 12345
// wubsie - index = 23456
فراخوانی wordLookup.Contains در predicate به query بهاندازهٔ کافی «کار واقعی» میدهد تا موازیسازی ارزشمند شود.
استفاده از ThreadLocal<T>
اگر بخواهیم ساخت همان فهرست واژهٔ تصادفی را نیز موازی کنیم، یک مانع وجود دارد: random.Next thread-safe نیست. قفلکردن دور آن concurrency را محدود میکند. راه بهتر، استفاده از ThreadLocal<Random> است تا برای هر thread یک شیء Random جداگانه ساخته شود:
var localRandom = new ThreadLocal<Random>
( () => new Random (Guid.NewGuid().GetHashCode()) );
string[] wordsToTest = Enumerable.Range (0, 1000000).AsParallel()
.Select (i => wordList [localRandom.Value.Next (0, wordList.Length)])
.ToArray();
در factory ساخت Random، hashcode یک Guid را میفرستیم تا اگر دو Random در فاصلهٔ زمانی بسیار کوتاهی ساخته شدند، دنبالههای تصادفی متفاوتی تولید کنند.
چه زمانی از PLINQ استفاده کنیم؟
جستوجوی همهٔ LINQ queryهای برنامه و موازیکردن آزمایشی آنها معمولاً بیثمر است، چون بیشتر مسائلی که LINQ بهطور طبیعی برایشان مناسب است بسیار سریع اجرا میشوند. رویکرد بهتر یافتن یک گلوگاه CPU-intensive و سپس بررسی امکان بیان آن بهصورت LINQ query است. مزیت جانبی این بازساخت آن است که LINQ معمولاً کد را کوتاهتر و خواناتر میکند.
PLINQ برای مسائل embarrassingly parallel بسیار مناسب است. برای پردازش تصویر میتواند انتخاب ضعیفی باشد، زیرا collating میلیونها pixel در یک output sequence خود به گلوگاه تبدیل میشود. در چنین مواردی بهتر است pixelها مستقیماً در array یا unmanaged memory نوشته شوند و multithreading با کلاس Parallel یا task parallelism مدیریت شود. البته میتوان collating نتیجه را با ForAll دور زد.
خلوص تابعی (Functional Purity)
چون PLINQ query را روی threadهای موازی اجرا میکند، باید از عملیات thread-unsafe پرهیز کنید. بهخصوص نوشتن در متغیرها side effect دارد و thread-unsafe است:
// The following query multiplies each element by its position.
// Given an input of Enumerable.Range(0,999), it should output squares.
int i = 0;
var query = from n in Enumerable.Range(0,999).AsParallel() select n * i++;
میتوان increment کردن i را با lock thread-safe کرد، اما مشکل دیگری باقی میماند: i لزوماً با موقعیت عنصر ورودی متناظر نیست. AsOrdered نیز این مسئله را حل نمیکند، چون فقط ترتیب خروجی را مطابق اجرای ترتیبی نگه میدارد و عناصر را واقعاً ترتیبی پردازش نمیکند. راه صحیح استفاده از overload indexed متد Select است:
var query = Enumerable.Range(0,999).AsParallel().Select ((n, i) => n * i);
برای بهترین performance، متدهای فراخوانیشده از query operatorها باید ذاتاً thread-safe باشند؛ یعنی field یا property ننویسند و side effect نداشته باشند. اگر thread safety فقط با locking حاصل شود، contention ظرفیت موازیسازی را محدود میکند.
تنظیم Degree of Parallelism
بهطور پیشفرض PLINQ درجهٔ موازیسازی بهینه را برای processor انتخاب میکند. میتوانید با WithDegreeOfParallelism پس از AsParallel آن را تغییر دهید:
...AsParallel().WithDegreeOfParallelism(4)...
یک مورد برای افزایش موازیسازی فراتر از تعداد coreها، کار I/O-bound مانند دانلود همزمان چند web page است؛ هرچند task combinatorها و تابعهای async معمولاً راه سادهتر و کارآمدتری ارائه میکنند. برخلاف Taskها، PLINQ برای I/O-bound ناچار است threadها ــ آن هم pooled threadها ــ را block کند.
تغییر درجهٔ موازیسازی
WithDegreeOfParallelism را در یک PLINQ query فقط یک بار میتوان صدا زد. برای تنظیم دوباره باید با یک AsParallel() دیگر query را مجبور به merge و repartition کرد:
"The Quick Brown Fox"
.AsParallel().WithDegreeOfParallelism (2)
.Where (c => !char.IsWhiteSpace (c))
.AsParallel().WithDegreeOfParallelism (3) // Forces Merge + Partition
.Select (c => char.ToUpper (c))
Cancellation
برای queryای که نتیجهاش را در foreach مصرف میکنید، کافی است از loop خارج شوید؛ با dispose شدن implicit enumerator، query خودکار cancel میشود. اگر query با conversion، element یا aggregation operator پایان مییابد، میتوان از thread دیگر با cancellation token آن را لغو کرد. پس از AsParallel، WithCancellation را صدا بزنید و Token یک CancellationTokenSource را بدهید:
IEnumerable<int> tenMillion = Enumerable.Range (3, 10_000_000);
var cancelSource = new CancellationTokenSource();
cancelSource.CancelAfter (100); // Cancel query after 100 milliseconds
var primeNumberQuery =
from n in tenMillion.AsParallel().WithCancellation (cancelSource.Token)
where Enumerable.Range (2, (int) Math.Sqrt (n)).All (i => n % i > 0)
select n;
try
{
int[] primes = primeNumberQuery.ToArray();
}
catch (OperationCanceledException)
{
Console.WriteLine ("Query canceled");
}
پس از cancellation، PLINQ صبر میکند هر worker thread عنصر فعلی خود را تمام کند و سپس query را خاتمه میدهد. بنابراین متدهای خارجیای که query فراخوانده است تا پایان اجرا میشوند.
بهینهسازی PLINQ
بهینهسازی سمت خروجی
یکی از مزیتهای PLINQ این است که نتایج کار موازی را بهسادگی در یک output sequence واحد collate میکند. اما گاهی تمام کاری که با آن sequence انجام میدهید اجرای یک تابع روی هر عنصر است:
foreach (int n in parallelQuery)
DoSomething (n);
اگر ترتیب پردازش عناصر اهمیتی ندارد، میتوان با ForAll کارایی را بهتر کرد. ForAll یک delegate را روی تمام عناصر خروجی ParallelQuery اجرا میکند و مستقیماً به internals PLINQ وصل میشود، بنابراین مرحلههای collating و enumeration را دور میزند:
"abcdef".AsParallel().Select (c => char.ToUpper(c)).ForAll (Console.Write);
شکل 22-3 ـ PLINQ ForAll، که خروجی هر worker را بدون مرحلهٔ نهایی collating مستقیماً به delegate مصرفکننده میدهد.
بهینهسازی سمت ورودی
PLINQ سه راهبرد partitioning برای تخصیص عناصر ورودی به threadها دارد:
| راهبرد | تخصیص عنصر | عملکرد نسبی |
|---|
| Chunk partitioning | پویا | متوسط |
| Range partitioning | ایستا | ضعیف تا عالی |
| Hash partitioning | ایستا | ضعیف |
برای query operatorهایی که باید عناصر را با هم مقایسه کنند ــ GroupBy، Join، GroupJoin، Intersect، Except، Union و Distinct ــ انتخابی وجود ندارد و PLINQ همیشه hash partitioning بهکار میبرد. این روش نسبتاً ناکارآمد است، چون باید hashcode همهٔ عناصر از پیش محاسبه شود تا عناصر دارای hashcode یکسان روی thread واحد پردازش شوند. اگر این روش بیش از حد کند بود، گزینهٔ شما فراخوانی AsSequential و غیرفعالکردن موازیسازی است.
برای سایر operatorها میتوان از range یا chunk partitioning استفاده کرد. بهطور پیشفرض اگر input sequence قابل index باشد ــ array باشد یا IList<T> را پیادهسازی کند ــ PLINQ range partitioning را انتخاب میکند؛ در غیر این صورت chunk partitioning را.
بهطور خلاصه range partitioning برای sequenceهای طولانی که زمان CPU لازم برای هر عنصر تقریباً برابر است سریعتر است؛ در غیر این صورت chunk partitioning معمولاً بهتر است.
برای اجبار range partitioning، اگر query با Enumerable.Range شروع میشود آن را با ParallelEnumerable.Range جایگزین کنید؛ در غیر این صورت input sequence را با ToList یا ToArray به مجموعهٔ قابل index تبدیل کنید، البته هزینهٔ این تبدیل را هم باید در نظر گرفت.
برای اجبار chunk partitioning، ورودی را با Partitioner.Create در System.Collections.Concurrent بپیچید:
int[] numbers = { 3, 4, 5, 6, 7, 8, 9 };
var parallelQuery =
Partitioner.Create (numbers, true).AsParallel()
.Where (...)
آرگومان دوم Partitioner.Create مشخص میکند query باید load-balanced باشد؛ این همان chunk partitioning است. در این راهبرد هر worker thread دورهای chunkهای کوچک از input sequence میگیرد. PLINQ ابتدا chunkهای بسیار کوچک ــ یک یا دو عنصر ــ اختصاص میدهد و در ادامه اندازهٔ chunk را افزایش میدهد. این کار باعث میشود sequenceهای کوچک بهخوبی موازی شوند و sequenceهای بزرگ هزینهٔ رفتوبرگشت بیشازحد نداشته باشند. اگر worker عناصر آسانی بگیرد که سریع پردازش میشوند، chunkهای بیشتری دریافت خواهد کرد. در نتیجه threadها تقریباً به یک اندازه مشغول میمانند؛ تنها عیب این است که گرفتن عنصر از input sequence مشترک نیازمند synchronization ــ معمولاً exclusive lock ــ است و میتواند overhead و contention ایجاد کند.
Range partitioning enumeration معمول سمت ورودی را دور میزند و از پیش تعداد مساوی عنصر به هر worker اختصاص میدهد، بنابراین contention روی input sequence حذف میشود. اما اگر بعضی threadها عناصر آسان بگیرند و زودتر تمام کنند، بیکار میمانند تا threadهای دیگر کارشان را کامل کنند. برای مثال محاسبهٔ اعداد اول قبلی ممکن است با range partitioning ضعیف عمل کند. در مقابل، محاسبهٔ مجموع ریشهٔ دوم ده میلیون عدد صحیح نخست نمونهای مناسب برای range partitioning است:
ParallelEnumerable.Range (1, 10000000).Sum (i => Math.Sqrt (i))