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.
| Approach | What each goroutine owns | Use it when |
|---|---|---|
| Document level | Its own reader over its own file. | You have a batch of documents. |
| Page level | One 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,LookupByNumberandResolve. Results are cached in async.Map, so repeat lookups are lock-free, and concurrent misses for the same object number are deduplicated so only one goroutine parses it. GetPageon*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
| Guide | Covers |
|---|---|
| Concurrent extraction | One goroutine per document, for batches of files. |
| Page-level extraction | One goroutine per page of a single shared document. |
For the full list of v5 signature changes behind this, see the v5 migration guide.