构建高性能的JSON解析器

2020-06-28 04:50:40

JSON很重要,我们作为程序员或操作员所做的几乎每一件事都会在某种程度上涉及JSON。JSON解码是昂贵的,如果您的产品使用JSON,那么进出JSON的数据编组的性能就很重要。这是关于设计一种高效的编码/json.Decoder替代方案的讨论。

JSON是一种重要的数据交换格式,几乎我们作为程序员所做的一切都在某种程度上涉及到JSON。

同时,JSON解码是昂贵的。每次发布GO时,我们都会看到编码/JSON包效率的提高。有时这些都是很大的改进,比如GO 1.3中不再使用分段堆栈,最近这些改进是适度的。在1.15周期中,我看到两个性能改进不得不回滚,因为虽然它们提高了速度,但它们打破了人们依赖的微妙的隐含行为。海伦定律。[1]。

在Go生态系统中有许多替代的JSON库,通常由少数人维护,所以这对我来说是一个问题,它的骨头上有一些肉,同样,也不是无可挑剔的。我想我应该试一试。

在最低级别pkg/json.scanner可以在不分配的情况下标记流JSON(只要提供几千字节的缓冲区)。

基准扫描程序/加拿大-161561 3524005 ns/op 638.78 MB/s 0 B/op 0分配/opBenchmarkScanner/CITM_CATALOG-163556 1555322 ns/op 1110.51 MB/s 0 B/op 0分配/opBenchmarkScanner/twitter-167068 836031 ns/op 755.37 MB/s 0 B/op 0分配/opBenchmarkScanner/CODE-16 1543 3640425 ns/op 533.03 MB/s 0 B/op 0分配/opBenchmarkscanner。-16 12771 467360 ns/op 1471.01 MB/s 0 B/op 0分配/op。

基准解码令牌/分组/加拿大-16267 22073095 ns/OP 101.98 MB/s 4402914 B/OP 222279 allocs/opBenchmarkDecoderToken/encodingjson/canada-16 86 67830737 ns/OP 33.19MB/s 17740387 B/OP 889106 allocs/opBenchmarkDecoderToken/pkgjson/citm_catalog-16 1114 5183226 ns/OP 333.23 MB/s 965992 B/OP 81995 allocs/opBenchmarkDecoderToken/encodingjson/citm_catalog-16 288 20882018 ns/OP 82.71MB/s 5661597 B/OP 324692 allocs/opBenchmarkDecoderToken/pkgjson/twitter-16 333.23 MB/OP 255.98 MB。/s 768354 B/OP 38992 allocs/opBenchmarkDecoderToken/encodingjson/twitter-16 471 12606205 ns/OP 50.10MB/s 3584017 B/OP 187319 allocs/opBenchmarkDecoderToken/pkgjson/code-16 346 16877006 ns/OP 114.98 MB/s 4304233 B/OP 320235 allocs/opBenchmarkDecoderToken/encodingjson/code-16 73 80255994 ns/OP 24.18MB/s 23355962 B/OP 1319125 allocs/opBenchmarkDecoderToken/pkgjson/example-16 113912 53083 ns/OP 245.35 MB/s 16016 B/OP 914 allocs/opBenchmarkDecoderToken/encodingjson/example-16 21734 273991 ns/。OP 47.53MB/s 82416 B/OP 4325 allocs/opBenchmarkDecoderToken/pkgjson/sample-16 213761 B/OP 871796 ns/OP 788.59 MB/s 213761 B/OP 5081 allocs/opBenchmarkDecoderToken/encodingjson/sample-16 1803 3287623 ns/OP 209.12 MB/s 723782 B/OP 26095分配/OP。

因为分配在Decoder.Token API中占很大比例,所以pkg/json.Decoder提供了一个替代API,该API产生的分配要少得多,并且速度快8-10倍。

