انتقل إلى المحتوى

ابحث في CooLWJ

选择搜索范围,输入关键词后按回车。

اللغة

التقنية من منظور شامل-تاريخ تطور تعدد الخيوط في Java

مقدّمة

في 22 من شهر 09 عام 2022، أُصدر JDK19، وكانت أبرز ميزاته دعم الخيوط الافتراضية، لينضم عضو بارز آخر إلى عائلة الخيوط خفيفة الوزن. وقد حررت الخيوط الافتراضية JVM من قيد جدولة الخيوط عبر نظام التشغيل، ليجدولها JVM بنفسه. وفي الواقع، سبق لشركة sun في بداياتها أن نفذت جدولة الخيوط بواسطة JVM في الآلة الافتراضية على نظام Solaris، لكن نظرًا إلى التعقيد واعتبارات قابلية الصيانة، عادت في النهاية إلى نمط جدولة الخيوط بواسطة نظام التشغيل.

«长安归来锦衣客,昨日城南起新宅。» (عاد من تشانغآن ضيف يرتدي الحرير الفاخر، وبالأمس أقام دارًا جديدة جنوب المدينة.) عندما أتأمل هذه الرحلة، تبدو مفاهيم تعدد الخيوط مبهرة حد الإرباك، والشروح المتعلقة بها على الإنترنت لا تُحصى، لكنني أشعر دائمًا بغياب منظور شامل. لذلك رتبت بصورة منهجية تاريخ تطور تعدد الخيوط في Java، آملًا أن يساعدك ذلك على إتقان معارفه.

ما لا يتناوله هذا المقال:

1 لا يتناول المبادئ التفصيلية لتنفيذ بعض النقاط التقنية، ولا يحلل الشيفرة المصدرية، ولا يرسم مخططات، فإذا وجدت في هذا المقال مفاهيم وتقنيات تهمك، يمكنك البحث عنها بنفسك 2 لا يتناول المكتبات وأطر العمل الداعمة للتزامن، مثل Quasar وAkka وGuava وغيرها

ما الذي تتناوله هذه المقالة

1 عرض تاريخ تطور تعدد الخيوط في JDK 2 شرح وظائف بعض التقنيات خلال هذا التطور ومبادئ عملها وخلفياتها والمشكلات التي حلّتها 3 عرض آراء الكاتب بشأن بعض الجوانب التقنية، ونرحب بمناقشة أصحاب الآراء المختلفة في قسم التعليقات

محطة بارزة

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

عصرالإصداروقت النشرالمفاهيم الأساسية
الوضع اليدويJDK1.01996-01-23Thread وRunnable
الوضع اليدويJDK1.21998-12-04ThreadLocal、Collections
الوضع التلقائيJDK1.5/5.02004-09-30توضيح نموذج ذاكرة Java، وإدخال حزمة التزامن
الوضع التلقائيJDK1.6/6.02006-12-11تحسين synchronized
الوضع التلقائيJDK1.7/7.02011-07-28إطار عمل Fork/Join
الوضع التلقائيJDK1.8/8.02014-03-18CompletableFuture، Stream
الوضع التلقائيJDK1.9/9.02014-09-08تحسين آلية التنافس على الأقفال
الوضع التلقائيJDK102018-03-21التحكّم المحلي لكل خيط تنفيذ
الوضع التلقائيJDK152020-09-15تعطيل الأقفال المنحازة وإهمالها تدريجيًا
القيادة الذاتيةJDK192022-09-22الخيوط الافتراضية

2f98c71c0436418d8d260d75cb921c81~tplv-73owjymdk6-jj-mark-v1_0_0_0_0_5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5Lqs5Lic5LqR5byA5Y-R6ICF_q75.webp

عصر ناقل الحركة اليدوي

يسمي الكاتب عصر JDK1.4 وما قبله عصر «ناقل الحركة اليدوي» لتعدد الخيوط، ويُعرف أيضًا بعصر تعدد الخيوط الأصلي. كانت عمليات الخيوط لا تزال بدائية نسبيًا، ولم تكن هناك مجمعات خيوط متاحة. وكان على المطورين كتابة أدوات بأنفسهم لتجنب هدر الموارد الناتج عن إنشاء الخيوط بشكل متكرر، ووضع أقفال على الموارد المشتركة يدويًا. وفي هذا العصر تحديدًا تبلورت أطر ممتازة عديدة لتعدد الخيوط، واعتمد JDK5.0 أشهرها.

