Skip to content
Concurrency

Concurrency

There are two ways to parallelize work in UniPDF, and they scale differently. Document-level concurrency runs each file in its own goroutine with its own reader, and works for every operation the library supports. Page-level concurrency shares one loaded document between goroutines, which only helps when you have a single large file rather than many small ones.

ApproachWhat each goroutine ownsUse it when
Document levelIts own reader over its own file.You have a batch of documents.
Page levelOne page of a shared reader.You have one document with many pages.

Both are read paths. Writing is not covered by any of this: a creator.Creator, a model.PdfWriter and a model.PdfAppender each hold mutable state and must stay on one goroutine.

What made page-level access possible

Through v4 the parser read through an io.ReadSeeker, so every object lookup moved a cursor that all callers shared. In v5 the parser and reader I/O layer takes an io.ReaderAt instead, and reads happen at absolute offsets through per-call cursors. That makes *core.PdfParser internally thread-safe and is what allows several goroutines to read pages of the same document.

Once a document’s structure is loaded, these are safe to use concurrently:

  • Object lookups on *core.PdfParser: LookupByReference, LookupByNumber and Resolve. Results are cached in a sync.Map, so repeat lookups are lock-free, and concurrent misses for the same object number are deduplicated so only one goroutine parses it.
  • GetPage on *model.PdfReader, plus traversal of the page content that follows from it.
  • parser.ReadBytesAt(offset, length), which reads at an absolute offset rather than moving a shared cursor. Signature validation goes through it, so verifying signatures no longer interferes with concurrent reads.

The rule is load once, then read in parallel. Loading is the part that is not concurrent, so a lazy reader is the wrong choice here: it defers object resolution to whenever a page is first touched, which is exactly the work you were trying to spread out.

Sharing one parser

model.NewPdfReaderFromParser builds a reader on top of a parser you created yourself, and resolves the whole document structure before returning. After that the object graph holds direct objects rather than lazy reference placeholders, so no goroutine can observe a half-resolved dictionary.

data, err := os.ReadFile("input.pdf")
if err != nil {
    return err
}

parser, err := core.NewParser(bytes.NewReader(data))
if err != nil {
    return err
}

reader, err := model.NewPdfReaderFromParser(parser)
if err != nil {
    return err
}

Two limits come with it. Encrypted documents are rejected outright, with an error saying so, so use model.NewPdfReader and Decrypt for those. And the resulting reader has no seekable source, so it cannot be passed to model.NewPdfAppender, which needs one for incremental updates and signing. Reading and validating existing signatures does work, since that path reads raw bytes through parser.ReadBytesAt.

model.NewPdfReader also loads eagerly and is equally safe to read from several goroutines. Reach for the parser form when you want one parser to back more than one reader, or when you already have a parser in hand.

Where to look

GuideCovers
Concurrent extractionOne goroutine per document, for batches of files.
Page-level extractionOne goroutine per page of a single shared document.

For the full list of v5 signature changes behind this, see the v5 migration guide.

Last updated on