BenchmarkDecoderNextToken/Pkgjson/Canada-161197 4825232 ns/op 466.52 MB/s 136 B/op 3 allocs/opBenchmarkDecoderNextToken/encodingjson/canada-16 90 65392440 ns/op 34.42MB/s 17740399 B/op 889106 allocs/opBenchmarkDecoderNextToken/pkgjson/citm_catalog-16 2709 2162849 ns/op 798.58 MB/s 136 B/op 3 allocs/opBenchmarkDecoderNextToken/encodingjson/citm_catalog-16 301 20064314 ns/op 86.08 MB/s 5661597 B/op 324692 allocs/opBenchmarkDecoderNextToken/pkgjson/twitter-16 5395 1068106 ns/op 591.25 MB。/S 152 B/OP 4 allocs/opBenchmarkDecoderNextToken/encodingjson/twitter-16 494 12072956 ns/OP 52.31 MB/s 3584013 B/OP 187319 allocs/opBenchmarkDecoderNextToken/pkgjson/code-16 1135 5124666 ns/OP 378.65 MB/s 248 B/OP 6 allocs/opBenchmarkDecoderNextToken/encodingjson/code-16 74 77579973 ns/OP 25.01 MB/s 23355955 B/OP 1319125 allocs/opBenchmarkDecoderNextToken/pkgjson/example-16 269010 22323 ns/OP 583.43 MB/s 152 B/OP 4 allocs/opBenchmarkDecoderNextToken/encodingjson/example-16 22707 264078 ns/。OP 49.32MB/s 82416 B/OP 4325 allocs/opBenchmarkDecoderNextToken/pkgjson/sample-16 10000 510445 ns/OP 1346.85 MB/s 114B/OP 9 allocs/opBenchmarkDecoderNextToken/encodingjson/sample-16 1836 3161804 ns/OP 217.44 MB/s 723781 B/OP 26095分配/OP。

在最高级别,pkg/json可以使用与coding/json相同的API将数据解组到Go对象中。这在很大程度上是一项正在进行的工作,但对于希望使用该包作为替代包的人来说,结果是很有希望的。

BenchmarkDecoderDecodeInterfaceAny/pkgjson/canada-16 217 27425893 ns/OP 82.08 MB/s 8747163 B/OP 281408 allocs/opBenchmarkDecoderDecodeInterfaceAny/encodingjson/canada-16 153 38347477 ns/OP 58.70MB/s 20647616 B/OP 392553 allocs/opBenchmarkDecoderDecodeInterfaceAny/pkgjson/citm_catalog-16 747 8008839 ns/OP 215.66 MB/s 5197853 B/OP 89673 allocs/opBenchmarkDecoderDecodeInterfaceAny/encodingjson/citm_catalog-16 360 16607501 ns/OP 104.00 MB/s 9406809 B/OP 95389 allocs/opBenchmarkDecoderDecodeInterfaceAny/pkgjson/twitter-16 1606 3714515 ns/OP 170.01 MB。/s 2130731 B/OP 30182 allocs/opBenchmarkDecoderDecodeInterfaceAny/encodingjson/twitter-16 862 6927998 ns/OP 91.15MB/s 4283407 B/OP 31278 allocs/opBenchmarkDecoderDecodeInterfaceAny/pkgjson/code-16 333 17939351 ns/OP 108.17 MB/s 7331643 B/OP 232059 allocs/opBenchmarkDecoderDecodeInterfaceAny/encodingjson/code-16 236 25324951 ns/OP 76.62MB/s 12332753 B/OP 271292 allocs/opBenchmarkDecoderDecodeInterfaceAny/pkgjson/example-16 76874 78079 ns/OP 166.81 MB/s 50980 B/OP 739 allocs/opBenchmarkDecoderDecodeInterfaceAny/encodingjson/example-16 40886 146685 ns/。OP 88.79MB/s 82855 B/OP 782 allocs/opBenchmarkDecoderDecodeInterfaceAny/pkgjson/sample-16 5240 1116081 ns/OP 615.99 MB/s 399970 B/OP 5542 allocs/opBenchmarkDecoderDecodeInterfaceAny/encodingjson/sample-16 1123 5369313 ns/OP 128.04 MB/s 2661872 B/OP 7527分配/OP。

