Kết xuất các trang PDF song song từ Delphi chung quy lại là một quy tắc duy nhất: cung cấp cho mỗi luồng làm việc (worker thread) một bộ kết xuất riêng của nó. losLab PDF Library cung cấp RenderPagesToFilesParallel cho chính xác công việc đó, phân bổ một phạm vi trang trên một nhóm TTask với một phiên bản TPDFlib cho mỗi worker, nhờ đó một máy đa nhân sẽ chuyển đổi một tác vụ raster hàng loạt thành thông lượng mở rộng gần bằng số lượng nhân. Thay vào đó, nếu bạn chia sẻ một phiên bản duy nhất giữa các luồng, quá trình chạy sẽ không bị chậm lại một cách êm đẹp, mà nó sẽ làm hỏng bộ nhớ và gây sập (crash)
Đây là bài viết bạn tìm đến khi một tác vụ chạy ban đêm phải chuyển đổi một tệp PDF 500 trang thành 500 tệp PNG, máy tính của bạn có 16 nhân đang nhàn rỗi, và nỗ lực đầu tiên của bạn trong việc xử lý luồng đã thất bại bên trong GDI+. Phiên bản ngắn gọn là an toàn luồng ở đây là một thuộc tính cấu trúc chứ không phải là một cờ (flag) bạn thiết lập, và phần còn lại của bài viết này sẽ đi sâu vào lý do tại sao cấu trúc an toàn lại có hình dạng như vậy cũng như giới hạn thực tế của việc tăng tốc thực sự nằm ở đâu
TPDFlib có an toàn luồng cho việc kết xuất song song không?
Không, và lý do của việc này rất đáng để tìm hiểu trước khi bạn thiết kế xung quanh nó. Một phiên bản TPDFlib duy nhất được khai báo cho mục đích sử dụng đơn luồng, và điểm nguy hiểm là TPDFPageTree.GetPage: it ghi một trường FPagePointer dùng chung trên phiên bản đó như một tác dụng phụ của việc chọn một trang. Hai luồng gọi vào cùng một phiên bản sẽ tranh chấp (race) trên trường đó, do đó worker A có thể đang xử lý được một nửa trang 3 khi worker B trỏ lại cây trang sang trang 40. Không có gì trong API ngăn bạn viết mã như bên dưới, và nó thậm chí sẽ chạy được vài trang trước khi gặp lỗi, đây là cách tồi tệ nhất để một lỗi như thế này biểu hiện
// DO NOT do this: one shared instance, many threads
var
Pdf: TPDFlib;
begin
Pdf := TPDFlib.Create;
Pdf.LoadFromFile('report.pdf', '');
TParallel.For(1, Pdf.PageCount,
procedure(Page: Integer)
begin
// every thread reenters the same instance -> data race on FPagePointer
Pdf.RenderPageToFile(150, Page, 0, 'page' + IntToStr(Page) + '.png');
end);
Pdf.Free;
end;
Sự thất bại không mang tính tất định, đó chính là lý do tại sao nó vượt qua được các thử nghiệm nhanh ban đầu rồi sau đó lại xuất hiện trên máy khách hàng có số lượng nhân khác biệt và tài liệu nặng nề hơn. Cũng không có khóa nào bạn có thể bọc xung quanh RenderPageToFile để khắc phục điều này một cách hiệu quả, vì việc giữ một mutex trên toàn bộ lệnh gọi kết xuất sẽ tuần tự hóa công việc và làm mất đi tính song song mà bạn đang hướng tới
Tại sao mỗi worker kết xuất lại cần phiên bản TPDFlib riêng của nó?
Bởi vì phiên bản đó chính là đơn vị cô lập. Khi mọi worker sở hữu một TPDFlib riêng tư đã tải tệp một cách độc lập, mỗi worker sẽ có cây trang riêng, trường FPagePointer riêng và trạng thái kết xuất riêng, do đó không có gì dùng chung để xảy ra tranh chấp. Sự an toàn đó có cái giá mà bạn nên cân nhắc trước: mọi worker đều phân tích cú pháp toàn bộ tài liệu vào bộ nhớ, vì vậy dung lượng bộ nhớ đỉnh xấp xỉ bằng N lần chi phí của một phiên bản duy nhất. Tám worker trên một tệp PDF 300 MB nghĩa là tám lượt phân tích cú pháp đầy đủ cùng tồn tại một lúc trong bộ nhớ, và trên các đầu vào rất lớn thì đó chính là ràng buộc quyết định số lượng worker của bạn chứ không phải là CPU. Khi tài liệu khổng lồ và bạn bị giới hạn bộ nhớ thay vì giới hạn CPU, đường dẫn truy cập trực tiếp trong việc xử lý các tệp PDF lớn mà không cần phân tích cú pháp toàn bộ tài liệu thường là đòn bẩy tốt hơn so với việc tăng thêm luồng kết xuất
API một lần gọi: RenderPagesToFilesParallel
losLab PDF Library đóng gói toàn bộ mô hình an toàn đằng sau một phương thức duy nhất, nhờ đó đối với trường hợp phổ biến bạn không cần tự tay viết bất kỳ phần nào trong đó. RenderPagesToFilesParallel nhận tên tệp và mật khẩu, một DPI, trang bắt đầu và trang kết thúc (bao gồm cả hai trang này), một giá trị Options được chuyển trực tiếp đến đường dẫn raster trên mỗi trang, một mẫu đầu ra trong đó %p được thay thế bằng số trang, và giới hạn worker với bất kỳ giá trị nào bằng hoặc nhỏ hơn 0 nghĩa là tự động. Nó trả về số lượng trang được kết xuất thành công, và đó là đường dẫn chỉ dành cho Windows vì nó phụ thuộc vào CoInitialize và GDI+
var
Pdf: TPDFlib;
Rendered: Integer;
begin
Pdf := TPDFlib.Create;
try
// FileName, Password, DPI, StartPage, EndPage, Options, Pattern, MaxWorkers
Rendered := Pdf.RenderPagesToFilesParallel(
'report.pdf', '', 150.0, 1, 500, 0, 'out\page_%p.png', 0);
// MaxWorkers = 0 -> auto: min(page count, CPU cores)
WriteLn(Format('%d pages rendered', [Rendered]));
finally
Pdf.Free;
end;
end;
Tại sao cần gọi CoInitialize trên mỗi luồng làm việc?
GDI+ là bộ chuyển đổi raster bên dưới quá trình kết xuất trang, và GDI+ is là apartment-threaded: nó yêu cầu COM phải được khởi tạo trên bất kỳ luồng nào gọi vào nó. Luồng chính của một ứng dụng VCL thường đã được thiết lập sẵn việc này, nhưng một worker TTask mới được sinh ra thì không, và việc gọi đường dẫn kết xuất từ một luồng chưa được khởi tạo là cách chắc chắn gây sập ứng dụng. Vì vậy, mỗi worker kết hợp một lệnh gọi CoInitialize(nil) khi bắt đầu với một lệnh gọi CoUninitialize khi kết thúc để bao bọc toàn bộ vòng đời của nó. Đây cũng là quy tắc mà bất kỳ công việc GDI+ hoặc COM nào cần thực hiện ngoài luồng chính, và nó là nửa thứ hai giúp cho sự cô lập trên mỗi worker thực sự có hiệu lực, với nửa đầu tiên là phiên bản riêng tư. Cùng đường dẫn raster GDI+ đó sẽ điều khiển các công cụ đơn luồng được đề cập trong việc lựa chọn công cụ kết xuất cho đầu ra PDF
Phân chia tĩnh so với giành quyền xử lý trang động
Cách hiển nhiên để phân chia 500 trang cho 8 worker là giao cho mỗi worker một phần cố định khoảng 62 trang. losLab PDF Library không làm như vậy, và lý do là sự cân bằng tải. Chi phí xử lý trang thay đổi rất lớn: một trang văn bản nội dung có thể kết xuất trong vài mili giây, trong khi một trang bản đồ vectơ dày đặc hoặc hình ảnh quét toàn trang có thể mất thời gian lâu gấp 50 lần. Việc chia nhỏ công việc thành các phân mảnh cố định sẽ khiến worker nhận được phần nặng chạy rất lâu sau khi các worker khác đã nhàn rỗi, do đó thời gian thực tế của bạn sẽ bị quyết định bởi phân mảnh kém may mắn nhất chứ không phải là mức trung bình. Thay vào đó, mỗi worker sẽ yêu cầu trang tiếp theo từ một bộ đếm dùng chung dưới một critical section ngắn, kết xuất trang đó và quay lại để nhận trang khác, điều này giúp mọi nhân CPU đều bận rộn cho đến khi toàn bộ phạm vi trang được hoàn thành
// What each worker does inside the pool (simplified)
NextPage := StartPage;
IdxLock := TCriticalSection.Create;
WorkerProc :=
procedure
var
LocalLib: TPDFlib;
PageNum: Integer;
begin
CoInitialize(nil); // GDI+ is apartment-threaded
try
LocalLib := TPDFlib.Create; // one private instance per worker
try
LocalLib.LoadFromFile(FileName, '');
while True do
begin
IdxLock.Enter; // claim the next page atomically
try
PageNum := NextPage;
Inc(NextPage);
finally
IdxLock.Leave;
end;
if PageNum > EndPage then Break;
LocalLib.RenderPageToFile(DPI, PageNum, 0,
Format('page_%d.png', [PageNum]));
end;
finally
LocalLib.Free;
end;
finally
CoUninitialize;
end;
end;
Ghi nhật ký có cấu trúc giữa các luồng làm việc
Gỡ lỗi một loạt tác vụ bị chết ở trang 213 trên 500 sẽ rất khốn khổ nếu không có tệp nhật ký, và một nhật ký sơ sài có thể tự tạo ra lỗi đồng thời của chính nó. losLab PDF Library cung cấp TPDFlibLogger, được liên kết thông qua thuộc tính TPDFlib.Logger và mặc định là nil để đường dẫn không nhật ký không tốn chi phí. Nó được thiết kế ưu tiên callback: bạn thiết lập OnLog và định tuyến các bản ghi đến bất kỳ nơi nào máy chủ mong muốn, được lọc theo cấp độ llDebug / llInfo / llWarn / llError, và PDFlibErrorMessage sẽ chuyển đổi các mã số thô thành văn bản dễ hiểu để một bản ghi Error hiển thị nhiều hơn là một số nguyên trần trụi. File sink tùy chọn là tài nguyên dùng chung duy nhất, và nó được bảo vệ bởi một TCriticalSection chính xác để nhiều worker có thể ghi thêm vào một tệp nhật ký một cách an toàn. Hãy lưu ý ranh giới thực tế: chỉ có file sink đó được đồng bộ hóa, vì vậy nếu bạn chia sẻ một bộ ghi nhật ký trên một nhóm tự xây dựng và OnLog của bạn chạm vào giao diện người dùng (UI), bạn vẫn phải tự mình truyền dữ liệu đó trở lại luồng chính
var
Pdf: TPDFlib;
Log: TPDFlibLogger;
begin
Log := TPDFlibLogger.Create;
Log.Level := llInfo; // llDebug, llInfo, llWarn, llError
Log.FileName := 'render.log'; // optional shared sink (lock-guarded)
Log.OnLog :=
procedure(Level: TPDFlibLogLevel; Code: Integer; const Msg: WideString)
begin
if Level = llError then
// marshal to the UI thread yourself; OnLog fires on worker threads
WriteLn(Format('[%d] %s', [Code, PDFlibErrorMessage(Code)]));
end;
Pdf := TPDFlib.Create;
Pdf.Logger := Log; // nil by default; zero-cost when unset
try
Pdf.RenderPagesToFilesParallel('report.pdf', '', 150.0, 1, 500, 0,
'out\page_%p.png', 0);
// an Error now carries text, e.g. 401 -> "Wrong password or permission denied"
finally
Pdf.Free;
Log.Free;
end;
end;
Bạn thực sự nên kỳ vọng mức độ tăng tốc là bao nhiêu?
Hãy trung thực với bản thân về nơi tiêu tốn thời gian, bởi vì kết xuất song song chỉ thực sự mang lại hiệu quả khi công việc thực sự bị giới hạn bởi CPU (CPU-bound). Đầu ra DPI cao và các trang vectơ phức tạp hoặc các trang có đổ bóng là những tác vụ nặng về tính toán, và những tác vụ đó mở rộng gần như tuyến tính với số lượng nhân cho đến khi bạn làm bão hòa CPU. Những trang đơn giản lại là một câu chuyện khác: ở đó, chi phí LoadFromFile trên mỗi worker, cộng với chi phí ghi tệp đầu ra của đĩa, có thể lấn át chính quá trình kết xuất, và tám worker tranh chấp một đĩa chậm có thể hoàn thành chậm hơn một vòng lặp tuần tự sạch sẽ. Hãy đặt MaxWorkers thành số lượng nhân vật lý của bạn thay vì một con số mong muốn nào đó, theo dõi bộ nhớ khi tệp PDF nguồn lớn, và nếu một loạt tác vụ hóa ra bị giới hạn bởi IO thì giải pháp là lưu trữ nhanh hơn hoặc ít worker hơn, chứ không phải tăng thêm luồng. Được sử dụng cho các công việc thiết kế riêng cho nó, đường dẫn kết xuất hàng loạt được hiển thị ở đây là một phần của losLab PDF Library tiêu chuẩn dành cho Delphi và C++Builder, và nó chuyển đổi các nhân CPU nhàn rỗi thành các trang đã hoàn thành mà không có bất kỳ bẫy an toàn luồng nào mà lẽ ra bạn phải tự giải quyết