Skip to content
Page-Level Concurrent Extraction

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:

ConstructorSourceNotes
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_dir

If 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.

Last updated on