مقال تقني

توقف تام في استدعاء مراقب الملفات في HotXLS في Delphi

يراقب HotXLS مصنفاً على القرص عبر TXLSFileMonitor ويرفع OnFileChanged عندما تحفظ عملية أخرى فوق الملف. كان تعيين Monitor.Active := False من داخل ذلك المعالج يتعطل إلى الأبد، لأن خيط العمل كان ما زال محجوباً بانتظار الاستدعاء الذي أرسله لتوه. الإصلاح هو أن يحمل كائن الخيط هوية الاستدعاء الذي ينفذه حالياً

لماذا يتعطل إيقاف مراقب الملفات داخل معالجه الخاص؟

لأن استدعاء الإيقاف والاستدعاء الراجع يعملان على خيطين مختلفين، وكل واحد منهما ينتظر انتهاء الآخر. يأتي TXLSFileMonitor مع SynchronizeEvent مضبوطاً على True، وهو ما يعني أن خيط الاستطلاع لا يستدعي معالجك بنفسه. بل يستدعي TThread.Synchronize، ويسلّم الدالة إلى الخيط الرئيسي، ثم يحجب نفسه حتى ينفذها الخيط الرئيسي ويشير إلى اكتمالها. هذا بالضبط ما تريده لمعالج يعيد تحميل شبكة أو يعيد رسم نموذج، وهو أيضاً ما يجعل استدعاء الإيقاف البديهي فخاً

وجهان للتوقف التام في مراقب ملفات HotXLS: مع SynchronizeEvent True يحجب الخيط الرئيسي في Worker.WaitFor بينما يحجب العامل في TThread.Synchronize، ومع False يعمل المعالج على خيط الاستطلاع فينتظر SetActive False الخيط نفسه، تاركاً OnFileChanged عاجزاً عن العودة
مع تفعيل SynchronizeEvent ينتظر الخيط الرئيسي في Worker.WaitFor بينما ينتظر العامل في Synchronize؛ ومع إيقافه يعمل المعالج على خيط الاستطلاع وينتظر خروجه هو — دورة مغلقة واحدة، بنمطي إرسال
procedure TMainForm.HandleFileChanged(Sender: TObject);
begin
  // نحن هنا في الخيط الرئيسي، بفضل TThread.Synchronize
  FBook.LoadFromFile(FMonitor.FileName);
  RefreshGrid;
  // يريد المستخدم إعادة تحميل واحدة فقط، لذا توقف عن المراقبة
  FMonitor.Active := False;   // لم يكن يعود أبدًا
end;

تتبع الكدستين وستصبح الحلقة واضحة. الخيط الرئيسي داخل SetActive(False)، التي تنهي الخيط العامل ثم تستدعي Worker.WaitFor لتتأكد أن الخيط خرج فعلاً قبل تحرير الكائن. أما خيط العمل فمتوقف داخل TThread.Synchronize بانتظار أن يعود الخيط الرئيسي من المعالج نفسه الذي يستدعي SetActive. لا يستطيع أي طرف التحرك. التطبيق لا ينهار ولا يرفع استثناء، إنه يتوقف عن إعادة الرسم فحسب، وهذا أسوأ توقيع تشخيصي يمكن أن يحمله عيب

نفس التوقف التام يرتدي وجهاً ثانياً

إيقاف تشغيل المزامنة لا يتجنب المشكلة، إنه ينقلها فقط. مع SynchronizeEvent مضبوطاً على False — الخيار الحكيم في خدمة أو أداة كونسول بلا حلقة رسائل — يستدعي TXLSFileMonitor المعالج مباشرةً على خيط الاستطلاع. معالجك الآن يعمل على الخيط العامل، لذا فإن استدعاء Monitor.Active := False من داخله يصل إلى Worker.WaitFor على الخيط العامل نفسه. الخيط لا يستطيع انتظار خروجه هو، فيحجب نفسه حتى يقتل شيء خارجي العملية. النمطان كلاهما نفس العيب: SetActive(False) افترضت أنها تُستدعى دائماً من مكان خارج الاستدعاء. هذا الافتراض يصمد مع زر الإغلاق أو مدمّر النموذج، ويفشل في موضع الاستدعاء الوحيد الذي يلجأ إليه الناس أولاً، ولهذا بقي طويلاً. أي مكوّن يملك خيطاً ويوزع كود المستخدم منه يرث الخطر نفسه، والحل واحد في كل الحالات — مسار التفكيك يجب أن يعرف إن كان يُنفذ من داخل استدعاء ما زال الخيط الذي يوشك على الانضمام إليه يحمله

