لغات البرمجة عملياً الأدلة التقنية

التزامن في Swift: async/await والممثلون Actors

قسنا مئة طلب HTTP وأربعة ملايين عملية SHA-256 بالتتابع وبالتوازي في Swift، وصنعنا سباق بيانات وجموداً حقيقيين وأصلحناهما، لترى ما يرفضه المترجم وما يبقى عليك.

التزامن في Swift: async/await والممثلون Actors

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

وهذه بالضبط الحالات الثلاث التي يجب أن يعرفها كل من يكتب كوداً متزامناً: انتظار الشبكة الذي يضيع الوقت، والحساب الذي لا يستخدم كل الأنوية، والبيانات المشتركة التي تفسد أو تقفل البرنامج. وفي هذا المقال سوف نقيسها كلها في Swift بنفس المهمتين اللتين نستخدمهما في كل لغات هذا المسار: مئة طلب HTTP إلى سيرفر محلي بطيء يرد في 50 مللي ثانية، وSHA-256 لألفي نص كل منها ألفي مرة، بالتتابع ثم بالتوازي، داخل Container محدود بمعالجين و512 MB. ثم نصنع سباق بيانات حقيقياً وجموداً حقيقياً ونصلحهما. وقد شرحنا المفاهيم العامة للخيوط والأقفال وasync/await في مقال Threads وasync/await، والفرق بين التزامن والتوازي على مستوى المعالج في التزامن Concurrency والخيوط، فلن نكررها، وإنما نرى ما تفعله Swift بها.

وسوف نناقش في هذا المقال ما يلي:

  • كيف وصلت Swift من الـ Callbacks وGrand Central Dispatch إلى async/await والتزامن المنظم Structured Concurrency.
  • النموذج من الداخل: المهام Tasks والمجمع التعاوني للخيوط Cooperative Thread Pool ونقاط التعليق، وasync let وTaskGroup.
  • الممثلون Actors وعزل البيانات Isolation، والبروتوكول Sendable، وفحص سباق البيانات Data Race Safety في وضع Swift 6.
  • التجربة: مئة طلب HTTP بالتتابع وبالتوازي، والحد الذي وضعه URLSession على Linux دون أن نطلبه.
  • SHA-256 لأربعة ملايين مرة بالتتابع وبـ TaskGroup وبـ concurrentPerform.
  • سباق بيانات حقيقي يمر من المترجم بسبب كلمة واحدة، وThread Sanitizer، والإصلاح بـ Mutex وبالممثل.
  • إعادة الدخول Reentrancy في الممثل، والجمود Deadlock بقفلين، وماذا يحدث إذا حجبت خيوط المجمع.
  • الإلغاء Cancellation عندما تفشل مهمة، ولماذا لم توقف waitForAll أحداً، وكيف يبدو كل ذلك على السيرفر مع SwiftNIO.

كيف وصلت Swift إلى async/await؟ من GCD إلى التزامن المنظم Structured Concurrency

لسنوات كانت الطريقة المعتادة للتزامن في عالم Apple هي Grand Central Dispatch، واختصاراً GCD أو Dispatch، وهي مكتبة طوابير Queues ترسل إليها كتلاً من الكود لتعمل على مجمع خيوط يديره النظام، ومعها دوال الإكمال Completion Handlers، أي أن الدالة التي تطلب شيئاً من الشبكة تأخذ إغلاقاً تستدعيه عندما يصل الرد. وكانت النتيجة كوداً يعمل، ولكنه يتداخل إلى اليمين مع كل خطوة، والأخطاء تمر في معامل منفصل يسهل نسيانه، وإلغاء العملية في منتصفها يحتاج كوداً يدوياً، ولا شيء في اللغة يمنعك من أن تقرأ متغيراً من خيطين في نفس الوقت.

وفي Swift 5.5 عام 2021 دخلت اللغة ثلاثة أشياء معاً، وكلها موثقة في مقترحات منفصلة: async/await في SE-0296، والتزامن المنظم في SE-0304، والممثلون في SE-0306. ثم جاء البروتوكول Sendable في SE-0302، وصار المترجم يستطيع أن يسأل عن كل قيمة تعبر من مهمة إلى أخرى: هل هي آمنة للمشاركة؟ وكانت هذه الأسئلة تحذيرات في Swift 5.10، ثم صارت أخطاء ترجمة في وضع Swift 6 عام 2024، وهذا ما يجعل Swift من اللغات القليلة التي ترفض سباق البيانات قبل أن يعمل الكود، مثل Rust، بينما Go وJava تكتشفانه وقت التشغيل إذا كنت محظوظاً.

ولم تختف Dispatch، فهي ما زالت موجودة على Linux كمكتبة libdispatch التي رأيناها في قائمة المكتبات التي يحتاجها البرنامج في المقال الأول، ووقت تشغيل المهام نفسه يعمل فوقها على Linux، وستجدها في كل مكتبة كتبت قبل 2021. ولذلك سوف تقرأ كود Dispatch وتحتاج أن تفهمه، وسوف نستخدم منها في التجربة concurrentPerform للمقارنة وDispatchSemaphore لنرى ماذا يحدث عندما تخلط بين العالمين، ولكن القاعدة في الكود الجديد أن تبدأ بـ async/await والممثلين، ولا تنزل إلى Dispatch إلا عندما تحتاج شيئاً لا يوجد غيره.

وقد يتساءل البعض: إذا كانت Dispatch تعمل، فلماذا الكود الجديد بـ async/await؟ والإجابة أن الفرق ليس في الشكل فقط، فالتزامن المنظم يعني أن كل مهمة لها أب، وأن الدالة لا تنتهي حتى تنتهي المهام التي أنشأتها، وأن الإلغاء Cancellation ينتقل من الأب إلى الأبناء تلقائياً، وأن الخطأ يصعد من الابن إلى الأب كما يصعد في الكود العادي. وهذا ما يجعل الكود المتزامن يقرأ مثل الكود المتتابع، وهو موضح في فصل Concurrency في كتاب اللغة.

النموذج من الداخل Concurrency Model: المهام Tasks والمجمع التعاوني

في Java التقليدية كل طلب يأخذ خيطاً Thread من نظام التشغيل، وفي Go كل go ينشئ Goroutine خفيفة يوزعها وقت التشغيل على خيوط قليلة. وSwift أقرب إلى Go في الفكرة: الوحدة التي تكتبها هي المهمة Task، وهي خفيفة، وتعمل على مجمع خيوط تعاوني Cooperative Thread Pool عدد خيوطه بقدر عدد الأنوية التي يراها، وسوف نرى أنه على Linux داخل Container يتبع أنوية الجهاز المضيف وليس الحصة، والدالة async عندما تصل إلى await على شيء لم يجهز بعد لا تحجب الخيط، وإنما تعلق Suspend، أي تحفظ حالتها وتترك الخيط لمهمة أخرى، ثم تستأنف Resume على أي خيط متاح عندما يجهز ما تنتظره. والكلمة «تعاوني» هي مفتاح الفهم: المجمع لا ينشئ خيوطاً جديدة لأن مهمة طال عملها، وإنما يفترض أن كل مهمة تتعاون فتعلق عند الانتظار، فإذا حجبت مهمة خيطها بانتظار لا يعرفه النظام، مثل قفل أو sleep قديم، فقد أخذت خيطاً من عدد قليل، وسوف نرى نتيجة ذلك بأعيننا في آخر التجربة.

