لاستئناف Swift continuation مرة واحدة بالضبط، اجعل الإكمال والإلغاء يتنافسان عبر آلة حالات واحدة متزامنة. خزّن الإلغاء حتى قبل التثبيت، واختر نتيجة نهائية واحدة، وحرّر القفل قبل استئناف المهمة المنتظرة.
يبني هذا الدليل محوّلًا محليًا من الاستدعاءات الراجعة إلى async لبرنامج سطر أوامر على Mac. تحسب العملية الاصطناعية ضعف عدد صحيح؛ ولا يستخدم المثال أي استدعاءات شبكة أو سجلات أو تخزين دائم. العقد الأساسي صريح: يفوز أول حدث نهائي يغيّر الحالة المحمية.
ما الضمانات التي يجب أن يوفرها المحوّل؟
تربط checked continuation الاستدعاءات الراجعة المتزامنة بشيفرة Swift غير المتزامنة. تشترط Apple استئنافها مرة واحدة بالضبط في كل مسار تنفيذ. يؤدي عدم استئنافها إلى بقاء المهمة معلّقة؛ ويساعد فحص وقت التشغيل على اكتشاف سوء الاستخدام، لكنه لا ينفّذ التنسيق نيابةً عنك. CheckedContinuation
يضيف الإلغاء جهةً محتملة أخرى لاستئنافها. الإلغاء في Swift تعاوني: إلغاء مهمة لا يوقف تلقائيًا عملية استدعاء راجع عشوائية، ولا يجعل continuation تطرح خطأ. يجب على المحوّل تحويل الإلغاء صراحةً إلى النتيجة التي يختارها. Task.cancel()
عقد القيمة لهذا المثال هو:
- تنتج المدخلات من
-1000إلى1000ضعف قيمتها. - تنتج المدخلات الأخرى
CalculationError.outOfRange. - ينتج الإلغاء
CancellationErrorإذا فاز بانتقال الحالة. - بعد اختيار نتيجة نهائية، لا يمكن لأحداث الإكمال أو الإلغاء اللاحقة أن تحل محلها.
- يملك كل استدعاء بوابة جديدة ويثبّت continuation واحدة.
هذا يعني أن الإكمال قد يفوز حتى لو حدث الإلغاء قبل أن يتلقى المستدعي القيمة. اللحظة الحاسمة هي انتقال الحالة المحمية، لا الوقت الذي تتابع فيه الشيفرة تنفيذها لدى المستدعي. هذه سياسة تطبيق اختيرت لهذا المثال، وليست ضمانًا عامًا للترتيب توفره Swift.
لماذا قد يصل الإلغاء قبل التثبيت؟
توثّق Apple أن معالج الإلغاء يعمل قبل closure العملية عندما تكون المهمة ملغاة بالفعل. ومع ذلك، تُنفّذ العملية. وقد يعمل الإلغاء أيضًا بالتزامن مع عملية قيد التنفيذ. لذلك، فإن المعالج الذي لا يفعل سوى استئناف continuation اختيارية يفقد الإلغاء إذا لم تكن تلك القيمة قد عُيّنت بعد. withTaskCancellationHandler(operation:onCancel:isolation:)
لذلك تحتاج البوابة إلى معلومات أكثر من مجرد وجود continuation حاليًا. قد يعني غيابها أن التثبيت لم يحدث بعد، أو أن نتيجة نهائية قد اختيرت بالفعل. وهاتان الحالتان تتطلبان سلوكين مختلفين.
| الحالة المحمية | تثبيت continuation | تلقي الإكمال أو الإلغاء |
|---|---|---|
| فارغة | تخزين continuation | تخزين النتيجة النهائية |
| قيد الانتظار | رفض تثبيت ثانٍ | إزالة continuation وتخزين النتيجة النهائية |
| منتهية | استئناف continuation المثبّتة حديثًا بالنتيجة المخزنة | تجاهل الحدث النهائي اللاحق |
يساعد الجدول أيضًا في التشخيص. إذا تعذّر على التنفيذ تمثيل نتيجة نهائية قبل التثبيت، فتحقّق من مسار المهمة الملغاة مسبقًا. وإذا أمكن لحدثين نهائيين استخراج continuation، فتحقّق مما إذا كان الفحص والإزالة يحدثان ضمن مقطع حرج واحد.
أنشئ مثالًا محليًا على Mac
استخدم سلسلة أدوات Swift 6 مع SDK الخاص بـ macOS. تحقّق من سلسلة الأدوات المحددة في Terminal باستخدام swift --version. لا يستخدم البرنامج أدناه سوى Foundation وDispatch؛ ولا يحتاج إلى أي تبعيات للحزم. يشرح دليل سطر الأوامر في Swift كيفية التحقق من التثبيت واستخدام نقطة دخول غير متزامنة من نوع @main. Build a Command-line Tool
احفظ كتل Swift الثلاث التالية، بالترتيب، في ملف واحد باسم ContinuationRace.swift. وهي تشكّل معًا برنامجًا كاملًا. تنفّذ الكتلة الأولى البوابة، وتوفّر الثانية عملية الحساب والمحوّل، وتوفّر الثالثة فحوصًا قابلة للتنفيذ. Swift غير متاح في بيئة البحث، لذا جرى فحص هذا البرنامج، لكن لم يُترجم أو يُنفّذ هنا.
نفّذ البوابة المتزامنة
توثّق Foundation أن NSLock وسيلة لحماية مقطع حرج. يجب أن يحدث القفل وفتح القفل على الخيط نفسه، والقفل غير متداخل. هنا، يبقى كل مقطع حرج داخل طريقة متزامنة، ولا يتضمن نقطة تعليق، وينتهي قبل استئناف continuation. NSLock
import Foundation
import Dispatch
final class IntContinuationGate: @unchecked Sendable {
private enum State {
case empty
case waiting(CheckedContinuation<Int, any Error>)
case finished(Result<Int, any Error>)
}
private let lock = NSLock()
private var state: State = .empty
private var didInstall = false
// Returns false if a terminal event already won.
func install(
_ continuation: CheckedContinuation<Int, any Error>
) -> Bool {
lock.lock()
precondition(!didInstall, "Install exactly once per gate")
didInstall = true
switch state {
case .empty:
state = .waiting(continuation)
lock.unlock()
return true
case .waiting:
preconditionFailure("Unexpected second installation")
case .finished(let result):
lock.unlock()
continuation.resume(with: result)
return false
}
}
func finish(_ result: Result<Int, any Error>) {
let continuation: CheckedContinuation<Int, any Error>?
lock.lock()
switch state {
case .empty:
continuation = nil
state = .finished(result)
case .waiting(let waiting):
continuation = waiting
state = .finished(result)
case .finished:
lock.unlock()
return
}
lock.unlock()
continuation?.resume(with: result)
}
func cancel() {
finish(.failure(CancellationError()))
}
}
تستخدم البوابة @unchecked Sendable لأن تخزينها القابل للتغيير محمي يدويًا. ينقل هذا الوسم المسؤولية إلى التنفيذ؛ ولا يضيف آلية تزامن. وتظل الخاصيتان القابلتان للتغيير خاصتين، وكل وصول إليهما يحدث تحت القفل نفسه. تصف Apple صراحةً الحالة المحمية بقفل على أنها وسيلة لاستيفاء المتطلبات الدلالية للإرسال غير المفحوص. Sendable
لاحظ أن finish تغيّر الحالة قبل فتح القفل. وعندها لا يمكن لحدث نهائي منافس أن يرى سوى finished، حتى لو لم يكن الخيط الفائز قد استدعى resume بعد. يحتفظ الفائز بـ continuation المستخرجة محليًا؛ ولا يمكن لأي استدعاء منافس استخراجها مجددًا.
اربط الإكمال والإلغاء بالبوابة نفسها
يستخدم حساب الاستدعاء الراجع قائمة إرسال عامة. توثّق Apple أن العمل المقدّم بشكل غير متزامن يُنفّذ بمعزل عن الشيفرة التي قدّمته، وأن عمل الإرسال في الخلفية لا يضمن هوية خيط بعينه. وهذا يجعل التنسيق المستقل عن الخيط مناسبًا هنا. DispatchQueue
enum CalculationError: Error {
case outOfRange
}
func startDoubling(
_ value: Int,
completion: @escaping @Sendable (Result<Int, any Error>) -> Void
) {
DispatchQueue.global().async {
guard (-1000...1000).contains(value) else {
completion(.failure(CalculationError.outOfRange))
return
}
completion(.success(value * 2))
}
}
func doubled(_ value: Int) async throws -> Int {
let gate = IntContinuationGate()
return try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation {
(continuation: CheckedContinuation<Int, any Error>) in
guard gate.install(continuation) else { return }
startDoubling(value) { result in
gate.finish(result)
}
}
} onCancel: {
gate.cancel()
}
}
أنشئ البوابة قبل الدخول إلى نطاق معالج الإلغاء. عندها يمكن لمهمة ملغاة مسبقًا تسجيل الإلغاء فورًا. وعندما تُثبّت continuation بعد ذلك، تستأنفها بالخطأ المخزّن وتُرجع false، لذا يتجاوز المحوّل بدء عملية الحساب.
ومع ذلك، قد يحدث الإلغاء بعد أن يُرجع التثبيت true وقبل استدعاء startDoubling. في هذا الترتيب، تبدأ عملية الحساب وتنتهي، لكن تُهمل نتيجتها لأن الإلغاء فاز بالفعل. يلغي الغلاف الانتظار؛ ولا يَعِد بإيقاف عملية الحساب الصغيرة والمحدودة هذه.
جسر continuation المدعوم هو withCheckedThrowingContinuation؛ إذ تتلقى closure الخاصة به continuation المستخدمة لإرجاع قيمة أو طرح خطأ. يحدّد المثال صراحةً نوعي نتيجة continuation، وهما العدد الصحيح والخطأ، لإزالة أي التباس في استنتاج النوع. withCheckedThrowingContinuation(isolation:function:_:)
افحص ترتيب الأحداث دون الاعتماد على فترات الانتظار
لا يحدد التأخير الزمني أي حدث فاز بالتسابق. بدلًا من ذلك، تقود الفحوص التالية جميع الترتيبات الستة للتثبيت والإكمال والإلغاء مباشرةً عبر البوابة. كما تتلقى كل سلسلة أحداث مزيدًا من الأحداث النهائية للتحقق من بقاء النتيجة المختارة دون تغيير.
enum GateEvent {
case install
case complete
case cancel
}
func checkOrder(
_ events: [GateEvent],
expected: Int?
) async throws {
let gate = IntContinuationGate()
do {
let value: Int = try await withCheckedThrowingContinuation {
(continuation: CheckedContinuation<Int, any Error>) in
for event in events {
switch event {
case .install:
_ = gate.install(continuation)
case .complete:
gate.finish(.success(42))
case .cancel:
gate.cancel()
}
}
// Neither of these may replace the selected result.
gate.finish(.success(99))
gate.cancel()
}
guard let expected else {
preconditionFailure("Expected cancellation")
}
precondition(value == expected, "Unexpected value")
} catch is CancellationError {
precondition(expected == nil, "Unexpected cancellation")
}
}
@main
struct ContinuationRaceDemo {
static func main() async throws {
try await checkOrder(
[.install, .complete, .cancel], expected: 42
)
try await checkOrder(
[.install, .cancel, .complete], expected: nil
)
try await checkOrder(
[.complete, .install, .cancel], expected: 42
)
try await checkOrder(
[.complete, .cancel, .install], expected: 42
)
try await checkOrder(
[.cancel, .install, .complete], expected: nil
)
try await checkOrder(
[.cancel, .complete, .install], expected: nil
)
let value = try await doubled(21)
precondition(value == 42)
do {
_ = try await doubled(1001)
preconditionFailure("Expected outOfRange")
} catch CalculationError.outOfRange {
// Expected input-domain error.
}
// Integration smoke check: either terminal outcome is valid.
for _ in 0..<100 {
let task = Task { try await doubled(21) }
task.cancel()
do {
let result = try await task.value
precondition(result == 42)
} catch is CancellationError {
// Cancellation won.
}
}
print("Event-order checks and integration checks passed.")
}
}
ترجم البرنامج وشغّله من الدليل الذي يحتوي الملف:
swiftc -swift-version 6 -parse-as-library ContinuationRace.swift -o ContinuationRace
./ContinuationRace
الناتج الناجح المتوقع هو Event-order checks and integration checks passed. تتحقق فحوص الترتيب الصريحة من انتقالات الحالة. وتختبر حلقة التكامل المحوّل الفعلي، لكنها لا تضمن حدوث تداخل جدولة بعينه ولا تثبت اختبار كل تنفيذ متزامن.
لماذا يستأنف التنفيذ مرة واحدة بالضبط؟
لضمان الاستئناف مرة واحدة على الأكثر، تأمل أول استدعاء ناجح لـ finish. فهو يستبدل الحالة الحالية ذريًا بـ finished. وإذا كانت هناك continuation تنتظر، فهذا الاستدعاء وحده يستخرجها. وكل استدعاء لاحق لـ finish يعود دون استئناف أي شيء. وإذا لم تكن هناك continuation بعد، تنتظر النتيجة حتى التثبيت الوحيد.
أما لضمان الاستئناف في نهاية المطاف، فهناك مساران. إما أن يحدث التثبيت أولًا، فيستأنف الحدث النهائي الأول continuation المنتظرة؛ أو أن يحدث حدث نهائي أولًا، فيستأنف التثبيت بالنتيجة المخزنة. تفترض هذه الحجة أن التثبيت يحدث مرة واحدة، وأن حدثًا نهائيًا واحدًا على الأقل يقع. سيظل المستدعي منتظرًا إذا كان مصدر استدعاء راجع عام لا يكتمل أبدًا، ولم تُلغَ المهمة.
ينفّذ الاستدعاء الراجع في المثال عملية حساب محدودة، ويستدعي الإكمال في مساري النجاح وخطأ الإدخال. ويُتجاهل عمدًا أي استدعاء نهائي مكرر بعد فوز أحدها. وهذا مناسب لعقد النتيجة الواحدة المحدد؛ أما تدفّق القيم ذات المعنى فيحتاج إلى واجهة مختلفة.
ما الاختصارات التي تخلّ بالعقد؟
إن فحص continuation اختيارية ثم تعيينها إلى nil لا يكفي عندما تكون هاتان العمليتان غير متزامنتين. فقد يقرأ مستدعيان متزامنان continuation نفسها غير-nil قبل أن يزيلها أي منهما. يجب أن يكون الفحص والاستخراج وتغيير الحالة النهائية عملية واحدة محمية.
كما أن فحص الإلغاء قبل إنشاء continuation يترك فجوة: فقد يصل الإلغاء مباشرةً بعد الفحص. ويسدّ المعالج والحالة النهائية المحفوظة هذه الفجوة. يشرح دليل لغة Swift أن الإلغاء تعاوني، ويحدّد معالجات الإلغاء بوصفها الآلية المستخدمة للإخطار الفوري. Concurrency — The Swift Programming Language
وينشأ إشكال آخر عند الاستئناف أثناء الإمساك بالقفل المشترك. تحذّر Apple تحديدًا من أن معالجات الإلغاء قد تعمل بينما تكون أقفال المهام الداخلية ممسوكة، وأن استئناف continuation قد يستحوذ على تلك الأقفال. لذلك يفتح المثال القفل قبل كل استدعاء لـ resume. Cancellation handlers and locks
وأخيرًا، فإن إضافة Task.checkCancellation() بعد عودة continuation ستغيّر العقد الظاهر لهذا المثال: إذ قد يحل الإلغاء محل قيمة حتى بعد فوز الإكمال بالبوابة. اختر هذه السياسة بعناية. في العقد المنفذ هنا، يستخدم التثبيت والإكمال والإلغاء آلة الحالات نفسها، ويحدد أول انتقال نهائي نتيجة المستدعي.
المصادر
- CheckedContinuation — Apple.
- withTaskCancellationHandler(operation:onCancel:isolation:) — Apple.
- cancel() — Apple.
- NSLock — Apple.
- Sendable — Apple.
- DispatchQueue — Apple.
- withCheckedThrowingContinuation(isolation:function:_:) — Apple.
- Build a Command-line Tool — Swift.org.
- Concurrency — The Swift Programming Language — The Swift Project.