جعل خيط العمل يحمل هوية الاستدعاء

يحل HotXLS المشكلة بتسجيل، على كائن الخيط نفسه، أي خيط من خيوط النظام ينفذ المعالج حالياً. يختم TXLSFileMonitorThread.FireEvent الحقل FCallbackThreadID بـ GetCurrentThreadId مباشرةً قبل استدعاء المعالج، ويصفره في كتلة finally بعد ذلك. هذا الحقل الواحد يكفي لمسار الإيقاف ليميز «استُدعيت من كود غير ذي صلة» عن «استُدعيت من داخل استدعائي الخاص»، ويعمل بشكل متطابق في نمطي الإرسال، لأنه في حالة المزامنة يعيد GetCurrentThreadId داخل FireEvent بالفعل الخيط الرئيسي

procedure TXLSFileMonitorThread.FireEvent;
var
  Owner: TXLSFileMonitor;
  Handler: TNotifyEvent;
begin
  // قد يُرسِل WaitFor استدعاءً معلقًا أثناء إيقاف التفعيل
  if Terminated then
    Exit;
  Owner := FOwner;
  Handler := Owner.FOnFileChanged;
  if not Assigned(Handler) then
    Exit;
  FCallbackThreadID := GetCurrentThreadId;
  try
    Handler(Owner);
  finally
    // قد يكون المعالج قد أتلف Owner، لكن هذا الخيط يبقى حيًا
    FCallbackThreadID := 0;
  end;
end;

فرع الإيقاف في SetActive يفعل بعدها ثلاثة أشياء بترتيب مدروس. يفصل الخيط عن المراقب بمسح FThread أولاً، فيبلغ GetActive بـ False من تلك اللحظة فصاعداً، ويخرج أي Active := False معيد الدخول داخل المعالج نفسه فوراً بدلاً من محاولة تمزيق الخيط مرتين. ثم يستدعي Terminate. وبعد ذلك فقط يقرر كيف يستعيد كائن الخيط

كيف يحل HotXLS تفكيك مراقب الملفات: يختم TXLSFileMonitorThread.FireEvent الحقل FCallbackThreadID بـ GetCurrentThreadId قبل استدعاء المعالج ويصفره في كتلة finally، فيستطيع SetActive فصل FThread واستدعاء Terminate والاختيار بين التحرير الذاتي FreeOnTerminate داخل استدعاء وWaitFor الكلاسيكية مع Free
يختم FireEvent الحقل FCallbackThreadID قبل استدعاء المعالج ويصفره في كتلة finally؛ ثم يختار SetActive بين FreeOnTerminate حين تتطابق الهوية وWaitFor الكلاسيكية مع Free حين يكون المستدعي غير ذي صلة
Worker := TXLSFileMonitorThread(FThread);
FThread := nil;            // المراقب غير نشط من هذه النقطة
Worker.Terminate;
FHasBaseline := False;
if Worker.FCallbackThreadID = GetCurrentThreadId then
  // يجب أن تعود المعالجات المباشرة والمتزامنة قبل أن يتمكن الخيط العامل
  // من الخروج؛ يحرر نفسه بعد انتهاء الاستدعاء
  Worker.FreeOnTerminate := True
else
begin
  Worker.WaitFor;
  Worker.Free;
end;

حين تتطابق الهوية، لا ينتظر HotXLS أبداً. يضبط FreeOnTerminate := True ويعود، تاركاً المعالج يكمل، واستدعاء Synchronize يتم، وExecute يلاحظ Terminated، وRTL يحرر كائن الخيط بعد أن تتكشف كدسة الاستدعاء بالكامل. أما حين لا تتطابق الهوية — زر إغلاق، مدمّر، مهمة خلفية — فتبقى WaitFor الكلاسيكية مع Free هي الصواب، لأنها تمنحك ضماناً صارماً بأنه لا استدعاء آخر سيُطلق بعد عودة Active := False. مسارا خروج، وحقل واحد للاختيار بينهما. ونتيجة واحدة تستحق التوضيح: في مسار التحرير الذاتي يعيش الخيط العامل أطول من المعالج. لا شيء في مسار العودة لـ FireEvent يجوز أن يلمس كائن المراقب مجدداً، لأن المعالج من حقه أن يكون قد حرره. ولهذا يخزّن FireEvent Owner وHandler في متغيرات محلية مسبقاً ولا يلمس سوى حقله الخاص في جملة finally. أي FOwner.Something شارد بعد استدعاء المعالج سيحوّل توقفاً تاماً مُصلَحاً إلى استخدام بعد تحرير، وهي نتيجة أسوأ بلا شك