Thread وRunnable في JDK 1.0

منذ إصدار JDK1.0 في الشهر 1 من عام 1996، أُرسيت أسس نموذج الخيوط الأساسي في Java، ولم يتغيّر هذا النموذج تغييرًا جوهريًا خلال التعديلات والإصلاحات اللاحقة، لذا يمكن وصفه بأنه تصميم جيد حافظ على استمراريته. الجدولة الاستباقية والتعاونية طريقتان شائعتان لجدولة العمليات والخيوط. وتناسب الجدولة الاستباقية أنظمة التشغيل كثيرًا، إذ تخصّص شرائح زمنية للعمليات المختلفة، وتستطيع سحب الموارد من عملية لا تستجيب لفترة طويلة، بل وإيقافها قسرًا. أما الجدولة التعاونية فتتطلّب من العملية أن تحرّر الموارد طوعًا وبمبادرة منها، وفي هذا النمط قد يتسبّب خيط يستغرق تنفيذه وقتًا طويلًا في «تجويع» جميع الخيوط الأخرى التي تحتاج إلى CPU. تستخدم آلة Java HotSpot الافتراضية الجدولة الاستباقية، ولذلك اعتمدت Java جدولة الخيوط الاستباقية منذ بدايتها. وفي JDK 1.0، كانت الطريقة الأساسية لإنشاء خيط هي وراثة الفئة Thread أو تنفيذ الواجهة Runnable، ثم تشغيله عبر الدالة start لكائن الفئة، ووضع الشيفرة المطلوب تنفيذها بالتوازي في الدالة run، مع استخدام أساليب مباشرة وفجّة مثل stop/resume/suspend للتعاون والتواصل بين الخيوط.

رسم توضيحي لحالات خيوط Java والانتقالات بينها

كيف نشرح مفاهيم stop/resume/suspend؟ المقصود أن الخيط الرئيسي يستطيع استدعاء طرائق إنهاء الخيط الفرعي وإيقافه مؤقتًا واستئنافه مباشرةً. إذا كنت قد استخدمت جهاز ووكمان في طفولتك، فستعرف أن فيه ثلاثة أزرار: الإيقاف، والإيقاف المؤقت، والاستئناف. تخيل أنك تستمع إلى 3 أجهزة ووكمان في الوقت نفسه؛ الأجهزة الثلاثة هي ثلاثة خيوط فرعية، وأنت الخيط الرئيسي، ويمكنك التحكم بحرية في تشغيل هذه الأجهزة الثلاثة وإيقافها.

لهذه الآلية مشكلة قاتلة، وهي سهولة حدوث توقف تام متبادل، والسبب هو أن الخيط A قد يقفل موردًا، ثم يعلّقه الخيط الرئيسي قبل أن يحرّره(فالطريقة suspend لا تحرّر القفل)، وعندئذ إذا أراد الخيط B الاستحواذ على هذا المورد، فلن يكون أمامه إلا انتظار استئناف الخيط A عمله(resume)ثم تحرير المورد، وإلا فلن يحصل عليه أبدًا، فيحدث التوقف التام المتبادل.

JDK 1.2

مُنع استخدام آلية stop/resume/suspend الخشنة في هذا الإصدار، واستُعيض عنها بأساليب مثل wait/notify/sleep التي تتعاون فيها خيوط تنفيذ متعددة. ويجدر بالذكر أن تصميم الكائنات الذرية AtomicityXXX كان قد اكتمل في هذا الإصدار، وكان هدفها الأساسي معالجة مشكلة عدم ذرية i++. وقد أضاف إدخال ThreadLocal وCollections طرقًا أخرى لاستخدام تعدد الخيوط، وبفضل هاتين التقنيتين أسمي هذا العصر عصر الشحن التوربيني لـ Java.

ThreadLocal

