السلطان
  1. الرئيسية
  2. التوثيق
  3. المرجع

التزامن

صفحات التوثيق

التزامن

تتبع السلطان نموذج Rust في الخيوط والعمليات الذرية والحالة المشتركة (std::thread، std::sync، std::sync::atomic، std::sync::mpsc)، وتأخذ وقت التشغيل في Go مرجعا لطبقة نظام التشغيل (بدء الخيط، وfutex، والسيمافور). وقد كُيِّف هذا التصميم مع نظام الملكية في السلطان، وهو نظام بلا سمات (traits) ولا يشغّل أي مُتلِف (destructor) يكتبه المستخدم. تشرح هذه الصفحة كل أداة من أدوات التزامن، وطبقة نظام التشغيل التي تحتها، والفحص الذي يجري وقت الترجمة على ما يجوز للخيط أن يلتقطه، والحدود الحالية. وهي موجهة إلى المبرمجين الذين يتشاركون البيانات بين الخيوط، وإلى من يعملون على std.sync.

الوحدات

الوحدة Rust / Go المحتوى
std.sync.atomic std::sync::atomic; sync/atomic أعداد صحيحة ذرية وترتيبات الذاكرة
std.thread.thread std::thread أطلق، وانضم، والقيم العائدة من الخيوط، والنوم، والتنازل
std.os.wait_address sys::futex; runtime.futex, sema النوم ما دامت خلية من 32 بتا تحمل قيمة معينة، وإيقاظ نائم واحد أو الجميع
std.sync.arc Arc ملكية مشتركة بعدّاد ذري
std.sync.mutex Mutex; sync.Mutex قفل futex بثلاث حالات، محصور في نطاق دالة مغلقة
std.sync.rwlock RwLock; sync.RWMutex قرّاء متعددون أو كاتب واحد
std.sync.condvar Condvar; sync.Cond الانتظار والتنبيه على كلمة تسلسل
std.sync.once Once, OnceLock; sync.Once تهيئة تجري مرة واحدة
std.sync.channel mpsc; قنوات Go قنوات غير محدودة ومحدودة السعة
std.os.poll mio::Poll; runtime.netpoll استطلاع جاهزية لمرة واحدة على عدد كبير من الواصفات غير الحاجبة

برنامج أول

عدّاد محمي بقفل، تتشاركه أربعة خيوط عبر مشترك، ويزيده كل خيط منها. يملك كل خيط نسخة من مشترك، ولا يُمسك القفل إلا داخل الدالة المغلقة المعطاة إلى نفذ_مع_القفل.

استورد المكتبة.sync.arc;
استورد المكتبة.sync.mutex;
استورد المكتبة.thread.thread;
استورد المكتبة.تنسيق.fmt;

عرف زد(ق: &متغير ص64): فراغ { *ق := *ق + 1; }
عرف اقرأ(ق: &متغير ص64): ص64 { أرجع *ق; }

عرف أضف_ألفا(ق: &مشترك<قفل<ص64>>): فراغ {
    دع ف: متغير ص64 = 0;
    طالما (ف < 1000) {
        نفذ_مع_القفل(اقرأ_مشترك(ق), دالة(س: &متغير ص64): فراغ -> زد(س));
        ف := ف + 1;
    }
}

عرف الرئيسية(): ص32 {
    دع العداد: مشترك<قفل<ص64>> = أنشئ_مشترك(أنشئ_قفل(0 مثل ص64));
    دع المقابض: متغير متجه مقبض_خيط = [];
    دع خ: متغير ص32 = 0;
    طالما (خ < 4) {
        دع نسختي: مشترك<قفل<ص64>> = استنسخ_مشترك(&العداد);
        طابق أطلق(دالة(): فراغ -> أضف_ألفا(&نسختي)) مع
        | نتيجة.نجح(م) -> { ألحق(المقابض, م); }
        | نتيجة.خطأ(_) -> { أرجع 1; };
        خ := خ + 1;
    }
    دع ض: متغير طبيعي = 0;
    طالما (ض < (الطول(المقابض) مثل طبيعي)) {
        انضم(&متغير المقابض[ض]);
        ض := ض + 1;
    }
    دع المجموع: ص64 = نفذ_مع_القفل(اقرأ_مشترك(&العداد), دالة(س: &متغير ص64): ص64 -> اقرأ(س));
    دع ر: نتيجة طبيعي, خطأ_تنسيق = اطبع_سطرا_منسقا(«المجموع = {}», [قيمة_تنسيق.ص64(المجموع)]);
    أرجع 0;
}

يطبع البرنامج المجموع، وهو 4000.

العمليات الذرية

توفر std.sync.atomic أعدادا صحيحة ذرية (ذرة_ط64، وذرة_ط32، وذرة_طبيعي) مع عمليات القراءة، والكتابة، والتبديل، والمقارنة ثم التبديل (compare-exchange)، والجلب مع الجمع (fetch-add)، وسياج الذاكرة. ولـ ذرة_ط64 أيضا الجلب مع «و» البتية والجلب مع «أو» البتية، وله صيغ بمؤشر تأخذ خلية *متغير. وتتبع الترتيبات Ordering في Rust:

بالإنجليزية بالعربية
memory_ordering.relaxed ترتيب_ذاكرة.مسترخ
memory_ordering.release ترتيب_ذاكرة.إطلاق
memory_ordering.acquire ترتيب_ذاكرة.اكتساب
memory_ordering.acq_rel ترتيب_ذاكرة.اكتساب_وإطلاق
memory_ordering.seq_cst ترتيب_ذاكرة.تسلسلي

