Porting a C JSON Tokenizer to Go: A Technical Deep-Dive into jsmn-go

· Albert ·

3 min read Original article ↗

The foundation of jsmn-go is the Parser, which contains a slice of Token structs and tracks the parsing state. Unlike a full deserialization library that maps JSON to Go structs, jsmn-go is a tokenizer. Its primary goal is to scan a JSON byte array and identify the boundaries of JSON elements (objects, arrays, strings, and primitives) without allocating memory for their actual values.

The main Parse method (func (p *Parser) Parse(json []byte)) iterates through the input []byte character by character.

  • When it encounters an opening brace { or bracket [, it allocates a new Token of type Object or Array respectively. It sets the token's Start position and assigns its parent index, creating a hierarchical relationship.

  • For strings, the parseString helper scans from the opening " until the closing " is found, accounting for escaped characters. The resulting Token captures the start and end positions of the string's content.

  • Primitives (like numbers, true, false, null) are handled by the parsePrimitive function, which scans until it hits a delimiter (,, ], }, or whitespace).

  • The parser maintains a parent-child relationship between tokens using the toksuper field, which is the index of the current parent object or array. This allows it to correctly nest tokens and set the Size attribute, which counts the number of direct children for an object or array.

The final output is a slice of Token structs, where each token provides the Type, Start and End positions in the original byte slice, the Size (for objects/arrays), and the ParentIdx.

A key Go-centric enhancement is the ParseParallel function, designed to speed up tokenization of large JSON files by leveraging multiple CPU cores.

The implementation works as follows:

  1. Chunking: The input []byte is divided into chunks based on the number of available CPUs (runtime.NumCPU()), with a cap at 4 workers for simplicity. To avoid overhead on small inputs, it falls back to the standard single-threaded Parse method if the JSON size is less than 512 bytes.

  2. Concurrent Processing: A sync.WaitGroup is used to manage a pool of goroutines. Each goroutine receives a chunk of the JSON data and a new instance of the Parser.

  3. Execution: Inside each goroutine, p.Parse(chunk) is called to tokenize its assigned portion of the JSON. The resulting token slices are stored in a shared results slice.

  4. Merging: After all goroutines complete (managed by wg.Wait()), the token slices from each worker are concatenated into a single merged slice.

It's important to note the README.md acknowledges that this chunking method is "naive". Splitting the raw byte array can cut through the middle of a JSON object or array, leading to parsing errors within a chunk. The current implementation lacks "smart chunk finding" to align splits with valid JSON structural boundaries.

To integrate smoothly with Go's I/O interfaces, jsmn-go provides ParseStream(r io.Reader, numTokens int). This function is a straightforward wrapper that makes the tokenizer compatible with network sockets, file streams, or any other data source that implements the io.Reader interface.

Its implementation is simple: it uses io.ReadAll(r) to consume the entire stream into a []byte slice. Once the data is in memory, it proceeds to call the standard p.Parse(json) method. While this provides a convenient API, it means the entire JSON object must still be read into memory before parsing can begin. True, non-blocking streaming tokenization is not yet implemented.

Discussion about this post

Ready for more?