يمثل ThreadLocal حلًا يتيح لعدة خيوط تنفيذ مشاركة كائنات غير آمنة للخيوط من دون استخدام الأقفال. وهو لا يحل مشكلات من قبيل «زيادة رصيد حساب مصرفي أو المخزون وإنقاصه»، لكنه يجيد تنفيذ أساليب الفئات ذات الطابع «الأداتي» بأمان من خلال إنشاء نسخ منها. ومن أمثلتها المعتادة SimpleDateFormat واتصالات قواعد البيانات وغيرها. ومن الجدير بالذكر أن تصميمه بارع جدًا؛ تخيل أنك ستصممه، فالفكرة البسيطة المعتادة هي الاحتفاظ داخل ThreadLocal بخريطة Map عامة آمنة للخيوط، يكون مفتاحها خيط التنفيذ وقيمتها الكائن المشترك. لكن لهذا التصميم عيبًا يتمثل في تسرب الذاكرة، لأن هذه الخريطة ستتضخم بلا حدود مع انضمام مزيد من الخيوط. ولحل تسرب الذاكرة، يجب تنظيف الخريطة عند انتهاء الخيط، وهو ما يتطلب بدوره تعزيز قدرة جمع القمامة GC، ومن الواضح أن العائد لا يتناسب مع الجهد. لذلك صُمم ThreadLocal بحيث يحتفظ خيط التنفيذ Thread نفسه بالخريطة، بدلًا من أن يحتفظ بها ThreadLocal. ويكون المفتاح متغير ThreadLocal، والقيمة هي القيمة المخزنة. ويضع كل Thread فيها جميع متغيرات ThreadLoacl التي يستخدمها(وبالطبع لهذا التصميم مشكلات أخرى مترتبة عليه لا نتناولها هنا، ويمكن للمهتمين البحث عنها بأنفسهم).

Collections

صُمّمت فئة الأدوات Collections في هذا الإصدار، وهي تغلّف بعض المجموعات الآمنة للخيوط مثل SynchronizedList. وفي تلك الحقبة التي لم تتوافر فيها سوى مجموعات آمنة للخيوط مثل Hashtable وVector وStack، كان ظهورها ذا دلالة تاريخية أيضًا. والفكرة الأساسية لأدوات Collections هي أنني أغلّف لك المجموعات غير الآمنة للخيوط لتصبح آمنة، وبذلك لا تحتاج إلى إنفاق وقت طويل لترقية شيفرتك الحالية وتعديلها، بل يكفي أن تهيّئ المجموعة عند إنشائها باستخدام الطريقة التي أوفّرها. وهذا يشبه تقنية الشحن التوربيني للسيارات، التي تزيد قدرة المحرك وعزم دورانه مع بقاء سعته ثابتة. لقد حلّ عصر الشحن التوربيني في Java ^\_^

عصر ناقل الحركة الأوتوماتيكي

JDK 5.0

استيراد حزمة التزامن

دوغ ليا، واسمه بالصينية 道格·利، أستاذ جامعي في الولايات المتحدة وشخصية من كبار الخبراء، وهو من أنشأ J.U.C. قبل JDK1.5، لم يكن بوسعنا استخدام سوى synchronized للتحكم بالوصول المتزامن إلى الشيفرة المتزامنة، ولم يكن أداء synchronized قد حُسّن جيدًا آنذاك، فكان ضعيفًا، كما لم يكن التحكم بالخيوط ممكنًا إلا باستخدام طريقتَي wait وnotify من Object. وفي ذلك الوقت، قدم دوغ ليا مقترح JSR-166 إلى JCP. وقبل تقديم JSR-166، كان قد استخدم شيفرة تؤدي وظائف شبيهة بحزمة J.U.C لأكثر من ثلاث سنوات، وكانت تلك الشيفرة النموذج الأولي لـ J.U.C.

يوفر J.U.C فئات رئيسية من الأدوات، منها الكائنات الذرّية، والأقفال ومجموعات الأدوات المساعدة، وتجمّعات الخيوط، والحاويات الآمنة للاستخدام بين الخيوط. ويمكن للمطورين استخدام أي من هذه الإمكانات بمرونة لبناء منتجاتهم، سواء باستخدام ReentrantLock لبناء أطر عمل أساسية، أو باستخدام أدوات وحاويات جاهزة مباشرة لكتابة شيفرة منطق الأعمال. ومن منظور تاريخي، يمكن بلا جدال وصف J.U.C في عام 2004 بأنه «منتج تقني متطور». فقد قدّم إسهامًا كبيرًا في انتشار Java. لقد حلّ عصر ناقل الحركة الأوتوماتيكي في Java؛ وكما سهّلت السيارات الأوتوماتيكية القيادة، خفّض J.U.C كثيرًا عتبة استخدام المبرمجين لتعدد الخيوط. إنه منتج افتتح عصرًا جديدًا.