هذه العمليات دوال مدمجة في المترجم (intrinsics): بدائي_ذري_حمل، وبدائي_ذري_خزن، وبدائي_ذري_تبديل، وبدائي_ذري_قارن_وبدل، وبدائي_ذري_جلب_جمع، والعمليات البتية بدائي_ذري_جلب_و وبدائي_ذري_جلب_أو وبدائي_ذري_جلب_أو_حصري (الجلب مع «أو» الحصرية)، وبدائي_سياج_ذاكرة (السياج). وهي، على خلاف كل عملية أخرى، تقرأ العدد الصحيح الذي يشير إليه مرجع مشترك وتكتب فيه. هذه هي النقطة السليمة الوحيدة للتعديل من خلال مرجع مشترك (interior mutability)، كما في Rust حيث تأخذ العمليات الذرية &self. وتقبل الواجهة الأمامية للمترجم مرجعا مشتركا & إلى عدد صحيح من 32 بتا أو 64 بتا أو بحجم المؤشر بوصفه المعامل الأول.

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

الخلفية الخفض
x86_64 (الأصلية وLLVM) LOCK XADD، وXCHG، وLOCK CMPXCHG (والعمليات البتية «و» و«أو» و«أو» الحصرية على شكل حلقة CMPXCHG)، وMFENCE
arm64 (الأصلية وLLVM) على macOS صيغ ARMv8.1 LSE: LDADDAL، وSWPAL، وLDCLRAL، وLDSETAL، وLDEORAL، وCASAL؛ وعلى Linux وAndroid وFreeBSD وWindows حلقات LDAXR/STLXR من ARMv8.0 (مع CLREX حين تفشل المقارنة والتبديل)، فتعمل الشيفرة على أنوية ARMv8.0؛ وفي كل مكان LDAR، وSTLR، وDMB ISH
wasm32 قراءات وكتابات عادية في الذاكرة الخطية: وحدة wasm32 تعمل بخيط واحد (لا تُستخدم خيوط WASI)؛ والسياج لا يفعل شيئا
LLVM atomicrmw، وcmpxchg، وfence بترتيب seq_cst
المفسر خيط نظام تشغيل واحد، فالعملية الذرية هي قراءة وتعديل وكتابة عادية للخلية التي يسميها المرجع؛ وتنفذ الكتابة حتى إذا كان المرجع مشتركا (compiler/interpreter/execution/primitives.sn)

خيوط نظام التشغيل

توفر std.thread ما يلي:

بالإنجليزية بالعربية Rust
spawn أطلق thread::spawn: تنقل دالة مغلقة إلى خيط جديد وتعيد مقبض انضمام
join انضم JoinHandle::join
spawn_value, join_value أطلق_بقيمة, انضم_بقيمة خيط تعيد الدالة المغلقة فيه قيمة، وتجيب بها انضم_بقيمة على شكل اختياري
sleep نم thread::sleep (بالثواني والنانوثواني)
yield_now تنازل thread::yield_now
current_id, join_handle_id معرف_الخيط_الحالي, معرف_المقبض معرّف الخيط في نظام التشغيل
available_parallelism عدد_المعالجات_المتاحة thread::available_parallelism

تعيد أطلق قيمة نتيجة خطؤها هو إخفاق المنصة في بدء خيط.

دعم المنصات

في std.os.threads فرع محروس واحد لكل نظام تشغيل. ويبدأ كل نظام الخيط بالآلية التي يستخدمها وقت التشغيل الخاص به.

نظام التشغيل البدء الانضمام
Linux وAndroid (كل المعماريات) clone على مكدس تحجزه المكتبة القياسية، باستدعاءات نظام مباشرة دون libc انتظار futex على خلية tid التي تصفّرها النواة عند خروج الخيط
FreeBSD thr_new على مكدس تحجزه المكتبة القياسية انتظار _umtx_op على كلمة الحالة الخاصة بـ thr_exit
macOS pthread_create pthread_join
Windows CreateThread WaitForSingleObject، وGetExitCodeThread، وCloseHandle
WASI، والأهداف المستقلة عن نظام التشغيل (freestanding)، والمفسر غير مدعوم: تعيد أطلق خطأ

طبقة الخيوط في Linux

تبدأ طبقة Linux (library/std/os/platform/unix/linux/threads.sn) الخيط بالطريقة التي يتبعها وقت التشغيل في Go وmusl. يحصل الخيط على مكدس حجمه 2 MiB محجوز بـ mmap مع صفحة حماية PROT_NONE، وعلى clone بالأعلام CLONE_VM|FS|FILES|SIGHAND|THREAD|SYSVSEM|PARENT_SETTID|CHILD_CLEARTID. واستدعاء clone هو الدالة المدمجة في المترجم بدائي_استنسخ_خيط_نواة: يشغّل الخيط الابن الدالة المغلقة على المكدس الجديد ثم ينهي الخيط. وتنتظر انضم على خلية tid التي تصفّرها النواة حين يخرج الخيط.

يختلف تفصيلان بين المعماريات. توضع خلية tid في آخر خانتين من وسائط clone كلتيهما، لأنهما (child_tid, tls) على x86_64 و(tls, child_tid) على المعماريات asm-generic (aarch64، وriscv64، وloongarch64)؛ وتُتجاهل خانة tls دون CLONE_SETTLS. وتفحص انضم الكلمة كاملة، لأن tid النواة ذا الـ 32 بتا يقع في النصف الأعلى على الأجهزة التي تضع البايت الأعلى أولًا (big-endian) مثل s390x وsparc64.

