أداة المحاسبة التي بنيناها في المقال الأول وأصلحنا تسرب ذاكرتها في المقال الثاني صار لها عملان جديدان في متجر التمور والقهوة. الأول أن تسأل خدمة الأسعار الداخلية عن سعر كل منتج قبل المطابقة، والخدمة ترد في 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 قديم، فقد أخذت خيطاً من عدد قليل، وسوف نرى نتيجة ذلك بأعيننا في آخر التجربة.

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 لأن بعض الطلبات انتهت قبل أن تبدأ أخرى.

وهذه أهم نتيجة في التجربة كلها، وهي ليست عن 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، أكبر من الفرق بين الطرق، وهذا تذكير بأن الأرقام من جهاز تطوير وليست من سيرفر مخصص.

وقد يتساءل البعض: لماذا لا نكتب كل شيء بـ 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.

الممثل آمن والمنطق لا: إعادة الدخول 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، والمكتبات القديمة التي تحجب داخلها، لا يعرف المترجم عنها شيئاً، وهنا تحتاج أن تعرف القاعدة بنفسك.

ماذا يحدث إذا فشلت مهمة؟ الإلغاء 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
| الموضوع | Go | Java 21+ | Kotlin | JavaScript | Swift |
|---|---|---|---|---|---|
| وحدة التزامن | Goroutine | Virtual Thread | Coroutine | Promise على خيط واحد | Task |
| شكل الكود | عادي بدون await | عادي بدون await | suspend | async/await | async/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 6 | swift-tools-version: 6.x بدون swiftLanguageModes: [.v5] |
لا @unchecked Sendable بدون سبب مكتوب | ابحث عنها في المراجعة |
| العمل المتوازي منظم | async let وTaskGroup قبل Task.detached |
| الحالة المشتركة محمية | ممثل، أو Mutex للأقسام القصيرة جداً |
| لا تعليق بين الفحص والتغيير | راجع كل await داخل دوال الممثلين |
لا انتظار حاجب داخل async | ابحث عن wait() وsleep والدوال القديمة |
| حدود الاتصالات معروفة | إعداد مكتبة HTTP ومجمع قاعدة البيانات |
| الفشل يوقف العمل الباقي | حلقة على next() وTask.checkCancellation() في الحساب الطويل |
| الأقفال بترتيب ثابت | وثق الترتيب في الكود |
| TSan في CI | swift 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.