我使用的是从源代码构建的GO 1.15的预发布版本。如果您使用的是较旧的版本,您的数字可能会有所不同。当Go 1.15发布时,你应该升级。

这个包提供了同样高级别的json.Decoder API,具有更高的吞吐量和/或更少的分配。这个包的成功标准是作为编码/json的替代产品。

如果您可以将整个输入放在内存中,这很好,但这是不现实的。输入大小通常是未知的,并且可能没有限制。在内存中缓冲会带来可用性风险。在处理之前缓冲会带来延迟,流式读取允许您在数据到达时进行处理,并在逻辑上与传输或读取重叠。此软件包支持通过io.Reader输入源执行流式操作(这也是与编码/json兼容所必需的)。

除了编码/json API之外,还提供了一个可用最少的(理想情况下不需要分配)操作的替代API。

我们都熟悉概要分析和跟踪(GO工具pprof,GO工具跟踪),它们是我们在编写程序后可以用来检查程序性能的技术。有没有工具可以用来在我们编写程序之前评估它的性能呢?

JSON不使用长度标记;要知道要读取多少,我们必须全部读取。这意味着处理文件的时间下限是文件的大小。具体来说,处理每个字节需要多长时间。

但是读取文件是不够的,我们必须遵循JSON状态机来确定令牌的开始和结束位置。现在,仅读取N个字节,我们需要处理这N个字节,因此性能至少是read(N)+parse(N)。但是还有其他开销,如果我们必须分配内存来读取或处理这些字节,那么这将耗费我们的成本。

我们知道,影响解析性能的重要因素是输入的大小。理想情况下,我们希望N是输入中的字节数,也就是说,我们希望每个字节只处理一次。如果我们多次接触同一字节,则会增加开销,并且如果我们必须保留这些字节才能返回并再次查看它们,则会使处理变得复杂。

就像我们不想多次处理一个字节一样,我们希望避免多次处理一个令牌,我们希望限制函数调用的数量,理想情况下是O(令牌),而不是O(字节)。

限制扫描仪或解码器内部热路径中的函数调用。Coding/json在每个字节使用一个函数调用,pkg/json在每个令牌一个调用上做得更好,如果我们不做其他事情,我们就领先了。

限制复制。如果我们设计限制数据复制,那么我们就会限制重新访问一个字节的次数。

限制分配。如果您限制可以从中复制的位置数,并且理想情况下仅在现有缓冲区内复制,则自然会限制分配。限制分配以两种方式缩短运行时间:

减少进行分配的开销。堆是共享资源,在堆上分配需要使用共享数据结构。这意味着锁、缓存争用等C.F.。阿姆达尔定律[2]

减少清理分配的开销。您进行的分配越少,消耗的堆就越少,产生的垃圾也就越少。减少这两个因素可以减少后台和前台垃圾收集的开销。

JSON是一个令牌流,要构建高级组件,比如漂亮的打印机和解码器,我们需要将流分解成令牌。

JSON解码器有两个主要组件1。将字节流转换为JSON令牌流的扫描器或令牌器。将JSON令牌流应用到GO对象的解组程序。让我们首先讨论一下标记化:

JSON是一种规则的、定义良好的语法,在json.org上有一组很棒的图表。

:,冒号,键/值对中键和值之间的分隔符。

Coding/json使用Decoder.Token API执行此操作。您声明一个json.Decoder,然后调用Token,直到err为非零。