حجز المكدس مشترك مع طبقة FreeBSD: تأخذ mmap وmprotect وmunmap الوسائط نفسها هناك، وتأتي الأعلام من UAPI النواة المختارة.

ملكية الدالة المغلقة

يملك الخيط دالته المغلقة، كما تُنقل FnOnce في Rust إلى الخيط. توضع الدالة المغلقة في خلية على الكومة تملكها دالة الدخول المغلقة الخاصة بالخيط. تُخرج دالة الدخول الدالة المغلقة بالتبديل، وتشغّلها، وتتلفها حين تعود، فيُحرَّر كل ما التقطته الدالة المغلقة (نسخة من مشترك، أو مرسل قناة) عند انتهاء الخيط، لا عند الانضمام إليه ولا بحسب حدوثه. وتعيد انضم آخر مرجع لدالة الدخول، بعد بنائه من عنوانه بـ بدائي_دالة_من_عنوان، فتُحرَّر دالة الدخول وخليتها. أما الخيط المنفصل فلا يسرّب إلا دالة الدخول (كتلتين صغيرتين).

النتائج والانهيار

الدالة المغلقة المعطاة إلى أطلق لا تعيد قيمة عبر انضم؛ أما أطلق_بقيمة وانضم_بقيمة فتعيدان القيمة عبر حالة مشتركة. وإنهيار في أي خيط ينهي العملية كلها (panic=abort)، فلا ترى انضم أبدا خطأ خيط، ولا يمكن أن يُعلَّم قفل بأنه معطوب بسبب انهيار حدث وهو ممسوك (poisoning).

الانتظار والإيقاظ

std.os.wait_address هي أداة الحجب الوحيدة التي تُبنى عليها كل أنواع std.sync، على غرار sys/sync/futex في Rust وruntime/sema في Go.

الاستدعاء المعنى
انتظر_عند_العنوان(&ذرة_ط32, المتوقعة, المهلة) النوم ما دامت الخلية تحمل المتوقعة؛ ولا تجيب بـ خطأ إلا حين تنقضي المهلة النسبية (بالنانوثواني، أو بلا مهلة)
أيقظ_واحدا_عند_العنوان، أيقظ_الكل_عند_العنوان إيقاظ نائم واحد أو كل النائمين على الخلية
انتظر_عند_الخلية، أيقظ_واحدا_عند_الخلية الشيء نفسه على خلية مجردة من 32 بتا، مثل متغير وحدة عددي يُستخدم كلمةَ قفل
هل_الانتظار_عند_العنوان_مدعوم هل للهدف انتظار حاجب

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

نظام التشغيل الانتظار الإيقاظ ملاحظات
Linux وAndroid futex(FUTEX_WAIT_PRIVATE) futex(FUTEX_WAKE_PRIVATE) تستخدم الانتظارات ذات المهلة futex_time64 على i686 وarmv7 (مستقبل_بمهلة)، فيكون timespec من 64 بتا في كل مكان؛ وقيمة ETIMEDOUT هي 110 (وعلى mips 145، وعلى sparc 60)
FreeBSD _umtx_op(UMTX_OP_WAIT_UINT_PRIVATE) _umtx_op(UMTX_OP_WAKE_PRIVATE) المهلة struct timespec نسبية في uaddr2 (مستقبل_بمهلة_بي_إس_دي)
macOS __ulock_wait2(UL_COMPARE_AND_WAIT | ULF_NO_ERRNO) __ulock_wake (ULF_WAKE_ALL) ما يستخدمه Rust قبل macOS 14.4؛ وos_sync_wait_on_address غير موجودة في حزمة تطوير macOS 11.3. تُستورد من libSystem ويفحصها tools/audit/macos_imports_audit.py
Windows WaitOnAddress WakeByAddressSingle / WakeByAddressAll api-ms-win-core-synch-l1-2-0.dll، وهو استيراد synchronization في Rust؛ وERROR_TIMEOUT هو انقضاء المهلة
WASI، والأهداف المستقلة تعود فورا (والانتظار ذو المهلة يبلّغ عن انقضائها) لا شيء خيط واحد: لا أحد يستطيع تغيير الخلية

تحمل استدعاءات النواة عقودا للذاكرة (توقيع_عملية_نواة_لينكس: الخلية ذات 4 بايتات، وtimespec ذو 16 بايتا)، كما يحملها كل استدعاء نواة في المكتبة القياسية يأخذ مؤشرا.

الملكية المشتركة

مشترك<ق> هو Arc<T> في Rust: قيمة واحدة على الكومة، وأي عدد من المقابض، وعدّاد مراجع ذري، وتُتلَف القيمة مرة واحدة بالضبط مع آخر مقبض.

لا تشغّل السلطان أي مُتلِف يكتبه المستخدم، ولذلك فالعدّاد والإتلاف من شأن بيئة دالة مغلقة. يحمل المقبض دالة مغلقة واحدة تملك بيئتُها القيمةَ المغلفة (التقاط بالنقل). وعدّاد المراجع في البيئة هو العدّاد القوي: تعدّ الدوال المغلقة مراجعها ذريا (LOCK XADD، وLDADDAL أو حلقة LDAXR/STLXR، وatomicrmw في LLVM)، ونسخ المقبض يضيف مرجعا، وإتلافه يزيل مرجعا، ويتلف مُتلِف البيئة القيمةَ المغلفة مع آخر مرجع. وتجيب الدالة المغلقة بعنوان القيمة المغلفة.