لماذا يجب على FireEvent فحص Terminated قبل أي شيء؟

لأن WaitFor ليست انتظاراً سلبياً. على الخيط الرئيسي، تنفيذ RTL لـ TThread.WaitFor يواصل تفريغ قائمة المزامنة بينما يحجب نفسه، تحديداً حتى يتمكن خيط متوقف في Synchronize من الاكتمال والخروج بدلاً من أن يتوقف تاماً ضد الإغلاق. هذه ميزة، وتعني أن استدعاءً دخل قائمة الانتظار قبل استدعائك Active := False من زر إغلاق ببضعة ميكروثوانٍ قد يُرسَل خلال التفكيك، بعد أن يكون المراقب أسقط مرجع FThread بالفعل. الحارس في أعلى FireEvent يجعل ذلك الإرسال بلا أثر. وبدونه، تطبيق يغلق نموذجه الرئيسي بينما يحط هبوطُ حفظٍ على الملف المراقب قد يشغّل معالج إعادة تحميل ضد حالة نصف مُتخلَّص منها، وانتهاك الوصول الناتج لا يبدو إطلاقاً كمشكلة ترتيب إغلاق. إن كان مكوّنك الخاص يوزع العمل عبر Synchronize فالقاعدة تعمم: كل دالة مزامنة تحتاج فحص Terminated كأول جملة فيها، لا لأن الخيط قد يكون رحل بل لأن القائمة تعيش أطول من قرار التوقف

نفس شكل العيب في مسار استيراد CSV

التدقيق الذي أنتج إصلاح المراقب كشف عيباً شقيقاً في مستورد النصوص، ويستحق القراءة جنباً إلى جنب لأن الخطأ الكامن متطابق: قطعة إعداد تُستهلك وكأنها حالة مؤقتة. يكشف TXLSCSVLoader خاصية SkipRows لسطور اللافتة التي تجلس فوق الترويسة الحقيقية في التقارير المصدرة. المحلل يحتاج عداً تنازلياً أثناء تجاوزه تلك السطور، والتنفيذ الأصلي كان يعد تنازلياً على حقل دعم الخاصية نفسه. استورد ملفاً واحداً فتصبح SkipRows صفراً بصمت؛ وأعد استخدام نفس المُحمّل لملف ثانٍ فتسقط سطور اللافتة في الشبكة

Loader := TXLSCSVLoader.Create;
try
  Loader.SkipRows := 2;             // سطران من اللافتة فوق الترويسة
  Loader.ParseFile('jan-export.csv');
  ReadGrid(Loader);
  // SkipRows ما زال 2 هنا: يُعاد تعيين FSkipRemaining في كل عملية تحليل
  Loader.ParseFile('feb-export.csv');
  ReadGrid(Loader);
finally
  Loader.Free;
end;

يبقي HotXLS الآن FSkipRows كإعداد معلن ويعيد تصفير عداد FSkipRemaining منفصل في بداية كل عملية تحليل، في مساري المحددات والعرض الثابت معاً. القاعدة العامة رخيصة التطبيق وتدفع ثمنها بنفسها: إن كان حلقة تنقص منه، فليس حقل دعم خاصية. كما شدّدت نفس الدفعة XlsCsvTryParseISODateTime، التي كانت تقبل بادئة قابلة للتحليل كتاريخ كامل. تتحقق الآن من الحقل كله — الطول، ومواضع الفواصل، وكل رقم — حتى تبقى قيمة كمرجع طلب يبدأ فقط بشيء شبيه بالتاريخ نصاً بدلاً من أن تصبح TDateTime خاطئة. التحويل النوعي الصامت عند الاستيراد أغلى بكثير في تصحيحه لاحقاً من حقل مرفوض، وهو موضوع يمتد أيضاً عبر كيف يحرس HotXLS مصنفاً ضد القراء والكتّاب المتزامنين