وبالطبع، توجد بعض العيوب في J.U.C أيضًا:

استهلاك كبير لموارد CPU:إذا ظلّت محاولات CAS المتكررة بالدوران تفشل لمدة طويلة، فستفرض عبئًا كبيرًا جدًا على CPU.

الحل: تضع بعض المواضع في JUC حدًا لعدد دورات الانتظار النشط في CAS، مثل SynchronousQueue التابعة لـ BlockingQueue.

مشكلة ABA:إذا كانت قيمة ما في الأصل A، ثم أصبحت B، ثم عادت إلى A، فسيجد فحص CAS أنها لم تتغير، مع أنها تغيرت بالفعل؛ وهذه هي مشكلة ABA. في معظم الحالات، لا تؤثر مشكلة ABA في صحة التنفيذ المتزامن للبرنامج.

الحل:نضيف رقم إصدار إلى كل متغير، ونزيده بمقدار 1 عند كل تغيير، أي إن A —> B —> A تصبح 1A —> 2B —> 3A. وتوفر Java الصنف AtomicStampedReference لحل ذلك. يغلّف AtomicStampedReference زوجًا من النوع [E,Integer] ليضع على الكائن ختم إصدار (stamp)، وبذلك يتجنب مشكلة ABA.

لا يمكنه ضمان ذرّية العمليات إلا لمتغير مشترك واحد:لا تضمن آلية CAS إلا ذرية العملية على متغير واحد، ولا تضمن ذرية كتلة الشيفرة بأكملها.

الحل:إذا كان المطلوب مثلًا ضمان تحديث 3 متغيرات معًا بصورة ذرية، فلا بد من استخدام Synchronized. ويمكن أيضًا التفكير في تغليف عدة متغيرات باستخدام AtomicReference، للتعامل بهذه الطريقة مع حالة تعدد المتغيرات المشتركة.

توضيح نموذج ذاكرة Java

أعاد هذا الإصدار من JDK تحديد نموذج ذاكرة Java بوضوح. وقبل ذلك، شملت نماذج الذاكرة الشائعة نموذج الاتساق التسلسلي ونموذج السبق في الحدوث. في نموذج الاتساق التسلسلي، يتطابق ترتيب تنفيذ البرنامج تمامًا مع الترتيب الظاهر في الشيفرة. ومن الصعب جدًا ضمان ذلك في وحدات CPU الحديثة متعددة النوى التي تحسّن تنفيذ التعليمات. كما أن ضمان الاتساق التسلسلي يقيّد بشدة تحسينات JVM للشيفرة أثناء التشغيل.

لكن علاقة الأسبقية في الحدوث (Happens-before) المحددة في مواصفة JSR 133 لهذا الإصدار تجعل ترتيب تنفيذ التعليمات مرنًا:

داخل الخيط نفسه، ووفق ترتيب تنفيذ الشيفرة (أي ترتيب دلالاتها)، تحدث العملية السابقة قبل العملية اللاحقة. تحدث عملية فك القفل على كائن monitor قبل عملية القفل اللاحقة على كائن monitor نفسه. تحدث الكتابة إلى حقل volatile قبل القراءة اللاحقة من هذا الحقل. تحدث عملية start على الخيط (استدعاء الدالة start() لكائن الخيط) قبل أي عملية أخرى لهذا الخيط. تحدث جميع العمليات في خيط ما قبل استدعاء أي خيط آخر للدالة join() على هذا الخيط. إذا حدثت العملية A قبل B، وحدثت B قبل C، فإن A تحدث قبل C