بالإنجليزية بالعربية Rust
arc_new أنشئ_مشترك Arc::new
arc_clone استنسخ_مشترك Arc::clone؛ ونسخ المقبض هو الاستنساخ نفسه، لأن الدوال المغلقة قيم قابلة للنسخ
arc_get اقرأ_مشترك Deref: مرجع مشترك
arc_strong_count عدد_مراجع_مشترك Arc::strong_count؛ تقرأ خانة العدّاد في البيئة
arc_ptr_eq هل_المشترك_نفسه Arc::ptr_eq
arc_as_ptr مؤشر_مشترك Arc::as_ptr

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

القفل

قفل<ق> هو قفل futex في Rust (sys/sync/mutex/futex.rs، المبني على ورقة Ulrich Drepper "Futexes Are Tricky"). الحالة 0 حين يكون مفتوحا، و1 حين يكون مقفلا، و2 حين يكون مقفلا وله منتظرون. القفل دون تنافس عملية مقارنة وتبديل واحدة، وفتحه دون تنافس عملية تبديل واحدة، دون أي استدعاء نظام. أما القفل مع التنافس فيدور 100 جولة، ثم يجعل الحالة 2 وينام؛ وفتح القفل الذي يجد الحالة 2 يوقظ نائما واحدا.

لا يُوصَل إلى القيمة إلا داخل دالة مغلقة تعمل والقفل ممسوك: نفذ_مع_القفل(&م, دالة(&متغير ق): ن): ن، أو حاول_القفل، التي تجيب بـ اختياري.لا_شيء حين يمسك خيط آخر القفل. هذا هو MutexGuard في Rust محصورا في استدعاء واحد، لأن السلطان لا تشغّل شيئا عند الخروج من النطاق: فلا يمكن نسيان فتح القفل، ولا يعيش مرجع إلى القيمة بعد القفل. وتبلّغ هل_القفل_مأخوذ عن الحالة.

ولا يُعلَّم القفل بأنه معطوب أبدا، لأن الانهيار ينهي العملية. والقفل يُنقل لكنه لا يُنسخ أبدا (تجعله علامة متجه لا يُحجز أبدا قيمة مملوكة)، ويُتشارك عبر مشترك (مشترك<قفل<ق>>)، كما في Rust.

قفل القراءة والكتابة

قفل_قراءة_كتابة<ق> كلمة futex واحدة: 0 حين يكون حرا، وعدد القرّاء حين يكون فيه قرّاء، و0xFFFFFFFF لكاتب. ويكون الوصول عبر نفذ_مع_قراءة ونفذ_مع_كتابة. وهو أبسط من قفل القراءة والكتابة المبني على futex في Rust (RwLock): لا أولوية فيه للكاتب، فقد يؤخر تدفق متواصل من القرّاء الكاتبَ.

شرط الانتظار

شرط_انتظار هو متغير الشرط المبني على futex في Rust (Condvar): رقم تسلسل من 32 بتا. تقرأ انتظر_الشرط(&ش, &م) الرقم، وتفتح القفل، وتنام ما دام الرقم لم يتغير، ثم تأخذ القفل من جديد. ويزيد التنبيه الرقم ويوقظ نائما واحدا (نبه_واحدا) أو الجميع (نبه_الكل).

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

التهيئة لمرة واحدة

مرة هو Once المبني على futex في Rust: 0 غير مكتمل، و1 قيد التشغيل، و2 قيد التشغيل وله نائمون، و3 مكتمل. تشغّل نفذ_مرة دالتها المغلقة مرة واحدة بالضبط مهما تسابق من خيوط، وينام الباقون حتى تكتمل. ومرة المكتملة كلفتها قراءة واحدة بترتيب الاكتساب.

تخزن خلية_مرة<ق> (OnceLock في Rust) قيمتها في متجه لا يزيد على عنصر واحد خلف مشترك. والعنصر موضع في الذاكرة، فتعيره اقرأ_خلية_مرة واقرأ_أو_هيئ_خلية_مرة في مكانه، ولا يكبر المتجه أبدا بعد الإلحاق الوحيد، فيبقى عنوانه ثابتا. وترفض اضبط_خلية_مرة قيمة ثانية.

القنوات

تعيد قناة<ق>() وقناة_متزامنة<ق>(الحد) الطرفين كليهما (طرفا_قناة)، ويفككهما نمط: | { المرسل: م, المستلم: س } -> …. وتنشئ مستلم_قناة ومرسل_لمستلم الطرفين واحدا بعد الآخر. للقناة مرسلون كثيرون (استنسخ_مرسل) ومستلم واحد، وهي تسلّم القيم لكل مرسل بترتيب إرسالها (FIFO).

العملية بالإنجليزية بالعربية
الإرسال، مع انتظار مكان في قناة محدودة send أرسل
الإرسال دون انتظار try_send حاول_الإرسال
الاستقبال، مع الانتظار recv استقبل
الاستقبال دون انتظار try_recv حاول_الاستقبال
الاستقبال بمهلة recv_timeout استقبل_بمهلة
إتلاف طرف صراحة drop_sender, drop_receiver أسقط_مرسل, أسقط_مستلم

تعيد أرسل القيمة بوصفها الخطأ إذا زال المستلم. وتبلّغ حاول_الإرسال عن ممتلئة أو منقطعة، مع القيمة. وتفشل استقبل إذا زال كل المرسلين ولم يبق شيء في الطابور. وتبلّغ حاول_الاستقبال واستقبل_بمهلة عن الفراغ، أو انقضاء المهلة، أو الانقطاع، مستخدمة أنواع الأخطاء في mpsc الخاصة بـ Rust (خطأ_محاولة_استقبال، وخطأ_استقبال_بمهلة، وخطأ_استقبال، وخطأ_محاولة_إرسال).

