PDFium در سطح module ای امن نیست، پس دو instance از TPdf که روی دو فایل متفاوت در دو thread کار میکنند همچنان میتوانند همدیگر را خراب کنند. PDFium Component برای Delphi این را دو جور هندل میکند: از v3.125.1 به بعد ValidatePdfFilesParallel هر فراخوانی بومی PDFium را پشت یک قفل در سطح کل پروسه serialize میکند، در حالی که TPdf.RenderPagesParallel به هر worker یک کپی ایزوله از module یعنی PDFium میدهد. باگی که فیکس را مجبور کرد بدترین جور متناوب بود. یک تست اعتبارسنجی دستهای بیشتر وقتها پاس میشد، بعد یکی از دو فایل سالم را ناموفق گزارش میکرد، بعد تست بعدی را در همان پروسه با یک access violation کرش میکرد، و گاهی کل runner را با یک exit code به پایین میکشید بهجای یک stack trace. تست هیچ عیبی نداشت و هیچ سندی تکی هم نداشت. اشتباه، فرض بود: یک TPdf بهازای هر thread ایزوله نیست
چرا یک TPdf بهازای هر thread کافی نیست؟
یک TPdf بهازای هر thread کافی نیست چون PDFium state ناامنش را در module نگه میدارد نه در سند. هر TPdf مالک handle یعنی FPDF_DOCUMENT خودش است، اما هر handle در پروسه توسط همان DLL بارگذاریشده سرو میشود، و آن DLL singletonهای در-سطح-پروسه دارد: کش فونت، page module، و ساختارهای سراسری دیگر که load و parse و رندر سند همگی به آنها دست میزنند. دو thread که دو فایل بیربط load میکنند دو thread هستند که همزمان داخل همان کش فونت مینویسند. هیچکس در سمت دلفی مالک آن داده نیست، پس هیچ چیز در سمت دلفی نمیتواند بهازای هر سند رویش قفل بگذارد
کامپوننت یک قفل دارد، و از رویش راحت میشود نتیجه اشتباه کشید. TPdf مسیرهای رندر خودش را در یک critical section داخلی میپیچد (EnterRenderLock / LeaveRenderLock، متدهای private مربوط به TPdf). آن قفل بهازای هر instance است. جلوی اینکه دو thread همزمان همان TPdf را برانند را میگیرد، که یک خطر واقعی است، اما نمیتواند یک instance دوم روی thread دیگر را ببیند، پس همزمانی بین-instanceای مستقیم از کنارش رد میشود. قاعده کلی به اندازه یک خط ساده است: در یک module ی PDFium بارگذاریشده، حداکثر یک thread در هر لحظه میتواند داخل PDFium باشد، فارغ از اینکه چند سند باز است
خرابی بین-اسنادی در یک پروسه دلفی چه شکلی است؟
خرابی بین-اسنادی شکلی مثل یک ترکیب تصادفی از شکستهای بیربط است، و آسیب از کدی که باعثش شد عمر بیشتر دارد. قبل از v3.125.1، ValidatePdfFilesParallel بهازای هر worker thread یک TPdf میساخت و مقدار Active := True بهعلاوه ساخت گزارش preflight را روی module مشترک بهطور همزمان اجرا میکرد. symptomهایی که روی buildهای Delphi و Free Pascal هر دو دیده شد کل طیف را پوشش میدادند:
- یک فایل سالم load نمیشود، یا از دسته بهعنوان ناموفق برمیگردد وقتی باید پاس میشد
- یک access violation در یک فراخوانی بعدی و بیربط بیرون میزند، اغلب در یک تست دیگر یا یک سند دیگر
- مقدار
External exception C000001Dدر Delphi ظاهر میشود. آن کدSTATUS_ILLEGAL_INSTRUCTIONاست، که توسط دستورالعملud2raise میشود که ماکروهایCHECKوIMMEDIATE_CRASHداخلی PDFium وقتی یک invariant میشکند اجرا میکنند - پروسه با
0xC0000409(fail-fast، گزارششده بهعنوان stack buffer overrun) یا0xC0000374(خرابی heap) خارج میشود، بدون هیچ exception دلفی ای
دو بند آخر دلیل سخت بودن پیدا کردن این باگ است. اعتبارسنجی موازی تمام میشد، state سراسری خرابشده جا میماند، و fixture بعدی در همان پروسه رویش سکندری میخورد. در یک ران regression مربوط به Delphi روی Win64، موجی از شکستهای C000001D به تستهایی میخورد که هرگز اعتبارسنجی دستهای را لمس نمیکردند؛ آنها صرفاً اولین کدهایی بودند که بعد از آسیب از PDFium استفاده میکردند. اعداد اندازهگیریشده مقیاس را بیپرده میکنند. یک probe دلفی که همان نمونه را با دو worker اجرا میکرد در یک ران 122 از 160 سند را ناموفق کرد و در ران دیگر 138 از 160، و یکی از آن رانها مستقیم External exception C000001D داد. یک حالت فشار با 8 سند و 4 worker و 5 دور، در 5 از 5 ران روی Free Pascal Win64 ناموفق یا کرش میکرد. بعد از فیکس، همان probe تعداد 0 از 1,200 سند را ناموفق کرد
ValidatePdfFilesParallel از v3.125.1 چطور امن میماند
مقدار ValidatePdfFilesParallel حالا نیمه بومی هر کار را serialize میکند و نیمه مدیریتشده را موازی نگه میدارد. هر worker قبل از ساختن TPdf یک critical section در-سطح-یونیت میگیرد و آن را در طول FileName و Active := True و ساخت گزارش preflight و Free نگه میدارد. ساخت و نابود کردن عمداً داخل قفل است: بستن یک سند هم مثل load کردن به داخل module فراخوانی برمیگردد. وقتی worker یک رکورد TPdfPreflightReport گرفتهشده داشته باشد، قفل را آزاد میکند و قواعد اعتبارسنجی را مقابل همان رکورد ارزیابی میکند، که به هیچ state ی PDFium دست نمیزند، پس ارزیابی قواعد یک فایل با کار PDFium فایل بعد همپوشانی دارد
دو تغییر کوچکتر با فیکس آمد. یک شکست load حالا EPdfError با LastLoadReport.ErrorMessage میدهد، پس مقدار ErrorMessage آن آیتم مشکل واقعی parse را نام میبرد نه یک خطای ثانویه یعنی «سند فعال نیست». و هزینه صادقانه گفته شده: نیمه PDFium دسته حالا سریال است، پس روی دستهای که parse و preflight غالباند، workerهای اضافی خریدار کمیاند. اگر روی نسخهای قبل از v3.125.1 هستید، مقدار WorkerCount را 1 بگذارید؛ همزمانی و خرابی را با هم حذف میکند
uses
System.SysUtils, PDFium, FPdfPreflightReport;
procedure ValidateBatch(const Files: array of string);
var
Registry: TPdfValidationRuleRegistry;
Options: TPdfBatchValidationOptions;
Report: TPdfBatchValidationReport;
I: Integer;
begin
Registry := CreateDefaultPdfValidationRuleRegistry;
try
Options := TPdfBatchValidationOptions.Default;
Options.WorkerCount := 4; // مقدار 0 = تعداد پردازنده، سقف 8
Options.Standards := [ppsPdfA];
// با یک registry صریح، پروفایل همخوان را خودتان انتخاب کنید.
// یک لیست Profiles خالی هر قاعده ثبتشده را اجرا میکند، و قواعد
// استانداردهایی که preflight نکردهاید گزارش میدهند «پاس نشد»
SetLength(Options.ValidationOptions.Profiles, 1);
Options.ValidationOptions.Profiles[0] := 'PDF/A';
Report := ValidatePdfFilesParallel(Files, Registry, Options);
finally
Registry.Free;
end;
for I := 0 to High(Report.Results) do
case Report.Results[I].Status of
pbvisPass: Writeln('PASS ', Report.Results[I].FileName);
pbvisFail: Writeln('FAIL ', Report.Results[I].FileName);
pbvisError: Writeln('ERROR ', Report.Results[I].FileName, ': ',
Report.Results[I].ErrorMessage);
else
Writeln('SKIP ', Report.Results[I].FileName); // مقدار pbvisCancelled
end;
Writeln(Report.PassedDocumentCount, ' passed, ',
Report.FailedDocumentCount, ' failed, ',
Report.ErrorDocumentCount, ' errors');
end;
دادن nil بهعنوان registry مسیر کوتاهتر است: ValidatePdfFilesParallel بعد خودش registry پیشفرض را میسازد، لیست پروفایلها را از Options.Standards استخراج میکند، و موقع برگشتن registry را آزاد میکند. نتایج همیشه به ترتیب ورودی برمیگردند، هر ترتیبی که workerها تمام کرده باشند. برای فرمتهای گزارش و wrapper خط-فرمان دور همان موتور، گزارشهای preflight دستهای PDF با CLI مربوط به PDFium Component را ببینید، و برای اینکه چکهای PDF/A خودشان چه چیزی را پوشش میدهند اعتبارسنجی preflight یعنی PDF/A در دلفی
RenderPagesParallel چطور صفحهها را واقعاً موازی اجرا میکند؟
TPdf.RenderPagesParallel موازی اجرا میشود چون workerهایش هرگز یک module ی PDFium را شریک نمیشوند. متد اول سند فعال را روی thread فراخواننده در یک مخزن منبع ذخیره میکند. بعد هر worker مقدار DLL ی PDFium بارگذاریشده را در فایلی با نام یکتا در پوشه temp کپی میکند، همان کپی را با LoadLibrary load میکند، و مقداردهی اولیهاش میکند. Windows یک DLL که از مسیر متفاوتی load شود را یک module متفاوت میگیرد، پس هر کپی سراسریهای خودش را دارد: کش فونت خودش، page module خودش، همهچیز خودش. worker سند ذخیرهشده را در module خصوصیاش باز میکند، صفحههایش را بهتدریج با چکهای لغو بین قدمها رندر میکند، بعد کتابخانه را نابود میکند، کپی را unload میکند و فایل را حذف میکند
ایزوله شدن رایگان نیست، و پیشفرضها همین را منعکس میکنند. هر worker هزینه یک کپی DLL روی دیسک، یک مجموعه دوم از سراسریهای PDFium در حافظه، و یک parse تازه از سند را میدهد. مقدار MaxWorkers = 0 یعنی حداکثر 4 worker، و MaxPixelsPerPage و MaxTotalOutputBytes خروجی خام را سقف میزنند، و گزینههای رندر معکوس و دوتون-شب رد میشوند چون بافرها خام برمیگردند. نتیجه یک TPdfParallelRenderReport است که آرایه Results اش بهازای هر صفحه درخواستشده یک بافر 32 بیتی از بالا-به-پایین دارد، به ترتیب درخواست
procedure RenderAllPages(Pdf: TPdf);
var
Options: TPdfParallelRenderOptions;
Report: TPdfParallelRenderReport;
Pages: array of Integer;
I: Integer;
begin
SetLength(Pages, Pdf.PageCount);
for I := 0 to High(Pages) do
Pages[I] := I + 1; // شماره صفحهها یک-مبنا هستند
Options := TPdfParallelRenderOptions.Default;
Options.Dpi := 150;
Options.MaxWorkers := 4;
// snapshot منبع روی module مشترک گرفته میشود، پس اگر threadهای
// دیگر هم از TPdf استفاده میکنند قفل PDFium در-سطح-پروسه را نگه دارید
PdfiumLock.Acquire;
try
Report := Pdf.RenderPagesParallel(Pages, Options);
finally
PdfiumLock.Release;
end;
for I := 0 to High(Report.Results) do
if Report.Results[I].Status = pprsSucceeded then
SavePageBuffer(Report.Results[I]) // مقدار Width و Height و Stride و PixelFormat و Pixels
else
Writeln('Page ', Report.Results[I].PageNumber, ': ',
Report.Results[I].ErrorMessage);
end;
به قفل دور فراخوانی توجه کنید. moduleهای worker خصوصیاند، اما قدم snapshot در شروع مقدار SaveAs را روی module مشترک از thread فراخواننده اجرا میکند. اگر هیچ چیز دیگری در پروسهتان همزمان به TPdf دست نمیزند میتوانید قفل را بردارید؛ اگر چیزی دست میزند، snapshot به همان حفاظتی نیاز دارد که هر فراخوانی دیگری از module مشترک
| الگو | امن بین اسناد | کار PDFium موازی اجرا میشود | هزینه |
|---|---|---|---|
یک TPdf بهازای هر thread، بدون قفل مشترک | نه | بله، تا وقتی خراب نکند | کرشهای متناوب، state پروسه آسیبدیده |
| یک قفل در سطح کل پروسه دور همه فراخوانیهای PDFium | بله | نه | نیمه PDFium سریال است |
ValidatePdfFilesParallel از v3.125.1 | بله | نه؛ ارزیابی قواعد موازی است | parse و preflight سریالاند |
TPdf.RenderPagesParallel | بله | بله | کپی DLL و حافظه و یک parse تازه بهازای هر worker |
کد چندthreadه خودتان را چطور ساختار بدهید؟
threadهای خودتان باید یک قفل در سطح کل پروسه را شریک شوند و آن را در تمام عمر هر TPdf ای که استفاده میکنند نگه دارند، یا وگرنه از یک API کامپوننت استفاده کنند که module را برایتان ایزوله میکند. قفل باید یک شیء واحد برای کل پروسه باشد، نه یکی بهازای هر thread و هر فرم و هر سند؛ قفلی که دو thread شریکش نیستند هیچ چیزی را حفاظت نمیکند. الگوی پایین آینه کاری است که کامپوننت از v3.125.1 در درون میکند: ساخت و load و خواندن و آزاد کردن داخل قفل، بعد همه چیزهایی که به PDFium دست نمیزنند بیرونش
uses
System.Classes, System.SysUtils, System.SyncObjs, PDFium;
var
PdfiumLock: TCriticalSection; // یک قفل برای کل پروسه
type
TTextExtractThread = class(TThread)
private
FFileName: string;
FText: string;
protected
procedure Execute; override;
public
constructor Create(const AFileName: string);
property ExtractedText: string read FText;
end;
constructor TTextExtractThread.Create(const AFileName: string);
begin
inherited Create(True);
FFileName := AFileName;
end;
procedure TTextExtractThread.Execute;
var
Pdf: TPdf;
Page: Integer;
Raw: TStringBuilder;
begin
Raw := TStringBuilder.Create;
try
PdfiumLock.Acquire;
try
Pdf := TPdf.Create(nil);
try
Pdf.FileName := FFileName;
Pdf.Active := True;
if not Pdf.Active then
raise EPdfError.Create(Pdf.LastLoadReport.ErrorMessage);
for Page := 1 to Pdf.PageCount do
begin
Pdf.PageNumber := Page;
Raw.AppendLine(Pdf.Text);
end;
finally
Pdf.Free; // بستن سند هم کار PDFium است
end;
finally
PdfiumLock.Release;
end;
// زیر این خط هیچ PDFium ای نیست، پس این بخش موازی اجرا میشود
FText := Raw.ToString.Trim;
finally
Raw.Free;
end;
end;
initialization
PdfiumLock := TCriticalSection.Create;
finalization
PdfiumLock.Free;
چند قاعده الگو را در یک برنامه واقعی صادق نگه میدارند:
- مقدار
TPdf.CreateوFreeرا داخل قفل بگذارید، نه فقط فراخوانیهای آشکار را. load کردن، بستن، خواندن ویژگیهایی مثلPageCount، تغییر صفحه، استخراج متن، رندر و ذخیره همه به داخل module دست دراز میکنند - مقدار
Activeرا بعد از assign کردنش چک کنید. یک load ناموفق مقدارActiveرا رویFalseمیگذارد، وLastLoadReport.ErrorMessageمیگوید چرا - قفل را بهازای هر سند نگه دارید نه بهازای هر فراخوانی. قفلبندی ریزتر در اصل ممکن است، اما فقط اگر هیچ عضوی از
TPdfهرگز بیرونش اجرا نشود، و نسخه درشت همان چیزی است که خود کامپوننت به آن تکیه دارد - کار کند غیر-PDFium مثل نوشتن در دیتابیس و ایندکسگذاری و فراخوانیهای شبکه را بیرون قفل نگه دارید، وگرنه یک مصرفکننده کند همه چیز را serialize میکند
- قفل رندر خصوصی بهازای هر instance را جانشین نگیرید. از یک
TPdfدر مقابل خودش محافظت میکند و همین
همان احتیاط برای کدی اعمال میشود که ننوشتهاید بهشکل threadهای خام. futureهای پسزمینه راه خوبی برای دور نگه داشتن رندرهای طولانی از thread یعنی UI هستند، همانطور که در رندر پسزمینه PDF با futureهای قابل-لغو توضیح داده شده، اما executor مربوط به future خودش یک قفل سراسری PDFium اضافه نمیکند. اگر چند future بتوانند همزمان instanceهای متفاوتی از TPdf را برانند، همان قفل در-سطح-پروسه را داخل هر worker بگیرید، و با یک viewer روی thread اصلی مثل یک کلاینت دیگر از module مشترک رفتار کنید. استفاده بین-instanceای از طریق APIهای ناهمزمان جداگانه ممیزی نشده، پس فرض محافظهکارانه این است که به همان serialize شدن دست میخواهد مثل threadهای دستنویس. وقتی به موازیسازی واقعی PDFium برای چیزی غیر از رندر صفحه نیاز دارید، پروسههای worker جدا به هر کار module خودش را بهطور ساختاری میدهند
مرجع سریع: قواعد threading یعنی PDFium برای دلفی
- state ناامن PDFium در کل module است: کش فونت و page module و بقیه سراسریها بین هر سند در پروسه مشترکاند
- یک
TPdfبهازای هر thread هیچ چیزی را ایزوله نمیکند؛ دو instance روی دو thread همچنان میتوانند همدیگر را خراب کنند - symptomهای معمول شکستهای load و access violation در کدهای بعدی و
External exception C000001Dو خروجها با0xC0000409یا0xC0000374هستند - خرابی در پروسه میماند، پس فراخوانی شکستخورده اغلب همان نیست که باعثش شده
-
ValidatePdfFilesParallelاز v3.125.1 امن است؛ روی نسخههای قدیمیتر مقدارWorkerCount := 1را بردارید -
TPdf.RenderPagesParallelواقعاً موازی است چون هر worker یک کپی ایزوله از module یعنی PDFium load میکند - threadها و taskها و futureهای خودتان به یک قفل در سطح کل پروسه نیاز دارند که هر
TPdfرا ازCreateتاFreeبپوشاند
PDFium Component موتور PDFium را برای Delphi با preflight و اعتبارسنجی دستهای و رندر موازی ایزوله و کار پسزمینه قابل-لغو و عیبیابی دقیق load wrap میکند. جزئیات و نسخهها در صفحه محصول PDFium Component است