أما في تخصيص الذاكرة، فإن فصل ذاكرة العمل الخاصة بكل خيط عن الذاكرة الرئيسية يمنح JVM مساحة واسعة لتحسين تنفيذ التعليمات داخل الخيط. ويمكن نسخ متغيرات الذاكرة الرئيسية إلى ذاكرة عمل الخيط لتنفيذ العمليات عليها بصورة مستقلة، ثم إعادة كتابة النتائج إلى الذاكرة الرئيسية في وقت ما بعد انتهاء التنفيذ: لكن كيف نضمن اتساق البيانات بين الخيوط المختلفة؟ الحل الذي تقدّمه JLS(مواصفات لغة Java)هو أنه، افتراضيًا، لا يمكن ضمان اتساق البيانات في أي لحظة، لكن يمكن تحقيقه باستخدام الكلمات المفتاحية synchronized وvolatile وfinal التي عُزّزت دلالاتها.

تحسين synchronized في JDK 6.0

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

أتاح هذا التحسين لـ synchronized أن ترفع رأسها بفخر، ومنذ ذلك الحين لم يعد مقبولًا أن يقال إن أداءها أقل من ReentrantLock. لكن المفاجآت لم تنتهِ بعد؛ فقد أُهمل القفل المنحاز في JDK 15(─.─||). أرى أن ما يضع synchronized في موقف أضعف هو كونها مجرد كلمة مفتاحية، إذ تتولى JVM عملياتها الأساسية، وعليها أن «تخمّن» الطريقة الأنسب لاستخدام الأقفال في التطبيق. أما ReentrantLock فتختلف؛ فهي تترك الأمر مباشرة للمبرمج: إن أردت الإنصاف فاستخدم قفلًا منصفًا، وإن أردت عدم الإنصاف فاستخدم قفلًا غير منصف. قد يُعدّ هذا نوعًا من التراخي على مستوى التصميم، لكنه يوفّر مرونة أيضًا.

إطار Fork/Join في JDK 7.0

كان ظهور Fork/Join أيضًا نتاجًا متقدمًا نسبيًا، وتكمن ميزته التنافسية الأساسية في دعم تفكيك المهام بصورة تكرارية ذاتية ثم دمج نتائجها. لكنه تقنية مألوفة وغريبة في آن واحد؛ مألوفة لأنها تُستخدم في مواضع شتى، مثل CompletableFuture وStream اللذين سنتناولهما لاحقًا في JDK8.0؛ وغريبة لأننا قلما نستخدمها، فيما يبدو، أثناء تطوير تطبيقات الأعمال.

بل إن بعضهم يرى أنها قليلة الجدوى. ورأيي أنك إن كنت مطورًا يعمل على متطلبات الأعمال، فهي قليلة الجدوى، لأنك لن تحتاج إليها أساسًا؛ ففي سيناريوهات أحجام البيانات الضخمة توجد أدوات مستودعات البيانات، وفي الحالات الأخرى يمكن استخدام مجمع خيوط بدلًا منها. أما إن كنت مطورًا يكتب أطر البرمجيات الوسيطة، فهي ليست قليلة الجدوى، وقد تحتاج إليها. قلّما يشكك أحد في هذه التقنية على الإنترنت الصيني، لكن هناك من يناقشها بالفعل في الخارج، ويمكن للمهتم الانتقال مباشرةً للاطلاع هل إطار Fork-Join في Java معطّل؟

JDK 8.0

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

CompletableFuture

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

الخلاصة:الميزة التنافسية الأساسية لـ CompletableFuture هي تنسيق المهام。يرث CompletableFuture خصائص واجهة Future، ومنها القدرة على تنفيذ المهام بالتزامن، وهذه القدرات تتوفر لها بدائل. لكن قدرته على تنسيق المهام لا بديل لها، إذ صُممت واجهات API الأساسية فيه، بما يشمل بناء سلاسل المهام ودمج نتائجها، من أجل تنسيق المهام. لذا فإن السبب الرئيسي لإدخال JDK هذا الإطار في هذا الإصدار هو معالجة الحاجة المتزايدة إلحاحًا إلى تنسيق المهام في تطوير تطبيقات الأعمال.

وأخيرًا، أضيف أن CompletableFuture يعتمد في تنفيذه الداخلي على إطار Fork/Join.

Stream