التنفيذ

تقع الحالة المشتركة خلف مشترك. وهي طابور من متجهين: تُلحق القيم بصندوق وارد، وتُسحب من صندوق صادر يُعاد ملؤه من الوارد، فكل قيمة تُنقل ولا تُنسخ أبدا. ويحمي الطابورَ القفلُ ذو الحالات الثلاث، وترافقه كلمتا تسلسل futex: "قيمة متاحة" و"خانة فارغة".

الانقطاع دون مُتلِفات

تعدّ القناة طرفيها برمزين مشتركين (من نوع مشترك). كل رمز تحمله الحالة المشتركة ويحمله كل مرسل (أو المستلم)، فعدد المرسلين الأحياء هو العدّاد القوي لرمز المرسل ناقصا ما تحمله الحالة نفسها. والطرف المعطى إلى أسقط_مرسل أو أسقط_مستلم يُتلف هناك ويُوقظ الطرف الآخر فورا. أما الطرف الذي يُتلف ضمنيا، بخيط ينتهي أو نطاق ينتهي، فيلاحظه الخيط المنتظر خلال بضع ميلي ثوان، لأن المنتظر يعيد فحص العدّادات بين فترات نوم محدودة (1 ms تتضاعف حتى 32 ms). والأطراف غير قابلة للنسخ، فالدالة المغلقة المُطلقة في خيط تنقل الطرف الذي تلتقطه.

الحد 0 يحمل قيمة واحدة: قناة rendezvous في Rust، التي يلتقي فيها المرسل والمستلم مباشرة، غير منفذة.

الخلو من سباق البيانات: فحص الالتقاط عند الإطلاق

لا يجوز للدالة المغلقة المسلَّمة إلى خيط آخر أن تلتقط إلا ما يجوز له عبور الخيوط، وهذا هو F: Send + 'static في Rust. يرفض المترجم دالة مغلقة تلتقط:

  • مؤشرا خاما، إلا إذا وقع الإطلاق داخل غير_آمن؛
  • مرجعا مستعارا أو شريحة؛
  • متغير وحدة قابلا للكتابة يستخدمه جسمها خارج كتلة غير آمنة.

لكل حالة تشخيصها الخاص، بالعربية والإنجليزية: SULTANC-THREAD-CAPTURE-RAW-POINTER، وSULTANC-THREAD-CAPTURE-REFERENCE، وSULTANC-THREAD-CAPTURE-NON-ATOMIC-SHARED. وتُستنتج الاستدعاءات التي ترسل دالة مغلقة من الدوال المدمجة الخاصة بعناوين الدوال المغلقة، لا من قائمة بالأسماء. والقواعد، وطريقة الاستنتاج، وحدودهما في نظام الأنواع.

تبقى الدوال المدمجة الخاصة بالخيوط نفسها خلف غير_آمن: فـ بدائي_استنسخ_خيط_نواة تشغّل شيفرة على مكدس يقدمه المستدعي، وبدائي_دالة_من_عنوان تعيد بناء دالة مغلقة من عنوان. وتسريب قيمة مغلفة (بدائي_سرب_قيمة) دالة مدمجة غير آمنة كذلك.

الحالة على مستوى العملية

جُعل جدولان على مستوى العملية آمنين مع الخيوط.

  • يخدم مخصِّص الذاكرة (library/std/os/allocation.sn) كل الخيوط. يضبط أول إطلاق علَما يفعّل قفلا حول الحجز وإعادة الحجز والتحرير؛ والبرنامج الذي لا يطلق خيطا أبدا يدفع تفرعا واحدا. وتكشف هل_بدأت_خيوط هذا العلم. والقفل هو بروتوكول futex ذو الحالات الثلاث الخاص بـ قفل على كلمة وحدة من 32 بتا: عملية مقارنة وتبديل واحدة حين يكون حرا، و100 دورة على الأكثر حين يكون ممسوكا، ثم نوم في النواة (os.platform.*.wait_address، الاستدعاءات بلا مهلة، وهي لا تحجز شيئا)، فلا يستهلك الخيط المنتظر وقت المعالج. ومع ذلك تتشارك كل الخيوط القفل الواحد؛ وذاكرة مؤقتة (cache) لكل خيط كانت ستزيله من أغلب عمليات الحجز.
  • يأخذ جدول البيئة (std.env.table؛ وهو ENV_LOCK في Rust وenvLock في Go) قفلا واحدا على مستوى العملية لكل قراءة وكتابة متى وُجد خيط، مستخدما البروتوكول ذا الحالات الثلاث على متغير وحدة عددي (انتظر_عند_الخلية). وقبل أول خيط لا يؤخذ أي قفل.

استطلاع الجاهزية

تتيح std.os.poll (على غرار mio في Rust وnetpoller في Go) لخيط واحد أن يخدم مقابس كثيرة غير حاجبة: epoll على Linux وAndroid، وkqueue على FreeBSD (باستدعاءات النظام) وmacOS (عبر libSystem). وتبلّغ Windows والأهداف الأخرى أن هل_المستطلع_مدعوم خطأ؛ فـ Windows تحتاج IOCP، وهو نموذج إكمال.

