يتجمد رسم دفعي batch render في منتصف الطريق لأن المنفِّذ executor في PDFium Component لا يعتبر مهمة منتهية حتى يُوزَّع ردّها. تحت padSynchronize يعمل ذلك الرد على الخيط الرئيسي. إن حُظِر الخيط الرئيسي دون ضخّ pump لـCheckSynchronize، ينتظر العامل worker على الخيط الرئيسي بينما ينتظر الخيط الرئيسي حالة الخمول idle
صورة المصحِّح debugger لا تُخطئ بمجرد أن تراها. أوقِف العملية المجمَّدة وستجد الخيط الرئيسي جالسًا داخل انتظار على حدث الخمول، عدة إطارات أسفل حلقة الدفعة الخاصة بك. انتقل إلى أي خيط عامل وستجده جالسًا داخل TThread.Synchronize، حاملًا نتيجة منتهية لا يستطيع تسليمها. لا شيء يدور، ولا وحدة معالجة مركزية تحترق، العملية متوقفة parked ببساطة. هذا المقال عن سبب وجود تلك الحالة أصلًا، وعن ثلاث قواعد مجاورة تقرر ما إذا كانت مجموعة عمّال Delphi فوق PDFium ستتصرف بشكل جيد أو تعضّ: ما الذي تحدّه QueueCapacity فعليًا، وبأي ترتيب يجب أن يُلغي الإغلاق shutdown، وما الذي لا يمنحك إياه التوازي حين يتعلق الأمر بملكية كائنات PDFium
لماذا تعلّق WaitForIdle الخيط الرئيسي؟
تعلّقه لأن الخمول في TPdfAsyncExecutor مُعرَّف ليشمل توزيع الرد، لا إنجاز العامل فقط. يُزاد العدّاد الجاري عند DequeueTask حين يلتقط عامل مهمة، ويُنقَص عند TaskFinished، التي يستدعيها العامل فقط بعد أن تُعيد TPdfAsyncTaskOperation.Execute. تلك الدالة تشغّل جسم العامل، وتسجّل النتيجة، ثم توزّع الرد وفق TPdfAsyncDispatchMode. مع padSynchronize يكون التوزيع استدعاء TThread.Synchronize، فلا تعود Execute حتى يشغّله الخيط الرئيسي
يضع Delphi النصف الثاني من ذلك العقد عليك. تُلحق TThread.Synchronize الطريقة بطابور عام وتحظر الخيط المستدعي على حدث؛ يجب أن يستدعي شيء ما على الخيط الرئيسي CheckSynchronize قبل أن يُشار إلى ذلك الحدث على الإطلاق. تفعل حلقة رسائل VCL هذا نيابة عنك بين الرسائل، وهذا بالضبط سبب أن العلّة غير مرئية أثناء الاستخدام التفاعلي وتظهر في اللحظة التي تكتب فيها حلقة دفعية حاظرة blocking. الخيط الرئيسي الحاظر هو خيط رئيسي غادر حلقة الرسائل، والخيط الرئيسي خارج حلقة الرسائل لا يصرّف أحدًا
uses
System.Classes, FPdfAsync, PDFium;
// The shape that deadlocks: a synchronized reply plus a blocking main thread
Task := Executor.Submit(RenderPageWorker, PageRendered, papNormal,
padSynchronize);
Task.WaitFor(High(Cardinal)); // the main thread now parks in a kernel wait
// Meanwhile TPdfAsyncTaskOperation.Execute has reached:
// TThread.Synchronize(AWorkerThread, DispatchReply);
// which enqueues DispatchReply and waits for the main thread to drain it.
// The main thread is draining nothing, so both sides wait forever.
إنجاز المهمة وخمول المنفِّذ محطتان مختلفتان
هما مفصولتان عمدًا، ومعرفة أيهما تنتظر هو الإصلاح بأكمله. IPdfAsyncTask.WaitFor تُستوفى في اللحظة التي تُحسَم فيها نتيجة العامل: تكتب Complete حالة TPdfAsyncTaskState النهائية وتضبط حدث الإنجاز قبل النظر في أي رد. TPdfAsyncExecutor.WaitForIdle تُستوفى لاحقًا، بمجرد أن يصبح العدّادان المُصفوف والجاري صفرًا كلاهما، ولا ينخفض الجاري حتى يهبط الرد. فيمكن لمهمة أن تكون patsSucceeded وقابلة للمراقبة عبر Snapshot بينما ما يزال المنفِّذ مشغولًا شرعيًا
// TPdfAsyncExecutor.WaitForIdle already pumps for you: it waits on the idle
// event in short slices and calls CheckSynchronize(0) between them.
if not Executor.WaitForIdle(30000) then
ReportBatchTimeout;
// Any hand-rolled main-thread wait has to do the same thing explicitly.
function WaitForTaskOnMainThread(const ATask: IPdfAsyncTask;
ATimeoutMs: Cardinal): Boolean;
var
StartedAt: UInt64;
begin
StartedAt := PdfAsyncTick;
repeat
if ATask.WaitFor(10) then
Exit(True);
CheckSynchronize(0); // release any pending padSynchronize reply
Result := PdfAsyncTickDelta(StartedAt, PdfAsyncTick) < ATimeoutMs;
until not Result;
end;
نتيجة واحدة تستحق الاستيعاب: الرد الذي يرفع استثناءً لا يعيد كتابة التاريخ. تلتقط DispatchReply الاستثناء وتخزّنه في ReplyErrorMessage، تاركة State وCancellationReason وErrorMessage تمامًا كما قرّرها العامل. استدعاء رجوع واجهة مستخدم ينفجر أثناء رسم صورة مصغَّرة لا يحوّل إذن رسمًا ناجحًا إلى فاشل قط، وتستمر قياساتك telemetry في الإبلاغ عمّا فعله محرك الرسم فعلًا. إن كنت تريد الواجهة البرمجية على شكل استدعاء رجوع حول عملية واحدة بدل مجموعة، فإن الرسم في الخلفية بمستقبَلات futures قابلة للإلغاء يغطي ذلك المسار
هل تحدّ QueueCapacity العمّال الجاريين أيضًا؟
لا. تعدّ QueueCapacity في PDFium Component المهام المُصفوفة فقط، أبدًا تلك التي تنفَّذ فعلًا على عامل. هذا متعمَّد: القصد من السعة التعبير عن ضغط عكسي backpressure حقيقي على خط الانتظار، وطي فتحات التزامن concurrency الثابتة في الرقم نفسه كان سيعدّها مرتين. مع أربعة عمّال وسعة ثمانية يمكن أن يكون لديك اثنا عشر مهمة قيد التنفيذ، وتُبلغ GetStats عن الانقسام بأمانة عبر QueuedCount وRunningCount
var
Stats: TPdfAsyncExecutorStats;
Task: IPdfAsyncTask;
begin
// TrySubmit never raises: it returns False when the waiting line is full or
// the executor is already shutting down, and bumps RejectedCount.
if not Executor.TrySubmit(RenderPageWorker, PageRendered, Task, papHigh,
padSynchronize) then
begin
Stats := Executor.GetStats;
// QueuedCount is what QueueCapacity bounds. RunningCount is bounded by
// WorkerCount and is never charged against the capacity.
LogBackpressure(Stats.QueuedCount, Stats.RunningCount,
Stats.RejectedCount);
Exit;
end;
حارات TPdfAsyncPriority الأربع صارمة، لا موزونة. تجتاز DequeueTask من papCritical نزولًا إلى papLow وتأخذ أول حارة غير فارغة، حافظة على ترتيب FIFO داخل كل واحدة. هذا يمنح طلبًا تفاعليًا طريقة نظيفة للتقدم أمام دفعة لم تبدأ بعد، لكنه لا يقاطع أبدًا عملًا يجري بالفعل، ومستدعٍ يواصل تغذية papCritical يمكن أن يُجوِّع papLow إلى ما لا نهاية. احجز الحارتين العلويتين لما ينتظره إنسان بشكل مرئي، واترك التصدير بالجملة على papNormal أو أدنى. استخدم Submit حين يكون طابور ممتلئ خطأ برمجيًا يستحق EPdfAsyncQueueFull، وTrySubmit حين يكون حالة عادية تنوي معالجتها
لماذا يُلغي Shutdown خارج قفل المنفِّذ؟
لأن الإلغاء داخله كان سيعكس ترتيب الأقفال ويعلّق الإغلاق نفسه الذي تحاول تنفيذه. تأخذ Shutdown(True) قفل المنفِّذ، وتقلب علامة الإغلاق، وتُلحق كل مهمة معلَّقة بمصفوفة لقطة snapshot محلية عبر AppendSnapshot. ثم تحرر القفل وتجتاز اللقطة فقط بعد ذلك مستدعية Cancel على كل مدخل. إلغاء مهمة يُطلق استدعاءات رجوع للمستخدم مسجَّلة على مصدر رمزه الخاص، وتلك الاستدعاءات كود تطبيق عادي: قد تستعلم GetStats، أو تُصدر عملًا تعويضيًا، أو تنتظر الخمول. كل واحد من هؤلاء يعيد الدخول إلى قفل المنفِّذ، واستدعاء رجوع يُستدعى بينما ذلك القفل ممسوك كان سيتجمد ضد نفسه
مصدر الرمز يطيع النظام نفسه بمستوى أدنى. تأخذ CancelWithReason قفل المصدر، وتقرر المُلغي الفائز الوحيد، وتكتب Reason وCancellationMessage وCancelledAtTick، وفقط بعد ذلك تقلب علامة الإلغاء ذريًا atomically. النشر قبل القلب هو ما يجعل البيانات الوصفية آمنة للقراءة: أي خيط يلاحظ IsCancelled كـTrue مضمون أن يجد سببًا كاملًا خلفه، والمستدعون اللاحقون يخسرون السباق، ويعيدون False، ولا يستطيعون الكتابة فوق السبب الأول. استدعاءات الرجوع المسجَّلة تُلتقَط كلقطة وتُمسَح داخل القفل لكنها تُستدعى خارجه، كل واحدة مُغلَّفة بحيث لا يستطيع معالج فاشل واحد قمع البقية. المهام التي تجري بالفعل لا تُقتَل أبدًا؛ فهي تنتهي تعاونيًا حين يستدعي جسم عاملها التالي ThrowIfCancelled، ولهذا ينهي Shutdown بـWaitForIdle ضاخّة قبل الانضمام إلى الخيوط
هل يُرخي العمّال المتوازيون ملكية كائنات PDFium؟
لا يفعلون، وهذا هو الحد الأكثر عرضة لسوء الفهم. TPdfAsyncExecutor يجدول العمل؛ لا يدّعي شيئًا عن انتماء الخيط لأي شيء تلمسه داخل ذلك العمل. مثيل TPdf حي لا يصبح قابلًا للوصول تزامنيًا لأن عاملين يصادف أن يستدعيا فيه، وقفل الرسم الداخلي حارس ضد استدعاءات رسم متداخلة، لا ترخيص لمشاركة مستند عبر الخيوط. الرسم أو التصدير المتوازي يعني TPdf واحد لكل عامل، يُنشَأ ويُتلَف داخل المهمة
type
TPageRenderJob = class
private
FFileName: string;
FPageIndex: Integer;
public
procedure Run(const AToken: IPdfCancellationToken);
end;
procedure TPageRenderJob.Run(const AToken: IPdfCancellationToken);
var
LocalPdf: TPdf; // one document instance per worker, never shared
Bmp: TBitmap;
begin
LocalPdf := TPdf.Create(nil);
try
LocalPdf.FileName := FFileName;
LocalPdf.Active := True;
LocalPdf.PageNumber := FPageIndex;
AToken.ThrowIfCancelled;
Bmp := LocalPdf.RenderPage(0, 0, 1024, 1448);
try
HandOffBitmap(FPageIndex, Bmp); // ownership moves to the reply stage
finally
Bmp.Free;
end;
finally
LocalPdf.Free;
end;
end;
الكلفة حقيقية وتستحق الذكر: كل عامل يدفع ثمن تحليله الخاص وذاكرة صفحته المؤقتة الخاصة، فتتوسع الذاكرة مع عدد العمّال لا مع عدد المستندات. هذا ثمن نموذج يمكن فيه إلغاء عامل أو انهياره دون إفساد أي أحد آخر. إن كان عمّالك يشاركون مستند جانب عارض viewer-side بدل ذلك، فقواعد القفل حول ذلك مشروحة في قفل الرسم والاستدعاءات التي تفوته، والمسار أحادي المستند القابل للإلغاء في الرسم التدريجي القابل للإلغاء
توسيع واجهة منشورة دون كسر جدول الدوال الافتراضية
IPdfCancellationToken وIPdfCancellationTokenSource واجهتان على طراز COM قد تستهلكهما ثنائيات binaries خارجية بالفعل، فإلحاق طريقة بأي منهما كان سيُزيح كل فتحة لاحقة في جدول الدوال الافتراضية vtable ويوجّه الاستدعاءات المُصرَّفة مقابل التخطيط القديم بصمت إلى مسار خاطئ. لذا تعيش قدرات التشخيص والانتظار واستدعاء الرجوع القابل للإزالة والإلغاء الذري في IPdfCancellationTokenEx وIPdfCancellationTokenSourceEx، اللتين ترثان بدل أن تُعدِّلا. تحافظ New وRun على دلالاتهما الأصلية للمستدعين الحاليين؛ ويصل الكود الجديد إلى NewEx وNewTimeout وRunEx حين يريد CancelWithReason أو WaitForCancellation أو IPdfCancellationRegistration مُدارًا. الوراثة هي الطريقة الآمنة الوحيدة لتوسيع واجهة منشورة، وتكلّف نوعًا إضافيًا واحدًا لكل جيل
NewTimeout تستحق ملاحظة صادقة. يملك كل مصدر مهلة خيطًا خفيفًا ينتظر على حدث الإلغاء أو الموعد النهائي، أيهما يأتي أولًا. لحفنة أو بضع دزينات من المواعيد النهائية هذا بسيط ومنخفض الكمون latency ومتطابق عبر Delphi وLazarus وC++Builder. لآلاف المواعيد النهائية القصيرة إنه الشكل الخاطئ، وعليك أن تقود الإلغاء من مؤقّت واحد على مستوى التطبيق بدل الاحتفاظ بآلاف الخيوط المنتظرة
لا شيء من هذه القواعد استثنائي بمجرد كتابته، لكن كل واحدة منها حادث إنتاج حين لا تُكتَب. انتظر على المحطة الصحيحة ودع شيئًا يضخّ طابور المزامنة، واقرأ QueueCapacity كحد على خط الانتظار فقط، وألغِ خارج أقفالك، وامنح كل عامل مستنده الخاص. الطبقة غير المتزامنة الموصوفة هنا تُشحن ضمن PDFium Component لـDelphi، إلى جانب واجهات برمجة الرسم والنص والنماذج التي يجدولها