ورد في كتاب «العمارة النظيفة» أن هناك ثلاثة أنماط للبرمجة، وهي البرمجة المهيكلة(البرمجة الإجرائية)、والبرمجة كائنية التوجه、والبرمجة الوظيفية. ويُعد Stream أحد تجليات البرمجة الوظيفية في لغة Java. وأرى أن إتقان Stream خطوة لا بد منها لانتقال المبرمج المبتدئ إلى المستوى المتوسط؛ فمن المؤكد أنك ستشعر بغربة شديدة عند التعامل معه أول مرة، لكنك بعد الاعتياد عليه ستنفتح على طريقة جديدة في التفكير بالبرمجة. وكثيرًا ما نخلط، نحن المطورين، بين ثلاثة مفاهيم:البرمجة الوظيفية、وStream、وتعبيرات Lambda، ونظن دائمًا أنها تشير إلى الشيء نفسه. وفيما يلي فهمي لها:

•البرمجة الوظيفية نهج في البرمجة، وله تطبيقات في مختلف لغات البرمجة

•Stream ميزة جديدة في JDK8.0، ويمكن فهمها أيضًا بوصفها مفهومًا مستحدثًا يهدف إلى مواكبة فكرة البرمجة الوظيفية؛ فمن خلال Stream يمكن تطبيق البرمجة الوظيفية على أصناف المجموعات

•تعبير Lambda هو دالة مجهولة الاسم، تتيح التعبير عن البرمجة الوظيفية بإيجاز وكفاءة أكبر

بعد كل هذا، ما علاقة Stream بتعدد الخيوط؟ تُنفَّذ أساليب المعالجة المتوازية ذات الصلة في Stream باستخدام إطار Fork/Join في الطبقات الأساسية. ويتضمن كتاب «Effective Java» توصية ذات صلة تقول «توخَّ الحذر عند استخدام المعالجة المتوازية في Stream»، لأن جميع العمليات المتوازية تعمل ضمن مجمّع Fork/Join مشترك، وقد يؤثر التشغيل غير الطبيعي لأحد مسارات المعالجة في أداء أجزاء أخرى لا صلة لها به.

JDK 9.0

تحسين آلية التنافس على الأقفال

يحد التنافس على الأقفال من أداء كثير من تطبيقات Java متعددة الخيوط. وقد حسّنت الآلية الجديدة للتنافس على الأقفال أداء مراقبات كائنات Java، وأثبتت ذلك اختبارات معيارية متعددة(مثل Volano)، وهي اختبارات تتيح تقدير أقصى إنتاجية لـJVM. وعمليًا، حققت الآلية الجديدة نتائج ممتازة في 22 اختبارًا معياريًا مختلفًا. وإذا استُخدمت الآلية الجديدة في Java 9، فسيتحسن أداء التطبيقات كثيرًا. والتفسير المبسط هو أنه عندما تتنافس عدة خيوط على قفل، فقبل التحسين: كانت الخيوط التي تصل متأخرة تتبع جميعها الإجراء القياسي نفسه لانتظار القفل. وبعد التحسين: حين تتعرف JVM إلى بعض الحالات القابلة للتحسين، تتيح مباشرة للخيوط المتأخرة التنافس على انتزاع القفل عبر ما يشبه «مسار كبار الشخصيات».

لشرح مفصل، يُرجى الرجوع إلى: شرح الأقفال المتنازَع عليها – مقاربة من منظور الأداء

التدفقات التفاعلية

التدفقات التفاعلية(Reactive Streams)معيار لمعالجة تدفقات البيانات غير المتزامنة باستخدام الضغط العكسي غير الحاجب، ويوفر مجموعة دنيا من الواجهات والطرائق والبروتوكولات لوصف العمليات والكيانات اللازمة.

ما المقصود بالضغط العكسي غير الحاجب؟ الضغط العكسي هو اختصار لعبارة back pressure؛ وببساطة، يدفع المنتج البيانات إلى المستهلك، وعندما يعجز المستهلك عن معالجتها، يُبلغ المنتج، فيخفض المنتج معدل الإنتاج. وأسهل طريقة لتنفيذ هذه الآلية هي استخدام الحجب، أي إرجاع بيانات الضغط مباشرةً عند الدفع. أما التنفيذ غير الحاجب فيزيد تعقيد التصميم، لكنه يحسّن الأداء أيضًا. ملاحظة: أشعر بأن ترجمة المصطلح إلى «背压» ليست جيدة، ولا يصح استنتاج معناه من حروفه وحدها. أليست «反压»، أي الضغط المضاد، أفضل؟ ^\_^

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