الاستدعاء mio / epoll
أنشئ_مستطلع Poll::new؛ epoll_create1(EPOLL_CLOEXEC)، kqueue()
سجل_في_مستطلع Registry::register؛ EPOLL_CTL_ADD، EV_ADD
أعد_تسليح_في_مستطلع Registry::reregister؛ EPOLL_CTL_MOD، EV_ADD
ألغ_من_مستطلع Registry::deregister؛ EPOLL_CTL_DEL، EV_DELETE
انتظر_مستطلع Poll::poll؛ epoll_pwait، kevent
حدث_مستطلع_عند Events::iter: الرمز، وقابلية القراءة، وقابلية الكتابة، والإغلاق من الطرف الآخر، أو الخطأ

التسجيلات لمرة واحدة (EPOLLONESHOT، EV_ONESHOT): يعطّل الحدثُ واصفه حتى يُعاد تسليحه، فتستطيع خيوط عدة أن تنتظر على مستطلع واحد، ويصل كل واصف إلى واحد منها في كل مرة. والاهتمام إما بالقراءة أو بالكتابة، لا بهما معا، للسبب نفسه (فقد ينطلق مرشحان من kqueue على خيطين). ويملك كل خيط منتظر مخزن أحداثه (أنشئ_مخزن_أحداث_مستطلع)، فلا يحجز الانتظار ذاكرة. وتتعرف هل_خطأ_سيحظر على خطأ «العملية ستحجب» (would-block) الخاص بالهدف.

وتُبنى عليها شغل_خادم_ويب_بأحداث في std.http.server. يملك كل خيط عامل (عددها الافتراضي هو التوازي المتاح) مستطلعه وجدول اتصالاته. ومع SO_REUSEPORT موزِّع (Linux وFreeBSD) يربط كل عامل مستمعه أيضا (شظية لكل نواة) ويقبل منه حتى يصير على وشك الحجب. وفي غيرهما (macOS) مستمع واحد، والعامل الذي يستنزفه يأخذ دفعة الاتصالات كلها؛ لذا، كمستقبِلات libuv وtokio، يقتصر الخيط المستدعي على القبول ويسلّم كل اتصال إلى العامل الأقل اتصالات مفتوحة (والمتساوون بالدور)، في جدول ذلك العامل ومستطلعه، حيث يبقى. ولا تراقب العمال المستمع، فلا تلمس أحداث الاتصال ولا إعادة تسليحه ولا تحديثات جدوله إلا مستطلع عامله وقفله. ويقرأ العامل حتى يصير المقبس على وشك الحجب، ويشغّل المعالج لكل طلب HTTP/1 مكتمل، ويكتب دون حجب (ينتظر الخرج غير المرسل قابلية الكتابة)، ثم يعيد تسليح الاتصال. ولا تحتجز الاتصالات الخاملة أي خيط. وتُتشارك الحالة على شكل مشترك<S>. ويغادر اتصال h2c المستطلع إلى خادم HTTP/2 الحاجب على عامله؛ وما زال HTTPS يستخدم شغل_خادم_ويب_آمن التسلسلية. ودون مستطلع تشغّل الدالة شغل_خادم_ويب.

المهام غير المتزامنة

تشغّل std.async.runtime مهام كثيرة على خيط واحد، كـ goroutines في Go على P واحد، أو الخيوط الافتراضية في Java، أو coroutines في Lua. والمهام ذات مكدس (stackful): لكل منها مكدسها (256 KiB محجوزة، أدناها 64 KiB حارس لا يُوصل إليه، وتثبّت النواة الصفحات عند أول لمس)، فالشفرة داخل المهمة شفرة عادية ولا تُوسم أي دالة بأنها غير متزامنة. والاستدعاء الذي يحتاج إلى الانتظار يعلّق مهمته فقط:

الاستدعاء عمله
أطلق_مهمة(د) يبدأ د: () -> ق مهمةً؛ ويعيد مقبض مهمة<ق>
انتظر_مهمة(&متغير م) يعلّق حتى تنتهي م؛ ويعيد ناتجها مرة، ثم لا_شيء
شغل_غير_متزامن(د) يطلق د وينتظرها: مدخل البرنامج غير المتزامن
انتهت_المهمة(&م) هل انتهت م
تنازل_مهمة() يدع المهام الجاهزة الأخرى تعمل أولا
نم_مهمة(ن) يعلّق ن نانوثانية على الأقل
انتظر_جاهزية_واصف(و, اهتمام) يعلّق حتى يصير الواصف قابلا للقراءة أو الكتابة
المهام_مدعومة() هل تتبدل المهام على هذا الهدف

وتقدم std.async.net استدعاءات TCP من std.net.tcp على مقابس غير حاجبة: اربط_مستمع_غير_متزامن، واقبل_غير_متزامن، واتصل_غير_متزامن، واقرأ_غير_متزامن، واكتب_غير_متزامن، واكتب_الكل_غير_متزامن، وأغلق_غير_متزامن، وأغلق_مستمعا_غير_متزامن. والاستدعاء الذي كان سيحجب يعلّق المهمة حتى يبلّغ المستطلع أن المقبس جاهز، ثم يعيد المحاولة.

استورد std.async.runtime;

عرف بطيء(ن: عدد): عدد { نم_مهمة(40000000); أرجع ن; }

عرف الرئيسية(): عدد {
    دع أ: متغير مهمة<عدد> = أطلق_مهمة(دالة(): عدد -> بطيء(1));
    دع ب: متغير مهمة<عدد> = أطلق_مهمة(دالة(): عدد -> بطيء(2));
    // تنامان معا: نحو 40 ms في المجموع.
    طابق انتظر_مهمة(&متغير أ) مع
    | اختياري.موجود(س) -> { طابق انتظر_مهمة(&متغير ب) مع
        | اختياري.موجود(ص) -> { أرجع س + ص; }
        | اختياري.لا_شيء -> { أرجع -1; }; }
    | اختياري.لا_شيء -> { أرجع -1; };
}

