Back home

سلسلة Swift Concurrency 09|مشكلة إلغاء الإبطال الدلالي في Swift Concurrency

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

بعد أن قام المشروع بتغيير رد الاتصال إلى غير متزامن/انتظار، هناك موقف شائع يتمثل في وجود وهم بأن مشكلة التزامن قد تم احتواؤها.

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

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

رأيي هو: ** بعد اكتمال ترحيل Swift Concurrency، فإن خطأ التزامن الأكثر شيوعًا هو أن الناس يعتقدون أن “المهمة الرئيسية قد تم إلغاؤها، والمهام التالية ستتوقف بشكل طبيعي.” في الواقع، طالما أن هناك طبقة Task غير قابلة للتحكم، أو غلافًا يربط واجهة برمجة التطبيقات القديمة، أو تأثيرًا جانبيًا لا يتحقق من حالة الإلغاء، فسيتم كسر دلالات الإلغاء في تلك الطبقة. في النهاية، تبدو الصفحة وكأنها ترتعش من حين لآخر، ولكن هذا يعني في الواقع أن الحالة قد تم تشعبها. **

عادة ما يتم الكشف عن هذا النوع من المشاكل بشكل رئيسي في تعليقات الدولة

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

منطق الصفحة ليس معقدًا:

  • يقوم المستخدم بإدخال الكلمات الرئيسية.
  • ViewModel يبدأ البحث.
  • إلغاء المهمة السابقة عند وصول كلمة رئيسية جديدة؛
  • قم بتحديث القائمة بعد إرجاع الطلب.

ظاهريًا، تتوافق هذه العملية تمامًا مع طريقة الكتابة الموصى بها في Swift Concurrency. المشكلة هي أنه يمكن رؤية ظاهرة غريبة جدًا في تسجيل الشاشة عبر الإنترنت:

  • يقوم المستخدم أولاً بالبحث عن swift؛
  • ثم قم بتغييره إلى swift concurrency؛
  • تظهر النتائج الجديدة أولاً على الواجهة؛
  • بعد نصف ثانية، تحل النتائج القديمة محل القائمة مرة أخرى.

ولا يمكن تفسير ذلك بمجرد “الطلب خارج النظام”. لأن searchTask?.cancel() موجود بشكل واضح في الكود، ويمكن أيضًا رؤية الإلغاء في السجل.

تكمن المشكلة الحقيقية في: **تم إلغاء مهمة المستوى الأعلى، لكن الطبقة السفلية لم تعتبر “الإلغاء” بمثابة تغيير في الحالة يجب إغلاقه على الفور. **

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

فشلت العديد من عمليات الإلغاء، وتعطلت الطبقة الأكثر ضررًا من كود التوصيل.

نقطة التوقف الأكثر شيوعًا هي أنه عند تغليف واجهة برمجة تطبيقات رد الاتصال القديمة في وظيفة غير متزامنة، فإنها تفعل فقط “الانتظار حتى تعود النتيجة” ولا تفعل “ما يجب فعله عندما لا تعود النتيجة”.

على سبيل المثال، أحد المواقف الشائعة هو تجميع طلب شبكة مثل هذا:

func loadUser(id: String) async throws -> User {
  try await withCheckedThrowingContinuation { continuation in
    apiClient.loadUser(id: id) { result in
      continuation.resume(with: result)
    }
  }
}

بناء الجملة جيد والوظائف تعمل. لكن هذا الكود له مقدمتان افتراضيتان قاتلتان:

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

غالبًا ما لا يكون هذان الفرضيان صحيحين في المشاريع الحقيقية.

إذا كانت apiClient لا تزال تحت URLSessionDataTask، أو SDK لجهة خارجية، أو طبقة تخزين رد الاتصال الخاصة بها، فلن يتم نقل إلغاء الطبقة الخارجية Task تلقائيًا. يقوم الغلاف غير المتزامن أعلاه بتغيير طريقة الاتصال إلى await فقط، ولكنه لا يسمح للطبقة الأساسية بالحصول على دلالات الإلغاء.

ما تحتاج طبقة الجسر إلى فعله حقًا هو “ترجمة إلغاء الطبقة الخارجية إلى إجراءات إلغاء قابلة للتنفيذ.” شيء من هذا القبيل:

func loadUser(id: String) async throws -> User {
  var request: Cancellable?

  return try await withTaskCancellationHandler {
    try await withCheckedThrowingContinuation { continuation in
      request = apiClient.loadUser(id: id) { result in
        continuation.resume(with: result)
      }
    }
  } onCancel: {
    request?.cancel()
  }
}

لقد بدأ هذا الرمز للتو في الاقتراب من “يمكن بالفعل تمرير الإلغاء”.

لكن كتابتها هنا لا تكفي، لأنها تحل فقط “حاول عدم الاستمرار في التشغيل”، ولا تحل “كيفية إغلاق النتائج المتأخرة”. إذا لم يكن cancel() الخاص بـ SDK الأساسي يحتوي على إلغاء دلالي قوي، ولكنه ينتهي فقط قدر الإمكان، فقد يظل رد الاتصال يعود في حالة السباق. سيتعين على المستوى الأعلى الاستمرار في إجراء فحص الإلغاء قبل تلقي النتائج.

ما يفسد الصفحة حقًا هو أن النتائج القديمة لا تزال تعتبر نتائج صالحة.

تشعر العديد من الفرق بالارتياح عندما ترى Task.isCancelled، ولكن يمكنها فقط الإجابة على “ما إذا تم وضع علامة على المهمة الحالية على أنها ملغاة”، ولكن لا يمكنها الإجابة على “هل يجب أن تظل هذه النتيجة موجودة في الصفحة الحالية؟”

في سيناريوهات مثل البحث والارتباط وتبديل التفاصيل، ما يجب حمايته حقًا هو ملكية النتائج.

الطريقة التالية لكتابة ViewModel شائعة جدًا:

final class SearchViewModel: ObservableObject {
  @Published private(set) var items: [Item] = []
  private var searchTask: Task<Void, Never>?

  func search(keyword: String) {
    searchTask?.cancel()
    searchTask = Task {
      do {
        let items = try await repository.search(keyword: keyword)
        self.items = items
      } catch {
        self.items = []
      }
    }
  }
}

يبدو أن المشكلة تكمن في مكالمة إلغاء واحدة فقط، ولكن ما ينقصنا حقًا هو طبقتان من الحماية:

  1. بعد العودة الناجحة، تأكد من أن المهمة الحالية لا تزال صالحة؛
  2. لا يمكن التعامل مع الإلغاء على أنه خطأ عادي عند فشله.

الطريقة الأكثر استقرارًا للكتابة ستكون كما يلي:

final class SearchViewModel: ObservableObject {
  @MainActor @Published private(set) var items: [Item] = []
  private var searchTask: Task<Void, Never>?

  func search(keyword: String) {
    searchTask?.cancel()

    searchTask = Task { [weak self] in
      guard let self else { return }

      do {
        let items = try await repository.search(keyword: keyword)
        try Task.checkCancellation()
        await MainActor.run {
          self.items = items
        }
      } catch is CancellationError {
        // 取消不是失败,不清空 UI,不弹错误
      } catch {
        await MainActor.run {
          self.items = []
        }
      }
    }
  }
}

ما يهم حقًا هنا هو الموقف الذي يقف وراءه: **الإلغاء هو تدفق تحكم طبيعي، وليس حادثًا غير طبيعي. **

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

هناك مشكلة أخرى مخفية وهي أن شجرة المهام قد تعطلت لفترة طويلة، ويعتقد الجميع أنهم في حالة توافق منظم.

إحدى فوائد Swift Concurrency هي أن التزامن المنظم يجعل علاقة دورة الحياة بين مهام الوالدين والطفل أكثر وضوحًا. لكن أسهل شيء يمكن خسارته في المشروع هو Task {} الذي يلتقطه الجميع بشكل عشوائي فقط “لتوفير المتاعب”.

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

func refresh() async {
  Task {
    async let detail = repository.loadDetail()
    async let recommendation = repository.loadRecommendation()
    let result = try await (detail, recommendation)
    render(result)
  }
}

يبدو أنه غير متزامن/منتظر، لكن المشكلة الأكثر خطورة في هذا الرمز هي: refresh() نفسه والطبقة Task {} الموجودة بداخله لم تعد لها علاقة منظمة بين الوالدين والطفل.

وهذا يعني:

  • تنتهي الطبقة العليا التي تستدعي refresh() على الفور؛
  • حتى لو تم إتلاف الصفحة؛
  • حتى لو تم إلغاء المهمة الخارجية؛

