بدء مثال
أدناه سنكتب تطبيقًا بسيطًا في java (استخدم المؤلف java 14 ، لكن java 8 جيد أيضًا) ، ونقيس أداءه باستخدام عدادات داخل التطبيق ، ونحاول تحسين النتيجة عن طريق تنفيذ الكود في عدة سلاسل. كل ما هو مطلوب لإعادة إنتاج المثال هو أي بيئة تطوير جافا أو مجرد jdk وأداة visualvm التي ستساعدنا في تشخيص المشكلات التي نشأت. لا يستخدم المثال عمدًا معايير مختلفة لقياس الأداء والأدوات المتقدمة الأخرى - في هذه الحالة ، تكون غير ضرورية. تم تشغيل حالة الاختبار تحت Windows على معالج Intel Core i7 مع 4 نوى مادية و 8 نوى منطقية.
لذلك ، دعونا ننشئ تطبيقًا بسيطًا ، في حلقة ، سينفذ مهمة حسابية تثقل كاهل المعالج ، وهي حساب العامل. علاوة على ذلك ، ستحسب كل مهمة في الحلقة أيضًا عاملي رقم في النطاق من 1 إلى 25. يتم أخذ النطاق العائم لتقريب المثال من الواقع. يوجد أدناه رمز وظيفة work ():
void work(int power) {
for (int i = 0; i < power; i++) {
long result = factorial(RandomUtils.nextInt(1, 25));
}
if (counter.incrementAndGet() % LOG_STEP == 0) {
System.out.printf("%d %d %n", counter.longValue(), (long) ((System.currentTimeMillis() - startTime) / 1000));
}
}
تستقبل الوظيفة كمدخل عدد الدورات لحساب العامل المحدد بواسطة ثابت:
private static final int POWER_BASE = 1000000;
بعد الانتهاء من عدد معين من المهام المحددة في المتغير
private static final int LOG_STEP = 10;
يتم تسجيل عدد المهام المكتملة والوقت الإجمالي لتنفيذها. تستخدم الدالة
work () أيضًا:
//
private long startTime;
//
private AtomicLong counter = new AtomicLong();
//
private long factorial(int power) {
if (power == 1) return power;
else return power * factorial(power - 1);
}
وتجدر الإشارة إلى أن تنفيذ وظيفة work () لمرة واحدة في مؤشر ترابط واحد يستغرق حوالي 20 مللي ثانية ، لذا فإن الاستدعاء المتزامن للعداد المتغير المشترك في النهاية ، والذي يمكن أن يكون عنق زجاجة ، لا يخلق مشاكل ، لأنه يحدث لكل مؤشر ترابط لا يزيد عن 20 مرة مللي ثانية ، والذي يتجاوز بشكل كبير وقت تنفيذ counter.incrementAndGet (). بمعنى آخر ، يجب ألا يؤثر الخلاف بين الخيوط المرتبطة بالوصول إلى عداد متزامن بشكل كبير على نتائج التجربة ويمكن إهماله.
لنقم بتشغيل الكود التالي في خيط واحد ونرى النتيجة:
startTime = System.currentTimeMillis();
for (int i = 0; i < Integer.MAX_VALUE; i++) {
work(POWER_BASE);
}
في وحدة التحكم ، نرى الناتج التالي:
10 مهام مكتملة في 0 ثانية
...
100 مهمة مكتملة في ثانيتين
...
500 مهمة مكتملة في 10 ثوانٍ
لذلك ، في سلسلة واحدة ، حصلنا على أداء يساوي 50 مهمة في الثانية أو 20 مللي ثانية لكل مهمة.
رمز مواز
إذا حصلنا على الأداء X في مؤشر ترابط واحد ، ثم على 4 معالجات ، في حالة عدم وجود حمل إضافي ، يمكننا أن نتوقع أن يكون الأداء حوالي 4 * X ، أي أنه سيزداد بمقدار 4 مرات. يبدو منطقيا تماما. حسنًا ، دعنا نحاول!
تقديم تجمع بسيط مع عدد ثابت من المواضيع:
private ExecutorService executorService = Executors.newFixedThreadPool(POOL_SIZE);
ثابت:
private static final int POOL_SIZE = 1;
سنغير النطاق من 1 إلى 16 ونصلح النتيجة.
إعادة تصميم رمز التشغيل:
startTime = System.currentTimeMillis();
for (int i = 0; i < Integer.MAX_VALUE; i++) {
executorService.execute(() -> work(POWER_BASE));
}
بشكل افتراضي ، يكون حجم قائمة انتظار المهام في تجمع مؤشرات الترابط عددًا صحيحًا .MAX_VALUE ، لا نضيف أكثر من مهام Integer.MAX_VALUE إلى تجمع مؤشرات الترابط ، لذلك يجب ألا تتجاوز قائمة انتظار المهام.
اذهب!
أولاً ، لنضبط ثابت POOL_SIZE على 8 سلاسل:
private static final int POOL_SIZE = 8;
قم بتشغيل التطبيق وانظر إلى وحدة التحكم: تم
إكمال 10 مهام في 3 ثوانٍ
20 مهمة في 6 ثوانٍ
30 مهمة مكتملة في 8 ثوانٍ
40 مهمة في 10 ثوانٍ تم
إكمال 50 مهمة في 14 ثانية
60 مهمة في 16 ثانية
70 مهمة مكتملة في 19 ثانية
80 اكتملت المهام في 20 ثانية
90 مهمة اكتملت في 23 ثانية
100 مهمة في 24 ثانية
110 مهمة اكتملت في 26 ثانية
120 مهمة اكتملت في 28 ثانية
130 مهمة مكتملة في 29 ثانية
140 مهمة في 31 ثانية
150 مهمة مكتملة في 33 ثانية
160 مهمة مكتملة في 36 ثانية ،
اكتملت 170 مهمة في 46 ثانية
ماذا نرى؟ بدلاً من الزيادة المتوقعة في الأداء ، انخفض بأكثر من 10 مرات من 20 مللي ثانية لكل مهمة إلى 270 مللي ثانية. لكن هذا ليس كل شيء! الرسالة حول 170 مهمة مكتملة هي الأخيرة في السجل. ثم يبدو أن التطبيق قد توقف تمامًا.
قبل التعامل مع أسباب هذا السلوك الغريب للبرنامج ، دعونا نفهم الديناميكيات ونزيل السجل بالتسلسل لـ 4 و 16 مؤشر ترابط من خلال ضبط ثابت POOL_SIZE على القيم المناسبة.
سجل لـ 4 سلاسل: تم
إكمال 10 مهام في ثانيتين
20 مهمة مكتملة في 4 ثوان
30 مهمة في 6 ثوان
40 مهمة مكتملة في 8 ثوان
50 مهمة مكتملة في 10 ثوانٍ
60 مهمة مكتملة في 13 ثانية
70 مهمة مكتملة في 15 ثانية
80 مهمة اكتملت في 18 ثانية ،
اكتملت 90 مهمة في 21 ثانية ،
اكتملت 100 مهمة في 33 ثانية
، اكتملت أول 90 مهمة في نفس الوقت تقريبا لـ 8 سلاسل ، ثم تطلب 12 ثانية أخرى لإكمال 10 مهام أخرى وتوقف التطبيق.
سجل لـ 16 سلسلة: تم
إكمال 10 مهام في ثانيتين
20 مهمة في 3 ثوان
30 مهمة في 6 ثوان
40 مهمة في 8 ثوان
...
290 مهمة مكتملة في 51 ثانية
300 مهمة مكتملة في 52 ثانية
310 مهمة مكتملة في 63 ثانية
بعد الانتهاء بالنسبة لـ 310 مهمة ، تم تجميد التطبيق ، وكما في الحالات السابقة ، استغرق إكمال آخر 10 مهام أكثر من 10 ثوانٍ.
دعونا نلخص:
يؤدي تنفيذ المهام بالتوازي إلى تدهور الأداء بمقدار 10 مرات أو أكثر ،
وفي جميع الحالات ، يتوقف التطبيق وكلما قل عدد الخيوط كلما توقف بشكل أسرع (سنعود إلى هذه الحقيقة)
ابحث عن المشاكل
من الواضح أن هناك خطأ ما في الكود الخاص بنا. لكن كيف تجد السبب؟ للقيام بذلك ، سنستخدم الأداة المساعدة visualvm. وسنقوم بتشغيله قبل تنفيذ تطبيقنا ، وبعد تشغيل التطبيق سننتقل إلى عملية java المطلوبة في واجهة visualvm. يمكن تشغيل التطبيق مباشرة من بيئة التطوير. بالطبع ، هذا خطأ بشكل عام ، لكن في مثالنا لن يؤثر على النتيجة.
بادئ ذي بدء ، ننظر إلى علامة التبويب الشاشة ونرى أن هناك خطأ ما في الذاكرة.
في أقل من دقيقة ، نفدت ذاكرة 4 جيجابايت ببساطة! لذلك ، توقف التطبيق. لكن أين ذهبت الذكرى؟
أعد تشغيل التطبيق واضغط على زر Heap Dump في علامة التبويب Monitor. بعد إزالة وفتح ملف تفريغ الذاكرة نرى:
في قسم الفئات حسب حجم المثيلات ، تشغل فئة LinkedBlockingQueue $ Node أكثر من 1 جيجابايت. إنه ليس أكثر من قمة قائمة انتظار مهام تجمع مؤشرات الترابط. ثاني أكبر فئة هي المهمة نفسها التي يتم إضافتها إلى تجمع مؤشرات الترابط. دعماً لذلك ، في قسم الفئات حسب عدد المثيلات ، نرى التطابق بين عدد مثيلات الفئتين الأولى والثانية (التطابق ليس دقيقًا تمامًا ، على ما يبدو يرجع ذلك إلى حقيقة أنه تم إنشاء مهمة أولاً ، وبعد ذلك فقط قمة جديدة من قائمة الانتظار ، وبسبب فارق التوقيت مضروبًا في عدد الخيوط ، لدينا اختلاف طفيف في عدد الحالات).
لنعد الآن. نقوم بإنشاء حوالي 2 مليار مهمة في حلقة (Integer.MAX_VALUE) ، أي حوالي 2 جيجابايت من المهام. يتم تنفيذ المهام بشكل أبطأ مما تم إنشاؤه ، لذلك يستمر حجم قائمة الانتظار في النمو. حتى لو كانت كل مهمة تتطلب 8 بايت فقط من الذاكرة ، فإن الحد الأقصى لحجم قائمة الانتظار سيكون:
8 * 2 جيجابايت = 16 جيجابايت
مع حجم كومة إجمالي يبلغ 4 جيجابايت ، فليس من المستغرب عدم وجود ذاكرة كافية. في الواقع ، إذا لم نقطع تنفيذ التطبيق الذي توقف سجله ، فبعد فترة سنرى OutOfMemoryError الشهير وحتى بدون visualvm ، بمجرد النظر إلى الكود ، يمكننا تخمين أين تذهب الذاكرة.
لنتذكر أنه كلما قل عدد سلاسل الرسائل التي تقوم بتشغيل المهام ، زادت سرعة توقف التطبيق. يمكننا الآن محاولة شرح هذا. كلما قل عدد الخيوط ، زادت سرعة تشغيل التطبيق (لماذا - لا يزال يتعين علينا اكتشاف ذلك) وكلما زادت سرعة ملء قائمة انتظار المهام وامتلاء الذاكرة.
حسنًا ، إصلاح مشكلة تجاوز الذاكرة أمر بسيط للغاية. لنقم بإنشاء ثابت بدلاً من عدد صحيح.
النهائي الثابت الخاص MAX_TASKS = 1024 * 1024 ؛
ودعنا نغير الكود كما يلي:
startTime = System.currentTimeMillis();
for (int i = 0; i < MAX_TASKS; i++) {
executorService.execute(() -> work(POWER_BASE));
}
يبقى الآن تشغيل التطبيق والتأكد من أن كل شيء متوافق مع الذاكرة:
نواصل التحليل
نبدأ تطبيقنا مرة أخرى ، ونزيد تدريجياً عدد الخيوط ونصلح النتيجة.
خيط واحد - 500 مهمة في 10 ثوانٍ
2 سلسلة - 500 مهمة في 21 ثانية
4 سلاسل - 500 مهمة في 37 ثانية
8 خيوط - 500 مهمة في 49 ثانية
16
سلسلة - 500 مهمة في 57 ثانية كما نرى ، وقت تنفيذ 500 مهمة عند الزيادة عدد الخيوط لا ينقص ، بل يزداد ، في حين أن سرعة تنفيذ كل جزء من 10 مهام موحدة ولا تتجمد الخيوط.
دعنا نستخدم الأداة المساعدة visualvm مرة أخرى ونأخذ تفريغ مؤشر الترابط أثناء تشغيل التطبيق. للحصول على صورة أكثر دقة ، من الأفضل أخذ تفريغ عند العمل على 16 سلسلة. هناك أدوات مساعدة مختلفة لتحليل تفريغ سلاسل الرسائل ، ولكن في حالتنا ، يمكنك ببساطة التمرير عبر جميع سلاسل الرسائل باستخدام الأسماء "pool-1-thread-1" و "pool-1-thread-2" وما إلى ذلك في واجهة visualvm وانظر ما يلي:
في وقت الإغراق ، تولد معظم الخيوط الرقم العشوائي التالي لحساب العامل. اتضح أن هذه هي الوظيفة الأكثر استهلاكا للوقت. لماذا اذن؟ لمعرفة ذلك ، دعنا ننتقل إلى الكود المصدري لـ Random.next () ونرى ما يلي:
private final AtomicLong seed;
protected int next(int bits) {
long oldseed, nextseed;
AtomicLong seed = this.seed;
do {
oldseed = seed.get();
nextseed = (oldseed * multiplier + addend) & mask;
} while (!seed.compareAndSet(oldseed, nextseed));
return (int)(nextseed >>> (48 - bits));
}
تشترك جميع مؤشرات الترابط في مثيل واحد من المتغير الأولي ، تتم مزامنة الوصول إليه باستخدام فئة AtomicLong. هذا يعني أنه عند إنشاء كل رقم عشوائي ، يتم وضع سلاسل الرسائل في قائمة الانتظار للوصول إلى هذا المتغير ، بدلاً من التنفيذ بشكل متوازٍ. لذلك ، لا تنمو الإنتاجية. لكن لماذا تسقط؟ الجواب بسيط. عند موازاة التنفيذ ، يتم إنفاق موارد إضافية على دعم المعالجة المتوازية ، على وجه الخصوص ، تبديل سياق المعالج بين مؤشرات الترابط. اتضح أن هناك تكاليف إضافية ظهرت ، ولا تزال الخيوط لا تعمل بالتوازي ، لأنها تتنافس للوصول إلى قيمة المتغير الأولي ويتم وضعها في قائمة الانتظار عند استدعاء seed.compareAndSet (). المنافسة بين الخيوط لمورد محدود ، ربماالسبب الأكثر شيوعًا لتدهور الأداء عند موازاة الحسابات.
دعنا نغير كود وظيفة work () على النحو التالي:
void work(int power) {
for (int i = 0; i < power; i++) {
long result = factorial(20);
}
if (counter.incrementAndGet() % LOG_STEP == 0) {
System.out.printf("%d %d %n", counter.longValue(), (long) ((System.currentTimeMillis() - startTime) / 1000));
}
}
ومرة أخرى تحقق من الأداء على عدد مختلف من الخيوط:
خيط واحد - 1000 مهمة في 17 ثانية
2 موضوع - 1000 مهمة في 10 ثوان
4 خيوط - 1000 مهمة في 5 ثوان
8 خيوط - 1000 مهمة في 4 ثوان
16 موضوع - 1000 مهمة في 4 ثوان
الآن النتيجة قريبة من توقعاتنا. زاد الأداء على 4 خيوط بحوالي 4 مرات. علاوة على ذلك ، توقفت الزيادة في الأداء عمليًا لأن الموازاة محدودة بموارد المعالج. دعنا نلقي نظرة على الرسوم البيانية لتحميل المعالج ، والتي تم التقاطها من خلال visualvm عند العمل على 4 و 8 سلاسل.
كما يتضح من الرسوم البيانية ، مع 4 خيوط ، أكثر من 50٪ من موارد المعالج مجانية ، ومع 8 خيوط ، يتم استخدام المعالج بنسبة 100٪ تقريبًا. هذا يعني أنه في هذا المثال ، 8 مؤشرات ترابط هي الحد الأقصى ، وسيقل الأداء الإضافي فقط. في مثالنا ، توقف نمو الأداء بالفعل على 4 مؤشرات ترابط ، ولكن إذا قامت الخيوط ، بدلاً من حساب العامل ، بإجراء إدخال / إخراج متزامن ، فعلى الأرجح ، يمكن زيادة حد الموازاة الذي يعطي عنده مكاسب في الأداء بشكل ملحوظ. يمكن للقراء التحقق من ذلك بأنفسهم وكتابة النتيجة في التعليقات على المقالة
إذا تحدثنا عن الممارسة ، فيمكن ملاحظة نقطتين مهمتين:
عادةً ما يكون التوازي فعالاً عندما يصل عدد الخيوط إلى ضعف عدد نوى المعالج (بالطبع ، في حالة عدم وجود حمل آخر للمعالج)
يجب ألا يتجاوز استخدام وحدة المعالجة المركزية في الممارسة العملية 80٪ لضمان التسامح مع الخطأ
تقليل الخلاف بين الخيوط
من خلال الحديث عن الأداء ، نسينا شيئًا أساسيًا واحدًا. من خلال تغيير الاستدعاء إلى RandomUtils.nextInt () في الكود إلى ثابت ، قمنا بتغيير منطق العمل لتطبيقنا. دعنا نعود إلى الخوارزمية القديمة مع تجنب مشكلات الأداء. لقد اكتشفنا أن استدعاء RandomUtils.nextInt () يتسبب في استخدام كل من الخيوط المتغيرة الأولية نفسها لتوليد رقم عشوائي ، وفي الوقت نفسه ، هذا اختياري تمامًا. استخدام في مثالنا بدلاً من
RandomUtils.nextInt(1, 25)
فئة ThreadLocalRandom:
ThreadLocalRandom.current().nextInt(1, 25)
سيحل مشكلة المنافسة. الآن سيستخدم كل مؤشر ترابط مثيله الخاص من المتغير الداخلي المطلوب لإنشاء الرقم العشوائي التالي.
يعد استخدام متغير منفصل لكل مؤشر ترابط ، بدلاً من الوصول المتزامن إلى مثيل واحد للفئة المشتركة بين سلاسل العمليات ، أسلوبًا شائعًا لتحسين الأداء عن طريق تقليل التنازع بين سلاسل العمليات. يمكن استخدام فئة java.lang.ThreadLocal لتخزين قيم المتغيرات في سياق سلسلة رسائل ، على الرغم من وجود أدوات أكثر تقدمًا ، على سبيل المثال ، سياق التشخيص المعين.
في الختام ، أود أن أشير إلى أن تقليل المنافسة بين الخيوط ليس مهمة فنية فحسب ، بل مهمة منطقية أيضًا. في مثالنا ، يمكن لكل مؤشر ترابط استخدام مثيله المتغير دون أي مشاكل ، ولكن ماذا لو احتجنا إلى مثيل واحد للجميع ، على سبيل المثال ، عداد مشترك؟ في هذه الحالة ، سيتعين عليك إعادة تشكيل الخوارزمية نفسها. على سبيل المثال ، قم بتخزين العداد في سياق كل دفق وحساب قيمة العداد الإجمالية بشكل دوري أو عند الطلب بناءً على قيم العدادات لكل دفق.
خاتمة
إذن ، هناك 3 نقاط تؤثر على أداء المعالجة المتوازية:
- موارد وحدة المعالجة المركزية
- المنافسة بين الخيوط
- عوامل أخرى تؤثر بشكل غير مباشر على النتيجة الإجمالية