يعمل المجدول على الخيط الذي تنتظر شفرته من خارج كل المهام (الشفرة الرئيسية تنتظر مهمة، أو تحجب على مقبس بالاستدعاءات نفسها): أثناء انتظارها تعمل المهام الجاهزة بالدور، الأول فالأول، وحين لا تكون مهمة جاهزة ينام الخيط في المستطلع (io_uring أو epoll على Linux، وkqueue على FreeBSD وmacOS) حتى يجهز مقبس أو يحين أقرب مؤقت. ومجدول واحد يخدم العملية، والمهمة تتبع الخيط الذي يقودها. وحين تنتظر كل المهام ولا يستطيع مقبس ولا مؤقت إيقاظ إحداها (مهمة تنتظر نفسها، أو دورة انتظارات) ينهار البرنامج برسالة بدل أن يتوقف إلى الأبد.

التبديل نداء إلى بدل_سياق_المهام_التشغيل، الذي تستبدل الخلفيات جسمه بشفرة آلة ثابتة: يدفع السجلات المحفوظة عند المستدعى (rbx وrbp وr12-r15 على x86_64؛ وx19-x30 وd8-d15 على arm64)، ويخزن مؤشر المكدس، ويحمّل مؤشر المهمة الأخرى ويسترجع سجلاتها. ويعود الإطار الأول لمهمة جديدة إلى منصة (trampoline) تستدعي الدالة المغلقة للمهمة. ويصدره مشفرا x86_64 وarm64 الأصليان ومزود LLVM (دالةً عارية naked) على أهداف System V x86_64 وAAPCS64. ويبدل المفسر المهام أيضا بتبديل مكادس أطره (انظر المفسر). وفي غيرها (wasm32، وWindows، والمعماريات الأخرى) تعيد المهام_مدعومة خطأ، وتشغّل أطلق_مهمة المهمة حتى نهايتها فورا، فيعمل البرنامج نفسه، مهمة بعد مهمة، ويحجب كل انتظار الخيط.

وتُفحص الدالة المغلقة الممررة إلى أطلق_مهمة كدالة الخيط (فحص الالتقاط عند الإطلاق): فالمهمة تعيش بعد إطار المستدعي.

وتخدم شغل_خادم_ويب_غير_متزامن في std.http.server كل اتصال مقبول في مهمة خاصة به عبر الناقل تي_سي_بي_غير_متزامن، فيحتجز العميل الخامل أو البطيء مكدس مهمة لا خيطا، ويتشارك HTTP/1 وh2c خيطا واحدا. وتفحص طلب الإيقاف مرة كل 250 ms على الأقل.

المفسر

ينفذ sultanc run على خيط واحد من خيوط نظام التشغيل. تعمل العمليات الذرية على شكل قراءة وتعديل وكتابة عادية، وتعمل القراءات في قفل، وقفل_قراءة_كتابة، وشرط_انتظار (التنبيه)، ومرة، وخلية_مرة، ومشترك، وتتفق نتائجها مع الشيفرة الأصلية؛ وtests/*/stdlib/behavior/sync_single_thread.sn زوج تفاضلي. ولا يبدأ هناك خيط، فيعيد أطلق خطأ بدل أن يوقف البرنامج: يجيب clone في النواة بـENOSYS على لينكس، وعلى macOS وويندوز لا مدخل C للدالة المغلقة المفسَّرة، وتفحص المكتبة القياسية ذلك قبل أن تحجز شيئا. ويأخذ المستدعي الذي له بديل ذلك البديل: تخدم خوادم HTTP في المكتبة القياسية على الخيط المستدعي، وتغلق حلقة الأحداث المجزأة مستمعَ SO_REUSEPORT ومستطلعَ كل جزء لم يبدأ خيطه (وإلا سلّمت النواة ذلك المستمع اتصالات لا يقبلها أحد). والبرنامج الذي حجب SIGINT وSIGTERM لخيط مراقبة لم يبدأ يرفع حجبهما بـ ارفع_حجب_إشارات_الإيقاف (std.os.signal)، فيوقفه Ctrl+C فورا.

وعلى لينكس تتبدل المهام غير المتزامنة تحت المفسر كما تتبدل أصليا (compiler/interpreter/execution/task_switch.sn). فالمفسر يملك أطره، والمهمة مكدس أطر آخر: يضع نداءُ روتين التبديل المكدسَ الجاري جانبا في جدول، ويترك رمزه (فهرسا موسوما لا عنوانا قابلا للربط) في كلمة الحفظ، ثم يستأنف المكدس الذي يسميه رمز، أو يبدأ مهمة جديدة من الدالة المغلقة التي وضعتها المكتبة القياسية في أول إطار للمهمة. ويُحل المكان الواقع في مكدس معلق (محلي مهمة تستعيره مهمة أخرى) هناك. وتعيد الدوال المدمجة الخاصة بعناوين الدوال المغلقة مقابض موسومة لدوال المفسر المغلقة، وبها تحفظ المكتبة مدخل المهمة؛ وتُرفض قراءة ذاكرة الآلة عبر مقبض كهذا (عدد_مراجع_مشترك، والقنوات التي تعدّ أطرافها بها) بدل أن يتعطل المفسر. والمستطلع epoll: فـio_uring يشارك حلقات يربطها النواة في العملية ويحدّثها بعمليات ذرية، فيعيد io_uring_setup تحت المفسر ENOSYS ويأخذ std.os.poll بـepoll. ومكدس المهمة ذاكرة آلة تربطها المكتبة القياسية، ولا يصل إليها المفسر إلا على لينكس؛ فعلى macOS وويندوز يجيب المفسر بأن المهام لا تتبدل، ويشغّل أطلق_مهمة كل مهمة حتى تكتمل فورا، كما على wasm32. وtests/*/stdlib/behavior/async_task_switch.sn زوج تفاضلي، ويعمل اختبار خادم HTTP غير المتزامن تحت sultanc run أيضا. وقد تخفق تأكيدات التوقيت المكتوبة لسرعة الشيفرة الأصلية (ثلاث نومات من 40 ms في أقل من 100 ms) في المفسر لأنه أبطأ.