مخطط نموذج التزامن في Swift: في الأعلى مهمة أب تنشئ أبناء بـ async let وTaskGroup وكلها تنتهي قبل الأب والإلغاء ينزل من الأب إلى الأبناء، وفي الوسط المجمع التعاوني بخيطين بقدر الأنوية وعليه مهام تعمل وأخرى معلقة عند await تنتظر الشبكة دون أن تحجب خيطاً، وفي الأسفل ممثل Actor له صندوق رسائل ينفذ رسالة واحدة في كل مرة ويحمي حالته، وبجانبه قاعدة Sendable: ما يعبر بين المهام يجب أن يكون قيمة أو ممثلاً أو نوعاً آمناً
مهام خفيفة على خيوط قليلة، وممثلون يحمون الحالة، والمترجم يفحص ما يعبر بينها

async let و TaskGroup: كيف تنشئ عملاً متوازياً؟

أبسط طريقتين لتشغيل أكثر من عمل في نفس الوقت:

// a fixed number of child tasks
async let price = fetchPrice(1)
async let stock = fetchStock(1)
let (p, s) = try await (price, stock)

// a dynamic number of child tasks
let total = try await withThrowingTaskGroup(of: Int.self) { group in
    for i in 0..<100 { group.addTask { try await fetch(i, session) } }
    return try await group.reduce(0, +)
}

async let تبدأ العمل فوراً في مهمة ابن، وawait بعدها تنتظر النتيجة، وهي مناسبة لعدد ثابت تعرفه وأنت تكتب الكود. وTaskGroup للعدد المتغير، تضيف إليها المهام في حلقة، ثم تجمع النتائج بترتيب انتهائها وليس بترتيب إضافتها. وفي الحالتين لا يمكن أن تخرج الدالة قبل أن تنتهي كل المهام الأبناء، فإذا رمت إحداها خطأ ألغيت البقية وصعد الخطأ. وتوجد أيضاً Task { } التي تنشئ مهمة غير منظمة Unstructured ترث سياق من أنشأها، وTask.detached التي لا ترث شيئاً، وهذه تحتاجها عندما تبدأ عملاً من كود غير متزامن، ولكنها تخرج من شجرة الأب والابن، فلا إلغاء تلقائي ولا انتظار تلقائي.

الممثلون Actors وعزل البيانات Isolation

actor ActorCounter { var value = 0; func increment() { value += 1 } }

let c = ActorCounter()
await c.increment()          // a message to the actor: may suspend
print(await c.value)

الممثل actor نوع مرجعي مثل الكلاس، ولكن حالته معزولة Isolated: لا يصل إليها إلا كود الممثل نفسه، والممثل ينفذ عملاً واحداً في كل مرة، فلا يمكن أن يغير خيطان value في نفس اللحظة. ومن خارج الممثل يجب أن تكتب await قبل كل استدعاء، لأن الممثل قد يكون مشغولاً بعمل آخر فتنتظر دورك. وهذه الفكرة قديمة من نموذج الممثلين Actor Model في Erlang وAkka، ولكن Swift جعلتها جزءاً من نظام الأنواع، فالمترجم هو الذي يرفض الوصول المباشر إلى حالة الممثل من الخارج، وليس قاعدة يتذكرها المطورون. ويوجد ممثل خاص اسمه @MainActor يمثل الخيط الرئيسي، وهو الذي تعمل عليه واجهات iOS وmacOS، وعلى السيرفر لا تحتاجه في الغالب.

البروتوكول Sendable وفحص سباق البيانات Data Race Safety في Swift 6

القيمة التي تعبر من مهمة إلى أخرى، أو تدخل إلى ممثل، يجب أن تكون Sendable، أي آمنة للمشاركة بين سياقات متزامنة. والقيم مثل struct وenum التي كل خصائصها Sendable تحصل على ذلك تلقائياً، لأن كل مستلم يأخذ نسخته كما رأينا في المقال الثاني، والممثلون Sendable لأنهم يحمون حالتهم، والكلاس العادي الذي فيه var ليس Sendable، لأن مرجعين في مهمتين يمكن أن يغيرا نفس الذاكرة. ومنذ Swift 6 صار المترجم يحلل أيضاً المناطق Regions، أي أنه يسمح بنقل قيمة غير Sendable إلى مهمة أخرى إذا أثبت أن من أرسلها لن يستخدمها بعد ذلك، وهذا موضح في SE-0414، وهو سبب كلمة sending التي سوف تراها في رسالة الخطأ بعد قليل. ودليل الترحيل الرسمي Data Race Safety يشرح هذه القواعد كاملة.

Swift 6.2 وما بعده: التزامن الأسهل Approachable Concurrency

وكانت الشكوى الأكبر بعد Swift 6 أن الكود البسيط الذي لا يحتاج توازياً أصلاً صار يمتلئ بأخطاء العزل، ولذلك جاء Swift 6.2 بمجموعة تغييرات اسمها Approachable Concurrency: خيار يجعل كل الكود معزولاً على الممثل الرئيسي افتراضياً، وهو مناسب للسكربتات والتطبيقات، كما في SE-0466، وتغيير يجعل الدالة async غير المعزولة تعمل في سياق من استدعاها بدل أن تنتقل إلى المجمع، كما في SE-0461، والوسم @concurrent لتقول صراحة إنك تريد أن تعمل الدالة بالتوازي. ولهذا رأيت في المقال الأول أن قالب المشروع في 6.4 يفعل ApproachableConcurrency. والفكرة كلها أن تبدأ متتابعاً وآمناً، ثم تضيف التوازي في المكان الذي تقيسه وتحتاجه. وتجاربنا في هذا المقال بدون هذا الخيار، لأننا نريد التوازي صراحة.

التجربة Lab: السيرفر البطيء والحزمة والقيود

السيرفر البطيء برنامج صغير بـ Node.js، كل طلب ينتظر 50 مللي ثانية ثم يرد بـ JSON صغير، ويعد كم طلباً كان قيد المعالجة في نفس اللحظة، ويعطي هذا الرقم في /stats، وهذا العداد هو الذي سوف يكشف لنا ما لم نتوقعه:

import http from 'node:http';
let inFlight = 0, maxInFlight = 0, served = 0;
http.createServer((req, res) => {
  if (req.url === '/stats') { res.end(JSON.stringify({ served, maxInFlight })); maxInFlight = 0; served = 0; return; }
  inFlight++; maxInFlight = Math.max(maxInFlight, inFlight);
  setTimeout(() => {
    inFlight--; served++;
    res.setHeader('content-type', 'application/json');
    res.end(JSON.stringify({ id: req.url.split('/').pop(), price_halalas: 990 }));
  }, 50);
}).listen(8080);

والسيرفر يكتب الرد كاملاً في res.end مرة واحدة، وهذا مهم، فالسيرفر الذي يكتب الرؤوس في كتابة والجسم في كتابة ثانية على اتصال Keep-Alive قد يضيف قرابة 40 مللي ثانية لكل طلب بسبب تفاعل خوارزمية Nagle مع التأكيد المتأخر Delayed ACK في TCP، فيصبح الطلب 90 مللي ثانية بدل 50 وتفسد المقارنة كلها. والبرنامج المقاس حزمة Swift فيها اعتمادية واحدة هي swift-crypto بالإصدار 4.5.2، وهي النسخة المفتوحة من CryptoKit التي لا توجد على Linux. وكل شيء يعمل على شبكة Docker خاصة:

docker network create slc-sw-net
docker run -d --name slc-sw-slow --network slc-sw-net --network-alias slow \
  -v "$PWD:/lab" node:22-slim node /lab/slow.mjs