تقدّم التدفقات التفاعلية (Reactive Streams)، عبر تعريف مجموعة من الكيانات والواجهات وطرائق التشغيل البيني، معيارًا لتنفيذ الضغط العكسي غير الحاجب. وتتّبع الأطراف الثالثة هذا المعيار لتقديم حلول ملموسة، ومن أشهرها Reactor وRxJava وAkka Streams وRatpack وغيرها.

JDK 10 التحكم المحلي للخيط

Safepoint وأوجه قصوره:

Safepoint آلية في Hotspot JVM لإيقاف جميع التطبيقات. ولكي تنفذ JVM بعض الأعمال منخفضة المستوى، تحتاج إلى Stop The World، أي إيقاف جميع خيوط التطبيق. لكنها لا توقفها مباشرة وبصورة قسرية، بل ترسل إليها إشارة تعليمات تخبرها بأن عليها التوقف. وحين يصل تنفيذ خيط التطبيق إلى نقطة Safepoint، يمتثل للتعليمات ويستجيب. ولهذا سميت Safepoint. وأُضيفت كلمة safe للتأكيد على أن JVM ستنفذ أعمالًا آمنة تشمل النظام كله، ولذلك وُصفت هذه النقطة بأنها آمنة.

تشمل العمليات التي تتطلب نقطة أمان عامة ما يلي: 1، التوقف لجمع القمامة 2، إلغاء تحسين الشيفرة(Code deoptimization). 3، تفريغ ذاكرة الشيفرة المؤقتة. 4، عند إعادة تعريف الفئات(Class redefinition، مثل التحديث الساخن أو instrumentation). 5، إلغاء الأقفال المنحازة(Biased lock revocation). 6، مختلف عمليات التصحيح(مثل: فحص حالات الجمود أو تفريغ تتبع المكدس وغيرها).

لكن جعل جميع خيوط التنفيذ تتوقف عند أقرب نقطة آمنة safepoint يستغرق في حد ذاته وقتًا طويلًا نسبيًا. ثم أليس إيقاف جميع خيوط التنفيذ أمرًا مفرطًا في التسرّع والتعسّف؟ لذلك قدّم Java10 طريقة لا تتطلب إيقاف جميع خيوط التنفيذ، وهي المصافحة المحلية لخيط التنفيذ (Thread Local Handshake).

مثلًا، يمكن إنجاز الحالات التالية دون إيقاف جميع الخيوط: 1、إلغاء انحياز القفل. لا يتطلب ذلك سوى إيقاف خيط واحد لإلغاء انحياز القفل، دون الحاجة إلى إيقاف جميع الخيوط. 2、تقليل الأثر الإجمالي لاستعلامات التشخيص المختلفة في زمن استجابة الآلة الافتراضية؛ فمثلًا، قد يكون الحصول على تتبّعات المكدس لجميع الخيوط في آلة افتراضية تضم عددًا كبيرًا من خيوط Java عملية بطيئة. 3、أخذ عينات أكثر أمانًا لتتبّع المكدس عبر تقليل الاعتماد على الإشارات (signals). 4、إزالة بعض حواجز الذاكرة عبر المصافحة مع خيوط Java باستخدام ما يُسمّى تقنية مزامنة Dekker غير المتماثلة. فمثلًا، لن تعود «شيفرة تعليم البطاقات المشروط» (conditional card mark code) المستخدمة في G1 وCMS بحاجة إلى «حواجز الذاكرة». وبذلك يمكن تحسين «حاجز الكتابة (write barrier)» الذي يصدره G1، كما يمكن حذف الفروع التي تحاول تجنّب «حواجز الذاكرة».

تعطيل الأقفال المنحازة وإهمالها في JDK 15

