Multi-Threading

قم بزيارة blog post للحصول على عرض تقديمي لميزات تعدد الخيوط في جوليا.

Starting Julia with multiple threads

بشكل افتراضي، يبدأ جوليا بموضوع واحد للتنفيذ. يمكن التحقق من ذلك باستخدام الأمر Threads.nthreads():

julia> Threads.nthreads()
1

يتم التحكم في عدد خيوط التنفيذ إما باستخدام وسيط سطر الأوامر -t/--threads أو باستخدام متغير البيئة JULIA_NUM_THREADS. عندما يتم تحديد كلاهما، فإن -t/--threads يأخذ الأولوية.

يمكن تحديد عدد الخيوط إما كعدد صحيح (--threads=4) أو كـ auto (--threads=auto)، حيث يحاول auto استنتاج عدد مفيد من الخيوط للاستخدام (انظر Command-line Options لمزيد من التفاصيل).

Julia 1.5

يتطلب وسيط سطر الأوامر -t/--threads على الأقل Julia 1.5. في الإصدارات الأقدم، يجب عليك استخدام متغير البيئة بدلاً من ذلك.

Julia 1.7

استخدام auto كقيمة لمتغير البيئة JULIA_NUM_THREADS يتطلب على الأقل جوليا 1.7. في الإصدارات الأقدم، يتم تجاهل هذه القيمة.

لنبدأ جوليا مع 4 خيوط:

$ julia --threads 4

دعونا نتحقق من وجود 4 خيوط تحت تصرفنا.

julia> Threads.nthreads()
4

لكننا حاليًا على الخيط الرئيسي. للتحقق، نستخدم الدالة Threads.threadid

julia> Threads.threadid()
1
Note

إذا كنت تفضل استخدام متغير البيئة، يمكنك تعيينه كما يلي في Bash (Linux/macOS):

export JULIA_NUM_THREADS=4

C شل على Linux/macOS، CMD على Windows:

set JULIA_NUM_THREADS=4

باورشل على ويندوز:

$env:JULIA_NUM_THREADS=4

لاحظ أن هذا يجب أن يتم قبل بدء جوليا.

Note

يتم تمرير عدد الخيوط المحدد باستخدام -t/--threads إلى عمليات العامل التي يتم إنشاؤها باستخدام خيارات سطر الأوامر -p/--procs أو --machine-file. على سبيل المثال، julia -p2 -t2 ينشئ عملية رئيسية واحدة مع عمليتي عامل، وجميع العمليات الثلاث لديها 2 خيوط مفعلة. للحصول على تحكم أكثر دقة في خيوط العامل، استخدم addprocs ومرر -t/--threads كـ exeflags.

Multiple GC Threads

يمكن لجمع القمامة (GC) استخدام عدة خيوط. الكمية المستخدمة هي إما نصف عدد خيوط العمل الحاسوبية أو يتم تكوينها إما بواسطة وسيط سطر الأوامر --gcthreads أو باستخدام متغير البيئة JULIA_NUM_GC_THREADS.

Julia 1.10

يتطلب وسيط سطر الأوامر --gcthreads على الأقل جوليا 1.10.

Threadpools

عندما تكون خيوط البرنامج مشغولة بالعديد من المهام للتنفيذ، قد تواجه المهام تأخيرات قد تؤثر سلبًا على استجابة البرنامج وتفاعليته. لمعالجة ذلك، يمكنك تحديد أن المهمة تفاعلية عندما تقوم بـ Threads.@spawn ذلك:

using Base.Threads
@spawn :interactive f()

يجب أن تتجنب المهام التفاعلية تنفيذ العمليات ذات الكمون العالي، وإذا كانت المهام طويلة المدة، يجب أن تتوقف بشكل متكرر.

يمكن بدء Julia مع خيط واحد أو أكثر محجوزة لتشغيل المهام التفاعلية:

$ julia --threads 3,1

يمكن أيضًا استخدام متغير البيئة JULIA_NUM_THREADS بطريقة مشابهة:

export JULIA_NUM_THREADS=3,1

هذا يبدأ جوليا مع 3 خيوط في مجموعة خيوط :default وخيط واحد في مجموعة خيوط :interactive:

julia> using Base.Threads

julia> nthreadpools()
2

julia> threadpool() # the main thread is in the interactive thread pool
:interactive

julia> nthreads(:default)
3

julia> nthreads(:interactive)
1

julia> nthreads()
3
Note

تُرجع النسخة التي لا تأخذ أي معطيات من nthreads عدد الخيوط في مجموعة الخيوط الافتراضية.

Note

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

يمكن استبدال أي من الرقمين أو كليهما بالكلمة auto، مما يجعل جوليا تختار قيمة افتراضية معقولة.