عيب مُحمّل CSV خلف تدقيق HotXLS: العد التنازلي على حقل دعم الخاصية صفّر SkipRows بصمت بعد عملية تحليل واحدة، فتسربت سطور لافتة ملف ثانٍ إلى الشبكة، بينما يبقي النموذج المصحح FSkipRows كإعداد معلن ويصفّر عداد FSkipRemaining منفصلاً في بداية كل استدعاء ParseFile
العد التنازلي على حقل دعم الخاصية يحوّل الإعداد إلى حالة مؤقتة: أول تحليل يصفّر SkipRows، بينما يبقي النموذج المصحح FSkipRows معلناً ويصفّر FSkipRemaining في بداية كل تحليل

ما الذي يمنحك إياه هذا الإصلاح وما لا يمنحه

كن دقيقاً بشأن الضمان. تغيير ملكية الخيط يجعل نمطاً واحداً كان مميتاً سابقاً آمناً: إيقاف أو تحرير TXLSFileMonitor من داخل معالج OnFileChanged الخاص به، في أي نمط إرسال. إنه ليس تصميماً عاماً للتوافقية، ولا يجعل المكوّن آمناً للقيادة من خيوط عشوائية

  • كتابات الخصائص ما زالت غير آمنة للخيوط. اضبط FileName وIntervalMs وSynchronizeEvent من خيط واحد، عادةً الخيط الذي أنشأ المراقب
  • يسري IntervalMs من التفعيل التالي، وينام الخيط العامل بشرائح قصيرة حتى يُحترم Terminate فوراً حتى مع فترة استطلاع طويلة
  • الاكتشاف بوقت آخر كتابة مضافاً إليه الحجم، لذا فالتغيير الذي يحافظ على الاثنين معاً غير مرئي؛ والملف الذي يختفي أثناء الحفظ لا يطلق الحدث، ويصل الحدث ما إن يصبح المحتوى الجديد في مكانه
  • مع SynchronizeEvent مضبوطاً على False يعمل معالجك على الخيط العامل، وكل قاعدة عن لمس عناصر VCL من خيط خلفية تنطبق على ما تفعله داخله
  • إعادة تحميل مصنف من المعالج ما زالت تحتاج انضباطها الخاص، خاصةً إن كان كود آخر يحمل مرجعاً للمصنف الجاري استبداله

عملياً، معالج إعادة التحميل هو الجزء الذي يستحق أكبر عناية، لا المراقب. حدث التغيير عادةً يصل بينما ما زالت عملية الكتابة تستقر، لذا فتحميل يفتح الملف فوراً قد يصطدم بحفظ غير مكتمل؛ إعادة المحاولة مرة بعد مهلة قصيرة أمتن من الثقة في الختم الأول. إن كان كودك هو من يكتب الملف المراقب، فإن الملف المؤقت المرحلي وإعادة التسمية الذرية المشروحين في كيف يجعل HotXLS كل حفظ آمناً ضد الانهيار يمنحان المراقب أيضاً حدث انتقال واحداً نظيفاً يراقبه بدلاً من تيار من الأحجام الوسيطة. وإن كانت عدة مراقبات تغذي نفس روتين إعادة التحميل، فمخاوف تسلسل الوصول في بوابة القراءة حول فك ضغط ZIP المتزامن هي التالي في القراءة. الدرس الأوسع ينجو من المكوّن المحدد. أي صنف يملك خيطاً ويستدعي كود المستخدم يحتاج مسار تفكيك يتعرف على إطار استدعائه الخاص، لأن المعالج هو أكثر مكان طبيعي في العالم ليقول فيه المستخدم توقف. تسجيل معرّف خيط الاستدعاء يكلف DWORD واحداً، ويحوّل توقفاً غير مفهوم إلى سؤال عادي عن عمر كائن. مراقبة الملفات والحفظ الذري واستيراد النصوص كلها تأتي في HotXLS Delphi Component لـ Delphi وC++Builder، من دون أي إعداد إضافي لتفعيل أيٍّ منها