لماذا يجري التخلي عن القفل المتحيز؟ لم تعد تحسينات الأداء التي قدمها القفل المتحيز في الماضي تبدو واضحة بالقدر نفسه اليوم. فالتطبيقات التي تستفيد منه غالبًا ما تستخدم واجهات API المبكرة لمجموعات Java ‏(JDK 1.1)، وهذه الواجهات (Hashtable وVector) تجري المزامنة عند كل وصول. وقد أدخل JDK 1.2 مجموعات غير متزامنة لسيناريوهات الخيط الواحد (HashMap وArrayList)، بينما قدم JDK 1.5 هياكل بيانات متزامنة أعلى أداءً لسيناريوهات الخيوط المتعددة. وهذا يعني أن التطبيقات التي تستفيد من القفل المتحيز بسبب المزامنة غير الضرورية قد تشهد تحسنًا كبيرًا في الأداء إذا حُدّثت شيفرتها لاستخدام الأصناف الأحدث. إضافة إلى ذلك، عادةً ما يتحسن أداء التطبيقات المبنية حول طوابير مجمعات الخيوط والخيوط العاملة عند تعطيل القفل المتحيز.

فيما يلي تنفيذ باستخدام واجهات API الخاصة بـ Hashtable وVector: java.lang.Classloader يستخدم Vector java.util.Properties extends Hashtable java.security.Provider extends Properties java.net.URL يستخدم Hashtable java.net.URConnection يستخدم Hashtable java.util.ZipOutputStream يستخدم Vector javax.management.timer.TimerMBean تتضمن الواجهة Vector

عصر القيادة الذاتية

أدخلت الخيوط الافتراضية Java عصر القيادة الذاتية. وتتوفر في لغات كثيرة تقنيات تشبه «الخيوط الافتراضية»، مثل Go وC# وErlang وLua وغيرها، وتُسمى «الروتينات التعاونية». هذه المرة لم تضف Java أي كلمة مفتاحية، بل لم تضف حتى مفهومًا جديدًا. والخيوط الافتراضية أسهل فهمًا بكثير من goroutine والروتينات التعاونية؛ فمن اسمها يمكن تكوين فكرة عما تفعله.

الخيوط الافتراضية في JDK 19

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

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

يشبه مبدأ تنفيذ الخيوط الافتراضية بعض أطر العمل غير المتزامنة، فهو يعتمد أيضًا على مشاركة الخيوط، ومن ثم لا يحتاج بالطبع إلى تجميعها في مجمعات. عند استخدامها، يمكنك اعتبار الخيوط الافتراضية وفيرة بلا حدود، وإنشاء ما تشاء منها دون القلق من حدوث مشكلات. وفوق ذلك، تدعم الخيوط الافتراضية تصحيح الأخطاء، وتدعمها أدوات المراقبة المتعلقة بـ Java، وهذا مهم جدًا. ستقلل الخيوط الافتراضية استهلاك برنامجك للذاكرة بدرجة كبيرة، ويمكن لجميع التطبيقات كثيفة الإدخال والإخراج، مثل خوادم الويب، زيادة إنتاجية الإدخال والإخراج بدرجة كبيرة باستخدام العتاد نفسه. في السابق، كانت ذاكرة بحجم 1 غيغابايت تستوعب 1000 طلب وصول في الوقت نفسه، وبعد استخدام الخيوط الافتراضية، يمكن وفقًا للتصريحات الرسمية التعامل بسهولة مع 100 × ١٠٬٠٠٠ طلب متزامن. أما قدرتها على تحمل ذلك في سيناريو عمل محدد، فتتطلب اختبارات ضغط، لكن حتى إن خفضنا هذا التقدير، ينبغي أن يكون تحقيق 10 × ١٠٬٠٠٠ طلب متزامن سهلًا، دون أن تضطر إلى دفع أي ثمن مقابل ذلك، وربما دون تعديل الشيفرة أصلًا. فالخيوط الافتراضية تتيح لك الاحتفاظ بأسلوب البرمجة التقليدي، أي نمط خيط واحد لكل طلب، واستخدامها كما تستخدم الخيوط العادية، ولا يحتاج البرنامج إلا إلى تعديلات قليلة جدًا. كذلك لم تُدخل الخيوط الافتراضية أي صياغة نحوية جديدة، لذا يمكن القول إن تكلفة تعلمها والانتقال إليها منخفضة للغاية.

تجدر الإشارة إلى أن الخيوط الافتراضية لا تزال تستخدم إطار Fork/Join في بنيتها الأساسية.


التعليقات

جارٍ الإرسال

اختر المظهر المفضل

يُطبّق المظهر على الموقع كله.