The @threads Macro

دعونا نعمل مثالًا بسيطًا باستخدام خيوطنا الأصلية. دعنا ننشئ مصفوفة من الأصفار:

julia> a = zeros(10)
10-element Vector{Float64}:
 0.0
 0.0
 0.0
 0.0
 0.0
 0.0
 0.0
 0.0
 0.0
 0.0

دعنا نعمل على هذه المصفوفة في نفس الوقت باستخدام 4 خيوط. سيكون لدينا كل خيط يكتب معرف خيطه في كل موقع.

تدعم جوليا الحلقات المتوازية باستخدام الماكرو Threads.@threads. يتم إرفاق هذا الماكرو أمام حلقة for للإشارة إلى جوليا بأن الحلقة هي منطقة متعددة الخيوط:

julia> Threads.@threads for i = 1:10
           a[i] = Threads.threadid()
       end

يتم تقسيم مساحة التكرار بين الخيوط، بعد ذلك يكتب كل خيط معرف خيطه إلى المواقع المخصصة له:

julia> a
10-element Vector{Float64}:
 1.0
 1.0
 1.0
 2.0
 2.0
 2.0
 3.0
 3.0
 4.0
 4.0

لاحظ أن Threads.@threads لا يحتوي على معلمة تقليل اختيارية مثل @distributed.

Using @threads without data-races

تم توضيح مفهوم سباق البيانات في "Communication and data races between threads". في الوقت الحالي، اعلم فقط أن سباق البيانات يمكن أن يؤدي إلى نتائج غير صحيحة وأخطاء خطيرة.

لنقل إننا نريد جعل الدالة sum_single أدناه متعددة الخيوط.

julia> function sum_single(a)
           s = 0
           for i in a
               s += i
           end
           s
       end
sum_single (generic function with 1 method)

julia> sum_single(1:1_000_000)
500000500000

ببساطة إضافة @threads تكشف عن سباق بيانات مع وجود عدة خيوط تقرأ وتكتب s في نفس الوقت.

julia> function sum_multi_bad(a)
           s = 0
           Threads.@threads for i in a
               s += i
           end
           s
       end
sum_multi_bad (generic function with 1 method)

julia> sum_multi_bad(1:1_000_000)
70140554652

لاحظ أن النتيجة ليست 500000500000 كما ينبغي، ومن المحتمل أن تتغير في كل تقييم.

لإصلاح ذلك، يمكن استخدام مخازن محددة للمهمة لتقسيم المجموع إلى أجزاء خالية من السباق. هنا يتم إعادة استخدام sum_single، مع مخزن داخلي خاص به s. يتم تقسيم المتجه المدخل a إلى أجزاء nthreads() للعمل المتوازي. ثم نستخدم Threads.@spawn لإنشاء مهام تقوم بجمع كل جزء بشكل فردي. أخيرًا، نقوم بجمع النتائج من كل مهمة باستخدام sum_single مرة أخرى:

julia> function sum_multi_good(a)
           chunks = Iterators.partition(a, length(a) ÷ Threads.nthreads())
           tasks = map(chunks) do chunk
               Threads.@spawn sum_single(chunk)
           end
           chunk_sums = fetch.(tasks)
           return sum_single(chunk_sums)
       end
sum_multi_good (generic function with 1 method)

julia> sum_multi_good(1:1_000_000)
500000500000
Note

يجب ألا تتم إدارة المخازن بناءً على threadid() أي buffers = zeros(Threads.nthreads()) لأن المهام المتزامنة يمكن أن تتوقف، مما يعني أن مهامًا متزامنة متعددة قد تستخدم نفس المخزن على خيط معين، مما يقدم خطر حدوث سباقات بيانات. علاوة على ذلك، عندما يتوفر أكثر من خيط، قد تتغير المهام الخيط عند نقاط التوقف، وهو ما يعرف بـ task migration.

خيار آخر هو استخدام العمليات الذرية على المتغيرات المشتركة بين المهام/الخيوط، والتي قد تكون أكثر كفاءة اعتمادًا على خصائص العمليات.

Communication and data-races between threads

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

Data-race freedom

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

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

Thread 1:
global b = false
global a = rand()
global b = true

Thread 2:
while !b; end
bad_read1(a) # it is NOT safe to access `a` here!

Thread 3:
while !@isdefined(a); end
bad_read2(a) # it is NOT safe to access `a` here

Using locks to avoid data-races

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

على سبيل المثال، يمكننا إنشاء قفل my_lock، وقفله أثناء تعديل متغير my_variable. يتم ذلك ببساطة باستخدام ماكرو @lock:

julia> my_lock = ReentrantLock();

julia> my_variable = [1, 2, 3];

julia> @lock my_lock my_variable[1] = 100
100