التحقق

أين ما الذي يُشغَّل
x86_64 Linux، الخلفية الأصلية كل الاختبارات أدناه، بالعربية والإنجليزية
aarch64 وriscv64 وs390x Linux (LLVM، qemu-user) الاختبارات نفسها
i686 وarmv7 Linux (LLVM، qemu-user) الإطلاق، والانضمام، وعدّاد بقفل عبر الخيوط
Windows x86_64 (Wine) wait_address_single، وsync_single_thread (WaitOnAddress، بما في ذلك الانتظار ذو المهلة)، وعدّاد بقفل في خيوط مُطلقة
macOS arm64 وx86_64 تُبنى؛ ولا يبلّغ macos_imports_audit.py عن أي استيراد ناقص (__ulock_wait2، __ulock_wake)
FreeBSD x86_64 وaarch64 وriscv64 تُبنى؛ ولا تُشغَّل

الاختبارات في tests/{ar,en}/stdlib/behavior/:

الاختبار ما الذي يفحصه
sync_mutex_counter 8 خيوط، 10 000 زيادة لكل منها، والمجموع مضبوط
sync_arc_threads_rss_flat 10^4 جولة مع ثبات البايتات الحية في مخصِّص الذاكرة
sync_condvar_ping_pong 2 000 دور وانتظار بمهلة
sync_once_contention 8 خيوط، وتشغيل واحد، وخلية_مرة
sync_channel_producers 4 منتجين، ومجموع مضبوط، وترتيب لكل منتج
sync_channel_bounded الضغط العكسي وحاول_الإرسال على قناة ممتلئة
sync_channel_disconnect الفراغ، وانقضاء المهلة، والانقطاع في الاتجاهين، ومرسل يتلفه خيط ينتهي
sync_atomic_register_pressure عمليات ذرية يُوصل إليها عبر دوال مغلقة وسلاسل استدعاء بعمق ثلاثة مع متغيرات محلية حية كثيرة على 4 خيوط، والمجاميع مضبوطة
sync_env_threads جدول البيئة مع الخيوط
sync_single_thread الزوج التفاضلي للمفسر
wait_address_single الانتظار والإيقاظ على خيط واحد
thread_spawn_join, thread_join_value, thread_current, thread_nested, thread_many_short, thread_slots واجهة الخيوط
async_tasks نوم متزامن، والانتظار، ومهمة تنتظر مهمة، والتنازل، وTCP غير متزامن بين مهمتين
http_server_async عميلان تخدمهما مهام خادم HTTP غير المتزامن، ثم الإيقاف

اختبارات الرفض هي tests/*/diagnostics/safety/thread_capture_{raw_pointer,reference,global}.sn، واختبار القبول هو tests/*/compiler/semantic/thread_capture_allowed.sn.

الحدود

  • لا يرى فحص الالتقاط عند الإطلاق ما وراء دالة مغلقة ممررة في متغير، أو دالة مغلقة ملتقطة، أو استدعاء يلمس متغير وحدة قابلا للكتابة. ويتتبع Rust هذه الحالات بالسمات التلقائية (auto traits) على أنواع الدوال المغلقة.
  • تحتاج الخيوط ذات النطاق (thread::scope) إلى دعم للمناطق وأعمار المراجع لا يعبّر عنه فاحص الملكية بكلفة منخفضة. ولا توجد متغيرات محلية للخيط (thread-locals)، ولا حواجز (barriers)، ولا WaitGroup.
  • تتجاهل العمليات الذرية الترتيبات الأضعف وتستخدم ترتيب_ذاكرة.تسلسلي دائما.
  • لا أولوية للكاتب في قفل القراءة والكتابة.
  • تلاحظ القنوات الطرف المُتلَف ضمنيا بالاستطلاع، خلال 32 ms.
  • تخدم شغل_خادم_ويب وشغل_خادم_ويب_آمن اتصالا واحدا في كل مرة؛ وتخدم شغل_خادم_ويب_بأحداث المبنية على الأحداث HTTP/1 بالتزامن، لكن اتصال h2c يحتجز عامله، وTLS ليس مبنيا على الأحداث بعد، ولا مستطلع في Windows (IOCP).
  • الخيوط غير متاحة على WASI، ولا على الأهداف المستقلة عن نظام التشغيل، ولا في المفسر.
  • تعمل المهام غير المتزامنة على خيط واحد في العملية (لا سرقة عمل بين الخيوط)، وتنتظر مهمةٌ واحدة مهمةً معينة في كل مرة، وتحجب اتصل_غير_متزامن أثناء المصافحة، ولا تتبدل المهام على Windows ولا wasm32 ولا أهداف 32 بت.

انظر أيضا