مقدمه
در درس قبل، با استفاده از کلاس Parallel برای موازیسازی حلقهها و عملیات آشنا شدیم. این
روش
بسیار قدرتمند است، اما میتواند برای کارهای مرتبط با داده، کمی طولانی باشد. LINQ یک
سینتکس اعلانی و خوانا برای کار با دادهها فراهم میکند؛ چه میشد اگر میتوانستیم به سادگی و تنها
با یک تغییر کوچک، این کوئریهای خوانا را به صورت موازی اجرا کنیم؟ این دقیقاً همان کاری است که
LINQ موازی (Parallel LINQ) یا
PLINQ برای ما انجام میدهد. PLINQ یک پیادهسازی موازی از
عملگرهای استاندارد LINQ است که به طور خودکار دادهها را بین هستههای مختلف CPU تقسیم
کرده و کوئری را به صورت موازی اجرا میکند تا سرعت را به حداکثر برساند.
از IEnumerable<T> تا ParallelQuery<T>
جادوی PLINQ در یک متد بسطی بسیار ساده به نام AsParallel() نهفته است. این متد که در فضای نام System.Linq
قرار دارد، یک کالکشن عادی IEnumerable<T> را به یک منبع دادهی
موازی از نوع ParallelQuery<T> تبدیل
میکند. پس از این فراخوانی، تمام عملگرهای بعدی LINQ که بر روی آن زنجیره
میشوند، از نسخهی موازی خود در PLINQ استفاده خواهند کرد.
تبدیل یک کوئری LINQ به PLINQ
بیایید یک مثال عملی را ببینیم. فرض کنید میخواهیم یک عملیات محاسباتی سنگین را بر روی هر عنصر از
یک آرایهی بزرگ انجام دهیم.
اجرای ترتیبی (Sequential)
Program.cs
int[] numbers = Enumerable.Range(0, 100_000_000).ToArray();
double ComplexCalculation(int n)
{
Thread.Sleep(1);
return Math.Sqrt(n);
}
var stopwatch = Stopwatch.StartNew();
var queryResult = numbers.Where(n => n % 2 == 0).Select(n => ComplexCalculation(n));
Console.WriteLine($"Sequential query took: {stopwatch.ElapsedMilliseconds} ms");
اجرای موازی (Parallel)
حالا برای موازی کردن این کوئری، تنها کاری که باید انجام دهیم افزودن یک فراخوانی به
AsParallel() در ابتدای زنجیره است.
Program.cs
stopwatch.Restart();
var parallelResult = numbers.AsParallel()
.Where(n => n % 2 == 0)
.Select(n => ComplexCalculation(n));
Console.WriteLine($"Parallel query took: {stopwatch.ElapsedMilliseconds} ms");
اگر این کد را بر روی یک کامپیوتر با چند هستهی پردازشی اجرا کنید، خواهید دید که نسخهی موازی به
طور چشمگیری سریعتر از نسخهی ترتیبی اجرا میشود. PLINQ به طور خودکار منبع داده را به
بخشهای کوچکتر تقسیم کرده و هر بخش را روی یک نخ جداگانه پردازش میکند و در نهایت نتایج را با هم
ترکیب میکند.
حفظ ترتیب با AsOrdered
یکی از نتایج جانبی موازیسازی این است که ترتیب عناصر در نتیجهی خروجی لزوماً با ترتیب آنها در
منبع ورودی یکسان نخواهد بود، زیرا بخشهای مختلف داده ممکن است با سرعتهای متفاوتی پردازش شوند.
در بیشتر موارد، این ترتیب اهمیتی ندارد. اما اگر حفظ ترتیب اصلی برای شما مهم است، میتوانید از
متد بسطی AsOrdered() به جای AsParallel() استفاده کنید.
Program.cs
var orderedParallelResult = numbers.AsParallel().AsOrdered()
.Where(n => n < 10)
.Select(n => n * n);
AsOrdered به PLINQ دستور میدهد که با وجود اجرای موازی، ترتیب عناصر را در نتیجهی
نهایی حفظ کند. این کار ممکن است کمی هزینهی سربار اضافی داشته باشد، زیرا PLINQ باید
نتایج را قبل از برگرداندن، مرتب کند.
چه زمانی از PLINQ استفاده کنیم (و نکنیم)؟
PLINQ یک ابزار قدرتمند است، اما یک راه حل جادویی برای همهی مشکلات نیست.
- بهترین کاربرد: برای کوئریهای محاسباتی سنگین (CPU-bound) بر روی دادههای
موجود در حافظه (LINQ to Objects). اگر هر تکرار از کوئری شما (به خصوص در بخش Select یا
Where) نیاز به محاسبات قابل توجهی دارد، PLINQ میتواند یک گزینهی عالی باشد.
- کاربردهای نامناسب:
- عملیات I/O-Bound: مانند LINQ to SQL یا LINQ to
Entities، موازیسازی در سمت کلاینت معمولاً فایدهای ندارد و ممکن است به
پایگاه داده فشار بیهوده وارد کند.
- کارهای بسیار کوچک و سریع: هزینهی سربار موازیسازی (تقسیم کار و
ترکیب نتایج) ممکن است از زمان اجرای خود کوئری بیشتر باشد و در نهایت باعث کندتر شدن
برنامه شود.
- کدهای غیر ایمن از نظر نخ (Non-Thread-Safe): مهمترین نکته این است
که PLINQ نیز شما را از مسئولیت ایمنسازی کد در برابر شرایط رقابتی معاف
نمیکند. اگر عبارات لامبدای شما به دادههای مشترک و قابل تغییر دسترسی دارند، باید
خودتان آن دسترسی را با lock یا روشهای دیگر همگامسازی کنید.
همیشه عملکرد را بسنجید! قبل از تصمیمگیری برای استفاده از PLINQ، عملکرد نسخهی ترتیبی
و موازی را با دادههای واقعی خود مقایسه کنید تا مطمئن شوید که واقعاً بهبودی حاصل میشود.