لا يزال من الممكن الاستمرار في تشغيل Task الذي تم افتتاحه حديثًا في هذا الطابق.

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

إذا كان هذا النوع من السيناريوهات يهدف فقط إلى الحصول على نتائج بالتوازي، فيكفي الكتابة مباشرةً في السياق غير المتزامن الحالي:

func refresh() async throws -> ScreenData {
  async let detail = repository.loadDetail()
  async let recommendation = repository.loadRecommendation()
  return try await ScreenData(
    detail: detail,
    recommendation: recommendation
  )
}

بهذه الطريقة، سيتم جمع دلالات الإلغاء مع سلسلة الاتصال. ومن بادر فهو مسؤول؛ ومن يلغيها سيوقفها معًا.

إذا لم يتم التحقق من حدود التأثير الجانبي للإلغاء، فستظهر الحالة القذرة الأكثر صعوبة في تفسيرها.

إذا لم يتم إيقاف الطلب، فهو مجرد مضيعة للموارد. إذا لم يتم إيقاف الآثار الجانبية، فسيتم كتابة الحالة قذرة.

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

كود مثل هذا خطير:

func refreshProfile() async throws {
  let profile = try await repository.fetchProfile()
  cache.save(profile)
  analytics.trackProfileLoaded(profile.id)
  state = .loaded(profile)
}

إذا تم إلغاء المهمة عند عودة fetchProfile()، ولكن لا يوجد فحص للإلغاء، فسيستمر حدوث عمليات الكتابة اللاحقة في ذاكرة التخزين المؤقت والنقاط المدفونة وتحديثات الحالة.

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

عادةً ما يكون النهج الأكثر حكمة هو إجراء فحص صريح آخر قبل حدود التأثير الجانبي:

func refreshProfile() async throws {
  let profile = try await repository.fetchProfile()
  try Task.checkCancellation()

  cache.save(profile)
  analytics.trackProfileLoaded(profile.id)
  state = .loaded(profile)
}

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

إن ما يجب حمايته حقًا هو في كثير من الأحيان الإجراءات القليلة التالية التي ستعيد كتابة العالم القديم.

سوء الفهم الأكثر شيوعًا في حالات الفشل هو التعامل مع جميع الأخطاء بشكل موحد.

السبب وراء ترك العديد من عمليات الترحيل المتزامنة ذيولًا طويلة هو أن الفرق ترغب في كتابة عمليات إغلاق الأخطاء في قالب موحد:

do {
  let data = try await service.load()
  state = .loaded(data)
} catch {
  state = .error(error)
}

هذه ليست مشكلة في سيناريوهات الفشل العادية، ولكن بمجرد وضعها في سيناريوهات مثل تبديل الصفحات عالي التردد، وبحث Lenovo، ومكافحة اهتزاز الإدخال، وإلغاء التحميل، فإن CancellationError ليس هو نفس فشل العمل الحقيقي.

والخلط بين الاثنين سيؤدي إلى ثلاث نتائج على الأقل:

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

طالما تم إظهار الإلغاء على أنه فشل مرة واحدة في المشروع، فسوف تظهر لاحقًا مجموعة من التعليقات الغريبة والتي تبدو غير ذات صلة:

  • يتم مسح القائمة بشكل متكرر عند البحث؛
  • في بعض الأحيان يحدث خطأ بعد اكتمال التحديث المنسدل؛
  • عندما تعود الصفحة إلى المستوى السابق، تومض حالة فشل التحميل.

هذه الظواهر مجزأة للغاية، ولكن الجذر هو نفس المشكلة: ** يُخطئ إلغاء تدفق التحكم في أنه استثناء تجاري. **

الحدود المطبقة: ليست كل وظيفة غير متزامنة بحاجة إلى أن تكون محشوة بفحوصات الإلغاء

تعد دلالات الإلغاء مهمة، ولكن ليس من الضروري أن تكتب كل طبقة Task.checkCancellation() ميكانيكيًا.

هناك ثلاثة مواقف أقدرها أكثر الآن:

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

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

ملخص

أسهل وهم تم إنشاؤه بواسطة Swift Concurrency هو أن الكود قد تم نقله من رد الاتصال إلى await، ومن الطبيعي أن يدخل النظام في عصر التزامن الأكثر موثوقية.

لكن المشاريع الحقيقية لن تكتسب تلقائيًا دلالات الإلغاء لمجرد أن بناء الجملة جديد.

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

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