تحسين الأداء لتطبيقات .NET (C #)

صورة

هناك الكثير من المقالات التي لها عنوان مماثل ، لذلك سأحاول تجنب موضوعات شائعة. آمل أن يجد مطور ذو خبرة كبيرة شيئًا مفيدًا هنا. ستأخذ هذه المقالة في الاعتبار آليات وطرق التحسين البسيطة التي ستسمح بتطبيقها بأقل جهد ممكن. وهذه التغييرات لن تزيد من إنتروبيا الكود. لن تهتم المقالة بما ومتى يتم تحسينه ، فهذه المقالة تدور حول طريقة كتابة التعليمات البرمجية بشكل عام.

1. ToArray مقابل ToList


public IEnumerable<string> GetItems() { return _storage.Items.Where(...).ToList(); } 

موافق ، رمز نموذجي للغاية للمشاريع الصناعية. ولكن ما هو الخطأ معه؟ تقوم الواجهة IEnumerable بإرجاع مجموعة يمكنك "الانتقال إليها" ؛ لا تعني هذه الواجهة أنه يمكننا إضافة / إزالة العناصر. وفقًا لذلك ، ليست هناك حاجة لإنهاء تعبير LINQ بالانتقال إلى قائمة (قائمة). في هذه الحالة ، يكون الاختيار المفضل هو Array (ToArray). نظرًا لأن List عبارة عن مجمّع فوق Array ، وجميع الميزات الإضافية التي يوفرها هذا المجمع ، فقد قطعنا الواجهة. يستهلك صفيف ذاكرة أقل ، والوصول إلى قيمه أسرع. وفقا لذلك ، لماذا تدفع أكثر. من ناحية ، هذا التحسين ليس مهمًا ، حيث يقولون "التحسين في المباريات" ، لكن هذا ليس صحيحًا تمامًا. الحقيقة هي أنه في تطبيق نموذجي تقوم فيه الخدمات بإرجاع نماذج لطبقة العرض التقديمي ، يمكن أن يكون هناك عدد لا يحصى من مكالمات ToList هذه. في المثال الموضح أعلاه ، يتم تقديم الواجهة IEnumerable لأغراض التوضيح فقط. هذا النهج مناسب لجميع الحالات عندما تحتاج إلى إرجاع مجموعة لن تتغير لاحقًا.

أتوقع تعليقًا مفاده أن Array and List لن يعملا بشكل متساوٍ في حالة الوصول إلى مجموعة الرسائل المتعددة الخيوط. انها حقا. ولكن إذا كنت ، كمطور ، تفكر في إمكانية الوصول المتعدد الخيوط إلى هذه المجموعة مع إمكانية تغييرها ، ثم بدرجة عالية من الاحتمال ، لن يناسبك Array أو List.

2. معلمة "مسار الملف" ليست دائمًا الخيار الأفضل لطريقتك


عند تطوير واجهة برمجة تطبيقات ، تجنب توقيعات الطريقة التي تتلقى مسار ملف كمدخل (للمعالجة اللاحقة بطريقتك). بدلاً من ذلك ، قم بتوفير القدرة على تمرير مجموعة من وحدات البايت إلى الإدخال أو كحل أخير دفق. الحقيقة هي أنه بمرور الوقت ، يمكن تطبيق الطريقة الخاصة بك ليس فقط على ملف من القرص ، ولكن أيضًا على ملف تم نقله عبر الشبكة ، إلى ملف من أرشيف ، إلى ملف من قاعدة بيانات ، إلى ملف يتم إنشاء محتوياته ديناميكيًا في الذاكرة ، إلخ. من خلال توفير طريقة مع معلمة إدخال "مسار الملف" ، فإنك تلزم مستخدم واجهة برمجة التطبيقات (API) بحفظ البيانات على القرص قبل قراءتها مرة أخرى. هذه العملية التي لا معنى لها تؤثر بشكل حاسم على الأداء. محرك الأقراص هو شيء بطيء للغاية. للراحة ، يمكنك توفير طريقة مع معلمة إدخال "مسار إلى ملف" ، ولكن في الداخل دائمًا استخدام طريقة محمّلة عامة مع مجموعة من وحدات البايت أو دفق عند الإدخال. يوجد "علامة" يمكن أن تساعد في العثور على عمليات قراءة / قراءة إضافية للقرص ، حاول العثور عليها في مشروعك باستخدام الطرق القياسية: Path.GetTempPath() و Path.GetRandomFileName() (من System.IO). مع درجة عالية من الاحتمال ، ستقابل حلاً للمشكلة المذكورة أعلاه أو ما شابه.

يلاحظ القارئ المهتم والخبير أنه في بعض الحالات ، يمكن للكتابة على القرص ، على العكس من ذلك ، تحسين الأداء ، على سبيل المثال ، إذا كنا نتعامل مع ملفات كبيرة للغاية. هذا صحيح ، يجب أن يؤخذ في الاعتبار ، لكنني أفترض أن هذا وضع نادر جدًا مع تنفيذ معين.

3. تجنب استخدام المواضيع كمعلمات ونتائج الإرجاع لأساليبك


ما هي المشكلة هنا ... عندما نحصل على تيار من "الصندوق الأسود" ، يجب أن نضع في اعتبارنا حالته. أي هل الدفق مفتوح؟ أين هي علامة القراءة / الكتابة؟ هل يمكن أن تتغير حالتها بغض النظر عن الرمز الخاص بنا؟ إذا تم الإعلان عن البث كفئة أساسية من البث ، فلن يكون لدينا حتى معلومات حول العمليات المتوفرة عليه. يتم حل كل هذا عن طريق الشيكات الإضافية ، وهذا هو رمز وتكاليف إضافية. أيضًا ، صادفت مرارًا موقفًا ، عندما استلم Stream من طريقة "غامضة" ، فضل المطور تشغيله بأمان و "نقل" البيانات منه إلى MemoryStream محلي جديد يتم التحكم فيه بالكامل. على الرغم من أن تيار المصدر يمكن أن يكون آمنًا تمامًا. ربما حتى هذا كان بالفعل على استعداد لقراءة MemoryStream. في بعض الأحيان يمكن أن يصل إلى نقطة العبث - داخل طريقة ، يتم وضع مجموعة من البايتات في MemoryStream ، ثم يتم إرجاع MemoryStream كنتيجة لطريقة تم الإعلان عنها كدفق أساسي. في الخارج ، يتحول هذا التدفق إلى MemoryStream جديد ، ثم يقوم ToArray () بإرجاع مجموعة من البايتات ، والتي كان لدينا في الأصل. بتعبير أدق ، سيكون نسخته التالية. المفارقة هي أن الشفرة صحيحة تمامًا داخل وخارج طريقة عملنا. في رأيي ، هذا المثال ليس خارج رأسي ، ولكن تم العثور عليه في مكان ما في القانون التجاري.

نتيجة لذلك ، إذا كان لديك القدرة على إرسال / استقبال بيانات "نظيفة" ، فلا تستخدم التدفقات لذلك - لا تقم بإنشاء مصائد لمن يستخدمونها. إذا كان التطبيق الخاص بك يحتوي بالفعل على تدفقات النقل / الإرجاع ، فقم بتحليل استخدامها بناءً على ما تقدم.

4. وراثة التعداد


هذا التحسين هو أمر شائع ، والجميع يعرف ذلك ، حتى الطلاب. ولكن من تجربتي ، نادرًا ما يتم استخدامه. لذلك ، بشكل افتراضي ، يرث التعداد من int. ومع ذلك ، يمكن توريثها من البايتة ، التي تحتوي على 256 قيمة (أو 8 قيم "قابلة للعلامة"). والتي تغطي دائمًا تقريبا وظائف التعداد "الأوسط". تغيير بسيط في الكود وكل قيم التعداد الخاص بك تستهلك ذاكرة أقل إلى الأبد. يوجد أدناه توضيح لمعيار لملء مجموعة بقيم التعداد الموروثة من int و byte.



رمز المعيار
 public class CollectEnums { [Params(1000, 10000, 100000, 1000000)] public int N; [Benchmark] public EnumFromInt[] EnumOfInt() { EnumFromInt[] results = new EnumFromInt[N]; for (int i = 0; i < N; i++) { results[i] = EnumFromInt.Value1; } return results; } [Benchmark] public EnumFromByte[] EnumOfByte() { EnumFromByte[] results = new EnumFromByte[N]; for (int i = 0; i < N; i++) { results[i] = EnumFromByte.Value1; } return results; } } public enum EnumFromInt { Value1, Value2 } public enum EnumFromByte: byte { Value1, Value2 } 


5. بضع كلمات أخرى حول فئات الصفيف والقائمة


باتباع المنطق ، يكون التكرار على صفيف دائمًا أكثر فعالية من التكرار على "ورقة" ، لأن "الورقة" عبارة عن غلاف على صفيف. أيضًا ، باتباع المنطق ، فإن "for" دائمًا أسرع من "foreach" ، لأن "foreach" يقوم بالكثير من الإجراءات المطلوبة من خلال تطبيق واجهة IEnumerable. كل شيء منطقي هنا ، لكن خطأ! دعنا نلقي نظرة على النتائج المرجعية:



رمز المعيار
 public class IterationBenchmark { private List<int> _list; private int[] _array; [Params(100000, 10000000)] public int N; [GlobalSetup] public void Setup() { const int MIN = 1; const int MAX = 10; Random rnd = new Random(); _list = Enumerable.Repeat(0, N).Select(i => rnd.Next(MIN, MAX)).ToList(); _array = _list.ToArray(); } [Benchmark] public int ForList() { int total = 0; for (int i = 0; i < _list.Count; i++) { total += _list[i]; } return total; } [Benchmark] public int ForeachList() { int total = 0; foreach (int i in _list) { total += i; } return total; } [Benchmark] public int ForeachArray() { int total = 0; foreach (int i in _array) { total += i; } return total; } [Benchmark] public int ForArray() { int total = 0; for (int i = 0; i < _array.Length; i++) { total += _array[i]; } return total; } } 


الحقيقة هي أنه بالنسبة للتكرار على صفيف ، لا يستخدم "foreach" تطبيق IEnumerable. في هذه الحالة بالذات ، يتم إجراء التكرار الأمثل من خلال الفهرس ، دون التحقق من خارج نطاق المصفوفة ، نظرًا لأن بنية "foreach" لا تعمل مع الفهارس ، لذلك لا يتوفر للمطور خيار "العبث" بالرمز. هذا هو الاستثناء للقاعدة. لذلك ، إذا قمت باستبدال استخدام "foreach" بـ "for" في بعض الأجزاء الهامة من الشفرة من أجل "التحسين" ، فأنت تطلق النار على قدمك. يرجى ملاحظة أن هذا مناسب فقط للصفائف . هناك عدة فروع على StackOverflow حيث تمت مناقشة هذه الميزة.

6. هو البحث من خلال جدول التجزئة له ما يبرره دائما؟


يعلم الجميع أن جداول التجزئة فعالة جدًا للبحث. لكن غالبًا ما ينسى أن سعر البحث السريع هو إضافة بطيئة لجدول التجزئة. ما يلي من هذا؟ من أجل تبرير استخدام جدول التجزئة ، من الضروري أن يكون عدد عناصر جدول التجزئة على الأقل 8 (تقريبًا). وهكذا كان عدد عمليات البحث على الأقل بترتيب أكبر من عدد عمليات الإضافة. خلاف ذلك ، استخدم مجموعة أبسط. ستقوم جودة دالة التجزئة بإجراء تعديلات خاصة بها على الكفاءة ، ولكن لن يتغير معنى ذلك. في ممارستي ، كانت هناك حالة عندما كان أكثر اختناق في التعليمات البرمجية التي تم تحميلها يتصل بالطريقة Dictionary.Add (). كان المفتاح عبارة عن سلسلة منتظمة بطول قصير. تذكر هذا وأصبح الزناد لكتابة هذه الفقرة. لتوضيح ، مثال على رمز سيء للغاية:

 private static int GetNumber(string numberStr) { Dictionary<string, int> dictionary = new Dictionary<string, int> { {"One", 1}, {"Two", 2}, {"Three", 3} }; dictionary.TryGetValue(numberStr, out int result); return result; } 

ربما يحدث شيء مشابه في مشروعك؟

7. طرق التضمين


يتم تقسيم الرمز إلى طرق في أغلب الأحيان لسببين. تأكد من إعادة استخدام التعليمات البرمجية والتحلل عند تقسيم مهمة واحدة إلى عدة مهام فرعية. إنه أسهل بالنسبة للشخص. التضمين هو عملية التحلل العكسي ، أي يتم تضمين رمز الطريقة في المكان الذي يجب أن يتم استدعاء الأسلوب فيه ، ونتيجة لذلك ، فإننا نحفظ على مكدس الاستدعاء ومعلمات المرور. أنا لا أوصي بأي حال من الأحوال بدفع كل شيء إلى طريقة واحدة. ولكن تلك الأساليب التي يمكننا نظريًا "تضمينها" يمكن تمييزها بالسمة المقابلة:

 [MethodImpl(MethodImplOptions.AggressiveInlining)] 

ستخبر هذه السمة النظام بأنه يمكن تضمين هذه الطريقة. هذا لا يعني أن الطريقة التي تحمل هذه السمة ستكون بالضرورة مدمجة. على سبيل المثال ، لا يمكن تضمين طرق عودية أو افتراضية. تجدر الإشارة أيضًا إلى أن آلية التضمين "حساسة للغاية". هناك العديد من الأسباب الأخرى وراء رفض النظام تضمين الطريقة الخاصة بك. ومع ذلك ، يستخدم فريق Microsoft الذي يعمل على .NET Core هذه السمة بنشاط. تحتوي التعليمات البرمجية المصدر لـ .NET Core على العديد من الأمثلة على استخدامه.

8. القدرة المقدرة


طورت أنا (وآمل أن يكون معظم المطورين أيضًا) رد فعل: لقد قمت بتهيئة المجموعة - فكرت فيما إذا كان من الممكن تعيين سعة لها. ومع ذلك ، فإن العدد الدقيق لعناصر المجموعة غير معروف دائمًا مقدمًا. ولكن هذا ليس سببا لتجاهل هذه المعلمة. على سبيل المثال ، إذا كنت تتحدث عن عدد العناصر الموجودة في مجموعتك ، فإنك تفترض "بضعة آلاف" ضبابية ، فهذه مناسبة لتعيين السعة على 1000. نظرية صغيرة ، على سبيل المثال ، للقائمة افتراضيًا ، السعة = 16 ، بحيث فقط يصل إلى 1000 ، سيقوم النظام بعمل 1008 (16 + 32 + 64 + 128 + 256 + 512) نسخ إضافية من العناصر وإنشاء 7 صفائف مؤقتة تحت رحمة مكالمة GC التالية. أي سوف يضيع كل هذا العمل. أيضًا ، لا يُسمح لأحد باستخدام الصيغة. إذا قدر حجم مجموعتك بثلث المجموعة الأخرى ، فيمكنك تعيين السعة مساوية لـ otherCollection.Count / 3. عند تعيين السعة ، من الجيد أن تفهم مدى الحجم الممكن للمجموعة ومدى توزيع قيمتها. هناك دائمًا فرصة للضرر ، ولكن إذا تم استخدامها بشكل صحيح ، فستعطيك السعة المقدرة ربحًا جيدًا.

9. دائما تحديد التعليمات البرمجية الخاصة بك.


استخدم بنشاط (للوهلة الأولى ، اختياري) الكلمات الرئيسية C # ، مثل: ثابت ، ثابت ، للقراءة فقط ، مختومة ، مجردة ، إلخ. بطبيعة الحال ، حيث أنها منطقية. وهنا هو الأداء؟ والحقيقة هي أنه كلما زاد وصفك لنظامك إلى المترجم ، زاد عدد الشفرات التي يمكن أن ينتجها. قد يلاحظ القارئ المهتم وذوي الخبرة أن الكلمة الرئيسية المختومة ، على سبيل المثال ، ليس لها أي تأثير على الأداء. هذا صحيح الآن ، لكن في الإصدارات المستقبلية ، يمكن أن يتغير كل شيء. إعطاء المترجم والجهاز الظاهري فرصة! الحصول على مكافأة ، وتحديد العديد من الأخطاء في الاستخدام غير الصحيح من التعليمات البرمجية الخاصة بك في مرحلة التجميع. قاعدة عامة: كلما كان النظام أكثر وضوحًا ، كلما كانت النتيجة أفضل. على ما يبدو ، مع الناس كذلك.

تؤكد القصة الحقيقية هذه القاعدة ، ولكن إذا قرأت الكسل ، يمكنك تخطي
في إحدى الليالي ، أثناء مشاركته في مشروع هوايته ، حدد لنفسه مهمة زيادة أداء قسم من التعليمات البرمجية أعلى من مستوى معين. لكن هذا الموقع كان قصيرًا وكان هناك القليل من الخيارات لما يجب فعله به. لقد وجدت في الوثائق أنه ، بدءًا من الإصدار C # 7.2 ، يمكن استخدام الكلمة "للقراءة فقط" في الهياكل. وفي حالتي ، تم استخدام هياكل ثابتة ، وذلك بإضافة كلمة واحدة "للقراءة فقط" حصلت على ما أردت ، حتى مع وجود هامش! النظام ، مع العلم أن بنائي غير مقصود تغييره ، كان قادرًا على إنشاء رمز أفضل لحالتي.

10. إذا أمكن ، استخدم إصدارًا واحدًا من .NET لجميع مشاريع الحلول


يجب عليك السعي لضمان أن جميع التجميعات داخل التطبيق الخاص بك تنتمي إلى نفس الإصدار من .NET. ينطبق هذا على كل من حزم NuGet (التي تم تحريرها في package.config / json) والتجمعات الخاصة بك (التي تم تحريرها في خصائص المشروع). سيؤدي ذلك إلى حفظ ذاكرة الوصول العشوائي وتسريع بدء التشغيل "البارد" ، كما في ذاكرة التطبيق الخاص بك لن يكون هناك نسخ من نفس المكتبات لإصدارات مختلفة من .NET. تجدر الإشارة إلى أنه في جميع الحالات ، ستنشئ إصدارات مختلفة من .NET نسخًا في الذاكرة. لكن افترض أن تطبيقًا مبني على نفس الإصدار من .NET أفضل دائمًا. أيضا ، هذا يلغي عددا من المشاكل المحتملة التي تقع خارج نطاق هذه المقالة. ستساهم أيضًا الإصدارات المدمجة لكل حزم NuGet التي تستخدمها في تحسين أداء التطبيق الخاص بك.

بعض الأدوات المفيدة


ILSpy هي أداة مجانية تسمح لك بمشاهدة شفرة مصدر التجميع المستعادة. إذا كان لدي سؤال حول أي آلية .NET أكثر فاعلية ، أولاً ، أفتح ILSpy (وليس Google أو StackOverflow) ، وهناك بالفعل أرى كيف يتم تنفيذه. على سبيل المثال ، لمعرفة ما هو أفضل استخدام من حيث الأداء لتلقي البيانات عبر HTTP أو فئة HttpWebRequest أو WebClient ، ما عليك سوى إلقاء نظرة على تنفيذها من خلال ILSpy. في هذه الحالة بالذات ، WebClient عبارة عن مجمّع عبر HttpWebRequest ، على التوالي ، تكون الإجابة واضحة. إن رموز مصدر .NET لا تستحق الخوف ، فهي مكتوبة بواسطة نفس المبرمجين العاديين.

BenchmarkDotNet هي مكتبة مجانية من المعايير. هناك ساعة توقيت بسيطة وبديهية (من System.Diagnostics). لكن في بعض الأحيان لا يكفي. نظرًا لأنه من الضروري أن تؤخذ في الاعتبار ليس نتيجة واحدة ، ولكن متوسط ​​عدة مقارنات ، فمن الأفضل مقارنة وسيطهم من أجل تقليل تأثير نظام التشغيل. أيضا ، تحتاج إلى أن تأخذ في الاعتبار "البداية الباردة" ومقدار الذاكرة المخصصة. لمثل هذه الاختبارات المعقدة ، تم إنشاء BenchmarkDotNet. هذه هي المكتبة التي يستخدمها مطورو .NET Core في الاختبارات الرسمية. المكتبة سهلة الاستخدام ، ولكن إذا قرأ مؤلفوها هذا المنشور فجأة ، فيرجى إعطاء فرصة أكثر ملاءمة للتأثير على هيكل جدول النتائج.

U2U Consult Performance Analyzers هو مكون إضافي مجاني لبرنامج Visual Studio يوفر نصائح حول تحسين الكود من حيث الأداء. 100 ٪ تعتمد على نصيحة هذا المحلل لا يستحق كل هذا العناء. بما أنني واجهت موقفًا فاجأتني فيه نصيحة واحدة قليلًا وبعد تحليل مفصل تبين أنه خاطئ حقًا. لسوء الحظ ، يتم فقد هذا المثال ، لذلك خذ كلمة. ومع ذلك ، إذا كنت تستخدمها بعناية ، فهي أداة مفيدة للغاية. على سبيل المثال ، سيقترح أنه بدلاً من myStr.Replace("*", "-") استخدام myStr.Replace('*', '-') أكثر فعالية. والاثنان حيث يتم دمج التعبيرات في LINQ بشكل أفضل في واحدة. هذه كلها "تحسين على التطابقات" ، لكنها سهلة التطبيق ولا تؤدي إلى زيادة في الشفرة / التعقيد.

في الختام


إذا كان كل شخص عاشر يقرأ المقال يطبق النهج أعلاه على مشروعه الحالي (أو جزء مهم منه) ، ويتقيد أيضًا بهذه الطرق في المستقبل ، فبإمكاننا معًا إنقاذ الغابة بأكملها! الغابات ؟؟ أي الموارد الموفرة لأنظمة الكمبيوتر ، في شكل كهرباء تم الحصول عليها من حرق الأخشاب ، ستظل غير مستخدمة. في هذه الحالة ، تعتبر "الغابة" مجرد نوع من المكافئ. ربما تم التوصل إلى نتيجة غريبة ، لكنني آمل أن تكون مستوحى من الفكرة.

PS تحديث بناء على تعليقات آخر


تعد ميزة ToArray over ToList مناسبة لـ .NET Core. ولكن إذا كنت تستخدم .NET Framework القديم ، فمن المحتمل أن تكون قائمة "ToList" هي الأفضل بالنسبة لك. المشكلة هي أنه في .NET Framework ، فإن استدعاء ToArray نفسه أبطأ بكثير من استدعاء القائمة. وقد لا يتم تعويض هذه الخسائر عن طريق الوصول السريع إلى العناصر وتخزين أقل في الصفيف. بشكل عام ، اتضح أن هذه المشكلة أكثر تعقيدًا ، لأن الفئات المختلفة التي تطبق IEnumerable قد يكون لها تطبيقات مختلفة لـ ToArray و ToList ، مع مستويات مختلفة من الكفاءة.

إذا تم استخدام التعداد الموروث من البايت كعضو في فئة (بنية) ، وليس بشكل منفصل ، فقد لا يكون هناك أي حفظ الذاكرة. بسبب محاذاة الذاكرة المحتلة لجميع أعضاء الفئة (بنية). هذه النقطة مفقودة في المقال. ومع ذلك ، فإن المكسب المحتمل أفضل من غيابه ، لأنه بالإضافة إلى الذاكرة المشغولة ، يتم استخدام التعداد أيضًا. لذلك ، لا تزال الفقرة 4 ذات صلة ، ولكن مع هذا التحفظ المهم.

بفضل KvanTTT و epetrukhin للتعليقات البناءة على هذه القضايا.

كما لاحظ Taritsyn ، لا يزال التحسين في مرحلة تجميع JIT للكلمة الرئيسية "المختومة" موجودًا. ولكن ، هذا يؤكد فقط جميع أطروحات الفقرة التاسعة.

يبدو أن جميع التعليقات البناءة قد أخذت بعين الاعتبار. أنا سعيد جداً بهذه التعليقات. منذ أن تلقيت نفسي ، كمؤلف ، تعليقات وتعلمت شيئًا جديدًا بنفسي.

Source: https://habr.com/ru/post/ar466931/


All Articles