Page-Level Concurrent Extraction
When the workload is one large document rather than a batch of files, the unit of
parallelism has to be the page. Since v5 the parser reads at absolute offsets
through an io.ReaderAt, so one loaded document can be read from several
goroutines at once. Load it on one goroutine, then fan the pages out.
Two constructors get you a reader that is safe to share:
| Constructor | Source | Notes |
|---|---|---|
model.NewPdfReader(r) | Any io.ReaderAt, such as an *os.File. | Loads the structure eagerly. Handles encrypted files through Decrypt, and can be appended to if the source is also an io.ReadSeeker. |
model.NewPdfReaderFromParser(parser) | A parser you built with core.NewParser. | Also eager. Lets one parser back several readers. No encryption, no appending. |
Do not use model.NewPdfReaderLazy for this. It defers object resolution until a
page is first touched, which moves the loading work into the goroutines you were
trying to parallelize.
Doing it
reader, err := model.NewPdfReaderFromParser(parser)
if err != nil {
return err
}
numPages, err := reader.GetNumPages()
if err != nil {
return err
}
var wg sync.WaitGroup
for i := 1; i <= numPages; i++ {
wg.Add(1)
go func(pageNum int) {
defer wg.Done()
texts[pageNum], errs[pageNum] = extractPage(reader, pageNum)
}(i)
}
wg.Wait()GetPage returns a page from a list the reader built during loading, so it is a
lookup rather than a parse, and calling it concurrently is safe. Each goroutine
should build its own extractor from the page it was given.
Collect results into a pre-sized slice indexed by page number, as the example does. Every goroutine then owns exactly one element, so no mutex is needed and the output comes back in page order for free.
Limitations
This is a read path. Nothing here makes writing concurrent: keep a
creator.Creator, a model.PdfWriter and a model.PdfAppender on a single
goroutine, and do not mutate a *model.PdfPage that another goroutine is
reading.
NewPdfReaderFromParser returns an error on an encrypted document, and the
reader it produces has no seekable source, so model.NewPdfAppender will not
take it. Use model.NewPdfReader when you need either. Validating existing
signatures does work, because that path reads raw bytes through
parser.ReadBytesAt rather than seeking.
Feeding the parser a bytes.Reader over the whole file, as the example does,
costs one copy of the document in memory. An *os.File is an io.ReaderAt too
and avoids that, at the price of a syscall per read.
Parallel page extraction is not free: goroutine and allocation overhead can make it slower than a single pass on small documents. Time it against the sequential version before adopting it.
Run the example
extractConcurrently buffers the file, builds a parser and a reader from it, and
launches one goroutine per page; extractPage is what runs in each. Every page
lands in its own numbered .txt file in the output directory.
git clone https://github.com/unidoc/unipdf-examples.git
cd unipdf-examples/concurrent-processing
go run concurrent_extraction_shared_parser.go input.pdf output_dirIf this is your first time using UniPDF, follow the getting started guide to create an API key and set up your development environment.
View the full source
concurrent_extraction_page_level.go, in the same folder, is the same pattern
built on model.NewPdfReader over an open file instead of a shared parser.