docker run --rm --cpus 2 --memory 512m --network slc-sw-net \
  -v "$PWD:/lab" -v slc-sw-build:/build -w /lab swift:6.4.2-noble bash run.sh

وأول مخرج من السكربت كان البناء وعدد الأنوية:

===== build (release) =====
[434 / 434] conc-product
Build complete! (472.57 secs)

===== cores seen by the process =====
8
cgroup cpu.max:

في المخرج أعلاه لاحظ التالي:

  • البناء أخذ قرابة ثماني دقائق، و434 مهمة بناء لبرنامج من صفحة واحدة، والسبب أن swift-crypto تبني معها مكتبة BoringSSL بلغة C كاملة من الكود المصدري. فإذا بدا لك البناء الأول في CI بطيئاً فهذه هي الأسباب في الغالب، والحل ذاكرة مؤقتة لمجلد البناء بين التشغيلات.
  • nproc قال 8، وهي أنوية الجهاز المضيف، رغم أن الـ Container محدود بمعالجين بالخيار --cpus 2، لأن هذا الخيار حصة زمنية من المعالج وليس عدد أنوية. وسوف نرى أن Foundation في Swift تقرأ الحصة وترى نواتين، وهذا فرق يجب أن تعرفه عندما تضبط عدد الخيوط في أي لغة داخل Container.

وطريقة القياس واحدة في كل التجارب: نبني بوضع الإنتاج -c release لأن وضع التصحيح أبطأ بعشرات المرات كما رأينا في المقال الأول، ونشغل كل طريقة مرتين على الأقل، ونقيس الزمن بـ ContinuousClock داخل البرنامج نفسه حتى لا يدخل فيه زمن تشغيل العملية. والجهاز المضيف جهاز تطوير يعمل عليه غيره، فالأرقام تتغير بين تشغيل وآخر بنسبة قد تصل إلى عشرين في المئة، ولذلك لا تقرأ الفروق الصغيرة بين الطرق كحقيقة، وإنما انظر إلى الفروق الكبيرة التي تتكرر في كل تشغيل، مثل الفرق بين ست ثوان وثلث ثانية.

المهمة المرتبطة بالإدخال والإخراج I/O-bound: مئة طلب HTTP

الدالة fetch تطلب منتجاً واحداً بـ URLSession، وهي على Linux من مكتبة FoundationNetworking. ونقيس ثلاث طرق: حلقة عادية، ثم TaskGroup بإعدادات الجلسة الافتراضية، ثم TaskGroup مع رفع حد الاتصالات لكل سيرفر:

func fetch(_ i: Int, _ session: URLSession) async throws -> Int {
    let (data, resp) = try await session.data(from: URL(string: "\(base)/products/\(i)")!)
    guard (resp as! HTTPURLResponse).statusCode == 200 else { throw URLError(.badServerResponse) }
    return data.count
}

func ioSequential() async throws {
    var bytes = 0
    for i in 0..<100 { bytes += try await fetch(i, .shared) }
}

func ioConcurrent(maxPerHost: Int?) async throws {
    let cfg = URLSessionConfiguration.default
    if let maxPerHost { cfg.httpMaximumConnectionsPerHost = maxPerHost }
    let session = URLSession(configuration: cfg)
    let bytes = try await withThrowingTaskGroup(of: Int.self) { group in
        for i in 0..<100 { group.addTask { try await fetch(i, session) } }
        return try await group.reduce(0, +)
    }
}

والمخرج لتشغيلين من كل طريقة سوف يكون كما يلي:

io sequential: 100 calls, 3090 bytes, 6.29 s, server {"served":100,"maxInFlight":1}
io task group (maxPerHost=default): 100 calls, 3090 bytes, 1.02 s, server {"served":100,"maxInFlight":6}
io task group (maxPerHost=100): 100 calls, 3090 bytes, 0.35 s, server {"served":100,"maxInFlight":63}
io sequential: 100 calls, 3090 bytes, 5.39 s, server {"served":100,"maxInFlight":1}
io task group (maxPerHost=default): 100 calls, 3090 bytes, 1.01 s, server {"served":100,"maxInFlight":6}
io task group (maxPerHost=100): 100 calls, 3090 bytes, 0.31 s, server {"served":100,"maxInFlight":63}

في المخرج أعلاه لاحظ التالي:

  • الطريقة المتتابعة أخذت 5.4 إلى 6.3 ثانية، أي 54 إلى 63 مللي ثانية لكل طلب: الخمسون التي ينتظرها السيرفر، وما بقي هو الشبكة الداخلية وتحليل الرد. والسيرفر يؤكد أن طلباً واحداً فقط كان قيد المعالجة في أي لحظة.
  • مع TaskGroup بالإعداد الافتراضي نزل الزمن إلى ثانية واحدة، وهذا تحسين ستة أضعاف، ولكنه ليس ما توقعناه، فمئة طلب كل منها 50 مللي ثانية يجب أن تنتهي في أقل من نصف ثانية إذا عملت معاً. والسيرفر أخبرنا بالسبب: maxInFlight كان 6، أي أن ستة طلبات فقط كانت تعمل في نفس الوقت، رغم أننا أنشأنا مئة مهمة.
  • السبب هو الخاصية httpMaximumConnectionsPerHost في إعدادات الجلسة، فالجلسة تفتح ستة اتصالات كحد أقصى لكل سيرفر، وتضع بقية الطلبات في طابور، و100 ÷ 6 تعطي قرابة 17 دفعة، و17 × 60 مللي ثانية قريبة جداً من الثانية التي قسناها.
  • بعد رفع الحد إلى 100 نزل الزمن إلى 0.31 و0.35 ثانية، أي قرابة 17 إلى 20 ضعف المتتابع، ووصل عدد الطلبات المتزامنة على السيرفر إلى 63. ولم يصل إلى 100 لأن بعض الطلبات انتهت قبل أن تبدأ أخرى.
أعمدة لزمن مئة طلب HTTP إلى سيرفر يرد في 50 مللي ثانية، تشغيلان لكل طريقة: بالتتابع 6.29 و5.39 ثانية وطلب واحد قيد المعالجة، وبـ TaskGroup مع إعداد URLSession الافتراضي 1.02 و1.01 ثانية وستة طلبات فقط قيد المعالجة لأن الحد الافتراضي ستة اتصالات لكل سيرفر، وبـ TaskGroup مع httpMaximumConnectionsPerHost = 100 بزمن 0.35 و0.31 ثانية و63 طلباً قيد المعالجة
مئة مهمة لا تعني مئة طلب: الحد الحقيقي كان في إعداد الجلسة

وهذه أهم نتيجة في التجربة كلها، وهي ليست عن Swift وحدها: التوازي في كودك لا يعني التوازي في الشبكة، فبين مهامك والسيرفر طبقات لها حدودها، مثل مجمع الاتصالات في مكتبة HTTP، أو مجمع الاتصالات بقاعدة البيانات، أو حدود السيرفر نفسه. ولولا العداد في السيرفر لظننا أن ثانية واحدة هي أفضل ما تستطيعه Swift. ولاحظ أن الحد الافتراضي ليس خطأ، فهو يحمي السيرفر الذي تطلبه من أن تغرقه بمئة اتصال من عميل واحد، والصحيح أن تختار الرقم بنفسك بحسب ما يتحمله الطرف الآخر. وعلى السيرفر، أي داخل خدمة Vapor، يستخدم أغلب الناس AsyncHTTPClient المبنية على SwiftNIO بدل URLSession، ولها نفس الفكرة بإعداد اسمه مختلف.