软件包mainimport(";编码/json";";fmt";";)func main(){input:=`{";a";:1,";b";:true,";c";:[1,";Two";,null]}`dec:=json.NewDecoder(string s.NewReader(Input))for{tok,err:=dec.Token()if err!=nil{Break}fmt.Printf(";%v\t(%T)\n";,tok,tok)}}。

这相当方便,tok是一个接口{}值,因此它既可以表示返回的值,也可以表示它的类型;字符串是字符串,数字是浮点64,布尔值是真正的true和false,甚至null也表示为nil。

但是这种便利是有代价的。要了解原因,让我们来谈谈字符串。当我们写下这条语句时。

编译器复制b,因为围棋规则说字符串是不可变的。如果字符串和字节片共享相同的后备数据,那么更改b可能会更改s的内容。这将是不好的,因此string(B)复制b的竞争。

%go doc coding/json NewDecoderpackage json//import";coding/json";func NewDecoder(rio.Reader)*Decoder NewDecoder返回从r读取的新解码器。解码器引入自己的缓冲区,并可能从r读取请求的JSON值以外的数据。

%go doc io Reader.Readpackage io//import";io";函数读取(p[]字节)(n int,错误)。

您给read设置了一个[]字节缓冲区,read返回它读取到缓冲区中的字节数,可能还会返回一个错误。

现在,我们知道输入是字节流,输出是runes、float64、bools和string。输入至少会产生一个字节切片{';h';,';e';,';l';,';l';},该字节切片将被复制。

软件包mainimport(";编码/json";";fmt";";io";";string";)func main(){input:=`";hello";`var rio.Reader=string。NewReader(Input)//将字符串作为[]byte Dec:=json.NewDecoder(R)tok,_:=dec.Token()fmt.Prk读取为[]byte dec:=json.NewDecoder(R)tok,_:=dec.Token()fmt.Pr.。、Tok、Tok)}。

从性能角度来看,更严重的问题是为接口赋值通常会导致分配。由于Decoder.Token API的设计,分配给每个令牌的具体值会导致该值转义到堆中。因此,我们不仅为每个[]字节到字符串的转换分配了一个分配值,而且每个令牌都会转储到堆中。分配的数量与文件中的令牌数量相关,而这些分配的大小将在一定程度上与

这有几个原因,它们都有相同的潜在原因,垃圾收集器。我们都知道接口值是一个两个字的结构,看起来像这样[3]。

上面的uintptr并不是说类型和数据是指针(尽管在大多数情况下是指针),只是因为这些字段的大小足以容纳指针大小的内存 - 中的值的地址。它是无符号的,因为有符号的指针会将地址空间减半,而负指针没有任何意义。

接口值的特殊之处在于它们同时记录值和值的类型。接口值的另一个属性是它们可以保存任何值,而不需要它们的类型。

在早期的GO版本中,接口可以将uintptr或更小的值直接存储在接口的数据字段中。但在GO 1.6 TODO检查中,这一点发生了变化,因为不可能自动更改两个字段,这会给并发收集器带来问题。

从编译器的角度来看,这必须将存储在x中的类型从int更改为string,并将值从1更改为";1";。

为此,编译器在数据槽中存储一个指向该值的指针,这意味着每个令牌都会转义到堆中,但更糟糕的是,我们知道GO字符串本身就是一个小结构。

因此,要将[]字节标记转换为字符串,我们首先复制堆中的[]字节,然后创建字符串头并将其放入堆中,最后将指向该头的指针放入接口中的数据槽。

软件包mainimport(";编码/json";";字符串";";测试";)func BenchmarkJSONDecodeHello(b*testing.B){input:=`";hello";`r:=string s.NewReader(Input)//以[]byte dec:=json.NewDecoder(R)b.ReportAllocs()b.SetBytes(int.。i++{r.Seek(0,0)tok,_:=dec.Token()if tok!=";hello";{b.Ftal()}

%进行测试-工作台=。-memprofile=M.P json/tok_test.gogoos:darwingoarch:amd64BenchmarkJSONDecodeHello-4 3523440 355 ns/op 19.73 MB/s 37 B/op 3 allocs/opPASSok命令行参数1.951s。

请注意,此包的大部分加速来自减少分配;具体地说,堆分配路径中未花费的时间和GC周期中未花费的时间可用于扫描。

如果我们想要构建一个比coding/json分配更少的API,我们必须解决我已经讨论过的每个问题。

让我们回顾一下标记序列;{";a";:1,";b";:true,";c";:[1,";Two";,null]和}。

原来,令牌中的第一个字符告诉您令牌是什么。

这是Scaner.Next和Decoder.NextToken API的第一个改进,它不是将[]字节转换为值,而是直接从输入返回令牌-​一个简单的子片。

软件包mainimport(";fmt";";github.com/pkg/json";";string";)func main(){input:=`{";a";:1,";b";:true,";c";:[1,";Two";,null]}`dec:=json.NewDecoder(string s.NewReader(Input))for{tok,err:=dec.NextToken()if err!=nil{Break}fmt.Printf(";%s\t(%T)\n";,tok,tok)}}。

因为输出是输入子切片,而不是副本,所以对输出的有效期有限制,这类似于bufio.Scanner API。

有时人们想知道令牌的类型;集合、数组、字符串、数字等等,有时他们想要令牌值、字符串、数字,以一种他们可以使用的形式。Scaner.Next和Decoder.NextToken在这方面并不方便,但它们可以用来更高效地构建更高级别的抽象。

让我们讨论一下读取数据。这可能很难有效地完成,因为JSON不是长度分隔的,您必须阅读直到找到标记的末尾。传统的方法是使用io.Reader。

您可以一次读取一个字节,但您需要一个地方来存储您醒来的东西,还可能需要将该字节放回原处,

您可以读入缓冲区,然后在缓冲区中查找标记的开始和结束。如果结束标记不在缓冲区中,则需要执行大量簿记和复制操作,以便在缓冲区中移动数据或增加缓冲区,以便为更多数据腾出空间。

Coding/json结合了这些功能,通常使用少量的sync.pool来尝试透明地重用小对象。

另一种想法的灵感来自史蒂芬·施维格霍夫(Steven Schveighoffer)的iopipe[4]和菲尔·珀尔(Phil PEAR)[5]。

//byteReader在io.Reader.type byteReader struct{data[]byte Offset int r io.Reader Err}//Release丢弃窗口前面的n个字节。func(b*byteReader)release(N Int){b.Offset+=n}//Window返回当前窗口。//调用Release或extend.func(b*byteReader)Window()[]reader.func(b*byteReader)Extension()int{。

JSON包含记号和空格的混合。记号之间可以出现空格、制表符、换行符和回车符,并被忽略,因此对记号的搜索从搜索第一个非空格字符开始。

现在是通过使用byteReader的示例讨论优化空格搜索的好时机。

函数countWhitespace(br*byteReader)int{n:=0 w:=br.window()for{for_,c:=range w{if isWhitespace(C){n++}}br.release(len(W))if br.add()==0{return n}w=br.window()}}。

这样做最少,访问每个字符并进行一次函数调用(内联),并计算空格字符的数量。任何(有用的)JSON解码器代码都不能比这更快。

实现isWhitespace的最快方式是什么?

函数isSpace(c字节)bool{return c<;=';&;&;(c==';';||c==';\t';||c==';\r';||c==';\n';)}}。

名称时间/opCountWhitespace/Canada-16 1.10ms±2%CountWhitespace/citm_CATALOG-16 838µs±1%CountWhitespace/Twitter-16 306µs±1%CountWhitespace/code-16 937µs±1%CountWhitespace/Example-16 6.40µs±1%CountWhitespace/sample-16 333µs±1%名称速度CountWhitespace/Canada-16 2.04 GB/s。示例-16 2.04 GB/s±1%计数空白/示例-16 2.06 GB/s±1%。

现在我们可以区分哪些字符是记号,哪些是简单的空格,让我们更上一层楼,讨论如何拆分这些记号。

//Next返回引用流中下一个词法标记的[]字节。//在再次调用Next之前,[]字节一直有效。//如果流在其末尾或发生错误,则Next返回零//Length[]字节切片。/有效的标记以以下内容之一开头:/{Object Start//[Array Start//}Object End//]Array End//,文字逗号//:文字冒号//t JSON TRUE//f JSON FALSE//n JSON NULL//";可能包含反斜杠转义实体的字符串。//-,0-9 A number func(s*Scanner)Next()[]byte{//释放上一个令牌。br.release(s.pos)s.pos=0 c:=s.Token()Length:=0开关c{case ObjectStart,ObjectEnd,Colon,Comma,ArrayStart,ArrayEnd:

..