باستخدام نمط مشابه مع نفس القفل والمتغير، ولكن على خيط آخر، فإن العمليات خالية من سباقات البيانات.

يمكننا أن نقوم بالعملية المذكورة أعلاه باستخدام النسخة الوظيفية من lock، بالطريقتين التاليتين:

julia> lock(my_lock) do
           my_variable[1] = 100
       end
100

julia> begin
           lock(my_lock)
           try
               my_variable[1] = 100
           finally
               unlock(my_lock)
           end
       end
100

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

Atomic Operations

تدعم جوليا الوصول إلى القيم وتعديلها ذاتيًا، أي بطريقة آمنة للخيوط لتجنب race conditions. يمكن لف قيمة (يجب أن تكون من نوع بدائي) أن تُغلف كـ Threads.Atomic للإشارة إلى أنه يجب الوصول إليها بهذه الطريقة. هنا يمكننا رؤية مثال:

julia> i = Threads.Atomic{Int}(0);

julia> ids = zeros(4);

julia> old_is = zeros(4);

julia> Threads.@threads for id in 1:4
           old_is[id] = Threads.atomic_add!(i, id)
           ids[id] = id
       end

julia> old_is
4-element Vector{Float64}:
 0.0
 1.0
 7.0
 3.0

julia> i[]
 10

julia> ids
4-element Vector{Float64}:
 1.0
 2.0
 3.0
 4.0

إذا حاولنا القيام بالجمع بدون العلامة الذرية، فقد نحصل على الإجابة الخاطئة بسبب حالة السباق. مثال على ما سيحدث إذا لم نتجنب السباق:

julia> using Base.Threads

julia> Threads.nthreads()
4

julia> acc = Ref(0)
Base.RefValue{Int64}(0)

julia> @threads for i in 1:1000
          acc[] += 1
       end

julia> acc[]
926

julia> acc = Atomic{Int64}(0)
Atomic{Int64}(0)

julia> @threads for i in 1:1000
          atomic_add!(acc, 1)
       end

julia> acc[]
1000

Per-field atomics

يمكننا أيضًا استخدام الذرات على مستوى أكثر دقة باستخدام @atomic، @atomicswap، @atomicreplace ماكرو، و @atomiconce ماكرو.

تفاصيل محددة عن نموذج الذاكرة وغيرها من تفاصيل التصميم مكتوبة في Julia Atomics Manifesto، والتي ستُنشر لاحقًا بشكل رسمي.

يمكن تزيين أي حقل في إعلان هيكل بـ @atomic، ثم يجب أن يتم وضع علامة على أي كتابة بـ @atomic أيضًا، ويجب أن تستخدم واحدة من الترتيبات الذرية المحددة (:monotonic، :acquire، :release، :acquire_release، أو :sequentially_consistent). يمكن أيضًا تمييز أي قراءة لحقل ذري بقيد ترتيب ذري، أو سيتم تنفيذها بترتيب مونوطوني (مريح) إذا لم يتم تحديده.

Julia 1.7

يتطلب الذرات لكل حقل على الأقل جوليا 1.7.

Side effects and mutable function arguments

عند استخدام البرمجة المتعددة الخيوط، يجب أن نكون حذرين عند استخدام الدوال التي ليست pure لأننا قد نحصل على إجابة خاطئة. على سبيل المثال، الدوال التي تحتوي على name ending with ! حسب الاتفاقية تعدل معطياتها وبالتالي ليست نقية.

@threadcall

ت pose مشكلة للمكتبات الخارجية، مثل تلك التي يتم استدعاؤها عبر ccall، لآلية الإدخال/الإخراج المعتمدة على المهام في جوليا. إذا كانت مكتبة C تقوم بعملية حظر، فإن ذلك يمنع جدولة جوليا من تنفيذ أي مهام أخرى حتى يعود الاستدعاء. (الاستثناءات هي الاستدعاءات إلى كود C مخصص يستدعي جوليا، والتي قد تقوم بعد ذلك بالتخلي، أو كود C الذي يستدعي jl_yield()، المعادل C لـ yield.)

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

من المهم جدًا أن الدالة المستدعاة لا تستدعي مرة أخرى في جوليا، حيث سيتسبب ذلك في حدوث خطأ في الذاكرة.

@threadcall قد يتم إزالته/تغييره في إصدارات مستقبلية من جوليا.

Caveats

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