المهمة المرتبطة بالمعالج CPU-bound: SHA-256 أربعة ملايين مرة

البصمة لكل فاتورة: نأخذ النص order-i، ونحسب SHA-256، ثم نحسب SHA-256 للناتج، وهكذا ألفي مرة، ونكرر ذلك لألفي فاتورة، أي أربعة ملايين عملية هاش. ونقيس ثلاث طرق: حلقة عادية، وTaskGroup بمهمة لكل فاتورة، وDispatchQueue.concurrentPerform من مكتبة Dispatch القديمة:

func chain(_ i: Int) -> String {
    var digest = Array(SHA256.hash(data: Data("order-\(i)".utf8)))
    for _ in 1..<2000 { digest = Array(SHA256.hash(data: digest)) }
    return digest.prefix(4).map { String(format: "%02x", $0) }.joined()
}

func cpuTaskGroup() async {
    let results = await withTaskGroup(of: (Int, String).self) { group in
        for i in 0..<2000 { group.addTask { (i, chain(i)) } }
        var out = [String](repeating: "", count: 2000)
        for await (i, h) in group { out[i] = h }
        return out
    }
}

func cpuDispatch() {
    let out = Mutex([String](repeating: "", count: 2000))
    DispatchQueue.concurrentPerform(iterations: 2000) { i in
        let h = chain(i)
        out.withLock { $0[i] = h }
    }
}

والمخرج لتشغيلين سوف يكون كما يلي:

cpu sequential: 4,000,000 hashes, last=aa0004c2, 11.18 s
cpu task group: 4,000,000 hashes, last=aa0004c2, cores=2, 5.84 s
cpu concurrentPerform: last=aa0004c2, 5.53 s
cpu sequential: 4,000,000 hashes, last=aa0004c2, 9.72 s
cpu task group: 4,000,000 hashes, last=aa0004c2, cores=2, 5.06 s
cpu concurrentPerform: last=aa0004c2, 4.58 s

في المخرج أعلاه لاحظ التالي:

  • البصمة الأخيرة aa0004c2 هي نفسها في الطرق الثلاث، فالتوازي لم يغير النتيجة، وهذا أول ما تفحصه قبل أن تنظر إلى الزمن.
  • التوازي أعطى قرابة الضعف: 11.2 إلى 5.8، و9.7 إلى 5.1، وهذا هو السقف مع نواتين، فلا توجد طريقة تعطي أكثر من ضعفين لعمل حسابي على معالجين، مهما كان عدد المهام.
  • cores=2 هو ProcessInfo.processInfo.activeProcessorCount، أي أن Foundation رأت حصة الـ Container، بينما nproc رأى ثماني أنوية كما رأينا. وتحسب هذا الفرق في رأسك، لأن ما يحدد التوازي الفعلي في العمل الحسابي هو الحصة، أي نواتان، مهما كان عدد خيوط المجمع.
  • concurrentPerform كانت أسرع قليلاً من TaskGroup في التشغيلين، والفرق صغير وقريب من ضجيج الجهاز، والفكرة أن TaskGroup تنشئ ألفي مهمة وتجمع ألفي نتيجة عبر for await، بينما concurrentPerform توزع ألفي استدعاء على خيوط ثابتة. والفرق بين التشغيل الأول والثاني في الطريقة نفسها، 11.18 و9.72، أكبر من الفرق بين الطرق، وهذا تذكير بأن الأرقام من جهاز تطوير وليست من سيرفر مخصص.
أعمدة لزمن أربعة ملايين عملية SHA-256 على معالجين، تشغيلان لكل طريقة: بالتتابع 11.18 و9.72 ثانية، وبـ TaskGroup بمهمة لكل فاتورة 5.84 و5.06 ثانية، وبـ DispatchQueue.concurrentPerform 5.53 و4.58 ثانية، والبصمة الأخيرة aa0004c2 في كل الطرق، وملاحظة أن السقف ضعفان مع نواتين وأن nproc رأى 8 بينما activeProcessorCount رأى 2
مع نواتين يكون السقف ضعفين، والطرق المتوازية وصلت إليه تقريباً

وقد يتساءل البعض: لماذا لا نكتب كل شيء بـ TaskGroup ونترك المجمع يقرر؟ والإجابة أن هذا هو الصحيح في أغلب الحالات، ولكن تذكر أن المجمع التعاوني يفترض أن المهام تعلق كثيراً، والمهمة الحسابية الطويلة لا تعلق أبداً، فإذا كانت لديك خدمة فيها طلبات HTTP قصيرة وحساب ثقيل في نفس المجمع، فقد يأخذ الحساب كل الخيوط وتتأخر الطلبات القصيرة. والحل إما أن تقسم الحساب وتستدعي await Task.yield() بين الأجزاء، أو تنقله إلى طابور أو خيوط منفصلة.

سباق بيانات حقيقي Data Race: ما يرفضه المترجم وما يمر

نعود إلى الزميل الذي صار عدد الفواتير يختلف عنده. كتب شيئاً مثل هذا: كلاس فيه عداد، ومهام متعددة تزيده:

final class Counter { var value = 0 }

func run() async {
    let c = Counter()
    await withTaskGroup(of: Void.self) { g in
        for _ in 0..<8 { g.addTask { c.value += 1 } }
    }
    print(c.value)
}

ونترجمه بوضع Swift 6:

swiftc -swift-version 6 race.swift -o /tmp/race

والمخرج سوف يكون كما يلي:

race.swift:7:28: error: passing closure as a 'sending' parameter risks causing data races between code in the current isolation context and concurrent execution of the closure [#RegionIsolation::SendingClosureRisksDataRace]
 5 |     let c = Counter()
 6 |     await withTaskGroup(of: Void.self) { g in
 7 |         for _ in 0..<8 { g.addTask { c.value += 1 } }
   |                            |         `- note: closure captures 'c' which is accessible to code in the current isolation context
   |                            `- error: passing closure as a 'sending' parameter risks causing data races between code in the current isolation context and concurrent execution of the closure [#RegionIsolation::SendingClosureRisksDataRace]

[#RegionIsolation]: <https://docs.swift.org/compiler/documentation/diagnostics/region-isolation>
[#SendingClosureRisksDataRace]: <https://docs.swift.org/compiler/documentation/diagnostics/sending-closure-risks-data-race>

والرسالة طويلة ولكنها دقيقة: الإغلاق الذي تمرره إلى addTask يرسل sending إلى مهمة تعمل بالتوازي، وهو يمسك c الذي ما زال الكود الحالي يستطيع الوصول إليه، فقد يغير الطرفان نفس الكائن في نفس اللحظة. ولاحظ الروابط في آخر الرسالة، فالمترجم في الإصدارات الحديثة يشير إلى صفحة شرح لكل نوع من التشخيصات في توثيق تشخيصات المترجم. ونفس الملف بوضع Swift 5، أي -swift-version 5، ترجم بدون أي خطأ، وهذا هو سبب قوة الترحيل إلى وضع Swift 6.

ولكن المترجم يمكن أن يسكت، والطريقة الأكثر انتشاراً لإسكاته هي @unchecked Sendable، أي «صدقني، هذا الكلاس آمن». وهذا ما فعله الزميل، فلنقس ما يحدث:

final class Stock: @unchecked Sendable {      // "trust me": the compiler stops checking
    var sold = 0
}

@inline(never) func sell(_ s: Stock) { s.sold += 1 }   // one real read-modify-write per call

let s = Stock()
await withTaskGroup(of: Void.self) { g in
    for _ in 0..<8 { g.addTask { for _ in 0..<100_000 { sell(s) } } }
}
print("unsafe: expected 800000, got \(s.sold)")

والمخرج لثلاثة تشغيلات، ثم الإصلاحان:

unsafe: expected 800000, got 292233
unsafe: expected 800000, got 222401
unsafe: expected 800000, got 352156
mutex: expected 800000, got 800000
time: 0.15886108 seconds
actor: expected 800000, got 800000
time: 0.128408289 seconds

في المخرج أعلاه لاحظ التالي:

  • ثماني مهام، كل واحدة تبيع مئة ألف، والمتوقع 800 ألف، والنتيجة بين 222 ألفاً و352 ألفاً، أي أن أكثر من نصف الزيادات ضاعت. والسبب أن sold += 1 ثلاث عمليات: اقرأ، أضف واحداً، اكتب، فإذا قرأ خيطان نفس القيمة كتب كل منهما نفس الناتج، وضاعت زيادة.
  • والنتيجة تختلف في كل تشغيل، وهذا ما رآه الزميل بالضبط: عدد مختلف كل مرة، وبدون أي رسالة خطأ.
  • الإصلاح الأول Mutex من مكتبة Synchronization التي دخلت مع Swift 6 في SE-0433، وهي قفل يحمي القيمة نفسها، فلا تستطيع أن تصل إلى القيمة إلا داخل withLock، وهذا أفضل من قفل منفصل عن البيانات يمكن أن تنساه.
  • الإصلاح الثاني الممثل، وكل بيعة صارت await s.sell()، والنتيجة صحيحة في الحالتين، والزمن 0.13 إلى 0.16 ثانية لثمانمئة ألف عملية، أي أن ثمن الأمان هنا أجزاء من الثانية.

وفي التجربة الأولى واجهنا مشكلة مفيدة: كتبنا الحلقة c.value += 1 مباشرة داخل المهمة بدون دالة منفصلة، فخرجت النتيجة 800000 في ثلاثة تشغيلات متتالية، وكدنا نكتب أن السباق لم يحدث. والتفسير الأقرب أن المترجم بتحسينات -O جمع الزيادات داخل كل مهمة في متغير محلي وكتبها مرة واحدة، فصار التداخل بين المهام نادراً. وعندما وضعنا الزيادة في دالة عليها @inline(never) صارت كل زيادة قراءة وكتابة حقيقية على الكائن، وظهر السباق فوراً. والدرس أن غياب الخطأ في تشغيلين أو ثلاثة لا يثبت شيئاً عن السباق، وأن الأداة الصحيحة لإثباته هي Thread Sanitizer.

Thread Sanitizer: اكشف السباق قبل أن يكشفه العميل

ThreadSanitizer، واختصاراً TSan، أداة من LLVM تضيف فحصاً لكل وصول إلى الذاكرة وتسجل أي خيط وصل ومتى، ثم تخبرك عندما يصل خيطان إلى نفس العنوان بدون ترتيب بينهما، وتعمل مع Swift على Linux كما في دليل LLVM Sanitizers:

swiftc -sanitize=thread -g race.swift -o /tmp/race-tsan
/tmp/race-tsan unsafe

والمخرج، مختصراً، سوف يكون كما يلي:

==================
WARNING: ThreadSanitizer: Swift access race (pid=107)
  Modifying access of Swift variable at 0x7208000000d0 by thread T1:
    #0 $s4main4sellyyAA5StockCF /s/race.swift (race-tsan+0x104fe0)
    #1 $s4mainyScGyytGzYaXEfU0_yyYacfU_TY0_ /s/race.swift:24:61 (race-tsan+0x101b87)
    #2 swift::runJobInEstablishedExecutorContext(...) (libswift_Concurrency.so+0x7272f)

  Previous write of size 1 at 0x7208000000d0 by thread T3:
    #0 $s4main4sellyyAA5StockCF /s/race.swift:10:47 (race-tsan+0x105005)

  Location is heap block of size 24 at 0x7208000000c0 allocated by main thread:
    #2 async_Main /s/race.swift:22:13 (race-tsan+0xfe2de)
...
SUMMARY: ThreadSanitizer: Swift access race /s/race.swift in $s4main4sellyyAA5StockCF

في المخرج أعلاه لاحظ التالي:

  • التقرير يحدد خيطين، T1 وT3، يكتبان نفس العنوان، وكلاهما في الدالة sell في السطر 10، وهي s.sold += 1.
  • الأسماء مثل $s4main4sellyyAA5StockCF هي الأسماء المرمزة Mangled Names للدوال، وتستطيع أن تفكها بالأداة swift demangle التي رأيناها في قائمة الأدوات، فتصبح main.sell(main.Stock) -> ().
  • آخر جزء يقول أين حجز الكائن: في async_Main في السطر 22، وهو let s = Stock()، فتعرف أي كائن مشترك هو المشكلة.
  • البرنامج مع TSan أبطأ عدة مرات ويستهلك ذاكرة أكثر، ولذلك يعمل في CI على الاختبارات، وليس في الإنتاج. ومع SwiftPM تكتب swift test --sanitize=thread.
أربع مراحل لنفس العداد: في Swift 6 كلاس عادي مشترك بين المهام يرفضه المترجم بخطأ sending closure risks causing data races، ومع @unchecked Sendable يمر من المترجم وتعطي ثلاثة تشغيلات 292233 و222401 و352156 بدل 800000، وThread Sanitizer يحدد الخيطين T1 وT3 والسطر 10، والإصلاح بـ Mutex يعطي 800000 في 0.16 ثانية وبالممثل 800000 في 0.13 ثانية
المترجم يرفض السباق، وكلمة واحدة تسكته، وTSan يكشفه، والقفل أو الممثل يصلحه

الممثل آمن والمنطق لا: إعادة الدخول Actor Reentrancy

بعد أن عرف الفريق الممثلين نقلوا المخزون إلى ممثل، وظنوا أن المشكلة انتهت، ثم ظهر مخزون بقيمة سالبة. والسبب ميزة في تصميم الممثلين في Swift اسمها إعادة الدخول Reentrancy: عندما تصل دالة في الممثل إلى await، يترك الممثل هذه الدالة معلقة ويقبل رسالة أخرى، حتى لا يتجمد الممثل كله بانتظار الشبكة. وهذا يعني أن ما فحصته قبل await قد يتغير بعده:

actor Stock {
    var available = 1
    func reserveWrong(_ who: String) async -> Bool {
        guard available >= 1 else { return false }
        try? await Task.sleep(for: .milliseconds(20))     // e.g. call the payment service
        available -= 1
        return true
    }
    func reserveRight(_ who: String) async -> Bool {
        guard available >= 1 else { return false }
        available -= 1                                    // change state before suspending
        try? await Task.sleep(for: .milliseconds(20))
        return true
    }
}

async let a = s.reserveWrong("A")
async let b = s.reserveWrong("B")

والمخرج سوف يكون كما يلي:

  B reserved, available=0
  A reserved, available=-1
reentrancy bug: A=true B=true final available=-1
  A reserved, available=0
reentrancy fixed: A=true B=false final available=0

قطعة واحدة في المخزون، وعميلان حجزاها معاً، والمخزون صار -1. فالممثل منع أن يكتب خيطان available في نفس اللحظة، وهذا وعده، ولكنه لم يعد أبداً بأن الدالة تعمل من أولها إلى آخرها دون أن يتدخل أحد. والعميل A فحص فوجد قطعة، ثم علق عند await، فدخل B وفحص فوجد نفس القطعة، ثم أنقص كل منهما. والإصلاح أن تغير الحالة قبل أي await، أي أن يكون الفحص والتغيير في جزء واحد لا يعلق، ثم إذا فشلت العملية الخارجية بعد ذلك ترجع القطعة. وهذه القاعدة مشروحة في مقترح الممثلين نفسه، وهي من أكثر ما يفاجئ القادمين من نموذج الأقفال.

الجمود Deadlock: قفلان بترتيب معكوس

والممثلون في Swift لا يتجمدون بهذه الطريقة لأنهم يعيدون الدخول، ولكن الكود الحقيقي فيه أقفال أيضاً، خاصة في المكتبات القديمة وفي الكود الذي يستخدم Dispatch. ولنصنع الجمود الكلاسيكي: خيط الطلبات يأخذ قفل المخزون ثم قفل الدفتر المحاسبي، وخيط الاسترجاع يأخذهما بالعكس:

let stockLock = NSLock()      // protects the stock table
let ledgerLock = NSLock()     // protects the accounting ledger

func orderThread() {          // takes stock, then ledger
    stockLock.lock(); print("order:  holds stock, wants ledger")
    Thread.sleep(forTimeInterval: 0.1)
    ledgerLock.lock(); print("order:  holds both")
    ledgerLock.unlock(); stockLock.unlock()
}

func refundThread() {         // bad: takes ledger, then stock
    if mode == "bad" {
        ledgerLock.lock(); print("refund: holds ledger, wants stock")
        Thread.sleep(forTimeInterval: 0.1)
        stockLock.lock()
    } else {                  // good: same order as everyone else
        stockLock.lock(); ledgerLock.lock()
    }
    print("refund: holds both")
    stockLock.unlock(); ledgerLock.unlock()
}
timeout 5 /tmp/dl bad; echo "exit=$?"
timeout 5 /tmp/dl good; echo "exit=$?"

والمخرج سوف يكون كما يلي:

order:  holds stock, wants ledger
refund: holds ledger, wants stock
exit=124
order:  holds stock, wants ledger
order:  holds both
refund: holds both
good: both threads finished
exit=0

في الحالة السيئة طبع كل خيط أنه يمسك قفلاً ويريد الآخر، ثم لم يطبع أحد شيئاً، حتى قتل timeout البرنامج بعد خمس ثوان برمز 124. والبرنامج لم يستهلك أي معالج طوال هذه المدة، فالخيطان نائمان ينتظر كل منهما الآخر، ولذلك يظهر الجمود في الإنتاج كخدمة «حية» لا ترد، وليس كخطأ. والإصلاح قاعدة واحدة: كل الكود يأخذ الأقفال بنفس الترتيب، وهنا المخزون ثم الدفتر، فصار كل خيط ينتظر دوره وانتهى البرنامج. وهذه نفس القاعدة التي شرحناها لقواعد البيانات في مقال الأقفال Locks والجمود Deadlock، وسوف نطبقها في المقال الرابع عندما نقفل صفوف المنتجات بترتيب أرقامها. وفي التشغيل الأول لهذه التجربة لم يظهر أي سطر قبل exit=124، لأن المخرج إلى Pipe كان في ذاكرة مؤقتة ضاعت عندما قتل البرنامج، فأضفنا setvbuf(stdout, nil, _IONBF, 0) في أول الملف.

ماذا يحدث إذا حجبت خيوط المجمع؟ Blocking the Pool

قلنا إن المجمع التعاوني يفترض أن المهام لا تحجب خيوطها، فلنختبر ذلك: ننشئ عدداً من المهام كل منها ينتظر على DispatchSemaphore، أي انتظار حاجب لا يعرف عنه نظام المهام شيئاً، ثم ننشئ مهمة أخيرة ترسل الإشارة لتحررها كلها، ونطبع عدد خيوط العملية من /proc/self/status:

let sem = DispatchSemaphore(value: 0)
print("cores=\(ProcessInfo.processInfo.activeProcessorCount) waiters=\(waiters) before: \(threads())")
for _ in 0..<waiters { Task.detached { sem.wait() } }
try? await Task.sleep(for: .milliseconds(500))
print("all waiters blocked: \(threads())")
await Task.detached { for _ in 0..<waiters { sem.signal() } }.value
print("signaller ran after \(clock.now - t), \(threads())")
for n in 2 8 16 64; do timeout 10 /tmp/bl $n; echo "exit=$?"; done

والمخرج سوف يكون كما يلي:

cores=2 waiters=2 before: Threads:	1
all waiters blocked: Threads:	8
signaller ran after 0.000222257 seconds, Threads:	8
exit=0
cores=2 waiters=8 before: Threads:	1
exit=124
cores=2 waiters=16 before: Threads:	1
exit=124
cores=2 waiters=64 before: Threads:	1
exit=124

في المخرج أعلاه لاحظ التالي:

  • مع مهمتين حاجبتين عمل كل شيء، والعملية كان فيها ثمانية خيوط، فبقي في المجمع خيط حر لمهمة الإشارة.
  • مع ثماني مهام حاجبة أو أكثر لم يطبع البرنامج حتى السطر «all waiters blocked»، وقتله timeout بعد عشر ثوان. فالمهام الحاجبة أخذت كل الخيوط المتاحة، ولم يبق خيط يستأنف عليه حتى الكود الرئيسي بعد Task.sleep، ولا مهمة الإشارة التي كانت ستحرر الجميع، فتجمد البرنامج كله.
  • هذا جمود بدون أي قفل في كودك، وسببه الوحيد أن مهاماً انتظرت بطريقة حاجبة. ولاحظ أن الحد كان ثماني مهام وليس اثنتين، رغم أن الـ Container محدود بنواتين، والتفسير الأقرب أن عرض المجمع على Linux يتبع عدد أنوية الجهاز التي يراها nproc، أي ثمانية، وليس حصة --cpus. ويؤكد ذلك قياس آخر سوف نراه في مقال الأخطاء الشائعة: ست عشرة مهمة تنام كل منها نصف ثانية بـ usleep الحاجبة أخذت 1.02 ثانية، أي دفعتين في كل منهما ثماني مهام. فلا تبن على رقم معين، لأنه يتغير بحسب الجهاز والنظام، وإنما على القاعدة: داخل async لا تستخدم DispatchSemaphore.wait ولا Thread.sleep ولا قراءة ملف أو شبكة بطريقة حاجبة.

والخبر الجيد أن المترجم يمسك بعض هذه الأخطاء، فـ Thread.sleep داخل دالة async يرفضه وضع Swift 6 برسالة «class method 'sleep' is unavailable from asynchronous contexts»، وسوف نرى ذلك في مقال الأخطاء الشائعة. ولكن الدوال القادمة من C مثل usleep، والمكتبات القديمة التي تحجب داخلها، لا يعرف المترجم عنها شيئاً، وهنا تحتاج أن تعرف القاعدة بنفسك.

ثلاث لوحات لطرق التوقف: في اليمين إعادة الدخول في الممثل حيث يفحص A المخزون 1 ثم يعلق عند await فيدخل B ويفحص نفس القطعة فينتهي المخزون عند -1، والإصلاح بالإنقاص قبل await، وفي الوسط الجمود بقفلين حيث يمسك خيط الطلب قفل المخزون ويريد الدفتر ويمسك خيط الاسترجاع الدفتر ويريد المخزون فيقتل البرنامج بعد خمس ثوان برمز 124، والإصلاح بترتيب واحد، وفي اليسار حجب المجمع حيث تعمل مهمتان حاجبتان مع ثمانية خيوط وتتجمد العملية مع ثماني مهام أو أكثر
ثلاث طرق ليتوقف كود متزامن: منطق يعبر await، وأقفال بترتيب معكوس، وخيوط محجوبة

ماذا يحدث إذا فشلت مهمة؟ الإلغاء Cancellation

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

func reconcile(_ invoice: Int) async throws {
    for _ in 0..<10 {                                    // 10 steps of 100 ms each
        if mode == "checks" { try Task.checkCancellation() }
        busy(100)
        steps.add(1, ordering: .relaxed)
    }
}

try await withThrowingTaskGroup(of: Void.self) { group in
    for i in 0..<4 { group.addTask { try await reconcile(i) } }
    group.addTask {                                  // the payment check fails after 250 ms
        try await Task.sleep(for: .milliseconds(250))
        throw PaymentDeclined()
    }
    if wait == "waitForAll" {
        try await group.waitForAll()             // rethrows the first error, but does NOT cancel the others
    } else {
        while try await group.next() != nil {}   // throws at the first error: leaving the body cancels the rest
    }
}

والمخرج لثلاث حالات سوف يكون كما يلي:

  payment check throws at 0.305140366 seconds
checks/waitForAll: group failed with PaymentDeclined() after 1.105994553 seconds, steps done: 40 of 40
  payment check throws at 0.288107822 seconds
checks/next: group failed with PaymentDeclined() after 0.42527815 seconds, steps done: 15 of 40
  payment check throws at 0.346208247 seconds
ignores/next: group failed with PaymentDeclined() after 1.137677138 seconds, steps done: 40 of 40

في المخرج أعلاه لاحظ التالي:

  • في الحالة الأولى رمت مهمة الدفع عند 0.3 ثانية، ومع ذلك أكملت المطابقة كل خطواتها الأربعين، ولم يخرج الخطأ إلا بعد 1.1 ثانية، رغم أن المهام تفحص الإلغاء في كل خطوة. وهذا لم نتوقعه، والسبب في waitForAll نفسها، فتوثيقها في كود المكتبة القياسية يقول إنها تحفظ أول خطأ حتى تنتهي كل المهام الأخرى ثم ترميه، أي أنها لا تلغي أحداً.
  • في الحالة الثانية استخدمنا group.next() في حلقة، فرمت عند أول خطأ، وخرجنا من جسم المجموعة، والخروج من الجسم برمي خطأ يلغي المهام الباقية، ففحصت المهام العلامة في الخطوة التالية وتوقفت، وانتهى كل شيء في 0.43 ثانية بعد 15 خطوة فقط.
  • في الحالة الثالثة نفس next()، ولكن المهام لا تفحص الإلغاء، فأكملت كل خطواتها، والمجموعة انتظرتها قبل أن ترجع، لأن التزامن المنظم يضمن ألا تبقى مهمة ابن بعد أبيها. فالإلغاء وصل، ولكن لم يقرأه أحد.

والدرس شيئان: الأول أن الإلغاء في Swift لا يوقف شيئاً بالقوة، فالعمل الحسابي الطويل يجب أن يفحص Task.checkCancellation() بين أجزائه، بينما الدوال التي تعلق مثل Task.sleep والطلبات الشبكية تفحصه بنفسها. والثاني أن تختار طريقة الانتظار بحسب ما تريد: waitForAll عندما تريد أن تكمل كل المهام مهما حدث، والحلقة على next() أو for try await عندما تريد أن يوقف أول فشل الجميع.

كيف يبدو هذا على السيرفر؟ SwiftNIO و Vapor

كل ما سبق كان في برنامج سطر أوامر، ولكن الخدمة التي سوف نبنيها في المقال الرابع تعمل فوق SwiftNIO، وهي مكتبة الشبكة غير الحاجبة التي يبنى عليها Vapor وAsyncHTTPClient ومكتبة PostgreSQL. وSwiftNIO لها خيوطها الخاصة اسمها حلقات الأحداث Event Loops، عادة حلقة لكل نواة، وكل اتصال مرتبط بحلقة واحدة، وهي فكرة قريبة من Node.js ولكن بعدة حلقات بدل حلقة واحدة. وعندما تكتب في Vapor معالج طلب بـ async فإن الكود يعمل كمهمة Swift عادية، وawait على قاعدة البيانات يعلق المهمة ويترك الخيط، فيخدم السيرفر آلاف الاتصالات بخيوط قليلة.

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

مقارنة مع Go و Java و Kotlin و JavaScript

الموضوعGoJava 21+KotlinJavaScriptSwift
وحدة التزامنGoroutineVirtual ThreadCoroutinePromise على خيط واحدTask
شكل الكودعادي بدون awaitعادي بدون awaitsuspendasync/awaitasync/await
حماية الحالةقنوات وأقفالأقفالأقفال وقنواتلا يوجد خيطانممثلون وMutex
كشف سباق البيانات-race وقت التشغيلأدوات خارجيةأدوات خارجيةلا ينطبقالمترجم في وضع Swift 6، وTSan
حجب خيط داخل المهمةوقت التشغيل يعوضيعوض في أغلب الحالاتيحجب الـ Dispatcherيجمد كل شيءيحجب المجمع، وقد يتجمد

ولاحظ الصف الأخير: Go وVirtual Threads في Java صممتا لتعويض الخيط المحجوب بخيط آخر، فالخطأ فيهما أرخص، بينما Swift اختارت مجمعاً صغيراً بحجم الأنوية وتعاوناً صريحاً، وهذا أكفأ عندما يكون الكود صحيحاً، وأخطر عندما لا يكون. وإذا كنت قادماً من JavaScript فقد شرحنا نموذجها في حلقة الأحداث Event Loop، وأهم فرق أن await في Swift قد تستأنف على خيط آخر، فالكود قبلها وبعدها ليس بالضرورة على نفس الخيط.

مشاكل واجهناها أثناء التجربة Troubleshooting

  • ثانية واحدة بدل ثلث ثانية في الطلبات المتوازية: السبب حد ستة اتصالات لكل سيرفر في URLSession، وما كشفه هو العداد في السيرفر البطيء، فضع دائماً في تجاربك طريقة ترى بها التوازي من الطرف الآخر.
  • سباق لا يظهر: العداد المشترك أعطى 800000 في ثلاثة تشغيلات بسبب تحسينات المترجم، وظهر السباق بعد أن صارت كل زيادة استدعاء دالة حقيقياً. لا تثق بغياب الخطأ، واستخدم TSan.
  • مخرج يختفي عند الجمود: برنامج يقتل بـ timeout لا يكتب ما في ذاكرته المؤقتة، والحل مخرج بلا ذاكرة مؤقتة في التجارب، وسجلات تكتب فوراً في الخدمات.
  • وضع اللغة في الملفات المنفردة: الحزمة conc تبنى بوضع Swift 6 لأن swift-tools-version فيها 6.2، أما الملفات المنفردة مثل race.swift وdeadlock.swift فترجمناها بـ swiftc الذي يستخدم وضع Swift 5 افتراضياً، ولذلك كتبنا -swift-version 6 صراحة عندما أردنا أن نرى خطأ سباق البيانات. فإذا جربت ملفاً منفرداً ولم يعترض المترجم فتأكد من الوضع أولاً.
  • بناء طويل: ثماني دقائق بسبب BoringSSL داخل swift-crypto، فاحفظ مجلد البناء بين تشغيلات CI.
  • عدد الأنوية: nproc رأى ثمانية والحصة اثنتان، فلا تحسب عدد الخيوط من nproc داخل Container محدود بـ --cpus.

أخطاء شائعة Common Mistakes

  • @unchecked Sendable لإسكات المترجم: رأينا نتيجتها: ضاع أكثر من نصف التحديثات. لا تكتبها إلا على نوع تحميه أنت بقفل داخله، واكتب بجانبها لماذا هو آمن.
  • الفحص قبل await والتغيير بعدها في الممثل: غير الحالة قبل أي تعليق، أو أعد الفحص بعده.
  • انتظار حاجب داخل async: DispatchSemaphore.wait وusleep والقراءة الحاجبة تأخذ خيطاً من مجمع صغير، وقد تجمد البرنامج كله.
  • Task.detached في كل مكان: تخرج المهمة من شجرة التزامن المنظم، فلا تلغى مع أبيها ولا ينتظرها أحد، وتضيع أخطاؤها إذا لم تقرأ .value.
  • افتراض أن مئة مهمة تعني مئة طلب: تحقق من حدود مكتبة HTTP ومجمع قاعدة البيانات.
  • waitForAll لتوقف المجموعة عند أول خطأ: هي تنتظر الجميع ثم ترمي، فإذا أردت أن يوقف الفشل الأول بقية العمل فاستخدم حلقة على next() أو استدع group.cancelAll() بنفسك.
  • تجاهل الإلغاء: المهمة الطويلة يجب أن تفحص Task.isCancelled أو تستدعي try Task.checkCancellation()، وإلا استمرت في العمل بعد أن ألغى العميل طلبه.

قائمة تحقق Checklist: كود متزامن في Swift

البندكيف تتحقق
وضع اللغة Swift 6swift-tools-version: 6.x بدون swiftLanguageModes: [.v5]
لا @unchecked Sendable بدون سبب مكتوبابحث عنها في المراجعة
العمل المتوازي منظمasync let وTaskGroup قبل Task.detached
الحالة المشتركة محميةممثل، أو Mutex للأقسام القصيرة جداً
لا تعليق بين الفحص والتغييرراجع كل await داخل دوال الممثلين
لا انتظار حاجب داخل asyncابحث عن wait() وsleep والدوال القديمة
حدود الاتصالات معروفةإعداد مكتبة HTTP ومجمع قاعدة البيانات
الفشل يوقف العمل الباقيحلقة على next() وTask.checkCancellation() في الحساب الطويل
الأقفال بترتيب ثابتوثق الترتيب في الكود
TSan في CIswift test --sanitize=thread

تدريب عملي

  • غير httpMaximumConnectionsPerHost إلى 10 ثم 25 ثم 50، وارسم الزمن مع maxInFlight، وابحث عن النقطة التي يتوقف عندها التحسن.
  • أضف إلى cpuTaskGroup حداً لعدد المهام النشطة، أي لا تضيف مهمة جديدة حتى تنتهي واحدة عندما يصل العدد إلى 4، وقارن الزمن والذاكرة.
  • اكتب نسخة من reserveWrong تفحص مرة ثانية بعد await، وقارن بينها وبين reserveRight: أيهما أوضح، وأيهما أصح إذا فشل الدفع؟
  • شغل race.swift مع TSan بالوضع mutex وبالوضع actor، وتأكد أن التقرير صار فارغاً.

الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

  • التزامن في Swift مهام خفيفة على مجمع خيوط تعاوني بحجم الأنوية، وawait تعلق المهمة ولا تحجب الخيط، والتزامن المنظم بـ async let وTaskGroup يربط الأبناء بالأب في الانتظار والإلغاء والأخطاء.
  • مئة طلب بالتتابع أخذت 5.4 إلى 6.3 ثانية، وبالتوازي ثانية واحدة لأن URLSession تفتح ستة اتصالات لكل سيرفر، و0.31 ثانية بعد رفع الحد.
  • أربعة ملايين SHA-256 أخذت 9.7 إلى 11.2 ثانية بالتتابع، و4.6 إلى 5.8 بالتوازي على نواتين، وهذا سقف الضعفين.
  • وضع Swift 6 رفض مشاركة كلاس عادي بين المهام، و@unchecked Sendable أسكت المترجم فضاع أكثر من نصف التحديثات، وTSan حدد الخيطين والسطر، وMutex والممثل أصلحا النتيجة.
  • الممثل يحمي البيانات وليس المنطق: إعادة الدخول عند await جعلت المخزون -1.
  • قفلان بترتيب معكوس جمدا البرنامج حتى قتله timeout، وثماني مهام حاجبة على نواتين جمدت العملية كلها بدون أي قفل.
  • الإلغاء تعاوني: waitForAll أكملت الخطوات الأربعين رغم فشل الدفع، والحلقة على next() مع checkCancellation أوقفت العمل بعد 15 خطوة في 0.43 ثانية.

والمقال التالي في هذه المرحلة يبني خدمة الطلبات الحقيقية بـ Vapor وPostgreSQL، وفيها سوف نرى قفل الصفوف واختبار عشرين طلباً متزامناً على منتج مخزونه عشر قطع. وهذه مقالات تكمل ما رأيناه هنا:

دليلSwift للمطورين: الأدوات والصياغة Syntax على Linuxثبتنا Swift 6.4.2 على Linux وبنينا مكتبة لحساب السعر والضريبة مع أداة سطر أوامر واختبارات، لترى اللغة مقارنة بما تعرفه وتعرف ما يحتاج Mac وما لا يحتاجه.دليلالأنواع والذاكرة ARC والأخطاء Errors في Swiftقسنا النسخ عند الكتابة وتسرب الذاكرة بالدورة المرجعية داخل Container محدود، ورأينا كيف تفشل Swift بـ throws والاختياري والتوقف، لتعرف ما يختلف فيها عن Java وPython وGo.دليلThreads وasync/await: كود لا يتجمد ولا يضيع بياناتهكيف تكتب كوداً يعمل في أكثر من خيط Thread دون أن تضيع بياناته أو يتجمد، ولماذا تظهر هذه الأخطاء على السيرفر ولا تظهر على جهازك.دليلالتزامن Concurrency والخيوط: ماذا يعني لسيرفرك وتطبيقاتكلماذا يكون المعالج عند 100% والتطبيق بطيئاً، ولا تسرع الخيوط كود Python، وكيف تضبط عدد العمال وحدود ال Containers.دليلحلقة الأحداث Event Loop: لماذا تتأخر النقرة في صفحتك؟كيف ترتب JavaScript المهام والوعود والرسم على خيط واحد، ولماذا تنتظر النقرة خلف مهمة طويلة، وكيف تقسم العمل لتبقى الصفحة سريعة.دليلالأقفال Locks والجمود Deadlock في قواعد البياناتلماذا يوقف أمر ALTER TABLE صغير كل الاستعلامات خلفه، وكيف تقرأ pg_locks وتتجنب الجمود وظواهر العزل في PostgreSQL.دليلأمان الذاكرة Memory Safety: لماذا تدفع الحكومات نحو Rust؟ماذا طلبت الحكومات فعلاً بشأن C وC++، ولماذا تسبب أخطاء الذاكرة أغلب الثغرات الخطيرة، ومتى يكون Rust هو الاختيار الصحيح.

سجل التحديثات

  • أكتوبر 2026: كتابة المقال، وتنفيذ التجارب بـ Swift 6.4.2 في swift:6.4.2-noble مع swift-crypto 4.5.2، والسيرفر البطيء بـ Node.js 22 في node:22-slim، داخل Container محدود بمعالجين و512 MB.
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.