翻译自内存屏障
那么到底是什么让CPU的设计大师们着了魔,要把内存屏障这个鬼东西强行塞给了毫不知情的多处理器系统的软件开发者? 简单点说,就是因为内存访问顺序的重排会带来更好的性能。同步原语的正确操作依赖于重排后的内存访问,因此需要使用内存屏障来强制对同步原语进行排序。
想要详细答案就需要很好的理解CPU缓存如何工作的,尤其需要理解什么才能让缓存真正工作良好。我们会分下面几个部分深入了解:
那么到底是什么让CPU的设计大师们着了魔,要把内存屏障这个鬼东西强行塞给了毫不知情的多处理器系统的软件开发者? 简单点说,就是因为内存访问顺序的重排会带来更好的性能。同步原语的正确操作依赖于重排后的内存访问,因此需要使用内存屏障来强制对同步原语进行排序。
想要详细答案就需要很好的理解CPU缓存如何工作的,尤其需要理解什么才能让缓存真正工作良好。我们会分下面几个部分深入了解:
1.展现缓存结构
2.描述缓存一致性协议如何确保多个CPU在内存中每个位置的值一致
3.概述 store buffers 和 invalidate queues 如何帮助缓存和缓存一致性协议来获取高性能
我们会明白为了获得更好的性能和高可扩展性,内存屏障是必要之恶。罪恶的源头都是出于:多CPU比它们之间的互连器和内存都快几个数量级。
现代CPU比现代内存系统快很多。一个2006年的CPU可能达到每纳秒执行十条指令,但是却需要数十纳秒去主内存读取数据。这种速度上的差异——超过两个数量级——导致了在现代CPU的兆字节缓存的出现。这些缓存与CPU关联,如图1所示,通常可以在几个周期内完成访问[1]。
数据在CPU的缓存和内存之间流动时,以固定长度的块,称为“缓存行”,这些缓存行的大小通常是2的幂,从16字节到256字节不等。当给定的数据项首次被给定的CPU访问时,它将不在CPU的缓存里,这意味着“缓存脱靶”(或者更具体地说,发生“启动”或“预热”缓存脱靶)。缓存脱靶意味着CPU将不得不等待(或“停滞”)数百个周期,以从内存中获取数据项时,但是随后该项会被加载到该CPU的缓存中,因此随后的访问将在缓存中找到它,并因此全速运行起来。
一段时间后CPU的缓存将要填满,为了给新获取的条目腾出空间,需要从缓存中弹出一个旧条目,这时再获取之前弹出的缓存就会脱靶。这种缓存脱靶称为“容量脱靶”,因为它是由缓存的有限容量引起的。然而,即使CPU缓存还没有满,大多数缓存也可能被迫弹出旧项,以便为新项腾出空间,这是因为大型缓存是用硬件哈希表实现的,哈希表有固定大小的哈希桶(或CPU设计人员称为“sets”),并没有用链表(译者注:类似线性探测法哈希表,但限制了探测范围),如图2所示
该缓存共有16个“set”和2个“way”,共32个“line”,每个“line”包含一个256字节的“缓存行”,这是一个256字节对齐的内存块。缓存行的大小是有点大了,但是可以让16进制的算术更简单。在硬件术语中,这是一种双通道关联集缓存,类似于具有16个桶的软件哈希表,其中每个桶的哈希链最多两个元素(译者注:前面已经提到不是链表,这里仅类比软件开发中用到的链地址法哈希表)。总大小(这里是32个缓存行)和 关联性大小(这里是2个缓存行)被统称为缓存的“几何结构”。由于这个缓存是在硬件中实现的,所以哈希函数非常简单:从内存地址中提取4比特位(译者注:4比特位表示0x0 到 0xF)。
在图2中,每个框对应一个缓存条目,每个条目包含256字节的缓存行。但是缓存条目可以是空的,如图中的空框所示。其余的框使用它们所包含的缓存行的内存地址进行标记。因为缓存行必须是256字节对齐的,所以每个地址的低8位是零,选择的硬件哈希函数表明下一个高4位(表示0x0 到 0xF)与哈希行号相等.
如果程序的代码位于地址0x43210E00到0x43210EFF之间,并且程序从0x12345000到0x12345EFF依次访问数据,那么就会出现图中所示的缓存结构(译者注:这里缓存既可以放代码指令,又可以存储数据,也就是L1缓存,由于缓存行大小时256字节,0x43210E00到0x43210EFF的指令刚好可以放到一个缓存行中,以此类推,0x0 到 0xE的第一通道刚好被填满)。假设程序现在要访问位置0x12345F00,这个地址散列到0xF行,这一行的两个通道都是空的,因此可以容纳对应的256字节行。如果程序访问位置0x1233000,它将散列到第0x0行,那么对应的256字节缓存行可以被安置在通道1(Way 1)。但是,如果程序要访问位置0x1233E00(它散列到第0xE行),则必须从缓存中弹出一个缓存行,以便为新的缓存行腾出空间。如果稍后访问此弹出的缓存行,则会导致缓存脱靶。这种缓存脱靶称为“关联性脱靶”。
到目前为止,我们只考虑CPU读取数据项的情况。当CPU写数据的时候会发生什么? 由于当所有的CPU读取给定的数据项的值的时候要保持一致性很重要,在给定CPU写入那个数据项之前,必须首先从其他CPU的缓存中删除该数据项,也就是使其“invalidate”。一旦“invalidate”完成,CPU可以安全地修改数据项。如果数据项存在于CPU的缓存中,但它是只读的,则此过程称为“写入脱靶”。一旦给定的CPU从其他CPU的缓存中完成对给定数据项的“invalidate”操作,该CPU就可以会重复写入和读取该数据项了
稍后,如果其他CPU尝试访问数据项,它将导致缓存脱靶,这一次是因为第一个CPU为了写入数据项而使该其他CPU的数据项“invalidate”。这种类型的缓存脱靶称为“通信脱靶”,因为它通常是由于多个CPU使用共享数据项进行通信的缘故(例如,CPU间互斥算法中使用的锁)。
显然,必须得花大力气维护所有CPU缓存中数据的一致性视图。通过读取、失效和写入操作,很容易想到数据会丢失,或者可能更糟糕的情况,如不同的CPU在各自的缓存中对相同地址的数据项具有冲突的值。下一节将介绍“缓存一致性协议”,它可以解决这些问题。
缓存一致性协议族管理着缓存行(cache-line)的状态,以防止数据不一致或丢失。这些协议可能非常复杂,有几十种状态[2],但是出于我们的目的,今天我们只需要关注包含四种状态的缓存一致性协议MESI
。(译者注:为了保持一致性,CPU大师们设计了各种模型和协议,如MSI、MESI(又名Illinois)、MOSI、MOESI、MERSI、MESIF、write-once、Synapse、Berkeley、Firefly和Dragon等协议。)
MESI代表“修改(modified)”、“独占(exclusive)”、“共享(shared)”和“无效(invalid)”,这是使用此协议的缓存行可以呈现的这四种状态。因此,使用此协议的缓存在每条缓存行上除了维护该行的物理地址和数据外,还要维护一个占两比特的状态“标记”。
M
: CPU的一个内存存储的操作会让缓存行处于“修改”状态,并且保证该内存值不会出现在任何其他CPU的缓存中,因此处于“修改”状态的缓存行可以说是由该CPU“独有”的。由于这个缓存保存着仅有的最新数据的副本,所以缓存最终是要将副本写回内存或传递给其他缓存的,而且在重复使用这一缓存行之前必须这样做,以保存其他地址的数据。
E
: “独占”状态与“修改”状态非常相似,唯一的区别是缓存行还没有被相应的CPU修改,这意味着保留在内存中的缓存行数据副本是最新的。但是因为CPU可以在任何时候存储数据到这一缓存行,而不需要询问其他CPU,所以处于“独占”状态的缓存行仍然可以说是属于该CPU所“独有”的。也就是说,因为内存中的值就是最新的,所以无需将其写回内存或将其传递给其他CPU,缓存就可以丢弃最新的副本以为其他数据腾出空间
S
: 处于“共享”状态的行可能被复制到其他CPU的缓存中,因此不允许该CPU在不与其他CPU协商的情况下存储数据到该行中(译者注:这时缓存行是只读状态,执行写入操作会出现“write miss”)。与“独占”状态一样,由于内存中对应的值是最新的,因此该缓存可以在不回写回数据或没有将其传递给其他CPU的情况下丢弃该数据。
I
: 处于“invalid”状态的缓存行是空的,换句话说,它不保存数据。当有新数据进入缓存时,它将可能被放置到“无效”状态的缓存行中,这种方式是首选的,如果将来查询被替换的缓存行,那么替换任何其他状态的行都可能导致昂贵的缓存脱靶(译者注:也就是M、E、S状态 都会导致的缓存脱靶)。
由于所有CPU都必须维护缓存行所携带数据的一致性视图,为此MESI
缓存一致性协议提供了多种消息来协调缓存行数据在多处理器系统中的迁移。
上一节中描述的状态的转换需要多个CPU之间进行通信。如果多个CPU共享一个总线,那么下面的通信消息就足够了:
有趣的是,共享内存多处理器系统实际上是隐式的消息传递计算机系统。这意味着分布式共享内存的SMP机器集群使用消息传递在两个不同级别的系统体系结构上实现共享内存。
在发送和接收协议消息时,给定的缓存行的状态会如图3所示发生变化。
转换(a): 缓存行被写回内存,但是CPU将它保留在缓存中,并进一步保留修改它的权利。此转换需要“writeback”消息。
转换(b): CPU写入数据到独占状态的缓存行。此转换不需要发送或接收任何消息。
转换(c): 已经被修改的缓存行对应的CPU接收到了 “read invalidate”消息,CPU必须使其存储的本地副本无效,然后使用“read response”和“invalidate acknowledge”消息进行应答,这两个消息都将发送数据给请求的CPU,并表明它不再具有本地副本。
转换(d):CPU对缓存中不存在的数据项执行读-修改-写的原子操作。它发送一个“read invalidate”,通过“read response”接收到数据。一旦CPU收到一组完整的“invalidate确认”响应,它就可以完成转换(译者注:执行读-修改-写的原子操作)。
转换(e):CPU对缓存中是只读的数据项执行读-修改-写的原子操作。它必须传输“invalidate”消息,并且在完成转换之前必须等待一组完整的“invalidate acknowledge”响应。
转换(f): 当前CPU独占该缓存行(M 状态),其他CPU需要读取该缓存行,缓存行这时会变成只读副本状态(S 状态),还需要将其写入内存。此转换由接收“read”消息开始,当前CPU使用包含所请求数据的“read response”消息进行响应。
转换(g): 当前CPU独占该缓存行(E 状态),其他CPU需要读取该缓存行或者内存,缓存行这时会变成只读的副本(S 状态)。此转换由接收“read”消息开始,当前CPU使用包含所请求数据的“read response”消息进行响应。
转换(h): 当前CPU将需要写入一些数据项到这个处于S状态的缓存行中,因此传递一条“invalidate”消息。在接收到一组完整的“invalidate acknowledge”响应之前,CPU无法转换到E状态。第二种方式是让所有其他CPU通过“writeback”消息使它们的缓存弹出这条缓存行(可能是为了给其他缓存行腾出空间),这样当前CPU就是最后一个缓存它的CPU。
转换(i): 其他一些CPU对仅保存在当前CPU缓存中的数据项执行 读-修改-写 原子操作,因此该CPU使该数据项对应的缓存行无效。此转换由接收“read invalidate”消息开始,该CPU同时使用“read response”和“invalidate confirm”消息进行响应
转换(j):这个CPU对本地缓存中不存在的数据项进行写操作,因此传递一条“read invalidate”消息。在接收到“read response”和一组完整的“invalidate acknowledge””消息之前,CPU无法完成转换。一旦存储完成,缓存行将可能会通过转换(b)变为“修改”状态。
转换(k): 此CPU加载不在其缓存中的数据项。CPU发送一个“read”消息,在接收到相应的“read response”后完成转换。
转换(l): 其他某个CPU对这条缓存行中的数据项进行写操作,但是由于被另外的CPU缓存也保存着(比如当前的CPU缓存),而处于只读的S状态。此转换由接收“invalidate”消息开始,当前CPU将以“invalidate acknowledge”消息进行响应
现在让我们从缓存行的数据值的角度来看这个协议,这些数据最初都在地址为0的内存中,它在包含4个CPU的单通道直接映射缓存的系统中流通,也就是缓存的“Way”只有一种。表1显示了数据的流向,第一列显示的是操作序列,第二列是执行操作的CPU,第三所执行的操作,接下来的是四个CPU的缓存行状态(内存地址后面跟着MESI状态,像这样:地址/状态),最后两列对应的是内存内容是否是最新,“V”表示有效,内存中是最新数据,意味着和缓存中数据保持一致,“I”表示无效,内存中的数据已经过时,与缓存中数据不一致。
最初,CPU缓存行处于“invalidate”状态,内存中的数据都是最新的有效的内容(V状态)。当CPU 0加载地址为0的数据时,CPU 0 的缓存中变为“shared”状态,并且内存中还是最新的有效的数据。CPU 3也加载地址为0的处数据,因此地址为0的数据在这两个CPU的缓存中都处于“shared”状态,并且内存中的数据仍然是有效。接下来,CPU 0加载地址8的数据,这需要通过发送“invalidate”消息将地址0的数据强制从缓存行中剔除,并将其替换为地址8上的数据(译者注:由于上面已经假设运行在4CPU的单行通道直接映射缓存系统,地址8和地址0恰好hash到同一个缓存行)。CPU 2 从地址0 加载数据,但这CPU意识到它将很快需要存储数据,所以它使用一个“read invalidate”消息获得独占副本(E状态),使 CPU 3 缓存的副本无效(I状态)(此时内存地址0的数据仍然是最新有效的)。接下来CPU 2进行预期的存储,将状态更改为“modified”,此时内存地址0的数据已经过时了。CPU 1执行一个原子增量,使用“read invalidate”消息,从CPU 2的缓存中嗅探到数据并使其无效,从而使CPU 1的缓存中的副本处于“modified”状态(并且内存中的副本仍然是过时的)。最后,CPU 1读取地址8的缓存行,由于缓存行已经被地址0占用,因此使用“write back”消息先将地址0的数据写回到内存中,然后弹出地址0相应的缓存数据,再把地址8的数据读取到缓存行中,此时状态为“shared”。
注意到:最后的结果就是上面一些CPU的缓存中有了数据。
尽管图1所示的缓存结构为从给定CPU到给定数据项的重复读写提供了良好的性能,但是对于给定缓存行的第一次写入,其性能就相当差。要了解这一点,请看图4,它显示了CPU 0 对 CPU 1缓存中的数据执行写入操作的时间轴。由于CPU 0没有此缓存行,必须等待CPU 1 的缓存行到达,然后才能写入该缓存行,从而CPU 0必须停下一段时间[3]。
但是没有真正的理由强迫CPU 0 停下这么长时间——毕竟,不管CPU 1 发送给它的缓存行里是什么样的数据,CPU 0都会无条件地覆盖它。
防止这种不必要的写入延迟的一种方法是在每个CPU及其缓存之间添加“存储缓冲区”,如图5所示。通过添加这些存储缓冲区,CPU 0可以简单地在其存储缓冲区中记录其写操作并继续执行。当缓存行最终从CPU 1 送达 CPU 0时,数据将从存储缓冲区移动到缓存行。但是,有一些复杂的问题必须加以解决,这些问题将在下两节中讨论。
先查看第一个复杂的情况,那就是违反了自洽性(也就是缓存一致性),请看下面的代码,其中变量“a”和“b”最初都为零,起初,包含变量“a”的缓存行属于CPU 1,而包含变量“b”的缓存行属于CPU 0:
a = 1
b = a + 1
assert(b == 2)
没人料到上面的断言会失败。然而,如果你愚蠢到使用图5中所示的如此简单的Store buffer体系结构,你会得到意外的结果。上面这样的系统可能会遇到以下一系列事件:
上面的问题在于我们有两个“a”副本,一个在缓存中,另一个在存储缓冲区中。 上面这个例子破坏了一个非常重要的原则,即每个CPU执行的命令应该和编码顺序一样。破坏了这种原则对于软件开发来说就是违反直观逻辑的。幸运的是这得到了硬件工程师的同情并实现了“存储转发”(store forwarding),其中每个CPU在加载数据时会同时访问(或“嗅探”)自己的存储缓冲区和缓存(译者注:CPU都只能访问自己的存储缓冲区),如图6所示,CPU 优先访问“Store Buffer”,如果没有找到则去缓存寻找,同时还会把结果放入“Store Buffer”。换句话说,不需要通过缓存,就可以把CPU的存储缓冲区的值直接转发给后续的读取操作。(译者注:这样看来Store Buffer 类似没有遵循缓存一致性协议的缓存了,各个CPU只关注自己的Store Buffer)
有了Store Forwarding之后,上面序列中的第8步将在存储缓冲区中为“a”找到正确的值1,因此“b”的最终值应该是2,和我们预料的一样。
上面是比较简单的场景,第二种复杂的情况如下:
void foo(void)
{
a = 1;
b = 1;
}
void bar(void)
{
while(b == 0) continue;
assert(a == 1);
}
假设CPU 0 执行foo(), CPU 1 执行bar()。进一步假设包含“a”的缓存线只驻留在CPU 1的缓存中,并且包含“b”的缓存行属于CPU 0。那么执行操作顺序可以是:
硬件设计人员在这里不能直接提供帮助,因为CPU不知道哪些变量是相关的,更不知道它们是如何相关的。因此,硬件设计人员提供内存屏障指令,允许软件工程师告诉CPU这种关系。修改程序片段,其中包含内存屏障:
void foo(void)
{
a = 1;
smp_mb();
b = 1;
}
void bar(void)
{
while(b == 0) continue;
assert(a == 1);
}
在内存屏障smp_mb()
后续的的存储操作开始执行之前,CPU会清空其存储缓冲区,把值转移到每个变量对应的缓存行,也就是把之前存储在缓冲区的值应用到缓存行。CPU可以简单粗暴地暂停一会,直到存储缓冲区为空,然后再继续后续的存储。但是让CPU等待是差强人意的方式。第二种更好方式:我们可以同时把后续的存储操作放到存储缓冲区,直到我们把smp_mb()之前的存储缓冲区中的条目都应用到缓存行时,才可以把后续的存储操作应用到缓存行。
使用后一种方法,操作顺序可能如下:
如您所见,这个过程涉及大量的记录工作。即使是一些直观上很简单的东西,比如“加载a的值”,在硬件芯片中也会涉及很多复杂的步骤。
不幸的是,每个存储缓冲区较小,这意味着执行少量存储序列的CPU可以填满其存储缓冲区(如果所有都是缓存脱靶导致的)。此时,CPU必须再次停下来等待“invalidate acknowledge” 消息的返回,以便在继续执行指令之前清空其存储缓冲区。而且上一节最后讨论的b的存储操作在内存屏障之后也会出现CPU停下来等待“invalidate acknowledate”的情况,所以无论这些存储是否会导致缓存脱靶都会让CPU等待失效确认消息。
显而易见,这种情况可以通过使“invalidate acknowledge”消息更快返回来改善上面两种场景导致的CPU等待时间。一来可以加快存储缓冲区的清空速度,二来加快了正常的回复速度。有一种改善方法是:使用单CPU无效消息队列,也就是“invalidate queues”。
"invalidate acknowledge"消息占用这么长时间的一个原因是:他们必须确保相应的缓存行真正无效后才发送回复。并且如果缓存处于繁忙,“invalidate”的确认消息还会被推迟。例如:如果当前CPU正在密集加载和存储数据,且所有这些数据都存在缓存中(译者注:意味着当前CPU不需要发送“invalidate” 消息),此外,这时如果还有大量的“invalidate”消息瞬间到达当前CPU,则当前的CPU无法及时处理这些“invalidate”消息,从而可能会使所有其他发送“invalidate” 消息的CPU陷入等待回复的状态。
但是,在发送‘invalidate’的确认消息之前,CPU实际上不需要使缓存行真正失效。相反,它可以把无效消息放入队列中,但必须在CPU发送针对此缓存行的相关消息之前,对该无效消息进行处理,也就是清空相关缓存行。
图7显示了一个带有无效队列的系统。具有无效队列的CPU可以在无效消息被放入队列时立即回复确认无效,而不必等到相应的行真正无效。当然,在准备发送失效消息给指定CPU时,CPU必须先查看它的失效队列,如果相应缓存行的条目在失效队列中,CPU不能立即发送失效消息;相反,它必须等待,直到处理了无效队列里的条目。
在“invalidate queue”中放置条目本质上就是承诺:CPU在传递任何关于该缓存行的MESI协议消息之前一定会处理该条目。只要相应的数据结构不是高度竞争的,CPU就很少会因为这样的承诺而不便。
然而,缓存了失效消息可能引入“memory-misordering”问题,下一节将对此进行讨论。
我们假设所有CPU缓存无效请求,并立即响应它们。虽然这种方法最小化了CPU执行存储时所带来的缓存失效延迟,但是却可以让内存障碍不起作用,如下面的示例所示:
假设“a”和“b”的值最初为零,“a”是只读复制的(“共享”状态),“b”为CPU 0所有(“独占”或“修改”状态)。然后假设CPU 0执行foo(), CPU 1执行函数bar(),代码片段如下:
void foo(void)
{
a = 1;
smp_mb();
b = 1;
}
void bar(void)
{
while (b == 0) continue;
assert(a == 1);
}
那么操作顺序可以是:
如果这样做会导致内存屏障被忽略,那么加速无效响应的速度显然没有多大意义。然而,memory barrier
指令可以与无效队列进行交互,因此当给定的CPU执行一个内存屏障时,它会标记其无效队列中当前的所有条目,并强制所有后续加载操作等待所有标记的条目都被应用到CPU的缓存中,也就是等待所有标记的都清空后再可以进行后面的加载操作。因此,我们可以在函数栏中添加内存屏障如下:
void foo(void)
{
a = 1;
smp_mb();
b = 1;
}
void bar(void)
{
while (b == 0) continue;
smp_mb();
assert(a == 1);
}
你说什么? ? ?既然CPU不可能在while循环完成之前执行assert(),那么为什么我们在这里需要一个内存屏障呢?
通过这种改变,操作顺序可能如下:
通过大量的MESI消息传递,CPU得到了正确的答案。本节说明了为什么CPU设计人员必须非常小心地进行缓存一致性优化。
在上一节中,内存屏障用于标记存储缓冲区(store buffer)和无效队列(invalidate queue)中的条目。但是在我们的代码片段中,foo()没有理由对无效队列做任何事情,bar()也没有理由对存储队列做任何事情。
因此许多CPU架构提供了较弱的内存屏障指令,这些指令只能执行这两种指令中的一种。粗略地说,read memory barrier
只标记无效队列,write memory barrier
只标记存储缓冲区,而完整的内存屏障两者都标记。
这样做的影响是一个读内存屏障会调整当前CPU执行的加载操作(load)顺序,所有在read memory barrier
之前的加载操作将要在它之后的加载操作之前完成。类似,write memory barrier
也会调整存储操作,当然也是在当前的CPU执行的存储操作。也就是所有在write memory barrier
之前的存储操作将要在它之后的存储操作之前完成。一个完整的内存屏障同时调整加载和存储操作的顺序,但也还是仅仅在当前执行内存屏障的CPU上。
如果我们更新foo和bar来使用读写内存屏障,它们会如下所示:
void foo(void)
{
a = 1;
smp_wmb();
b = 1;
}
void bar(void)
{
while (b == 0) continue;
smp_rmb();
assert(a == 1);
}
有些计算机甚至有更多类型的内存屏障,但是理解三种内存屏障为以后学习其他类型的屏障提供了很好的入门介绍。
[1]标准的做法是使用多个级别的缓存,一个较小的一级缓存靠近CPU,具有单周期访问时间;一个较大的二级缓存具有较长的访问时间,可能大约有10个时钟周期。性能更好的CPU通常有三层甚至四层缓存。
[2]参见卡尔等人著作的[CSG99]第670和671页,分别介绍了SGI Origin2000和Sequent NUMA-Q(现在是IBM的)的9种状态图和26种状态图。这两个图都比实际简单得多。
[3]将缓存行从一个CPU的缓存传输到另一个CPU的缓存所需的时间通常比执行简单的寄存器到寄存器指令所需的时间多几个数量级。
[4]希望详细了解真实硬件架构的读者可以参考CPU供应商的手册 [SW95, Adv02, Int02b, IBM94, LSH02, SPA94, Int04b,Int04a, Int04c], Gharachorloo的论文[Gha95],或者Peter Sewell的作品[Sew]。
[CSG99]《并行计算机架构: 硬件/软件相结合的设计与分析方法》作者 卡尔,辛格,古普塔,1999年摩根考夫曼。 一个充满了关于并行机器和算法的细节的宝藏。正如马克·希尔在书的封面上幽默地评论的那样,这本书包含的信息比大多数研究论文都要多。
转载于:https://juejin.im/post/5c4719576fb9a049fb43fe55