هناك بعض القيود والتحذيرات المحددة التي يجب أن تكون على دراية بها عند استخدام الخيوط في جوليا:

  • تتطلب أنواع المجموعات الأساسية قفلًا يدويًا إذا تم استخدامها في وقت واحد بواسطة عدة خيوط حيث يقوم أحد الخيوط على الأقل بتعديل المجموعة (تشمل الأمثلة الشائعة push! على المصفوفات، أو إدخال عناصر في Dict).
  • الجدول الزمني المستخدم بواسطة @spawn غير حتمي ولا ينبغي الاعتماد عليه.
  • يمكن أن تمنع المهام المعتمدة على المعالجة، والتي لا تقوم بتخصيص الذاكرة، جمع القمامة من التشغيل في خيوط أخرى تقوم بتخصيص الذاكرة. في هذه الحالات، قد يكون من الضروري إدخال استدعاء يدوي إلى GC.safepoint() للسماح لجمع القمامة بالتشغيل. سيتم إزالة هذا القيد في المستقبل.
  • تجنب تشغيل العمليات على المستوى الأعلى، مثل include، أو eval من نوع، أو تعريفات الطريقة، والوحدة بشكل متوازي.
  • كن على علم بأن المنهيات المسجلة بواسطة مكتبة قد تتعطل إذا كانت الخيوط مفعلة. قد يتطلب ذلك بعض العمل الانتقالي عبر النظام البيئي قبل أن يمكن اعتماد الخيوط على نطاق واسع بثقة. راجع القسم حول the safe use of finalizers لمزيد من التفاصيل.

Task Migration

بعد أن يبدأ تنفيذ مهمة على خيط معين، قد تنتقل إلى خيط مختلف إذا قامت المهمة بالتخلي.

قد تكون هذه المهام قد بدأت بـ @spawn أو @threads، على الرغم من أن خيار الجدول :static لـ @threads يقوم بتجميد معرف الخيط.

هذا يعني أنه في معظم الحالات threadid() يجب ألا يُعتبر ثابتًا ضمن مهمة، وبالتالي يجب عدم استخدامه كفهرس في مصفوفة من المخازن أو الكائنات ذات الحالة.

Julia 1.7

تم تقديم ترحيل المهام في جوليا 1.7. قبل ذلك، كانت هذه المهام دائمًا تبقى على نفس الخيط الذي بدأت عليه.

Safe use of Finalizers

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

  1. عند استخدام خيط واحد، يمكن للكود استدعاء دالة C الداخلية jl_gc_enable_finalizers لمنع جدولة المنهيات داخل منطقة حرجة. يتم استخدام ذلك داخليًا داخل بعض الدوال (مثل أقفال C لدينا) لمنع التكرار عند القيام بعمليات معينة (تحميل الحزم بشكل تدريجي، توليد الكود، إلخ). يمكن استخدام مجموعة من قفل وهذا العلم لجعل المنهيات آمنة.

  2. استراتيجية ثانية، تم استخدامها من قبل Base في عدة أماكن، هي تأخير المُنهي بشكل صريح حتى يتمكن من الحصول على قفله بشكل غير تكراري. المثال التالي يوضح كيف يمكن تطبيق هذه الاستراتيجية على Distributed.finalize_ref:

    julia function finalize_ref(r::AbstractRemoteRef) if r.where > 0 # Check if the finalizer is already run if islocked(client_refs) || !trylock(client_refs) # delay finalizer for later if we aren't free to acquire the lock finalizer(finalize_ref, r) return nothing end try # `lock` should always be followed by `try` if r.where > 0 # Must check again here # Do actual cleanup here r.where = 0 end finally unlock(client_refs) end end nothing end

  3. استراتيجية ثالثة ذات صلة هي استخدام قائمة انتظار خالية من العائد. لا يوجد لدينا حاليًا قائمة انتظار خالية من القفل تم تنفيذها في Base، ولكن Base.IntrusiveLinkedListSynchronized{T} مناسبة. يمكن أن تكون هذه استراتيجية جيدة للاستخدام في الشيفرات التي تحتوي على حلقات أحداث. على سبيل المثال، يتم استخدام هذه الاستراتيجية من قبل Gtk.jl لإدارة عدّ مرجع عمر الكائنات. في هذا النهج، لا نقوم بأي عمل صريح داخل finalizer، وبدلاً من ذلك نضيفه إلى قائمة انتظار للتنفيذ في وقت أكثر أمانًا. في الواقع، يستخدم جدولة المهام في جوليا هذا بالفعل، لذا فإن تعريف finalizer كـ x -> @spawn do_cleanup(x) هو أحد الأمثلة على هذا النهج. ومع ذلك، لاحظ أن هذا لا يتحكم في أي خيط يتم تشغيل do_cleanup عليه، لذا سيتعين على do_cleanup الحصول على قفل. لا يحتاج ذلك إلى أن يكون صحيحًا إذا قمت بتنفيذ قائمة الانتظار الخاصة بك، حيث يمكنك تصريف تلك القائمة من خيطك فقط.