mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-16 06:03:45 +08:00
mod
This commit is contained in:
102
极客时间专栏/Go语言核心36讲/模块三:Go语言实战与应用/23 | 测试的基本规则和流程 (上).md
Normal file
102
极客时间专栏/Go语言核心36讲/模块三:Go语言实战与应用/23 | 测试的基本规则和流程 (上).md
Normal file
@@ -0,0 +1,102 @@
|
||||
<audio id="audio" title="23 | 测试的基本规则和流程 (上)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c5/c3/c54162aa99b40342c4e04653c0ca74c3.mp3"></audio>
|
||||
|
||||
你好,我是郝林,今天我分享的主题是:测试的基本规则和流程(上)。
|
||||
|
||||
你很棒,已经学完了本专栏最大的一个模块!这涉及了Go语言的所有内建数据类型,以及非常有特色的那些流程和语句。
|
||||
|
||||
你已经完全可以去独立编写各种各样的Go程序了。如果忘了什么,回到之前的文章再复习一下就好了。
|
||||
|
||||
在接下来的日子里,我将带你去学习在Go语言编程进阶的道路上,必须掌握的附加知识,比如:Go程序测试、程序监测,以及Go语言标准库中各种常用代码包的正确用法。
|
||||
|
||||
从上个世纪到今日今时,程序员们,尤其是国内的程序员们,都对编写程序乐此不疲,甚至废寝忘食(比如我自己就是一个例子)。
|
||||
|
||||
因为这是我们普通人训练自我、改变生活、甚至改变世界的一种特有的途径。不过,同样是程序,我们却往往对编写用于测试的程序敬而远之。这是为什么呢?
|
||||
|
||||
我个人感觉,从人的本性来讲,我们都或多或少会否定“对自我的否定”。我们不愿意看到我们编写的程序有Bug(即程序错误或缺陷),尤其是刚刚倾注心血编写的,并且信心满满交付的程序。
|
||||
|
||||
不过,我想说的是,**人是否会进步以及进步得有多快,依赖的恰恰就是对自我的否定,这包括否定的深刻与否,以及否定自我的频率如何。这其实就是“不破不立”这个词表达的含义。**
|
||||
|
||||
对于程序和软件来讲,尽早发现问题、修正问题其实非常重要。在这个网络互联的大背景下,我们所做的程序、工具或者软件产品往往可以被散布得更快、更远。但是,与此同时,它们的错误和缺陷也会是这样,并且可能在短时间内就会影响到成千上万甚至更多的用户。
|
||||
|
||||
你可能会说:“在开源模式下这就是优势啊,我就是要让更多的人帮我发现错误甚至修正错误,我们还可以一起协作、共同维护程序。”但这其实是两码事,协作者往往是由早期或核心的用户转换过来的,但绝对不能说程序的用户就肯定会成为协作者。
|
||||
|
||||
当有很多用户开始对程序抱怨的时候,很可能就预示着你对此的人设要崩塌了。你会发现,或者总有一天会发现,越是人们关注和喜爱的程序,它的测试(尤其是自动化的测试)做得就越充分,测试流程就越规范。
|
||||
|
||||
即使你想众人拾柴火焰高,那也得先让别人喜欢上你的程序。况且,对于优良的程序和软件来说,测试必然是非常受重视的一个环节。所以,尽快用测试为你的程序建起堡垒吧!
|
||||
|
||||
对于程序或软件的测试也分很多种,比如:单元测试、API测试、集成测试、灰度测试,等等。我在本模块会主要针对单元测试进行讲解。
|
||||
|
||||
## 前导内容:go程序测试基础知识
|
||||
|
||||
我们来说一下单元测试,它又称程序员测试。顾名思义,这就是程序员们本该做的自我检查工作之一。
|
||||
|
||||
Go语言的缔造者们从一开始就非常重视程序测试,并且为Go程序的开发者们提供了丰富的API和工具。利用这些API和工具,我们可以创建测试源码文件,并为命令源码文件和库源码文件中的程序实体,编写测试用例。
|
||||
|
||||
在Go语言中,一个测试用例往往会由一个或多个测试函数来代表,不过在大多数情况下,每个测试用例仅用一个测试函数就足够了。测试函数往往用于描述和保障某个程序实体的某方面功能,比如,该功能在正常情况下会因什么样的输入,产生什么样的输出,又比如,该功能会在什么情况下报错或表现异常,等等。
|
||||
|
||||
我们可以为Go程序编写三类测试,即:功能测试(test)、基准测试(benchmark,也称性能测试),以及示例测试(example)。
|
||||
|
||||
对于前两类测试,从名称上你就应该可以猜到它们的用途。而示例测试严格来讲也是一种功能测试,只不过它更关注程序打印出来的内容。
|
||||
|
||||
一般情况下,一个测试源码文件只会针对于某个命令源码文件,或库源码文件(以下简称被测源码文件)做测试,所以我们总会(并且应该)把它们放在同一个代码包内。
|
||||
|
||||
测试源码文件的主名称应该以被测源码文件的主名称为前导,并且必须以“_test”为后缀。例如,如果被测源码文件的名称为demo52.go,那么针对它的测试源码文件的名称就应该是demo52_test.go。
|
||||
|
||||
每个测试源码文件都必须至少包含一个测试函数。并且,从语法上讲,每个测试源码文件中,都可以包含用来做任何一类测试的测试函数,即使把这三类测试函数都塞进去也没有问题。我通常就是这么做的,只要把控好测试函数的分组和数量就可以了。
|
||||
|
||||
我们可以依据这些测试函数针对的不同程序实体,把它们分成不同的逻辑组,并且,利用注释以及帮助类的变量或函数来做分割。同时,我们还可以依据被测源码文件中程序实体的先后顺序,来安排测试源码文件中测试函数的顺序。
|
||||
|
||||
此外,不仅仅对测试源码文件的名称,对于测试函数的名称和签名,Go语言也是有明文规定的。你知道这个规定的内容吗?
|
||||
|
||||
**所以,我们今天的问题就是:Go语言对测试函数的名称和签名都有哪些规定?**
|
||||
|
||||
**这里我给出的典型回答是下面三个内容。**
|
||||
|
||||
- 对于功能测试函数来说,其名称必须以`Test`为前缀,并且参数列表中只应有一个`*testing.T`类型的参数声明。
|
||||
- 对于性能测试函数来说,其名称必须以`Benchmark`为前缀,并且唯一参数的类型必须是`*testing.B`类型的。
|
||||
- 对于示例测试函数来说,其名称必须以`Example`为前缀,但对函数的参数列表没有强制规定。
|
||||
|
||||
## 问题解析
|
||||
|
||||
我问这个问题的目的一般有两个。
|
||||
|
||||
<li>
|
||||
第一个目的当然是考察Go程序测试的基本规则。如果你经常编写测试源码文件,那么这道题应该是很容易回答的。
|
||||
</li>
|
||||
<li>
|
||||
第二个目的是作为一个引子,引出第二个问题,即:`go test`命令执行的主要测试流程是什么?不过在这里我就不问你了,我直接说一下答案。
|
||||
</li>
|
||||
|
||||
我们首先需要记住一点,只有测试源码文件的名称对了,测试函数的名称和签名也对了,当我们运行`go test`命令的时候,其中的测试代码才有可能被运行。
|
||||
|
||||
`go test`命令在开始运行时,会先做一些准备工作,比如,确定内部需要用到的命令,检查我们指定的代码包或源码文件的有效性,以及判断我们给予的标记是否合法,等等。
|
||||
|
||||
在准备工作顺利完成之后,`go test`命令就会针对每个被测代码包,依次地进行构建、执行包中符合要求的测试函数,清理临时文件,打印测试结果。这就是通常情况下的主要测试流程。
|
||||
|
||||
请注意上述的“依次”二字。对于每个被测代码包,`go test`命令会串行地执行测试流程中的每个步骤。
|
||||
|
||||
但是,为了加快测试速度,它通常会并发地对多个被测代码包进行功能测试,只不过,在最后打印测试结果的时候,它会依照我们给定的顺序逐个进行,这会让我们感觉到它是在完全串行地执行测试流程。
|
||||
|
||||
另一方面,由于并发的测试会让性能测试的结果存在偏差,所以性能测试一般都是串行进行的。更具体地说,只有在所有构建步骤都做完之后,`go test`命令才会真正地开始进行性能测试。
|
||||
|
||||
并且,下一个代码包性能测试的进行,总会等到上一个代码包性能测试的结果打印完成才会开始,而且性能测试函数的执行也都会是串行的。
|
||||
|
||||
一旦清楚了Go程序测试的具体过程,我们的一些疑惑就自然有了答案。比如,那个名叫`testIntroduce`的测试函数为什么没执行,又比如,为什么即使是简单的性能测试执行起来也会比功能测试慢,等等。
|
||||
|
||||
## 总结
|
||||
|
||||
在本篇文章的一开始,我就试图向你阐释程序测试的重要性。在我经历的公司中起码有一半都不重视程序测试,或者说没有精力去做程序测试。
|
||||
|
||||
尤其是中小型的公司,他们往往完全依靠软件质量保障团队,甚至真正的用户去帮他们测试。在这些情况下,软件错误或缺陷的发现、反馈和修复的周期通常会很长,成本也会很大,也许还会造成很不好的影响。
|
||||
|
||||
Go语言是一门很重视程序测试的编程语言,它不但自带了`testing`包,还有专用于程序测试的命令`go test`。我们要想真正用好一个工具,就需要先了解它的核心逻辑。所以,我今天问你的第一个问题就是关于`go test`命令的基本规则和主要流程的。在知道这些之后,也许你对Go程序测试就会进入更深层次的了解。
|
||||
|
||||
## 思考题
|
||||
|
||||
除了本文中提到的,你还知道或用过`testing.T`类型和`testing.B`类型的哪些方法?它们都是做什么用的?你可以给我留言,我们一起讨论。
|
||||
|
||||
感谢你的收听,我们下次再见。
|
||||
|
||||
[戳此查看Go语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers)
|
||||
|
||||
|
||||
165
极客时间专栏/Go语言核心36讲/模块三:Go语言实战与应用/24 | 测试的基本规则和流程(下).md
Normal file
165
极客时间专栏/Go语言核心36讲/模块三:Go语言实战与应用/24 | 测试的基本规则和流程(下).md
Normal file
@@ -0,0 +1,165 @@
|
||||
<audio id="audio" title="24 | 测试的基本规则和流程(下)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/dd/eb/dddc989129c0726984a2a105db8205eb.mp3"></audio>
|
||||
|
||||
你好,我是郝林。今天我分享的主题是测试的基本规则和流程的(下)篇。
|
||||
|
||||
Go语言是一门很重视程序测试的编程语言,所以在上一篇中,我与你再三强调了程序测试的重要性,同时,也介绍了关于`go test`命令的基本规则和主要流程的内容。今天我们继续分享测试的基本规则和流程。本篇代码和指令较多,你可以点击文章查看原文。
|
||||
|
||||
## 知识扩展
|
||||
|
||||
### 问题 1:怎样解释功能测试的测试结果?
|
||||
|
||||
我们先来看下面的测试命令和结果:
|
||||
|
||||
```
|
||||
$ go test puzzlers/article20/q2
|
||||
ok puzzlers/article20/q2 0.008s
|
||||
|
||||
```
|
||||
|
||||
以`$`符号开头表明此行展现的是我输入的命令。在这里,我输入了`go test puzzlers/article20/q2`,这表示我想对导入路径为`puzzlers/article20/q2`的代码包进行测试。代码下面一行就是此次测试的简要结果。
|
||||
|
||||
这个简要结果有三块内容。最左边的`ok`表示此次测试成功,也就是说没有发现测试结果不如预期的情况。
|
||||
|
||||
当然了,这里全由我们编写的测试代码决定,我们总是认定测试代码本身没有Bug,并且忠诚地落实了我们的测试意图。在测试结果的中间,显示的是被测代码包的导入路径。
|
||||
|
||||
而在最右边,展现的是此次对该代码包的测试所耗费的时间,这里显示的`0.008s`,即8毫秒。不过,当我们紧接着第二次运行这个命令的时候,输出的测试结果会略有不同,如下所示:
|
||||
|
||||
```
|
||||
$ go test puzzlers/article20/q2
|
||||
ok puzzlers/article20/q2 (cached)
|
||||
|
||||
```
|
||||
|
||||
可以看到,结果最右边的不再是测试耗时,而是`(cached)`。这表明,由于测试代码与被测代码都没有任何变动,所以`go test`命令直接把之前缓存测试成功的结果打印出来了。
|
||||
|
||||
go命令通常会缓存程序构建的结果,以便在将来的构建中重用。我们可以通过运行`go env GOCACHE`命令来查看缓存目录的路径。缓存的数据总是能够正确地反映出当时的各种源码文件、构建环境、编译器选项等等的真实情况。
|
||||
|
||||
一旦有任何变动,缓存数据就会失效,go命令就会再次真正地执行操作。所以我们并不用担心打印出的缓存数据不是实时的结果。go命令会定期地删除最近未使用的缓存数据,但是,如果你想手动删除所有的缓存数据,运行一下`go clean -cache`命令就好了。
|
||||
|
||||
对于测试成功的结果,go命令也是会缓存的。运行`go clean -testcache`将会删除所有的测试结果缓存。不过,这样做肯定不会删除任何构建结果缓存。
|
||||
|
||||
>
|
||||
此外,设置环境变量`GODEBUG`的值也可以稍稍地改变go命令的缓存行为。比如,设置值为`gocacheverify=1`将会导致go命令绕过任何的缓存数据,而真正地执行操作并重新生成所有结果,然后再去检查新的结果与现有的缓存数据是否一致。
|
||||
|
||||
|
||||
总之,我们并不用在意缓存数据的存在,因为它们肯定不会妨碍`go test`命令打印正确的测试结果。
|
||||
|
||||
你可能会问,如果测试失败,命令打印的结果将会是怎样的?如果功能测试函数的那个唯一参数被命名为`t`,那么当我们在其中调用`t.Fail`方法时,虽然当前的测试函数会继续执行下去,但是结果会显示该测试失败。如下所示:
|
||||
|
||||
```
|
||||
$ go test puzzlers/article20/q2
|
||||
--- FAIL: TestFail (0.00s)
|
||||
demo53_test.go:49: Failed.
|
||||
FAIL
|
||||
FAIL puzzlers/article20/q2 0.007s
|
||||
|
||||
```
|
||||
|
||||
我们运行的命令与之前是相同的,但是我新增了一个功能测试函数`TestFail`,并在其中调用了`t.Fail`方法。测试结果显示,对被测代码包的测试,由于`TestFail`函数的测试失败而宣告失败。
|
||||
|
||||
注意,对于失败测试的结果,`go test`命令并不会进行缓存,所以,这种情况下的每次测试都会产生全新的结果。另外,如果测试失败了,那么`go test`命令将会导致:失败的测试函数中的常规测试日志一并被打印出来。
|
||||
|
||||
在这里的测试结果中,之所以显示了“demo53_test.go:49: Failed.”这一行,是因为我在`TestFail`函数中的调用表达式`t.Fail()`的下边编写了代码`t.Log("Failed.")`。
|
||||
|
||||
`t.Log`方法以及`t.Logf`方法的作用,就是打印常规的测试日志,只不过当测试成功的时候,`go test`命令就不会打印这类日志了。如果你想在测试结果中看到所有的常规测试日志,那么可以在运行`go test`命令的时候加入标记`-v`。
|
||||
|
||||
>
|
||||
若我们想让某个测试函数在执行的过程中立即失败,则可以在该函数中调用`t.FailNow`方法。
|
||||
我在下面把`TestFail`函数中的`t.Fail()`改为`t.FailNow()`。
|
||||
与`t.Fail()`不同,在`t.FailNow()`执行之后,当前函数会立即终止执行。换句话说,该行代码之后的所有代码都会失去执行机会。在这样修改之后,我再次运行上面的命令,得到的结果如下:
|
||||
|
||||
|
||||
```
|
||||
--- FAIL: TestFail (0.00s)
|
||||
FAIL
|
||||
FAIL puzzlers/article20/q2 0.008s
|
||||
|
||||
```
|
||||
|
||||
>
|
||||
显然,之前显示在结果中的常规测试日志并没有出现在这里。
|
||||
|
||||
|
||||
顺便说一下,如果你想在测试失败的同时打印失败测试日志,那么可以直接调用`t.Error`方法或者`t.Errorf`方法。
|
||||
|
||||
前者相当于`t.Log`方法和`t.Fail`方法的连续调用,而后者也与之类似,只不过它相当于先调用了`t.Logf`方法。
|
||||
|
||||
除此之外,还有`t.Fatal`方法和`t.Fatalf`方法,它们的作用是在打印失败错误日志之后立即终止当前测试函数的执行并宣告测试失败。更具体地说,这相当于它们在最后都调用了`t.FailNow`方法。
|
||||
|
||||
好了,到此为止,你是不是已经会解读功能测试的测试结果了呢?
|
||||
|
||||
### 问题 2:怎样解释性能测试的测试结果?
|
||||
|
||||
性能测试与功能测试的结果格式有很多相似的地方。我们在这里仅关注前者的特殊之处。请看下面的打印结果。
|
||||
|
||||
```
|
||||
$ go test -bench=. -run=^$ puzzlers/article20/q3
|
||||
goos: darwin
|
||||
goarch: amd64
|
||||
pkg: puzzlers/article20/q3
|
||||
BenchmarkGetPrimes-8 500000 2314 ns/op
|
||||
PASS
|
||||
ok puzzlers/article20/q3 1.192s
|
||||
|
||||
```
|
||||
|
||||
我在运行`go test`命令的时候加了两个标记。第一个标记及其值为`-bench=.`,只有有了这个标记,命令才会进行性能测试。该标记的值`.`表明需要执行任意名称的性能测试函数,当然了,函数名称还是要符合Go程序测试的基本规则的。
|
||||
|
||||
第二个标记及其值是`-run=^$`,这个标记用于表明需要执行哪些功能测试函数,这同样也是以函数名称为依据的。该标记的值`^$`意味着:只执行名称为空的功能测试函数,换句话说,不执行任何功能测试函数。
|
||||
|
||||
你可能已经看出来了,这两个标记的值都是正则表达式。实际上,它们只能以正则表达式为值。此外,如果运行`go test`命令的时候不加`-run`标记,那么就会使它执行被测代码包中的所有功能测试函数。
|
||||
|
||||
再来看测试结果,重点说一下倒数第三行的内容。`BenchmarkGetPrimes-8`被称为单个性能测试的名称,它表示命令执行了性能测试函数`BenchmarkGetPrimes`,并且当时所用的最大P数量为`8`。
|
||||
|
||||
最大P数量相当于可以同时运行goroutine的逻辑CPU的最大个数。这里的逻辑CPU,也可以被称为CPU核心,但它并不等同于计算机中真正的CPU核心,只是Go语言运行时系统内部的一个概念,代表着它同时运行goroutine的能力。
|
||||
|
||||
顺便说一句,一台计算机的CPU核心的个数,意味着它能在同一时刻执行多少条程序指令,代表着它并行处理程序指令的能力。
|
||||
|
||||
我们可以通过调用 `runtime.GOMAXPROCS`函数改变最大P数量,也可以在运行`go test`命令时,加入标记`-cpu`来设置一个最大P数量的列表,以供命令在多次测试时使用。
|
||||
|
||||
至于怎样使用这个标记,以及`go test`命令执行的测试流程,会因此做出怎样的改变,我们在下一篇文章中再讨论。
|
||||
|
||||
在性能测试名称右边的是,`go test`命令最后一次执行性能测试函数(即`BenchmarkGetPrimes`函数)的时候,被测函数(即`GetPrimes`函数)被执行的实际次数。这是什么意思呢?
|
||||
|
||||
`go test`命令在执行性能测试函数的时候会给它一个正整数,若该测试函数的唯一参数的名称为`b`,则该正整数就由`b.N`代表。我们应该在测试函数中配合着编写代码,比如:
|
||||
|
||||
```
|
||||
for i := 0; i < b.N; i++ {
|
||||
GetPrimes(1000)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我在一个会迭代`b.N`次的循环中调用了`GetPrimes`函数,并给予它参数值`1000`。`go test`命令会先尝试把`b.N`设置为`1`,然后执行测试函数。
|
||||
|
||||
如果测试函数的执行时间没有超过上限,此上限默认为1秒,那么命令就会改大`b.N`的值,然后再次执行测试函数,如此往复,直到这个时间大于或等于上限为止。
|
||||
|
||||
当某次执行的时间大于或等于上限时,我们就说这是命令此次对该测试函数的最后一次执行。这时的`b.N`的值就会被包含在测试结果中,也就是上述测试结果中的`500000`。
|
||||
|
||||
我们可以简称该值为执行次数,但要注意,它指的是被测函数的执行次数,而不是性能测试函数的执行次数。
|
||||
|
||||
最后再看这个执行次数的右边,`2314 ns/op`表明单次执行`GetPrimes`函数的平均耗时为`2314`纳秒。这其实就是通过将最后一次执行测试函数时的执行时间,除以(被测函数的)执行次数而得出的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/78/69/78d4c73a9aa9d48b59d3fd304d4b2069.png" alt="">
|
||||
|
||||
(性能测试结果的基本解读)
|
||||
|
||||
以上这些,就是对默认情况下的性能测试结果的基本解读。你看明白了吗?
|
||||
|
||||
## 总结
|
||||
|
||||
注意,对于功能测试和性能测试,命令执行测试流程的方式会有些不同。另外一个重要的问题是,我们在与`go test`命令交互时,怎样解读它提供给我们的信息。只有解读正确,你才能知道测试的成功与否,失败的具体原因以及严重程度等等。
|
||||
|
||||
除此之外,对于性能测试,你还需要关注命令输出的计算资源使用提示,以及各种性能度量。
|
||||
|
||||
这两篇的文章中,我们一起学习了不少东西,但是其实还不够。我们只是探讨了`go test`命令以及`testing`包的基本使用方式。
|
||||
|
||||
在下一篇,我们还会讨论更高级的内容。这将涉及`go test`命令的各种标记、`testing`包的更多API,以及更复杂的测试结果。
|
||||
|
||||
## 思考题
|
||||
|
||||
在编写示例测试函数的时候,我们怎样指定预期的打印内容?
|
||||
|
||||
[戳此查看Go语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers)
|
||||
|
||||
|
||||
209
极客时间专栏/Go语言核心36讲/模块三:Go语言实战与应用/25 | 更多的测试手法.md
Normal file
209
极客时间专栏/Go语言核心36讲/模块三:Go语言实战与应用/25 | 更多的测试手法.md
Normal file
@@ -0,0 +1,209 @@
|
||||
<audio id="audio" title="25 | 更多的测试手法" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/15/ba/15b8903ce2bda93a08ec5cbcacbd84ba.mp3"></audio>
|
||||
|
||||
在前面的文章中,我们一起学习了Go程序测试的基础知识和基本测试手法。这主要包括了Go程序测试的基本规则和主要流程、`testing.T`类型和`testing.B`类型的常用方法、`go test`命令的基本使用方式、常规测试结果的解读等等。
|
||||
|
||||
在本篇文章,我会继续为你讲解更多更高级的测试方法。这会涉及`testing`包中更多的API、`go test`命令支持的,更多标记更加复杂的测试结果,以及测试覆盖度分析等等。
|
||||
|
||||
## 前导内容:-cpu的功能
|
||||
|
||||
续接前文。我在前面提到了`go test`命令的标记`-cpu`,它是用来设置测试执行最大P数量的列表的。
|
||||
|
||||
>
|
||||
复习一下,我在讲go语句的时候说过,这里的P是processor的缩写,每个processor都是一个可以承载若干个G,且能够使这些G适时地与M进行对接并得到真正运行的中介。
|
||||
正是由于P的存在,G和M才可以呈现出多对多的关系,并能够及时、灵活地进行组合和分离。
|
||||
这里的G就是goroutine的缩写,可以被理解为Go语言自己实现的用户级线程。M即为machine的缩写,代表着系统级线程,或者说操作系统内核级别的线程。
|
||||
|
||||
|
||||
Go语言并发编程模型中的P,正是goroutine的数量能够数十万计的关键所在。P的数量意味着Go程序背后的运行时系统中,会有多少个用于承载可运行的G的队列存在。
|
||||
|
||||
每一个队列都相当于一条流水线,它会源源不断地把可运行的G输送给空闲的M,并使这两者对接。
|
||||
|
||||
一旦对接完成,被对接的G就真正地运行在操作系统的内核级线程之上了。每条流水线之间虽然会有联系,但都是独立运作的。
|
||||
|
||||
因此,最大P数量就代表着Go语言运行时系统同时运行goroutine的能力,也可以被视为其中逻辑CPU的最大个数。而`go test`命令的`-cpu`标记正是用于设置这个最大个数的。
|
||||
|
||||
也许你已经知道,在默认情况下,最大P数量就等于当前计算机CPU核心的实际数量。
|
||||
|
||||
当然了,前者也可以大于或者小于后者,如此可以在一定程度上模拟拥有不同的CPU核心数的计算机。
|
||||
|
||||
所以,也可以说,使用`-cpu`标记可以模拟:被测程序在计算能力不同计算机中的表现。
|
||||
|
||||
现在,你已经知道了`-cpu`标记的用途及其背后的含义。那么它的具体用法,以及对`go test`命令的影响你是否也清楚呢?
|
||||
|
||||
**我们今天的问题是:怎样设置`-cpu`标记的值,以及它会对测试流程产生什么样的影响?**
|
||||
|
||||
**这里的典型回答是:**
|
||||
|
||||
标记`-cpu`的值应该是一个正整数的列表,该列表的表现形式为:以英文半角逗号分隔的多个整数字面量,比如`1,2,4`。
|
||||
|
||||
针对于此值中的每一个正整数,`go test`命令都会先设置最大P数量为该数,然后再执行测试函数。
|
||||
|
||||
如果测试函数有多个,那么`go test`命令会依照此方式逐个执行。
|
||||
|
||||
>
|
||||
以`1,2,4`为例,`go test`命令会先以`1`,`2`,`4`为最大P数量分别去执行第一个测试函数,之后再用同样的方式执行第二个测试函数,以此类推。
|
||||
|
||||
|
||||
## 问题解析
|
||||
|
||||
实际上,不论我们是否追加了`-cpu`标记,`go test`命令执行测试函数时流程都是相同的,只不过具体执行步骤会略有不同。
|
||||
|
||||
`go test`命令在进行准备工作的时候会读取`-cpu`标记的值,并把它转换为一个以`int`为元素类型的切片,我们也可以称它为逻辑CPU切片。
|
||||
|
||||
如果该命令发现我们并没有追加这个标记,那么就会让逻辑CPU切片只包含一个元素值,即最大P数量的默认值,也就是当前计算机CPU核心的实际数量。
|
||||
|
||||
在准备执行某个测试函数的时候,无论该函数是功能测试函数,还是性能测试函数,`go test`命令都会迭代逻辑CPU切片,并且在每次迭代时,先依据当前的元素值设置最大P数量,然后再去执行测试函数。
|
||||
|
||||
注意,对于性能测试函数来说,这里可能不只执行了一次。你还记得测试函数的执行时间上限,以及那个由`b.N`代表的被测程序的执行次数吗?
|
||||
|
||||
如果你忘了,那么可以再复习一下上篇文章中的第二个扩展问题。概括来讲,`go test`命令每一次对性能测试函数的执行,都是一个探索的过程。它会在测试函数的执行时间上限不变的前提下,尝试找到被测程序的最大执行次数。
|
||||
|
||||
在这个过程中,性能测试函数可能会被执行多次。为了以后描述方便,我们把这样一个探索的过程称为:对性能测试函数的一次探索式执行,这其中包含了对该函数的若干次执行,当然,肯定也包括了对被测程序更多次的执行。
|
||||
|
||||
说到多次执行测试函数,我们就不得不提及另外一个标记,即`-count`。`-count`标记是专门用于重复执行测试函数的。它的值必须大于或等于`0`,并且默认值为`1`。
|
||||
|
||||
如果我们在运行`go test`命令的时候追加了`-count 5`,那么对于每一个测试函数,命令都会在预设的不同条件下(比如不同的最大P数量下)分别重复执行五次。
|
||||
|
||||
如果我们把前文所述的`-cpu`标记、`-count`标记,以及探索式执行联合起来看,就可以用一个公式来描述单个性能测试函数,在`go test`命令的一次运行过程中的执行次数,即:
|
||||
|
||||
```
|
||||
性能测试函数的执行次数 = `-cpu`标记的值中正整数的个数 x `-count`标记的值 x 探索式执行中测试函数的实际执行次数
|
||||
|
||||
```
|
||||
|
||||
对于功能测试函数来说,这个公式会更加简单一些,即:
|
||||
|
||||
```
|
||||
功能测试函数的执行次数 = `-cpu`标记的值中正整数的个数 x `-count`标记的值
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8d/56/8dc543c7ac67dca3dae3eebc53067c56.png" alt="">
|
||||
|
||||
(测试函数的实际执行次数)
|
||||
|
||||
看完了这两个公式,我想,你也许遇到过这种情况,**在对Go程序执行某种自动化测试的过程中,测试日志会显得特别多,而且好多都是重复的。**
|
||||
|
||||
这时,我们首先就应该想到,上面这些导致测试函数多次执行的标记和流程。我们往往需要检查这些标记的使用是否合理、日志记录是否有必要等等,从而对测试日志进行精简。
|
||||
|
||||
比如,对于功能测试函数来说,我们通常没有必要重复执行它,即使是在不同的最大P数量下也是如此。注意,这里所说的重复执行指的是,在被测程序的输入(比如说被测函数的参数值)相同情况下的多次执行。
|
||||
|
||||
有些时候,在输入完全相同的情况下,被测程序会因其他外部环境的不同,而表现出不同的行为。这时我们需要考虑的往往应该是:这个程序在设计上是否合理,而不是通过重复执行测试来检测风险。
|
||||
|
||||
还有些时候,我们的程序会无法避免地依赖一些外部环境,比如数据库或者其他服务。这时,我们依然不应该让测试的反复执行成为检测手段,而应该在测试中通过仿造(mock)外部环境,来规避掉它们的不确定性。
|
||||
|
||||
其实,单元测试的意思就是:对单一的功能模块进行边界清晰的测试,并且不掺杂任何对外部环境的检测。这也是“单元”二字要表达的主要含义。
|
||||
|
||||
正好相反,对于性能测试函数来说,我们常常需要反复地执行,并以此试图抹平当时的计算资源调度的细微差别对被测程序性能的影响。通过`-cpu`标记,我们还能够模拟被测程序在计算能力不同计算机中的性能表现。
|
||||
|
||||
不过要注意,这里设置的最大P数量,最好不要超过当前计算机CPU核心的实际数量。因为一旦超出计算机实际的并行处理能力,Go程序在性能上就无法再得到显著地提升了。
|
||||
|
||||
这就像一个漏斗,不论我们怎样灌水,水的漏出速度总是有限的。更何况,为了管理过多的P,Go语言运行时系统还会耗费额外的计算资源。
|
||||
|
||||
显然,上述模拟得出的程序性能一定是不准确的。不过,这或多或少可以作为一个参考,因为,这样模拟出的性能一般都会低于程序在计算环境中的实际性能。
|
||||
|
||||
好了,关于`-cpu`标记,以及由此引出的`-count`标记和测试函数多次执行的问题,我们就先聊到这里。不过,为了让你再巩固一下前面的知识,我现在给出一段测试结果:
|
||||
|
||||
```
|
||||
pkg: puzzlers/article21/q1
|
||||
BenchmarkGetPrimesWith100-2 10000000 218 ns/op
|
||||
BenchmarkGetPrimesWith100-2 10000000 215 ns/op
|
||||
BenchmarkGetPrimesWith100-4 10000000 215 ns/op
|
||||
BenchmarkGetPrimesWith100-4 10000000 216 ns/op
|
||||
BenchmarkGetPrimesWith10000-2 50000 31523 ns/op
|
||||
BenchmarkGetPrimesWith10000-2 50000 32372 ns/op
|
||||
BenchmarkGetPrimesWith10000-4 50000 32065 ns/op
|
||||
BenchmarkGetPrimesWith10000-4 50000 31936 ns/op
|
||||
BenchmarkGetPrimesWith1000000-2 300 4085799 ns/op
|
||||
BenchmarkGetPrimesWith1000000-2 300 4121975 ns/op
|
||||
BenchmarkGetPrimesWith1000000-4 300 4112283 ns/op
|
||||
BenchmarkGetPrimesWith1000000-4 300 4086174 ns/op
|
||||
|
||||
```
|
||||
|
||||
现在,我希望让你反推一下,我在运行`go test`命令时追加的`-cpu`标记和`-count`标记的值都是什么。反推之后,你可以用实验的方式进行验证。
|
||||
|
||||
## 知识扩展
|
||||
|
||||
### 问题1:`-parallel`标记的作用是什么?
|
||||
|
||||
我们在运行`go test`命令的时候,可以追加标记`-parallel`,该标记的作用是:设置同一个被测代码包中的功能测试函数的最大并发执行数。该标记的默认值是测试运行时的最大P数量(这可以通过调用表达式`runtime.GOMAXPROCS(0)`获得)。
|
||||
|
||||
我在上篇文章中已经说过,对于功能测试,为了加快测试速度,命令通常会并发地测试多个被测代码包。
|
||||
|
||||
但是,在默认情况下,对于同一个被测代码包中的多个功能测试函数,命令会串行地执行它们。除非我们在一些功能测试函数中显式地调用`t.Parallel`方法。
|
||||
|
||||
这个时候,这些包含了`t.Parallel`方法调用的功能测试函数就会被`go test`命令并发地执行,而并发执行的最大数量正是由`-parallel`标记值决定的。不过要注意,同一个功能测试函数的多次执行之间一定是串行的。
|
||||
|
||||
你可以运行命令`go test -v puzzlers/article21/q2`或者`go test -count=2 -v puzzlers/article21/q2`,查看测试结果,然后仔细地体会一下。
|
||||
|
||||
最后,强调一下,`-parallel`标记对性能测试是无效的。当然了,对于性能测试来说,也是可以并发进行的,不过机制上会有所不同。
|
||||
|
||||
概括地讲,这涉及了`b.RunParallel`方法、`b.SetParallelism`方法和`-cpu`标记的联合运用。如果想进一步了解,你可以查看`testing`代码包的文档。([https://golang.google.cn/pkg/testing)](https://golang.google.cn/pkg/testing%EF%BC%89)
|
||||
|
||||
### 问题2:性能测试函数中的计时器是做什么用的?
|
||||
|
||||
如果你看过`testing`包的文档,那么很可能会发现其中的`testing.B`类型有这么几个指针方法:`StartTimer`、`StopTimer`和`ResetTimer`。这些方法都是用于操作当前的性能测试函数专属的计时器的。
|
||||
|
||||
所谓的计时器,是一个逻辑上的概念,它其实是`testing.B`类型中一些字段的统称。这些字段用于记录:当前测试函数在当次执行过程中耗费的时间、分配的堆内存的字节数以及分配次数。
|
||||
|
||||
我在下面会以测试函数的执行时间为例,来说明此计时器的用法。不过,你需要知道的是,这三个方法在开始记录、停止记录或重新记录执行时间的同时,也会对堆内存分配字节数和分配次数的记录起到相同的作用。
|
||||
|
||||
实际上,`go test`命令本身就会用到这样的计时器。当准备执行某个性能测试函数的时候,命令会重置并启动该函数专属的计时器。一旦这个函数执行完毕,命令又会立即停止这个计时器。
|
||||
|
||||
如此一来,命令就能够准确地记录下(我们在前面多次提到的)测试函数执行时间了。然后,命令就会将这个时间与执行时间上限进行比较,并决定是否在改大`b.N`的值之后,再次执行测试函数。
|
||||
|
||||
还记得吗?这就是我在前面讲过的,对性能测试函数的探索式执行。显然,如果我们在测试函数中自行操作这个计时器,就一定会影响到这个探索式执行的结果。也就是说,这会让命令找到被测程序的最大执行次数有所不同。
|
||||
|
||||
请看在demo57_test.go文件中的那个性能测试函数,如下所示:
|
||||
|
||||
```
|
||||
func BenchmarkGetPrimes(b *testing.B) {
|
||||
b.StopTimer()
|
||||
time.Sleep(time.Millisecond * 500) // 模拟某个耗时但与被测程序关系不大的操作。
|
||||
max := 10000
|
||||
b.StartTimer()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
GetPrimes(max)
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
需要注意的是该函数体中的前四行代码。我先停止了当前测试函数的计时器,然后通过调用`time.Sleep`函数,模拟了一个比较耗时的额外操作,并且在给变量`max`赋值之后又启动了该计时器。
|
||||
|
||||
你可以想象一下,我们需要耗费额外的时间去确定`max`变量的值,虽然在后面它会被传入`GetPrimes`函数,但是,针对`GetPrimes`函数本身的性能测试并不应该包含确定参数值的过程。
|
||||
|
||||
因此,我们需要把这个过程所耗费的时间,从当前测试函数的执行时间中去除掉。这样就能够避免这一过程对测试结果的不良影响了。
|
||||
|
||||
每当这个测试函数执行完毕后,`go test`命令拿到的执行时间都只应该包含调用`GetPrimes`函数所耗费的那些时间。只有依据这个时间做出的后续判断,以及找到被测程序的最大执行次数才是准确的。
|
||||
|
||||
在性能测试函数中,我们可以通过对`b.StartTimer`和`b.StopTimer`方法的联合运用,再去除掉任何一段代码的执行时间。
|
||||
|
||||
相比之下,`b.ResetTimer`方法的灵活性就要差一些了,它只能用于:去除在调用它之前那些代码的执行时间。不过,无论在调用它的时候,计时器是不是正在运行,它都可以起作用。
|
||||
|
||||
## 总结
|
||||
|
||||
在本篇文章中,我假设你已经理解了上一篇文章涉及的内容。因此,我在这里围绕着几个可以被`go test`命令接受的重要标记,进一步地阐释了功能测试和性能测试在不同条件下的测试流程。
|
||||
|
||||
其中,比较重要的有最大P数量的含义,`-cpu`标记的作用及其对测试流程的影响,针对性能测试函数的探索式执行的意义,测试函数执行时间的计算方法,以及`-count`标记的用途和适用场景。
|
||||
|
||||
当然了,学会怎样并发地执行多个功能测试函数也是很有必要的。这需要联合运用`-parallel`标记和功能测试函数中的`t.Parallel`方法。
|
||||
|
||||
另外,你还需要知道性能测试函数专属计时器的内涵,以及那三个方法对计时器起到的作用。通过对计时器的操作,我们可以达到精确化性能测试函数的执行时间的目的,从而帮助`go test`命令找到被测程序真实的最大执行次数。
|
||||
|
||||
到这里,我们对Go程序测试的讨论就要告一段落了。我们需要搞清楚的是,`go test`命令所执行的基本测试流程是什么,以及我们可以通过什么样的手段让测试流程产生变化,从而满足我们的测试需求并为我们提供更加充分的测试结果。
|
||||
|
||||
希望你已经从中学到了一些东西,并能够学以致用。
|
||||
|
||||
## 思考题
|
||||
|
||||
`-benchmem`标记和`-benchtime`标记的作用分别是什么?<br>
|
||||
怎样在测试的时候开启测试覆盖度分析?如果开启,会有什么副作用吗?
|
||||
|
||||
关于这两个问题,你都可以参考官方的[go命令文档中的测试标记部分进行](https://golang.google.cn/cmd/go/#hdr-Testing_flags)回答。
|
||||
|
||||
[戳此查看Go语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers)
|
||||
|
||||
|
||||
207
极客时间专栏/Go语言核心36讲/模块三:Go语言实战与应用/26 | sync.Mutex与sync.RWMutex.md
Normal file
207
极客时间专栏/Go语言核心36讲/模块三:Go语言实战与应用/26 | sync.Mutex与sync.RWMutex.md
Normal file
@@ -0,0 +1,207 @@
|
||||
<audio id="audio" title="26 | sync.Mutex与sync.RWMutex" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/08/e5/0835ae30d16b2f864e5781e029b0b7e5.mp3"></audio>
|
||||
|
||||
我在前面用20多篇文章,为你详细地剖析了Go语言本身的一些东西,这包括了基础概念、重要语法、高级数据类型、特色语句、测试方案等等。
|
||||
|
||||
这些都是Go语言为我们提供的最核心的技术。我想,这已经足够让你对Go语言有一个比较深刻的理解了。
|
||||
|
||||
从本篇文章开始,我们将一起探讨Go语言自带标准库中一些比较核心的代码包。这会涉及这些代码包的标准用法、使用禁忌、背后原理以及周边的知识。
|
||||
|
||||
既然Go语言是以独特的并发编程模型傲视群雄的语言,那么我们就先来学习与并发编程关系最紧密的代码包。
|
||||
|
||||
## 前导内容: 竞态条件、临界区与同步工具
|
||||
|
||||
我们首先要看的就是`sync`包。这里的“sync”的中文意思是“同步”。我们下面就从同步讲起。
|
||||
|
||||
相比于Go语言宣扬的“用通讯的方式共享数据”,通过共享数据的方式来传递信息和协调线程运行的做法其实更加主流,毕竟大多数的现代编程语言,都是用后一种方式作为并发编程的解决方案的(这种方案的历史非常悠久,恐怕可以追溯到上个世纪多进程编程时代伊始了)。
|
||||
|
||||
一旦数据被多个线程共享,那么就很可能会产生争用和冲突的情况。这种情况也被称为**竞态条件(race condition)**,这往往会破坏共享数据的一致性。
|
||||
|
||||
共享数据的一致性代表着某种约定,即:多个线程对共享数据的操作总是可以达到它们各自预期的效果。
|
||||
|
||||
如果这个一致性得不到保证,那么将会影响到一些线程中代码和流程的正确执行,甚至会造成某种不可预知的错误。这种错误一般都很难发现和定位,排查起来的成本也是非常高的,所以一定要尽量避免。
|
||||
|
||||
举个例子,同时有多个线程连续向同一个缓冲区写入数据块,如果没有一个机制去协调这些线程的写入操作的话,那么被写入的数据块就很可能会出现错乱。比如,在线程A还没有写完一个数据块的时候,线程B就开始写入另外一个数据块了。
|
||||
|
||||
显然,这两个数据块中的数据会被混在一起,并且已经很难分清了。因此,在这种情况下,我们就需要采取一些措施来协调它们对缓冲区的修改。这通常就会涉及同步。
|
||||
|
||||
概括来讲,**同步的用途有两个,一个是避免多个线程在同一时刻操作同一个数据块,另一个是协调多个线程,以避免它们在同一时刻执行同一个代码块。**
|
||||
|
||||
由于这样的数据块和代码块的背后都隐含着一种或多种资源(比如存储资源、计算资源、I/O资源、网络资源等等),所以我们可以把它们看做是共享资源,或者说共享资源的代表。我们所说的同步其实就是在控制多个线程对共享资源的访问。
|
||||
|
||||
一个线程在想要访问某一个共享资源的时候,需要先申请对该资源的访问权限,并且只有在申请成功之后,访问才能真正开始。
|
||||
|
||||
而当线程对共享资源的访问结束时,它还必须归还对该资源的访问权限,若要再次访问仍需申请。
|
||||
|
||||
你可以把这里所说的访问权限想象成一块令牌,线程一旦拿到了令牌,就可以进入指定的区域,从而访问到资源,而一旦线程要离开这个区域了,就需要把令牌还回去,绝不能把令牌带走。
|
||||
|
||||
如果针对某个共享资源的访问令牌只有一块,那么在同一时刻,就最多只能有一个线程进入到那个区域,并访问到该资源。
|
||||
|
||||
这时,我们可以说,多个并发运行的线程对这个共享资源的访问是完全串行的。只要一个代码片段需要实现对共享资源的串行化访问,就可以被视为一个临界区(critical section),也就是我刚刚说的,由于要访问到资源而必须进入的那个区域。
|
||||
|
||||
比如,在我前面举的那个例子中,实现了数据块写入操作的代码就共同组成了一个临界区。如果针对同一个共享资源,这样的代码片段有多个,那么它们就可以被称为相关临界区。
|
||||
|
||||
它们可以是一个内含了共享数据的结构体及其方法,也可以是操作同一块共享数据的多个函数。临界区总是需要受到保护的,否则就会产生竞态条件。**施加保护的重要手段之一,就是使用实现了某种同步机制的工具,也称为同步工具。**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/73/6c/73d3313640e62bb95855d40c988c2e6c.png" alt="">
|
||||
|
||||
(竞态条件、临界区与同步工具)
|
||||
|
||||
**在Go语言中,可供我们选择的同步工具并不少。其中,最重要且最常用的同步工具当属互斥量(mutual exclusion,简称mutex)。**`sync`包中的`Mutex`就是与其对应的类型,该类型的值可以被称为互斥量或者互斥锁。
|
||||
|
||||
一个互斥锁可以被用来保护一个临界区或者一组相关临界区。我们可以通过它来保证,在同一时刻只有一个goroutine处于该临界区之内。
|
||||
|
||||
为了兑现这个保证,每当有goroutine想进入临界区时,都需要先对它进行锁定,并且,每个goroutine离开临界区时,都要及时地对它进行解锁。
|
||||
|
||||
锁定操作可以通过调用互斥锁的`Lock`方法实现,而解锁操作可以调用互斥锁的`Unlock`方法。以下是demo58.go文件中重点代码经过简化之后的片段:
|
||||
|
||||
```
|
||||
mu.Lock()
|
||||
_, err := writer.Write([]byte(data))
|
||||
if err != nil {
|
||||
log.Printf("error: %s [%d]", err, id)
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
```
|
||||
|
||||
你可能已经看出来了,这里的互斥锁就相当于我们前面说的那块访问令牌。那么,我们怎样才能用好这块访问令牌呢?请看下面的问题。
|
||||
|
||||
**我们今天的问题是:我们使用互斥锁时有哪些注意事项?**
|
||||
|
||||
这里有一个典型回答。
|
||||
|
||||
使用互斥锁的注意事项如下:
|
||||
|
||||
1. 不要重复锁定互斥锁;
|
||||
1. 不要忘记解锁互斥锁,必要时使用`defer`语句;
|
||||
1. 不要对尚未锁定或者已解锁的互斥锁解锁;
|
||||
1. 不要在多个函数之间直接传递互斥锁。
|
||||
|
||||
## 问题解析
|
||||
|
||||
首先,你还是要把互斥锁看作是针对某一个临界区或某一组相关临界区的唯一访问令牌。
|
||||
|
||||
虽然没有任何强制规定来限制,你用同一个互斥锁保护多个无关的临界区,但是这样做,一定会让你的程序变得很复杂,并且也会明显地增加你的心智负担。
|
||||
|
||||
你要知道,对一个已经被锁定的互斥锁进行锁定,是会立即阻塞当前的goroutine的。这个goroutine所执行的流程,会一直停滞在调用该互斥锁的`Lock`方法的那行代码上。
|
||||
|
||||
直到该互斥锁的`Unlock`方法被调用,并且这里的锁定操作成功完成,后续的代码(也就是临界区中的代码)才会开始执行。这也正是互斥锁能够保护临界区的原因所在。
|
||||
|
||||
一旦,你把一个互斥锁同时用在了多个地方,就必然会有更多的goroutine争用这把锁。这不但会让你的程序变慢,还会大大增加死锁(deadlock)的可能性。
|
||||
|
||||
所谓的死锁,指的就是当前程序中的主goroutine,以及我们启用的那些goroutine都已经被阻塞。这些goroutine可以被统称为用户级的goroutine。这就相当于整个程序都已经停滞不前了。
|
||||
|
||||
Go语言运行时系统是不允许这种情况出现的,只要它发现所有的用户级goroutine都处于等待状态,就会自行抛出一个带有如下信息的panic:
|
||||
|
||||
```
|
||||
fatal error: all goroutines are asleep - deadlock!
|
||||
|
||||
```
|
||||
|
||||
**注意,这种由Go语言运行时系统自行抛出的panic都属于致命错误,都是无法被恢复的,调用`recover`函数对它们起不到任何作用。也就是说,一旦产生死锁,程序必然崩溃。**
|
||||
|
||||
因此,我们一定要尽量避免这种情况的发生。而最简单、有效的方式就是让每一个互斥锁都只保护一个临界区或一组相关临界区。
|
||||
|
||||
在这个前提之下,我们还需要注意,对于同一个goroutine而言,既不要重复锁定一个互斥锁,也不要忘记对它进行解锁。
|
||||
|
||||
一个goroutine对某一个互斥锁的重复锁定,就意味着它自己锁死了自己。先不说这种做法本身就是错误的,在这种情况下,想让其他的goroutine来帮它解锁是非常难以保证其正确性的。
|
||||
|
||||
我以前就在团队代码库中见到过这样的代码。那个作者的本意是先让一个goroutine自己锁死自己,然后再让一个负责调度的goroutine定时地解锁那个互斥锁,从而让前一个goroutine周期性地去做一些事情,比如每分钟检查一次服务器状态,或者每天清理一次日志。
|
||||
|
||||
这个想法本身是没有什么问题的,但却选错了实现的工具。对于互斥锁这种需要精细化控制的同步工具而言,这样的任务并不适合它。
|
||||
|
||||
在这种情况下,即使选用通道或者`time.Ticker`类型,然后自行实现功能都是可以的,程序的复杂度和我们的心智负担也会小很多,更何况还有不少已经很完备的解决方案可供选择。
|
||||
|
||||
话说回来,其实我们说“不要忘记解锁互斥锁”的一个很重要的原因就是:**避免重复锁定。**
|
||||
|
||||
因为在一个goroutine执行的流程中,可能会出现诸如“锁定、解锁、再锁定、再解锁”的操作,所以如果我们忘记了中间的解锁操作,那就一定会造成重复锁定。
|
||||
|
||||
除此之外,忘记解锁还会使其他的goroutine无法进入到该互斥锁保护的临界区,这轻则会导致一些程序功能的失效,重则会造成死锁和程序崩溃。
|
||||
|
||||
在很多时候,一个函数执行的流程并不是单一的,流程中间可能会有分叉,也可能会被中断。
|
||||
|
||||
如果一个流程在锁定了某个互斥锁之后分叉了,或者有被中断的可能,那么就应该使用`defer`语句来对它进行解锁,而且这样的`defer`语句应该紧跟在锁定操作之后。这是最保险的一种做法。
|
||||
|
||||
忘记解锁导致的问题有时候是比较隐秘的,并不会那么快就暴露出来。这也是我们需要特别关注它的原因。相比之下,解锁未锁定的互斥锁会立即引发panic。
|
||||
|
||||
并且,与死锁导致的panic一样,它们是无法被恢复的。**因此,我们总是应该保证,对于每一个锁定操作,都要有且只有一个对应的解锁操作。**
|
||||
|
||||
换句话说,我们应该让它们成对出现。这也算是互斥锁的一个很重要的使用原则了。在很多时候,利用`defer`语句进行解锁可以更容易做到这一点。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4f/0d/4f86467d09ffca6e0c02602a9cb7480d.png" alt="">
|
||||
|
||||
(互斥锁的重复锁定和重复解锁)
|
||||
|
||||
最后,可能你已经知道,Go语言中的互斥锁是开箱即用的。换句话说,一旦我们声明了一个`sync.Mutex`类型的变量,就可以直接使用它了。
|
||||
|
||||
不过要注意,该类型是一个结构体类型,属于值类型中的一种。把它传给一个函数、将它从函数中返回、把它赋给其他变量、让它进入某个通道都会导致它的副本的产生。
|
||||
|
||||
并且,原值和它的副本,以及多个副本之间都是完全独立的,它们都是不同的互斥锁。
|
||||
|
||||
如果你把一个互斥锁作为参数值传给了一个函数,那么在这个函数中对传入的锁的所有操作,都不会对存在于该函数之外的那个原锁产生任何的影响。
|
||||
|
||||
所以,你在这样做之前,一定要考虑清楚,这种结果是你想要的吗?我想,在大多数情况下应该都不是。即使你真的希望,在这个函数中使用另外一个互斥锁也不要这样做,这主要是为了避免歧义。
|
||||
|
||||
以上这些,就是我想要告诉你的关于互斥锁的锁定、解锁,以及传递方面的知识。这其中还包括了我的一些理解。希望能够对你有用。相关的例子我已经写在demo59.go文件中了,你可以去阅读一番,并运行起来看看。
|
||||
|
||||
## 知识扩展
|
||||
|
||||
问题1:读写锁与互斥锁有哪些异同?
|
||||
|
||||
读写锁是读/写互斥锁的简称。在Go语言中,读写锁由`sync.RWMutex`类型的值代表。与`sync.Mutex`类型一样,这个类型也是开箱即用的。
|
||||
|
||||
顾名思义,读写锁是把对共享资源的“读操作”和“写操作”区别对待了。它可以对这两种操作施加不同程度的保护。换句话说,相比于互斥锁,读写锁可以实现更加细腻的访问控制。
|
||||
|
||||
一个读写锁中实际上包含了两个锁,即:读锁和写锁。`sync.RWMutex`类型中的`Lock`方法和`Unlock`方法分别用于对写锁进行锁定和解锁,而它的`RLock`方法和`RUnlock`方法则分别用于对读锁进行锁定和解锁。
|
||||
|
||||
另外,对于同一个读写锁来说有如下规则。
|
||||
|
||||
1. 在写锁已被锁定的情况下再试图锁定写锁,会阻塞当前的goroutine。
|
||||
1. 在写锁已被锁定的情况下试图锁定读锁,也会阻塞当前的goroutine。
|
||||
1. 在读锁已被锁定的情况下试图锁定写锁,同样会阻塞当前的goroutine。
|
||||
1. 在读锁已被锁定的情况下再试图锁定读锁,并不会阻塞当前的goroutine。
|
||||
|
||||
换一个角度来说,对于某个受到读写锁保护的共享资源,多个写操作不能同时进行,写操作和读操作也不能同时进行,但多个读操作却可以同时进行。
|
||||
|
||||
当然了,只有在我们正确使用读写锁的情况下,才能达到这种效果。还是那句话,我们需要让每一个锁都只保护一个临界区,或者一组相关临界区,并以此尽量减少误用的可能性。顺便说一句,我们通常把这种不能同时进行的操作称为互斥操作。
|
||||
|
||||
再来看另一个方面。对写锁进行解锁,会唤醒“所有因试图锁定读锁,而被阻塞的goroutine”,并且,这通常会使它们都成功完成对读锁的锁定。
|
||||
|
||||
然而,对读锁进行解锁,只会在没有其他读锁锁定的前提下,唤醒“因试图锁定写锁,而被阻塞的goroutine”;并且,最终只会有一个被唤醒的goroutine能够成功完成对写锁的锁定,其他的goroutine还要在原处继续等待。至于是哪一个goroutine,那就要看谁的等待时间最长了。
|
||||
|
||||
除此之外,读写锁对写操作之间的互斥,其实是通过它内含的一个互斥锁实现的。因此,也可以说,Go语言的读写锁是互斥锁的一种扩展。
|
||||
|
||||
最后,需要强调的是,与互斥锁类似,解锁“读写锁中未被锁定的写锁”,会立即引发panic,对于其中的读锁也是如此,并且同样是不可恢复的。
|
||||
|
||||
总之,读写锁与互斥锁的不同,都源于它把对共享资源的写操作和读操作区别对待了。这也使得它实现的互斥规则要更复杂一些。
|
||||
|
||||
不过,正因为如此,我们可以使用它对共享资源的操作,实行更加细腻的控制。另外,由于这里的读写锁是互斥锁的一种扩展,所以在有些方面它还是沿用了互斥锁的行为模式。比如,在解锁未锁定的写锁或读锁时的表现,又比如,对写操作之间互斥的实现方式。
|
||||
|
||||
## 总结
|
||||
|
||||
我们今天讨论了很多与多线程、共享资源以及同步有关的知识。其中涉及了不少重要的并发编程概念,比如,竞态条件、临界区、互斥量、死锁等。
|
||||
|
||||
虽然Go语言是以“用通讯的方式共享数据”为亮点的,但是它依然提供了一些易用的同步工具。其中,互斥锁是我们最常用到的一个。
|
||||
|
||||
互斥锁常常被用来:保证多个goroutine并发地访问同一个共享资源时的完全串行,这是通过保护针对此共享资源的一个临界区,或一组相关临界区实现的。因此,我们可以把它看做是goroutine进入相关临界区时,必须拿到的访问令牌。
|
||||
|
||||
为了用对并且用好互斥锁,我们需要了解它实现的互斥规则,更要理解一些关于它的注意事项。
|
||||
|
||||
比如,不要重复锁定或忘记解锁,因为这会造成goroutine不必要的阻塞,甚至导致程序的死锁。
|
||||
|
||||
又比如,不要传递互斥锁,因为这会产生它的副本,从而引起歧义并可能导致互斥操作的失效。
|
||||
|
||||
再次强调,我们总是应该让每一个互斥锁都只保护一个临界区,或一组相关临界区。
|
||||
|
||||
至于读写锁,它是互斥锁的一种扩展。我们需要知道它与互斥锁的异同,尤其是互斥规则和行为模式方面的异同。一个读写锁中同时包含了读锁和写锁,由此也可以看出它对于针对共享资源的读操作和写操作是区别对待的。我们可以基于这件事,对共享资源实施更加细致的访问控制。
|
||||
|
||||
最后,需要特别注意的是,无论是互斥锁还是读写锁,我们都不要试图去解锁未锁定的锁,因为这样会引发不可恢复的panic。
|
||||
|
||||
## 思考题
|
||||
|
||||
1. 你知道互斥锁和读写锁的指针类型都实现了哪一个接口吗?
|
||||
1. 怎样获取读写锁中的读锁?
|
||||
|
||||
[戳此查看Go语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers)
|
||||
|
||||
|
||||
143
极客时间专栏/Go语言核心36讲/模块三:Go语言实战与应用/27 | 条件变量sync.Cond (上).md
Normal file
143
极客时间专栏/Go语言核心36讲/模块三:Go语言实战与应用/27 | 条件变量sync.Cond (上).md
Normal file
@@ -0,0 +1,143 @@
|
||||
<audio id="audio" title="27 | 条件变量sync.Cond (上)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ec/48/ece047da1672d0fe5ea940d15ffd5048.mp3"></audio>
|
||||
|
||||
在上篇文章中,我们主要说的是互斥锁,今天我和你来聊一聊条件变量(conditional variable)。
|
||||
|
||||
## 前导内容:条件变量与互斥锁
|
||||
|
||||
我们常常会把条件变量这个同步工具拿来与互斥锁一起讨论。实际上,条件变量是基于互斥锁的,它必须有互斥锁的支撑才能发挥作用。
|
||||
|
||||
条件变量并不是被用来保护临界区和共享资源的,它是用于协调想要访问共享资源的那些线程的。当共享资源的状态发生变化时,它可以被用来通知被互斥锁阻塞的线程。
|
||||
|
||||
比如说,我们两个人在共同执行一项秘密任务,这需要在不直接联系和见面的前提下进行。我需要向一个信箱里放置情报,你需要从这个信箱中获取情报。这个信箱就相当于一个共享资源,而我们就分别是进行写操作的线程和进行读操作的线程。
|
||||
|
||||
如果我在放置的时候发现信箱里还有未被取走的情报,那就不再放置,而先返回。另一方面,如果你在获取的时候发现信箱里没有情报,那也只能先回去了。这就相当于写的线程或读的线程阻塞的情况。
|
||||
|
||||
虽然我们俩都有信箱的钥匙,但是同一时刻只能有一个人插入钥匙并打开信箱,这就是锁的作用了。更何况咱们俩是不能直接见面的,所以这个信箱本身就可以被视为一个临界区。
|
||||
|
||||
尽管没有协调好,咱们俩仍然要想方设法的完成任务啊。所以,如果信箱里有情报,而你却迟迟未取走,那我就需要每过一段时间带着新情报去检查一次,若发现信箱空了,我就需要及时地把新情报放到里面。
|
||||
|
||||
另一方面,如果信箱里一直没有情报,那你也要每过一段时间去打开看看,一旦有了情报就及时地取走。这么做是可以的,但就是太危险了,很容易被敌人发现。
|
||||
|
||||
后来,我们又想了一个计策,各自雇佣了一个不起眼的小孩儿。如果早上七点有一个戴红色帽子的小孩儿从你家楼下路过,那么就意味着信箱里有了新情报。另一边,如果上午九点有一个戴蓝色帽子的小孩儿从我家楼下路过,那就说明你已经从信箱中取走了情报。
|
||||
|
||||
这样一来,咱们执行任务的隐蔽性高多了,并且效率的提升非常显著。这两个戴不同颜色帽子的小孩儿就相当于条件变量,在共享资源的状态产生变化的时候,起到了通知的作用。
|
||||
|
||||
当然了,我们是在用Go语言编写程序,而不是在执行什么秘密任务。因此,条件变量在这里的最大优势就是在效率方面的提升。当共享资源的状态不满足条件的时候,想操作它的线程再也不用循环往复地做检查了,只要等待通知就好了。
|
||||
|
||||
说到这里,想考考你知道怎么使用条件变量吗?所以,**我们今天的问题就是:条件变量怎样与互斥锁配合使用?**
|
||||
|
||||
**这道题的典型回答是:条件变量的初始化离不开互斥锁,并且它的方法有的也是基于互斥锁的。**
|
||||
|
||||
条件变量提供的方法有三个:等待通知(wait)、单发通知(signal)和广播通知(broadcast)。
|
||||
|
||||
我们在利用条件变量等待通知的时候,需要在它基于的那个互斥锁保护下进行。而在进行单发通知或广播通知的时候,却是恰恰相反的,也就是说,需要在对应的互斥锁解锁之后再做这两种操作。
|
||||
|
||||
## 问题解析
|
||||
|
||||
这个问题看起来很简单,但其实可以基于它,延伸出很多其他的问题。比如,每个方法的使用时机是什么?又比如,每个方法执行的内部流程是怎样的?
|
||||
|
||||
下面,我们一边用代码实现前面那个例子,一边讨论条件变量的使用。
|
||||
|
||||
首先,我们先来创建如下几个变量。
|
||||
|
||||
```
|
||||
var mailbox uint8
|
||||
var lock sync.RWMutex
|
||||
sendCond := sync.NewCond(&lock)
|
||||
recvCond := sync.NewCond(lock.RLocker())
|
||||
|
||||
```
|
||||
|
||||
**变量`mailbox`代表信箱,是`uint8`类型的。** 若它的值为`0`则表示信箱中没有情报,而当它的值为`1`时则说明信箱中有情报。`lock`是一个类型为`sync.RWMutex`的变量,是一个读写锁,也可以被视为信箱上的那把锁。
|
||||
|
||||
另外,基于这把锁,我还创建了两个代表条件变量的变量,**名字分别叫`sendCond`和`recvCond`。** 它们都是`*sync.Cond`类型的,同时也都是由`sync.NewCond`函数来初始化的。
|
||||
|
||||
与`sync.Mutex`类型和`sync.RWMutex`类型不同,`sync.Cond`类型并不是开箱即用的。我们只能利用`sync.NewCond`函数创建它的指针值。这个函数需要一个`sync.Locker`类型的参数值。
|
||||
|
||||
还记得吗?我在前面说过,条件变量是基于互斥锁的,它必须有互斥锁的支撑才能够起作用。因此,这里的参数值是不可或缺的,它会参与到条件变量的方法实现当中。
|
||||
|
||||
`sync.Locker`其实是一个接口,在它的声明中只包含了两个方法定义,即:`Lock()`和`Unlock()`。`sync.Mutex`类型和`sync.RWMutex`类型都拥有`Lock`方法和`Unlock`方法,只不过它们都是指针方法。因此,这两个类型的指针类型才是`sync.Locker`接口的实现类型。
|
||||
|
||||
我在为`sendCond`变量做初始化的时候,把基于`lock`变量的指针值传给了`sync.NewCond`函数。
|
||||
|
||||
原因是,**`lock`变量的`Lock`方法和`Unlock`方法分别用于对其中写锁的锁定和解锁,它们与`sendCond`变量的含义是对应的。**`sendCond`是专门为放置情报而准备的条件变量,向信箱里放置情报,可以被视为对共享资源的写操作。
|
||||
|
||||
相应的,**`recvCond`变量代表的是专门为获取情报而准备的条件变量。** 虽然获取情报也会涉及对信箱状态的改变,但是好在做这件事的人只会有你一个,而且我们也需要借此了解一下,条件变量与读写锁中的读锁的联用方式。所以,在这里,我们暂且把获取情报看做是对共享资源的读操作。
|
||||
|
||||
因此,为了初始化`recvCond`这个条件变量,我们需要的是`lock`变量中的读锁,并且还需要是`sync.Locker`类型的。
|
||||
|
||||
可是,`lock`变量中用于对读锁进行锁定和解锁的方法却是`RLock`和`RUnlock`,它们与`sync.Locker`接口中定义的方法并不匹配。
|
||||
|
||||
好在`sync.RWMutex`类型的`RLocker`方法可以实现这一需求。我们只要在调用`sync.NewCond`函数时,传入调用表达式`lock.RLocker()`的结果值,就可以使该函数返回符合要求的条件变量了。
|
||||
|
||||
为什么说通过`lock.RLocker()`得来的值就是`lock`变量中的读锁呢?实际上,这个值所拥有的`Lock`方法和`Unlock`方法,在其内部会分别调用`lock`变量的`RLock`方法和`RUnlock`方法。也就是说,前两个方法仅仅是后两个方法的代理而已。
|
||||
|
||||
好了,我们现在有四个变量。一个是代表信箱的`mailbox`,一个是代表信箱上的锁的`lock`。还有两个是,代表了蓝帽子小孩儿的`sendCond`,以及代表了红帽子小孩儿的`recvCond`。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/36/5d/3619456ade9d45a4d9c0fbd22bb6fd5d.png" alt="">
|
||||
|
||||
(互斥锁与条件变量)
|
||||
|
||||
我,现在是一个goroutine(携带的`go`函数),想要适时地向信箱里放置情报并通知你,应该怎么做呢?
|
||||
|
||||
```
|
||||
lock.Lock()
|
||||
for mailbox == 1 {
|
||||
sendCond.Wait()
|
||||
}
|
||||
mailbox = 1
|
||||
lock.Unlock()
|
||||
recvCond.Signal()
|
||||
|
||||
```
|
||||
|
||||
我肯定需要先调用`lock`变量的`Lock`方法。注意,这个`Lock`方法在这里意味的是:持有信箱上的锁,并且有打开信箱的权利,而不是锁上这个锁。
|
||||
|
||||
然后,我要检查`mailbox`变量的值是否等于`1`,也就是说,要看看信箱里是不是还存有情报。如果还有情报,那么我就回家去等蓝帽子小孩儿了。
|
||||
|
||||
这就是那条`for`语句以及其中的调用表达式`sendCond.Wait()`所表示的含义了。你可能会问,为什么这里是`for`语句而不是`if`语句呢?我在后面会对此进行解释的。
|
||||
|
||||
我们再往后看,如果信箱里没有情报,那么我就把新情报放进去,关上信箱、锁上锁,然后离开。用代码表达出来就是`mailbox = 1`和`lock.Unlock()`。
|
||||
|
||||
离开之后我还要做一件事,那就是让红帽子小孩儿准时去你家楼下路过。也就是说,我会及时地通知你“信箱里已经有新情报了”,我们调用`recvCond`的`Signal`方法就可以实现这一步骤。
|
||||
|
||||
另一方面,你现在是另一个goroutine,想要适时地从信箱中获取情报,然后通知我。
|
||||
|
||||
```
|
||||
lock.RLock()
|
||||
for mailbox == 0 {
|
||||
recvCond.Wait()
|
||||
}
|
||||
mailbox = 0
|
||||
lock.RUnlock()
|
||||
sendCond.Signal()
|
||||
|
||||
```
|
||||
|
||||
你跟我做的事情在流程上其实基本一致,只不过每一步操作的对象是不同的。你需要调用的是`lock`变量的`RLock`方法。因为你要进行的是读操作,并且会使用`recvCond`变量作为辅助。`recvCond`与`lock`变量的读锁是对应的。
|
||||
|
||||
在打开信箱后,你要关注的是信箱里是不是没有情报,也就是检查`mailbox`变量的值是否等于`0`。如果它确实等于`0`,那么你就需要回家去等红帽子小孩儿,也就是调用`recvCond`的`Wait`方法。这里使用的依然是`for`语句。
|
||||
|
||||
如果信箱里有情报,那么你就应该取走情报,关上信箱、锁上锁,然后离开。对应的代码是`mailbox = 0`和`lock.RUnlock()`。之后,你还需要让蓝帽子小孩儿准时去我家楼下路过。这样我就知道信箱中的情报已经被你获取了。
|
||||
|
||||
以上这些,就是对咱们俩要执行秘密任务的代码实现。其中的条件变量的用法需要你特别注意。
|
||||
|
||||
再强调一下,只要条件不满足,我就会通过调用`sendCond`变量的`Wait`方法,去等待你的通知,只有在收到通知之后我才会再次检查信箱。
|
||||
|
||||
另外,当我需要通知你的时候,我会调用`recvCond`变量的`Signal`方法。你使用这两个条件变量的方式正好与我相反。你可能也看出来了,利用条件变量可以实现单向的通知,而双向的通知则需要两个条件变量。这也是条件变量的基本使用规则。
|
||||
|
||||
你可以打开demo61.go文件,看到上述例子的全部实现代码。
|
||||
|
||||
## 总结
|
||||
|
||||
我们这两期的文章会围绕条件变量的内容展开,条件变量是基于互斥锁的一种同步工具,它必须有互斥锁的支撑才能发挥作用。 条件变量可以协调那些想要访问共享资源的线程。当共享资源的状态发生变化时,它可以被用来通知被互斥锁阻塞的线程。我在文章举了一个两人访问信箱的例子,并用代码实现了这个过程。
|
||||
|
||||
## 思考题
|
||||
|
||||
`*sync.Cond`类型的值可以被传递吗?那`sync.Cond`类型的值呢?
|
||||
|
||||
感谢你的收听,我们下期再见。
|
||||
|
||||
[戳此查看Go语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers)
|
||||
|
||||
|
||||
91
极客时间专栏/Go语言核心36讲/模块三:Go语言实战与应用/28 | 条件变量sync.Cond (下).md
Normal file
91
极客时间专栏/Go语言核心36讲/模块三:Go语言实战与应用/28 | 条件变量sync.Cond (下).md
Normal file
@@ -0,0 +1,91 @@
|
||||
<audio id="audio" title="28 | 条件变量sync.Cond (下)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/dc/c1/dc69b4e352ee74948a122e27450197c1.mp3"></audio>
|
||||
|
||||
你好,我是郝林,今天我继续分享条件变量sync.Cond的内容。我们紧接着上一篇的内容进行知识扩展。
|
||||
|
||||
## 问题 1:条件变量的`Wait`方法做了什么?
|
||||
|
||||
在了解了条件变量的使用方式之后,你可能会有这么几个疑问。
|
||||
|
||||
1. 为什么先要锁定条件变量基于的互斥锁,才能调用它的`Wait`方法?
|
||||
1. 为什么要用`for`语句来包裹调用其`Wait`方法的表达式,用`if`语句不行吗?
|
||||
|
||||
这些问题我在面试的时候也经常问。你需要对这个`Wait`方法的内部机制有所了解才能回答上来。
|
||||
|
||||
条件变量的`Wait`方法主要做了四件事。
|
||||
|
||||
1. 把调用它的goroutine(也就是当前的goroutine)加入到当前条件变量的通知队列中。
|
||||
1. 解锁当前的条件变量基于的那个互斥锁。
|
||||
1. 让当前的goroutine处于等待状态,等到通知到来时再决定是否唤醒它。此时,这个goroutine就会阻塞在调用这个`Wait`方法的那行代码上。
|
||||
1. 如果通知到来并且决定唤醒这个goroutine,那么就在唤醒它之后重新锁定当前条件变量基于的互斥锁。自此之后,当前的goroutine就会继续执行后面的代码了。
|
||||
|
||||
你现在知道我刚刚说的第一个疑问的答案了吗?
|
||||
|
||||
因为条件变量的`Wait`方法在阻塞当前的goroutine之前,会解锁它基于的互斥锁,所以在调用该`Wait`方法之前,我们必须先锁定那个互斥锁,否则在调用这个`Wait`方法时,就会引发一个不可恢复的panic。
|
||||
|
||||
为什么条件变量的`Wait`方法要这么做呢?你可以想象一下,如果`Wait`方法在互斥锁已经锁定的情况下,阻塞了当前的goroutine,那么又由谁来解锁呢?别的goroutine吗?
|
||||
|
||||
先不说这违背了互斥锁的重要使用原则,即:成对的锁定和解锁,就算别的goroutine可以来解锁,那万一解锁重复了怎么办?由此引发的panic可是无法恢复的。
|
||||
|
||||
如果当前的goroutine无法解锁,别的goroutine也都不来解锁,那么又由谁来进入临界区,并改变共享资源的状态呢?只要共享资源的状态不变,即使当前的goroutine因收到通知而被唤醒,也依然会再次执行这个`Wait`方法,并再次被阻塞。
|
||||
|
||||
所以说,如果条件变量的`Wait`方法不先解锁互斥锁的话,那么就只会造成两种后果:不是当前的程序因panic而崩溃,就是相关的goroutine全面阻塞。
|
||||
|
||||
再解释第二个疑问。很显然,`if`语句只会对共享资源的状态检查一次,而`for`语句却可以做多次检查,直到这个状态改变为止。那为什么要做多次检查呢?
|
||||
|
||||
**这主要是为了保险起见。如果一个goroutine因收到通知而被唤醒,但却发现共享资源的状态,依然不符合它的要求,那么就应该再次调用条件变量的`Wait`方法,并继续等待下次通知的到来。**
|
||||
|
||||
这种情况是很有可能发生的,具体如下面所示。
|
||||
|
||||
<li>
|
||||
有多个goroutine在等待共享资源的同一种状态。比如,它们都在等`mailbox`变量的值不为`0`的时候再把它的值变为`0`,这就相当于有多个人在等着我向信箱里放置情报。虽然等待的goroutine有多个,但每次成功的goroutine却只可能有一个。别忘了,条件变量的`Wait`方法会在当前的goroutine醒来后先重新锁定那个互斥锁。在成功的goroutine最终解锁互斥锁之后,其他的goroutine会先后进入临界区,但它们会发现共享资源的状态依然不是它们想要的。这个时候,`for`循环就很有必要了。
|
||||
</li>
|
||||
<li>
|
||||
共享资源可能有的状态不是两个,而是更多。比如,`mailbox`变量的可能值不只有`0`和`1`,还有`2`、`3`、`4`。这种情况下,由于状态在每次改变后的结果只可能有一个,所以,在设计合理的前提下,单一的结果一定不可能满足所有goroutine的条件。那些未被满足的goroutine显然还需要继续等待和检查。
|
||||
</li>
|
||||
<li>
|
||||
有一种可能,共享资源的状态只有两个,并且每种状态都只有一个goroutine在关注,就像我们在主问题当中实现的那个例子那样。不过,即使是这样,使用`for`语句仍然是有必要的。原因是,在一些多CPU核心的计算机系统中,即使没有收到条件变量的通知,调用其`Wait`方法的goroutine也是有可能被唤醒的。这是由计算机硬件层面决定的,即使是操作系统(比如Linux)本身提供的条件变量也会如此。
|
||||
</li>
|
||||
|
||||
综上所述,在包裹条件变量的`Wait`方法的时候,我们总是应该使用`for`语句。
|
||||
|
||||
好了,到这里,关于条件变量的`Wait`方法,我想你知道的应该已经足够多了。
|
||||
|
||||
## 问题 2:条件变量的`Signal`方法和`Broadcast`方法有哪些异同?
|
||||
|
||||
条件变量的`Signal`方法和`Broadcast`方法都是被用来发送通知的,不同的是,前者的通知只会唤醒一个因此而等待的goroutine,而后者的通知却会唤醒所有为此等待的goroutine。
|
||||
|
||||
条件变量的`Wait`方法总会把当前的goroutine添加到通知队列的队尾,而它的`Signal`方法总会从通知队列的队首开始,查找可被唤醒的goroutine。所以,因`Signal`方法的通知,而被唤醒的goroutine一般都是最早等待的那一个。
|
||||
|
||||
这两个方法的行为决定了它们的适用场景。如果你确定只有一个goroutine在等待通知,或者只需唤醒任意一个goroutine就可以满足要求,那么使用条件变量的`Signal`方法就好了。
|
||||
|
||||
否则,使用`Broadcast`方法总没错,只要你设置好各个goroutine所期望的共享资源状态就可以了。
|
||||
|
||||
此外,再次强调一下,与`Wait`方法不同,条件变量的`Signal`方法和`Broadcast`方法并不需要在互斥锁的保护下执行。恰恰相反,我们最好在解锁条件变量基于的那个互斥锁之后,再去调用它的这两个方法。这更有利于程序的运行效率。
|
||||
|
||||
最后,请注意,条件变量的通知具有即时性。也就是说,如果发送通知的时候没有goroutine为此等待,那么该通知就会被直接丢弃。在这之后才开始等待的goroutine只可能被后面的通知唤醒。
|
||||
|
||||
你可以打开demo62.go文件,并仔细观察它与demo61.go的不同。尤其是`lock`变量的类型,以及发送通知的方式。
|
||||
|
||||
## 总结
|
||||
|
||||
我们今天主要讲了条件变量,它是基于互斥锁的一种同步工具。在Go语言中,我们需要用`sync.NewCond`函数来初始化一个`sync.Cond`类型的条件变量。
|
||||
|
||||
`sync.NewCond`函数需要一个`sync.Locker`类型的参数值。
|
||||
|
||||
`*sync.Mutex`类型的值以及`*sync.RWMutex`类型的值都可以满足这个要求。都可以满足这个要求。另外,后者的`RLocker`方法可以返回这个值中的读锁,也同样可以作为`sync.NewCond`函数的参数值,如此就可以生成与读写锁中的读锁对应的条件变量了。
|
||||
|
||||
条件变量的`Wait`方法需要在它基于的互斥锁保护下执行,否则就会引发不可恢复的panic。此外,我们最好使用`for`语句来检查共享资源的状态,并包裹对条件变量的`Wait`方法的调用。
|
||||
|
||||
不要用`if`语句,因为它不能重复地执行“检查状态-等待通知-被唤醒”的这个流程。重复执行这个流程的原因是,一个“因为等待通知,而被阻塞”的goroutine,可能会在共享资源的状态不满足其要求的情况下被唤醒。
|
||||
|
||||
条件变量的`Signal`方法只会唤醒一个因等待通知而被阻塞的goroutine,而它的`Broadcast`方法却可以唤醒所有为此而等待的goroutine。后者比前者的适应场景要多得多。
|
||||
|
||||
这两个方法并不需要受到互斥锁的保护,我们也最好不要在解锁互斥锁之前调用它们。还有,条件变量的通知具有即时性。当通知被发送的时候,如果没有任何goroutine需要被唤醒,那么该通知就会立即失效。
|
||||
|
||||
## 思考题
|
||||
|
||||
`sync.Cond`类型中的公开字段`L`是做什么用的?我们可以在使用条件变量的过程中改变这个字段的值吗?
|
||||
|
||||
[戳此查看Go语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers)
|
||||
|
||||
|
||||
92
极客时间专栏/Go语言核心36讲/模块三:Go语言实战与应用/29 | 原子操作(上).md
Normal file
92
极客时间专栏/Go语言核心36讲/模块三:Go语言实战与应用/29 | 原子操作(上).md
Normal file
@@ -0,0 +1,92 @@
|
||||
<audio id="audio" title="29 | 原子操作(上)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/06/3a/06e95abc524474dcaa4bf2844565583a.mp3"></audio>
|
||||
|
||||
我们在前两篇文章中讨论了互斥锁、读写锁以及基于它们的条件变量,先来总结一下。
|
||||
|
||||
互斥锁是一个很有用的同步工具,它可以保证每一时刻进入临界区的goroutine只有一个。读写锁对共享资源的写操作和读操作则区别看待,并消除了读操作之间的互斥。
|
||||
|
||||
条件变量主要是用于协调想要访问共享资源的那些线程。当共享资源的状态发生变化时,它可以被用来通知被互斥锁阻塞的线程,它既可以基于互斥锁,也可以基于读写锁。当然了,读写锁也是一种互斥锁,前者是对后者的扩展。
|
||||
|
||||
通过对互斥锁的合理使用,我们可以使一个goroutine在执行临界区中的代码时,不被其他的goroutine打扰。不过,虽然不会被打扰,但是它仍然可能会被中断(interruption)。
|
||||
|
||||
## 前导内容:原子性执行与原子操作
|
||||
|
||||
我们已经知道,对于一个Go程序来说,Go语言运行时系统中的调度器会恰当地安排其中所有的goroutine的运行。不过,在同一时刻,只可能有少数的goroutine真正地处于运行状态,并且这个数量只会与M的数量一致,而不会随着G的增多而增长。
|
||||
|
||||
所以,为了公平起见,调度器总是会频繁地换上或换下这些goroutine。**换上**的意思是,让一个goroutine由非运行状态转为运行状态,并促使其中的代码在某个CPU核心上执行。
|
||||
|
||||
**换下**的意思正好相反,即:使一个goroutine中的代码中断执行,并让它由运行状态转为非运行状态。
|
||||
|
||||
这个中断的时机有很多,任何两条语句执行的间隙,甚至在某条语句执行的过程中都是可以的。
|
||||
|
||||
即使这些语句在临界区之内也是如此。所以,我们说,互斥锁虽然可以保证临界区中代码的串行执行,但却不能保证这些代码执行的原子性(atomicity)。
|
||||
|
||||
在众多的同步工具中,真正能够保证原子性执行的只有[原子操作](https://baike.baidu.com/item/%E5%8E%9F%E5%AD%90%E6%93%8D%E4%BD%9C/1880992?fr=aladdin)(atomic operation)。原子操作在进行的过程中是不允许中断的。在底层,这会由CPU提供芯片级别的支持,所以绝对有效。即使在拥有多CPU核心,或者多CPU的计算机系统中,原子操作的保证也是不可撼动的。
|
||||
|
||||
这使得原子操作可以完全地消除竞态条件,并能够绝对地保证并发安全性。并且,它的执行速度要比其他的同步工具快得多,通常会高出好几个数量级。不过,它的缺点也很明显。
|
||||
|
||||
**更具体地说,正是因为原子操作不能被中断,所以它需要足够简单,并且要求快速。**
|
||||
|
||||
你可以想象一下,如果原子操作迟迟不能完成,而它又不会被中断,那么将会给计算机执行指令的效率带来多么大的影响。因此,操作系统层面只对针对二进制位或整数的原子操作提供了支持。
|
||||
|
||||
Go语言的原子操作当然是基于CPU和操作系统的,所以它也只针对少数数据类型的值提供了原子操作函数。这些函数都存在于标准库代码包`sync/atomic`中。
|
||||
|
||||
我一般会通过下面这道题初探一下应聘者对`sync/atomic`包的熟悉程度。
|
||||
|
||||
**我们今天的问题是:`sync/atomic`包中提供了几种原子操作?可操作的数据类型又有哪些?**
|
||||
|
||||
**这里的典型回答是:**
|
||||
|
||||
`sync/atomic`包中的函数可以做的原子操作有:加法(add)、比较并交换(compare and swap,简称CAS)、加载(load)、存储(store)和交换(swap)。
|
||||
|
||||
这些函数针对的数据类型并不多。但是,对这些类型中的每一个,`sync/atomic`包都会有一套函数给予支持。这些数据类型有:`int32`、`int64`、`uint32`、`uint64`、`uintptr`,以及`unsafe`包中的`Pointer`。不过,针对`unsafe.Pointer`类型,该包并未提供进行原子加法操作的函数。
|
||||
|
||||
此外,`sync/atomic`包还提供了一个名为`Value`的类型,它可以被用来存储任意类型的值。
|
||||
|
||||
## 问题解析
|
||||
|
||||
这个问题很简单,因为答案是明摆在代码包文档里的。不过如果你连文档都没看过,那也可能回答不上来,至少是无法做出全面的回答。
|
||||
|
||||
我一般会通过此问题再衍生出来几道题。下面我就来逐个说明一下。
|
||||
|
||||
**第一个衍生问题** :我们都知道,传入这些原子操作函数的第一个参数值对应的都应该是那个被操作的值。比如,`atomic.AddInt32`函数的第一个参数,对应的一定是那个要被增大的整数。可是,这个参数的类型为什么不是`int32`而是`*int32`呢?
|
||||
|
||||
回答是:因为原子操作函数需要的是被操作值的指针,而不是这个值本身;被传入函数的参数值都会被复制,像这种基本类型的值一旦被传入函数,就已经与函数外的那个值毫无关系了。
|
||||
|
||||
所以,传入值本身没有任何意义。`unsafe.Pointer`类型虽然是指针类型,但是那些原子操作函数要操作的是这个指针值,而不是它指向的那个值,所以需要的仍然是指向这个指针值的指针。
|
||||
|
||||
只要原子操作函数拿到了被操作值的指针,就可以定位到存储该值的内存地址。只有这样,它们才能够通过底层的指令,准确地操作这个内存地址上的数据。
|
||||
|
||||
**第二个衍生问题:** 用于原子加法操作的函数可以做原子减法吗?比如,`atomic.AddInt32`函数可以用于减小那个被操作的整数值吗?
|
||||
|
||||
回答是:当然是可以的。`atomic.AddInt32`函数的第二个参数代表差量,它的类型是`int32`,是有符号的。如果我们想做原子减法,那么把这个差量设置为负整数就可以了。
|
||||
|
||||
对于`atomic.AddInt64`函数来说也是类似的。不过,要想用`atomic.AddUint32`和`atomic.AddUint64`函数做原子减法,就不能这么直接了,因为它们的第二个参数的类型分别是`uint32`和`uint64`,都是无符号的,不过,这也是可以做到的,就是稍微麻烦一些。
|
||||
|
||||
例如,如果想对`uint32`类型的被操作值`18`做原子减法,比如说差量是`-3`,那么我们可以先把这个差量转换为有符号的`int32`类型的值,然后再把该值的类型转换为`uint32`,用表达式来描述就是`uint32(int32(-3))`。
|
||||
|
||||
不过要注意,直接这样写会使Go语言的编译器报错,它会告诉你:“常量`-3`不在`uint32`类型可表示的范围内”,换句话说,这样做会让表达式的结果值溢出。
|
||||
|
||||
不过,如果我们先把`int32(-3)`的结果值赋给变量`delta`,再把`delta`的值转换为`uint32`类型的值,就可以绕过编译器的检查并得到正确的结果了。
|
||||
|
||||
最后,我们把这个结果作为`atomic.AddUint32`函数的第二个参数值,就可以达到对`uint32`类型的值做原子减法的目的了。
|
||||
|
||||
还有一种更加直接的方式。我们可以依据下面这个表达式来给定`atomic.AddUint32`函数的第二个参数值:
|
||||
|
||||
```
|
||||
^uint32(-N-1))
|
||||
|
||||
```
|
||||
|
||||
其中的`N`代表由负整数表示的差量。也就是说,我们先要把差量的绝对值减去`1`,然后再把得到的这个无类型的整数常量,转换为`uint32`类型的值,最后,在这个值之上做按位异或操作,就可以获得最终的参数值了。
|
||||
|
||||
这么做的原理也并不复杂。简单来说,此表达式的结果值的补码,与使用前一种方法得到的值的补码相同,所以这两种方式是等价的。我们都知道,整数在计算机中是以补码的形式存在的,所以在这里,结果值的补码相同就意味着表达式的等价。
|
||||
|
||||
## 总结
|
||||
|
||||
今天,我们一起学习了`sync/atomic`代码包中提供的原子操作函数和原子值类型。原子操作函数使用起来都非常简单,但也有一些细节需要我们注意。我在主问题的衍生问题中对它们进行了逐一说明。
|
||||
|
||||
在下一篇文章中,我们会继续分享原子操作的衍生内容。如果你对原子操作有什么样的问题,都可以给我留言,我们一起讨论,感谢你的收听,我们下期再见。
|
||||
|
||||
[戳此查看Go语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers)
|
||||
|
||||
|
||||
130
极客时间专栏/Go语言核心36讲/模块三:Go语言实战与应用/30 | 原子操作(下).md
Normal file
130
极客时间专栏/Go语言核心36讲/模块三:Go语言实战与应用/30 | 原子操作(下).md
Normal file
@@ -0,0 +1,130 @@
|
||||
<audio id="audio" title="30 | 原子操作(下)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6f/4d/6fa3e694ea58b9daf7d0337ff6ab8a4d.mp3"></audio>
|
||||
|
||||
你好,我是郝林,今天我们继续分享原子操作的内容。
|
||||
|
||||
我们接着上一篇文章的内容继续聊,上一篇我们提到了,`sync/atomic`包中的函数可以做的原子操作有:加法(add)、比较并交换(compare and swap,简称CAS)、加载(load)、存储(store)和交换(swap)。并且以此衍生出了两个问题。
|
||||
|
||||
今天我们继续来看**第三个衍生问题: 比较并交换操作与交换操作相比有什么不同?优势在哪里?**
|
||||
|
||||
回答是:比较并交换操作即CAS操作,是有条件的交换操作,只有在条件满足的情况下才会进行值的交换。
|
||||
|
||||
所谓的交换指的是,把新值赋给变量,并返回变量的旧值。
|
||||
|
||||
在进行CAS操作的时候,函数会先判断被操作变量的当前值,是否与我们预期的旧值相等。如果相等,它就把新值赋给该变量,并返回`true`以表明交换操作已进行;否则就忽略交换操作,并返回`false`。
|
||||
|
||||
可以看到,CAS操作并不是单一的操作,而是一种操作组合。这与其他的原子操作都不同。正因为如此,它的用途要更广泛一些。例如,我们将它与`for`语句联用就可以实现一种简易的自旋锁(spinlock)。
|
||||
|
||||
```
|
||||
for {
|
||||
if atomic.CompareAndSwapInt32(&num2, 10, 0) {
|
||||
fmt.Println("The second number has gone to zero.")
|
||||
break
|
||||
}
|
||||
time.Sleep(time.Millisecond * 500)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在`for`语句中的CAS操作可以不停地检查某个需要满足的条件,一旦条件满足就退出`for`循环。这就相当于,只要条件未被满足,当前的流程就会被一直“阻塞”在这里。
|
||||
|
||||
这在效果上与互斥锁有些类似。不过,它们的适用场景是不同的。我们在使用互斥锁的时候,总是假设共享资源的状态会被其他的goroutine频繁地改变。
|
||||
|
||||
而`for`语句加CAS操作的假设往往是:共享资源状态的改变并不频繁,或者,它的状态总会变成期望的那样。这是一种更加乐观,或者说更加宽松的做法。
|
||||
|
||||
**第四个衍生问题:假设我已经保证了对一个变量的写操作都是原子操作,比如:加或减、存储、交换等等,那我对它进行读操作的时候,还有必要使用原子操作吗?**
|
||||
|
||||
回答是:很有必要。其中的道理你可以对照一下读写锁。为什么在读写锁保护下的写操作和读操作之间是互斥的?这是为了防止读操作读到没有被修改完的值,对吗?
|
||||
|
||||
如果写操作还没有进行完,读操作就来读了,那么就只能读到仅修改了一部分的值。这显然破坏了值的完整性,读出来的值也是完全错误的。
|
||||
|
||||
所以,一旦你决定了要对一个共享资源进行保护,那就要做到完全的保护。不完全的保护基本上与不保护没有什么区别。
|
||||
|
||||
好了,上面的主问题以及相关的衍生问题涉及了原子操作函数的用法、原理、对比和一些最佳实践,希望你已经理解了。
|
||||
|
||||
由于这里的原子操作函数只支持非常有限的数据类型,所以在很多应用场景下,互斥锁往往是更加适合的。
|
||||
|
||||
不过,一旦我们确定了在某个场景下可以使用原子操作函数,比如:只涉及并发地读写单一的整数类型值,或者多个互不相关的整数类型值,那就不要再考虑互斥锁了。
|
||||
|
||||
这主要是因为原子操作函数的执行速度要比互斥锁快得多。而且,它们使用起来更加简单,不会涉及临界区的选择,以及死锁等问题。当然了,在使用CAS操作的时候,我们还是要多加注意的,因为它可以被用来模仿锁,并有可能“阻塞”流程。
|
||||
|
||||
## 知识扩展
|
||||
|
||||
问题:怎样用好`sync/atomic.Value`?
|
||||
|
||||
为了扩大原子操作的适用范围,Go语言在1.4版本发布的时候向`sync/atomic`包中添加了一个新的类型`Value`。此类型的值相当于一个容器,可以被用来“原子地”存储和加载任意的值。
|
||||
|
||||
`atomic.Value`类型是开箱即用的,我们声明一个该类型的变量(以下简称原子变量)之后就可以直接使用了。这个类型使用起来很简单,它只有两个指针方法:`Store`和`Load`。不过,虽然简单,但还是有一些值得注意的地方的。
|
||||
|
||||
首先一点,一旦`atomic.Value`类型的值(以下简称原子值)被真正使用,它就不应该再被复制了。什么叫做“真正使用”呢?
|
||||
|
||||
我们只要用它来存储值了,就相当于开始真正使用了。`atomic.Value`类型属于结构体类型,而结构体类型属于值类型。
|
||||
|
||||
所以,复制该类型的值会产生一个完全分离的新值。这个新值相当于被复制的那个值的一个快照。之后,不论后者存储的值怎样改变,都不会影响到前者,反之亦然。
|
||||
|
||||
另外,关于用原子值来存储值,有两条强制性的使用规则。**第一条规则,不能用原子值存储`nil`。**
|
||||
|
||||
也就是说,我们不能把`nil`作为参数值传入原子值的`Store`方法,否则就会引发一个panic。
|
||||
|
||||
这里要注意,如果有一个接口类型的变量,它的动态值是`nil`,但动态类型却不是`nil`,那么它的值就不等于`nil`。我在前面讲接口的时候和你说明过这个问题。正因为如此,这样一个变量的值是可以被存入原子值的。
|
||||
|
||||
**第二条规则,我们向原子值存储的第一个值,决定了它今后能且只能存储哪一个类型的值。**
|
||||
|
||||
例如,我第一次向一个原子值存储了一个`string`类型的值,那我在后面就只能用该原子值来存储字符串了。如果我又想用它存储结构体,那么在调用它的`Store`方法的时候就会引发一个panic。这个panic会告诉我,这次存储的值的类型与之前的不一致。
|
||||
|
||||
你可能会想:我先存储一个接口类型的值,然后再存储这个接口的某个实现类型的值,这样是不是可以呢?
|
||||
|
||||
很可惜,这样是不可以的,同样会引发一个panic。因为原子值内部是依据被存储值的实际类型来做判断的。所以,即使是实现了同一个接口的不同类型,它们的值也不能被先后存储到同一个原子值中。
|
||||
|
||||
遗憾的是,我们无法通过某个方法获知一个原子值是否已经被真正使用,并且,也没有办法通过常规的途径得到一个原子值可以存储值的实际类型。这使得我们误用原子值的可能性大大增加,尤其是在多个地方使用同一个原子值的时候。
|
||||
|
||||
**下面,我给你几条具体的使用建议。**
|
||||
|
||||
1. 不要把内部使用的原子值暴露给外界。比如,声明一个全局的原子变量并不是一个正确的做法。这个变量的访问权限最起码也应该是包级私有的。
|
||||
1. 如果不得不让包外,或模块外的代码使用你的原子值,那么可以声明一个包级私有的原子变量,然后再通过一个或多个公开的函数,让外界间接地使用到它。注意,这种情况下不要把原子值传递到外界,不论是传递原子值本身还是它的指针值。
|
||||
1. 如果通过某个函数可以向内部的原子值存储值的话,那么就应该在这个函数中先判断被存储值类型的合法性。若不合法,则应该直接返回对应的错误值,从而避免panic的发生。
|
||||
1. 如果可能的话,我们可以把原子值封装到一个数据类型中,比如一个结构体类型。这样,我们既可以通过该类型的方法更加安全地存储值,又可以在该类型中包含可存储值的合法类型信息。
|
||||
|
||||
除了上述使用建议之外,我还要再特别强调一点:尽量不要向原子值中存储引用类型的值。因为这很容易造成安全漏洞。请看下面的代码:
|
||||
|
||||
```
|
||||
var box6 atomic.Value
|
||||
v6 := []int{1, 2, 3}
|
||||
box6.Store(v6)
|
||||
v6[1] = 4 // 注意,此处的操作不是并发安全的!
|
||||
|
||||
```
|
||||
|
||||
我把一个`[]int`类型的切片值`v6`,存入了原子值`box6`。注意,切片类型属于引用类型。所以,我在外面改动这个切片值,就等于修改了`box6`中存储的那个值。这相当于绕过了原子值而进行了非并发安全的操作。那么,应该怎样修补这个漏洞呢?可以这样做:
|
||||
|
||||
```
|
||||
store := func(v []int) {
|
||||
replica := make([]int, len(v))
|
||||
copy(replica, v)
|
||||
box6.Store(replica)
|
||||
}
|
||||
store(v6)
|
||||
v6[2] = 5 // 此处的操作是安全的。
|
||||
|
||||
```
|
||||
|
||||
我先为切片值`v6`创建了一个完全的副本。这个副本涉及的数据已经与原值毫不相干了。然后,我再把这个副本存入`box6`。如此一来,无论我再对`v6`的值做怎样的修改,都不会破坏`box6`提供的安全保护。
|
||||
|
||||
以上,就是我要告诉你的关于`atomic.Value`的注意事项和使用建议。你可以在demo64.go文件中看到相应的示例。
|
||||
|
||||
## 总结
|
||||
|
||||
我们把这两篇文章一起总结一下。相对于原子操作函数,原子值类型的优势很明显,但它的使用规则也更多一些。首先,在首次真正使用后,原子值就不应该再被复制了。
|
||||
|
||||
其次,原子值的`Store`方法对其参数值(也就是被存储值)有两个强制的约束。一个约束是,参数值不能为`nil`。另一个约束是,参数值的类型不能与首个被存储值的类型不同。也就是说,一旦一个原子值存储了某个类型的值,那它以后就只能存储这个类型的值了。
|
||||
|
||||
基于上面这几个注意事项,我提出了几条使用建议,包括:不要对外暴露原子变量、不要传递原子值及其指针值、尽量不要在原子值中存储引用类型的值,等等。与之相关的一些解决方案我也一并提出了。希望你能够受用。
|
||||
|
||||
原子操作明显比互斥锁要更加轻便,但是限制也同样明显。所以,我们在进行二选一的时候通常不会太困难。但是原子值与互斥锁之间的选择有时候就需要仔细的考量了。不过,如果你能牢记我今天讲的这些内容的话,应该会有很大的助力。
|
||||
|
||||
## 思考题
|
||||
|
||||
今天的思考题只有一个,那就是:如果要对原子值和互斥锁进行二选一,你认为最重要的三个决策条件应该是什么?
|
||||
|
||||
[戳此查看Go语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers)
|
||||
|
||||
|
||||
174
极客时间专栏/Go语言核心36讲/模块三:Go语言实战与应用/31 | sync.WaitGroup和sync.Once.md
Normal file
174
极客时间专栏/Go语言核心36讲/模块三:Go语言实战与应用/31 | sync.WaitGroup和sync.Once.md
Normal file
@@ -0,0 +1,174 @@
|
||||
<audio id="audio" title="31 | sync.WaitGroup和sync.Once" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6b/7e/6b88fa520c495b8cbb58b1f263fc6f7e.mp3"></audio>
|
||||
|
||||
我们在前几次讲的互斥锁、条件变量和原子操作都是最基本重要的同步工具。在Go语言中,除了通道之外,它们也算是最为常用的并发安全工具了。
|
||||
|
||||
说到通道,不知道你想过没有,之前在一些场合下里,我们使用通道的方式看起来都似乎有些蹩脚。
|
||||
|
||||
比如:**声明一个通道,使它的容量与我们手动启用的goroutine的数量相同,之后再利用这个通道,让主goroutine等待其他goroutine的运行结束。**
|
||||
|
||||
这一步更具体地说就是:让其他的goroutine在运行结束之前,都向这个通道发送一个元素值,并且,让主goroutine在最后从这个通道中接收元素值,接收的次数需要与其他的goroutine的数量相同。
|
||||
|
||||
这就是下面的`coordinateWithChan`函数展示的多goroutine协作流程。
|
||||
|
||||
```
|
||||
func coordinateWithChan() {
|
||||
sign := make(chan struct{}, 2)
|
||||
num := int32(0)
|
||||
fmt.Printf("The number: %d [with chan struct{}]\n", num)
|
||||
max := int32(10)
|
||||
go addNum(&num, 1, max, func() {
|
||||
sign <- struct{}{}
|
||||
})
|
||||
go addNum(&num, 2, max, func() {
|
||||
sign <- struct{}{}
|
||||
})
|
||||
<-sign
|
||||
<-sign
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
其中的`addNum`函数的声明在demo65.go文件中。`addNum`函数会把它接受的最后一个参数值作为其中的`defer`函数。
|
||||
|
||||
我手动启用的两个goroutine都会调用`addNum`函数,而它们传给该函数的最后一个参数值(也就是那个既无参数声明,也无结果声明的函数)都只会做一件事情,那就是向通道`sign`发送一个元素值。
|
||||
|
||||
看到`coordinateWithChan`函数中最后的那两行代码了吗?重复的两个接收表达式`<-sign`,是不是看起来很丑陋?
|
||||
|
||||
## 前导内容:`sync`包的`WaitGroup`类型
|
||||
|
||||
其实,在这种应用场景下,我们可以选用另外一个同步工具,即:`sync`包的`WaitGroup`类型。它比通道更加适合实现这种一对多的goroutine协作流程。
|
||||
|
||||
`sync.WaitGroup`类型(以下简称`WaitGroup`类型)是开箱即用的,也是并发安全的。同时,与我们前面讨论的几个同步工具一样,它一旦被真正使用就不能被复制了。
|
||||
|
||||
`WaitGroup`类型拥有三个指针方法:`Add`、`Done`和`Wait`。你可以想象该类型中有一个计数器,它的默认值是`0`。我们可以通过调用该类型值的`Add`方法来增加,或者减少这个计数器的值。
|
||||
|
||||
一般情况下,我会用这个方法来记录需要等待的goroutine的数量。相对应的,这个类型的`Done`方法,用于对其所属值中计数器的值进行减一操作。我们可以在需要等待的goroutine中,通过`defer`语句调用它。
|
||||
|
||||
而此类型的`Wait`方法的功能是,阻塞当前的goroutine,直到其所属值中的计数器归零。如果在该方法被调用的时候,那个计数器的值就是`0`,那么它将不会做任何事情。
|
||||
|
||||
你可能已经看出来了,`WaitGroup`类型的值(以下简称`WaitGroup`值)完全可以被用来替换`coordinateWithChan`函数中的通道`sign`。下面的`coordinateWithWaitGroup`函数就是它的改造版本。
|
||||
|
||||
```
|
||||
func coordinateWithWaitGroup() {
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
num := int32(0)
|
||||
fmt.Printf("The number: %d [with sync.WaitGroup]\n", num)
|
||||
max := int32(10)
|
||||
go addNum(&num, 3, max, wg.Done)
|
||||
go addNum(&num, 4, max, wg.Done)
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
很明显,整体代码少了好几行,而且看起来也更加简洁了。这里我先声明了一个`WaitGroup`类型的变量`wg`。然后,我调用了它的`Add`方法并传入了`2`,因为我会在后面启用两个需要等待的goroutine。
|
||||
|
||||
由于`wg`变量的`Done`方法本身就是一个既无参数声明,也无结果声明的函数,所以我在`go`语句中调用`addNum`函数的时候,可以直接把该方法作为最后一个参数值传进去。
|
||||
|
||||
在`coordinateWithWaitGroup`函数的最后,我调用了`wg`的`Wait`方法。如此一来,该函数就可以等到那两个goroutine都运行结束之后,再结束执行了。
|
||||
|
||||
以上就是`WaitGroup`类型最典型的应用场景了。不过不能止步于此,对于这个类型,我们还是有必要再深入了解一下的。我们一起看下面的问题。
|
||||
|
||||
**问题:`sync.WaitGroup`类型值中计数器的值可以小于`0`吗?**
|
||||
|
||||
这里的典型回答是:不可以。
|
||||
|
||||
## 问题解析
|
||||
|
||||
为什么不可以呢,我们解析一下。**之所以说`WaitGroup`值中计数器的值不能小于`0`,是因为这样会引发一个panic。** 不适当地调用这类值的`Done`方法和`Add`方法都会如此。别忘了,我们在调用`Add`方法的时候是可以传入一个负数的。
|
||||
|
||||
实际上,导致`WaitGroup`值的方法抛出panic的原因不只这一种。
|
||||
|
||||
你需要知道,在我们声明了这样一个变量之后,应该首先根据需要等待的goroutine,或者其他事件的数量,调用它的`Add`方法,以使计数器的值大于`0`。这是确保我们能在后面正常地使用这类值的前提。
|
||||
|
||||
如果我们对它的`Add`方法的首次调用,与对它的`Wait`方法的调用是同时发起的,比如,在同时启用的两个goroutine中,分别调用这两个方法,**那么就有可能会让这里的`Add`方法抛出一个panic。**
|
||||
|
||||
这种情况不太容易复现,也正因为如此,我们更应该予以重视。所以,虽然`WaitGroup`值本身并不需要初始化,但是尽早地增加其计数器的值,还是非常有必要的。
|
||||
|
||||
另外,你可能已经知道,`WaitGroup`值是可以被复用的,但需要保证其计数周期的完整性。这里的计数周期指的是这样一个过程:该值中的计数器值由`0`变为了某个正整数,而后又经过一系列的变化,最终由某个正整数又变回了`0`。
|
||||
|
||||
也就是说,只要计数器的值始于`0`又归为`0`,就可以被视为一个计数周期。在一个此类值的生命周期中,它可以经历任意多个计数周期。但是,只有在它走完当前的计数周期之后,才能够开始下一个计数周期。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fa/8d/fac7dfa184053d2a95e121aa17141d8d.png" alt=""><br>
|
||||
(sync.WaitGroup的计数周期)
|
||||
|
||||
因此,也可以说,如果一个此类值的`Wait`方法在它的某个计数周期中被调用,那么就会立即阻塞当前的goroutine,直至这个计数周期完成。在这种情况下,该值的下一个计数周期,必须要等到这个`Wait`方法执行结束之后,才能够开始。
|
||||
|
||||
如果在一个此类值的`Wait`方法被执行期间,跨越了两个计数周期,**那么就会引发一个panic。**
|
||||
|
||||
例如,在当前的goroutine因调用此类值的`Wait`方法,而被阻塞的时候,另一个goroutine调用了该值的`Done`方法,并使其计数器的值变为了`0`。
|
||||
|
||||
这会唤醒当前的goroutine,并使它试图继续执行`Wait`方法中其余的代码。但在这时,又有一个goroutine调用了它的`Add`方法,并让其计数器的值又从`0`变为了某个正整数。**此时,这里的`Wait`方法就会立即抛出一个panic。**
|
||||
|
||||
纵观上述会引发panic的后两种情况,我们可以总结出这样一条关于`WaitGroup`值的使用禁忌,即:**不要把增加其计数器值的操作和调用其`Wait`方法的代码,放在不同的goroutine中执行。换句话说,要杜绝对同一个`WaitGroup`值的两种操作的并发执行。**
|
||||
|
||||
除了第一种情况外,我们通常需要反复地实验,才能够让`WaitGroup`值的方法抛出panic。再次强调,虽然这不是每次都发生,但是在长期运行的程序中,这种情况发生的概率还是不小的,我们必须要重视它们。
|
||||
|
||||
如果你对复现这些异常情况感兴趣,那么可以参看`sync`代码包中的waitgroup_test.go文件。其中的名称以`TestWaitGroupMisuse`为前缀的测试函数,很好地展示了这些异常情况的发生条件。你可以模仿这些测试函数自己写一些测试代码,执行一下试试看。
|
||||
|
||||
## 知识扩展
|
||||
|
||||
### 问题:`sync.Once`类型值的`Do`方法是怎么保证只执行参数函数一次的?
|
||||
|
||||
与`sync.WaitGroup`类型一样,`sync.Once`类型(以下简称`Once`类型)也属于结构体类型,同样也是开箱即用和并发安全的。由于这个类型中包含了一个`sync.Mutex`类型的字段,所以,复制该类型的值也会导致功能的失效。
|
||||
|
||||
`Once`类型的`Do`方法只接受一个参数,这个参数的类型必须是`func()`,即:无参数声明和结果声明的函数。
|
||||
|
||||
该方法的功能并不是对每一种参数函数都只执行一次,而是只执行“首次被调用时传入的”那个函数,并且之后不会再执行任何参数函数。
|
||||
|
||||
所以,如果你有多个只需要执行一次的函数,那么就应该为它们中的每一个都分配一个`sync.Once`类型的值(以下简称`Once`值)。
|
||||
|
||||
`Once`类型中还有一个名叫`done`的`uint32`类型的字段。它的作用是记录其所属值的`Do`方法被调用的次数。不过,该字段的值只可能是`0`或者`1`。一旦`Do`方法的首次调用完成,它的值就会从`0`变为`1`。
|
||||
|
||||
你可能会问,既然`done`字段的值不是`0`就是`1`,那为什么还要使用需要四个字节的`uint32`类型呢?
|
||||
|
||||
原因很简单,因为对它的操作必须是“原子”的。`Do`方法在一开始就会通过调用`atomic.LoadUint32`函数来获取该字段的值,并且一旦发现该值为`1`,就会直接返回。这也初步保证了“`Do`方法,只会执行首次被调用时传入的函数”。
|
||||
|
||||
不过,单凭这样一个判断的保证是不够的。因为,如果有两个goroutine都调用了同一个新的`Once`值的`Do`方法,并且几乎同时执行到了其中的这个条件判断代码,那么它们就都会因判断结果为`false`,而继续执行`Do`方法中剩余的代码。
|
||||
|
||||
在这个条件判断之后,`Do`方法会立即锁定其所属值中的那个`sync.Mutex`类型的字段`m`。然后,它会在临界区中再次检查`done`字段的值,并且仅在条件满足时,才会去调用参数函数,以及用原子操作把`done`的值变为`1`。
|
||||
|
||||
如果你熟悉GoF设计模式中的单例模式的话,那么肯定能看出来,这个`Do`方法的实现方式,与那个单例模式有很多相似之处。它们都会先在临界区之外,判断一次关键条件,若条件不满足则立即返回。这通常被称为**“快路径”**,或者叫做**“快速失败路径”。**
|
||||
|
||||
如果条件满足,那么到了临界区中还要再对关键条件进行一次判断,这主要是为了更加严谨。这两次条件判断常被统称为(跨临界区的)“双重检查”。
|
||||
|
||||
由于进入临界区之前,肯定要锁定保护它的互斥锁`m`,显然会降低代码的执行速度,所以其中的第二次条件判断,以及后续的操作就被称为“慢路径”或者“常规路径”。
|
||||
|
||||
别看`Do`方法中的代码不多,但它却应用了一个很经典的编程范式。我们在Go语言及其标准库中,还能看到不少这个经典范式及它衍生版本的应用案例。
|
||||
|
||||
**下面我再来说说这个`Do`方法在功能方面的两个特点。**
|
||||
|
||||
**第一个特点**,由于`Do`方法只会在参数函数执行结束之后把`done`字段的值变为`1`,因此,如果参数函数的执行需要很长时间或者根本就不会结束(比如执行一些守护任务),那么就有可能会导致相关goroutine的同时阻塞。
|
||||
|
||||
例如,有多个goroutine并发地调用了同一个`Once`值的`Do`方法,并且传入的函数都会一直执行而不结束。那么,这些goroutine就都会因调用了这个`Do`方法而阻塞。因为,除了那个抢先执行了参数函数的goroutine之外,其他的goroutine都会被阻塞在锁定该`Once`值的互斥锁`m`的那行代码上。
|
||||
|
||||
**第二个特点**,`Do`方法在参数函数执行结束后,对`done`字段的赋值用的是原子操作,并且,这一操作是被挂在`defer`语句中的。因此,不论参数函数的执行会以怎样的方式结束,`done`字段的值都会变为`1`。
|
||||
|
||||
也就是说,即使这个参数函数没有执行成功(比如引发了一个panic),我们也无法使用同一个`Once`值重新执行它了。所以,如果你需要为参数函数的执行设定重试机制,那么就要考虑`Once`值的适时替换问题。
|
||||
|
||||
在很多时候,我们需要依据`Do`方法的这两个特点来设计与之相关的流程,以避免不必要的程序阻塞和功能缺失。
|
||||
|
||||
## 总结
|
||||
|
||||
`sync`代码包的`WaitGroup`类型和`Once`类型都是非常易用的同步工具。它们都是开箱即用和并发安全的。
|
||||
|
||||
利用`WaitGroup`值,我们可以很方便地实现一对多的goroutine协作流程,即:一个分发子任务的goroutine,和多个执行子任务的goroutine,共同来完成一个较大的任务。
|
||||
|
||||
在使用`WaitGroup`值的时候,我们一定要注意,千万不要让其中的计数器的值小于`0`,否则就会引发panic。
|
||||
|
||||
另外,**我们最好用“先统一`Add`,再并发`Done`,最后`Wait`”这种标准方式,来使用`WaitGroup`值。** 尤其不要在调用`Wait`方法的同时,并发地通过调用`Add`方法去增加其计数器的值,因为这也有可能引发panic。
|
||||
|
||||
`Once`值的使用方式比`WaitGroup`值更加简单,它只有一个`Do`方法。同一个`Once`值的`Do`方法,永远只会执行第一次被调用时传入的参数函数,不论这个函数的执行会以怎样的方式结束。
|
||||
|
||||
只要传入某个`Do`方法的参数函数没有结束执行,任何之后调用该方法的goroutine就都会被阻塞。只有在这个参数函数执行结束以后,那些goroutine才会逐一被唤醒。
|
||||
|
||||
`Once`类型使用互斥锁和原子操作实现了功能,而`WaitGroup`类型中只用到了原子操作。 所以可以说,它们都是更高层次的同步工具。它们都基于基本的通用工具,实现了某一种特定的功能。`sync`包中的其他高级同步工具,其实也都是这样的。
|
||||
|
||||
## 思考题
|
||||
|
||||
今天的思考题是:在使用`WaitGroup`值实现一对多的goroutine协作流程时,怎样才能让分发子任务的goroutine获得各个子任务的具体执行结果?
|
||||
|
||||
[戳此查看Go语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers)
|
||||
|
||||
|
||||
199
极客时间专栏/Go语言核心36讲/模块三:Go语言实战与应用/32 | context.Context类型.md
Normal file
199
极客时间专栏/Go语言核心36讲/模块三:Go语言实战与应用/32 | context.Context类型.md
Normal file
@@ -0,0 +1,199 @@
|
||||
<audio id="audio" title="32 | context.Context类型" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/aa/ff/aa51f1f404677c148c385f61c8c6e9ff.mp3"></audio>
|
||||
|
||||
我们在上篇文章中讲到了`sync.WaitGroup`类型:一个可以帮我们实现一对多goroutine协作流程的同步工具。
|
||||
|
||||
**在使用`WaitGroup`值的时候,我们最好用“先统一`Add`,再并发`Done`,最后`Wait`”的标准模式来构建协作流程。**
|
||||
|
||||
如果在调用该值的`Wait`方法的同时,为了增大其计数器的值,而并发地调用该值的`Add`方法,那么就很可能会引发panic。
|
||||
|
||||
这就带来了一个问题,如果我们不能在一开始就确定执行子任务的goroutine的数量,那么使用`WaitGroup`值来协调它们和分发子任务的goroutine,就是有一定风险的。一个解决方案是:分批地启用执行子任务的goroutine。
|
||||
|
||||
## 前导内容:WaitGroup值补充知识
|
||||
|
||||
我们都知道,`WaitGroup`值是可以被复用的,但需要保证其计数周期的完整性。尤其是涉及对其`Wait`方法调用的时候,它的下一个计数周期必须要等到,与当前计数周期对应的那个`Wait`方法调用完成之后,才能够开始。
|
||||
|
||||
我在前面提到的可能会引发panic的情况,就是由于没有遵循这条规则而导致的。
|
||||
|
||||
只要我们在严格遵循上述规则的前提下,分批地启用执行子任务的goroutine,就肯定不会有问题。具体的实现方式有不少,其中最简单的方式就是使用`for`循环来作为辅助。这里的代码如下:
|
||||
|
||||
```
|
||||
func coordinateWithWaitGroup() {
|
||||
total := 12
|
||||
stride := 3
|
||||
var num int32
|
||||
fmt.Printf("The number: %d [with sync.WaitGroup]\n", num)
|
||||
var wg sync.WaitGroup
|
||||
for i := 1; i <= total; i = i + stride {
|
||||
wg.Add(stride)
|
||||
for j := 0; j < stride; j++ {
|
||||
go addNum(&num, i+j, wg.Done)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
fmt.Println("End.")
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里展示的`coordinateWithWaitGroup`函数,就是上一篇文章中同名函数的改造版本。而其中调用的`addNum`函数,则是上一篇文章中同名函数的简化版本。这两个函数都已被放置在了demo67.go文件中。
|
||||
|
||||
我们可以看到,经过改造后的`coordinateWithWaitGroup`函数,循环地使用了由变量`wg`代表的`WaitGroup`值。它运用的依然是“先统一`Add`,再并发`Done`,最后`Wait`”的这种模式,只不过它利用`for`语句,对此进行了复用。
|
||||
|
||||
好了,至此你应该已经对`WaitGroup`值的运用有所了解了。不过,我现在想让你使用另一种工具来实现上面的协作流程。
|
||||
|
||||
**我们今天的问题就是:怎样使用`context`包中的程序实体,实现一对多的goroutine协作流程?**
|
||||
|
||||
更具体地说,我需要你编写一个名为`coordinateWithContext`的函数。这个函数应该具有上面`coordinateWithWaitGroup`函数相同的功能。
|
||||
|
||||
显然,你不能再使用`sync.WaitGroup`了,而要用`context`包中的函数和`Context`类型作为实现工具。这里注意一点,是否分批启用执行子任务的goroutine其实并不重要。
|
||||
|
||||
我在这里给你一个参考答案。
|
||||
|
||||
```
|
||||
func coordinateWithContext() {
|
||||
total := 12
|
||||
var num int32
|
||||
fmt.Printf("The number: %d [with context.Context]\n", num)
|
||||
cxt, cancelFunc := context.WithCancel(context.Background())
|
||||
for i := 1; i <= total; i++ {
|
||||
go addNum(&num, i, func() {
|
||||
if atomic.LoadInt32(&num) == int32(total) {
|
||||
cancelFunc()
|
||||
}
|
||||
})
|
||||
}
|
||||
<-cxt.Done()
|
||||
fmt.Println("End.")
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这个函数体中,我先后调用了`context.Background`函数和`context.WithCancel`函数,并得到了一个可撤销的`context.Context`类型的值(由变量`cxt`代表),以及一个`context.CancelFunc`类型的撤销函数(由变量`cancelFunc`代表)。
|
||||
|
||||
在后面那条唯一的`for`语句中,我在每次迭代中都通过一条`go`语句,异步地调用`addNum`函数,调用的总次数只依据了`total`变量的值。
|
||||
|
||||
请注意我给予`addNum`函数的最后一个参数值。它是一个匿名函数,其中只包含了一条`if`语句。这条`if`语句会“原子地”加载`num`变量的值,并判断它是否等于`total`变量的值。
|
||||
|
||||
如果两个值相等,那么就调用`cancelFunc`函数。其含义是,如果所有的`addNum`函数都执行完毕,那么就立即通知分发子任务的goroutine。
|
||||
|
||||
这里分发子任务的goroutine,即为执行`coordinateWithContext`函数的goroutine。它在执行完`for`语句后,会立即调用`cxt`变量的`Done`函数,并试图针对该函数返回的通道,进行接收操作。
|
||||
|
||||
由于一旦`cancelFunc`函数被调用,针对该通道的接收操作就会马上结束,所以,这样做就可以实现“等待所有的`addNum`函数都执行完毕”的功能。
|
||||
|
||||
## 问题解析
|
||||
|
||||
`context.Context`类型(以下简称`Context`类型)是在Go 1.7发布时才被加入到标准库的。而后,标准库中的很多其他代码包都为了支持它而进行了扩展,包括:`os/exec`包、`net`包、`database/sql`包,以及`runtime/pprof`包和`runtime/trace`包,等等。
|
||||
|
||||
`Context`类型之所以受到了标准库中众多代码包的积极支持,主要是因为它是一种非常通用的同步工具。它的值不但可以被任意地扩散,而且还可以被用来传递额外的信息和信号。
|
||||
|
||||
更具体地说,`Context`类型可以提供一类代表上下文的值。此类值是并发安全的,也就是说它可以被传播给多个goroutine。
|
||||
|
||||
由于`Context`类型实际上是一个接口类型,而`context`包中实现该接口的所有私有类型,都是基于某个数据类型的指针类型,所以,如此传播并不会影响该类型值的功能和安全。
|
||||
|
||||
`Context`类型的值(以下简称`Context`值)是可以繁衍的,这意味着我们可以通过一个`Context`值产生出任意个子值。这些子值可以携带其父值的属性和数据,也可以响应我们通过其父值传达的信号。
|
||||
|
||||
正因为如此,所有的`Context`值共同构成了一颗代表了上下文全貌的树形结构。这棵树的树根(或者称上下文根节点)是一个已经在`context`包中预定义好的`Context`值,它是全局唯一的。通过调用`context.Background`函数,我们就可以获取到它(我在`coordinateWithContext`函数中就是这么做的)。
|
||||
|
||||
这里注意一下,这个上下文根节点仅仅是一个最基本的支点,它不提供任何额外的功能。也就是说,它既不可以被撤销(cancel),也不能携带任何数据。
|
||||
|
||||
除此之外,`context`包中还包含了四个用于繁衍`Context`值的函数,即:`WithCancel`、`WithDeadline`、`WithTimeout`和`WithValue`。
|
||||
|
||||
这些函数的第一个参数的类型都是`context.Context`,而名称都为`parent`。顾名思义,这个位置上的参数对应的都是它们将会产生的`Context`值的父值。
|
||||
|
||||
`WithCancel`函数用于产生一个可撤销的`parent`的子值。在`coordinateWithContext`函数中,我通过调用该函数,获得了一个衍生自上下文根节点的`Context`值,和一个用于触发撤销信号的函数。
|
||||
|
||||
而`WithDeadline`函数和`WithTimeout`函数则都可以被用来产生一个会定时撤销的`parent`的子值。至于`WithValue`函数,我们可以通过调用它,产生一个会携带额外数据的`parent`的子值。
|
||||
|
||||
到这里,我们已经对`context`包中的函数和`Context`类型有了一个基本的认识了。不过这还不够,我们再来扩展一下。
|
||||
|
||||
## 知识扩展
|
||||
|
||||
### 问题1:“可撤销的”在`context`包中代表着什么?“撤销”一个`Context`值又意味着什么?
|
||||
|
||||
我相信很多初识`context`包的Go程序开发者,都会有这样的疑问。确实,“可撤销的”(cancelable)这个词在这里是比较抽象的,很容易让人迷惑。我这里再来解释一下。
|
||||
|
||||
这需要从`Context`类型的声明讲起。这个接口中有两个方法与“撤销”息息相关。`Done`方法会返回一个元素类型为`struct{}`的接收通道。不过,这个接收通道的用途并不是传递元素值,而是让调用方去感知“撤销”当前`Context`值的那个信号。
|
||||
|
||||
一旦当前的`Context`值被撤销,这里的接收通道就会被立即关闭。我们都知道,对于一个未包含任何元素值的通道来说,它的关闭会使任何针对它的接收操作立即结束。
|
||||
|
||||
正因为如此,在`coordinateWithContext`函数中,基于调用表达式`cxt.Done()`的接收操作,才能够起到感知撤销信号的作用。
|
||||
|
||||
除了让`Context`值的使用方感知到撤销信号,让它们得到“撤销”的具体原因,有时也是很有必要的。后者即是`Context`类型的`Err`方法的作用。该方法的结果是`error`类型的,并且其值只可能等于`context.Canceled`变量的值,或者`context.DeadlineExceeded`变量的值。
|
||||
|
||||
前者用于表示手动撤销,而后者则代表:由于我们给定的过期时间已到,而导致的撤销。
|
||||
|
||||
你可能已经感觉到了,对于`Context`值来说,“撤销”这个词如果当名词讲,指的其实就是被用来表达“撤销”状态的信号;如果当动词讲,指的就是对撤销信号的传达;而“可撤销的”指的则是具有传达这种撤销信号的能力。
|
||||
|
||||
我在前面讲过,当我们通过调用`context.WithCancel`函数产生一个可撤销的`Context`值时,还会获得一个用于触发撤销信号的函数。
|
||||
|
||||
通过调用这个函数,我们就可以触发针对这个`Context`值的撤销信号。一旦触发,撤销信号就会立即被传达给这个`Context`值,并由它的`Done`方法的结果值(一个接收通道)表达出来。
|
||||
|
||||
撤销函数只负责触发信号,而对应的可撤销的`Context`值也只负责传达信号,它们都不会去管后边具体的“撤销”操作。实际上,我们的代码可以在感知到撤销信号之后,进行任意的操作,`Context`值对此并没有任何的约束。
|
||||
|
||||
最后,若再深究的话,这里的“撤销”最原始的含义其实就是,终止程序针对某种请求(比如HTTP请求)的响应,或者取消对某种指令(比如SQL指令)的处理。这也是Go语言团队在创建`context`代码包,和`Context`类型时的初衷。
|
||||
|
||||
如果我们去查看`net`包和`database/sql`包的API和源码的话,就可以了解它们在这方面的典型应用。
|
||||
|
||||
### 问题2:撤销信号是如何在上下文树中传播的?
|
||||
|
||||
我在前面讲了,`context`包中包含了四个用于繁衍`Context`值的函数。其中的`WithCancel`、`WithDeadline`和`WithTimeout`都是被用来基于给定的`Context`值产生可撤销的子值的。
|
||||
|
||||
`context`包的`WithCancel`函数在被调用后会产生两个结果值。第一个结果值就是那个可撤销的`Context`值,而第二个结果值则是用于触发撤销信号的函数。
|
||||
|
||||
在撤销函数被调用之后,对应的`Context`值会先关闭它内部的接收通道,也就是它的`Done`方法会返回的那个通道。
|
||||
|
||||
然后,它会向它的所有子值(或者说子节点)传达撤销信号。这些子值会如法炮制,把撤销信号继续传播下去。最后,这个`Context`值会断开它与其父值之间的关联。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a8/9e/a801f8f2b5e89017ec2857bc1815fc9e.png" alt="">
|
||||
|
||||
(在上下文树中传播撤销信号)
|
||||
|
||||
我们通过调用`context`包的`WithDeadline`函数或者`WithTimeout`函数生成的`Context`值也是可撤销的。它们不但可以被手动撤销,还会依据在生成时被给定的过期时间,自动地进行定时撤销。这里定时撤销的功能是借助它们内部的计时器来实现的。
|
||||
|
||||
当过期时间到达时,这两种`Context`值的行为与`Context`值被手动撤销时的行为是几乎一致的,只不过前者会在最后停止并释放掉其内部的计时器。
|
||||
|
||||
最后要注意,通过调用`context.WithValue`函数得到的`Context`值是不可撤销的。撤销信号在被传播时,若遇到它们则会直接跨过,并试图将信号直接传给它们的子值。
|
||||
|
||||
### 问题 3:怎样通过`Context`值携带数据?怎样从中获取数据?
|
||||
|
||||
既然谈到了`context`包的`WithValue`函数,我们就来说说`Context`值携带数据的方式。
|
||||
|
||||
`WithValue`函数在产生新的`Context`值(以下简称含数据的`Context`值)的时候需要三个参数,即:父值、键和值。与“字典对于键的约束”类似,这里键的类型必须是可判等的。
|
||||
|
||||
原因很简单,当我们从中获取数据的时候,它需要根据给定的键来查找对应的值。不过,这种`Context`值并不是用字典来存储键和值的,后两者只是被简单地存储在前者的相应字段中而已。
|
||||
|
||||
`Context`类型的`Value`方法就是被用来获取数据的。在我们调用含数据的`Context`值的`Value`方法时,它会先判断给定的键,是否与当前值中存储的键相等,如果相等就把该值中存储的值直接返回,否则就到其父值中继续查找。
|
||||
|
||||
如果其父值中仍然未存储相等的键,那么该方法就会沿着上下文根节点的方向一路查找下去。
|
||||
|
||||
注意,除了含数据的`Context`值以外,其他几种`Context`值都是无法携带数据的。因此,`Context`值的`Value`方法在沿路查找的时候,会直接跨过那几种值。
|
||||
|
||||
如果我们调用的`Value`方法的所属值本身就是不含数据的,那么实际调用的就将会是其父辈或祖辈的`Value`方法。这是由于这几种`Context`值的实际类型,都属于结构体类型,并且它们都是通过“将其父值嵌入到自身”,来表达父子关系的。
|
||||
|
||||
最后,提醒一下,`Context`接口并没有提供改变数据的方法。因此,在通常情况下,我们只能通过在上下文树中添加含数据的`Context`值来存储新的数据,或者通过撤销此种值的父值丢弃掉相应的数据。如果你存储在这里的数据可以从外部改变,那么必须自行保证安全。
|
||||
|
||||
## 总结
|
||||
|
||||
我们今天主要讨论的是`context`包中的函数和`Context`类型。该包中的函数都是用于产生新的`Context`类型值的。`Context`类型是一个可以帮助我们实现多goroutine协作流程的同步工具。不但如此,我们还可以通过此类型的值传达撤销信号或传递数据。
|
||||
|
||||
`Context`类型的实际值大体上分为三种,即:根`Context`值、可撤销的`Context`值和含数据的`Context`值。所有的`Context`值共同构成了一颗上下文树。这棵树的作用域是全局的,而根`Context`值就是这棵树的根。它是全局唯一的,并且不提供任何额外的功能。
|
||||
|
||||
可撤销的`Context`值又分为:只可手动撤销的`Context`值,和可以定时撤销的`Context`值。
|
||||
|
||||
我们可以通过生成它们时得到的撤销函数来对其进行手动的撤销。对于后者,定时撤销的时间必须在生成时就完全确定,并且不能更改。不过,我们可以在过期时间达到之前,对其进行手动的撤销。
|
||||
|
||||
一旦撤销函数被调用,撤销信号就会立即被传达给对应的`Context`值,并由该值的`Done`方法返回的接收通道表达出来。
|
||||
|
||||
“撤销”这个操作是`Context`值能够协调多个goroutine的关键所在。撤销信号总是会沿着上下文树叶子节点的方向传播开来。
|
||||
|
||||
含数据的`Context`值可以携带数据。每个值都可以存储一对键和值。在我们调用它的`Value`方法的时候,它会沿着上下文树的根节点的方向逐个值的进行查找。如果发现相等的键,它就会立即返回对应的值,否则将在最后返回`nil`。
|
||||
|
||||
含数据的`Context`值不能被撤销,而可撤销的`Context`值又无法携带数据。但是,由于它们共同组成了一个有机的整体(即上下文树),所以在功能上要比`sync.WaitGroup`强大得多。
|
||||
|
||||
## 思考题
|
||||
|
||||
今天的思考题是:`Context`值在传达撤销信号的时候是广度优先的,还是深度优先的?其优势和劣势都是什么?
|
||||
|
||||
[戳此查看Go语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers)
|
||||
|
||||
|
||||
173
极客时间专栏/Go语言核心36讲/模块三:Go语言实战与应用/33 | 临时对象池sync.Pool.md
Normal file
173
极客时间专栏/Go语言核心36讲/模块三:Go语言实战与应用/33 | 临时对象池sync.Pool.md
Normal file
@@ -0,0 +1,173 @@
|
||||
<audio id="audio" title="33 | 临时对象池sync.Pool" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a7/db/a7cf48a570b80a3e4026b572e4971fdb.mp3"></audio>
|
||||
|
||||
到目前为止,我们已经一起学习了Go语言标准库中最重要的那几个同步工具,这包括非常经典的互斥锁、读写锁、条件变量和原子操作,以及Go语言特有的几个同步工具:
|
||||
|
||||
1. `sync/atomic.Value`;
|
||||
1. `sync.Once`;
|
||||
1. `sync.WaitGroup`
|
||||
1. `context.Context`。
|
||||
|
||||
今天,我们来讲Go语言标准库中的另一个同步工具:`sync.Pool`。
|
||||
|
||||
`sync.Pool`类型可以被称为临时对象池,它的值可以被用来存储临时的对象。与Go语言的很多同步工具一样,`sync.Pool`类型也属于结构体类型,它的值在被真正使用之后,就不应该再被复制了。
|
||||
|
||||
这里的“临时对象”的意思是:不需要持久使用的某一类值。这类值对于程序来说可有可无,但如果有的话会明显更好。它们的创建和销毁可以在任何时候发生,并且完全不会影响到程序的功能。
|
||||
|
||||
同时,它们也应该是无需被区分的,其中的任何一个值都可以代替另一个。如果你的某类值完全满足上述条件,那么你就可以把它们存储到临时对象池中。
|
||||
|
||||
你可能已经想到了,我们可以把临时对象池当作针对某种数据的缓存来用。实际上,在我看来,临时对象池最主要的用途就在于此。
|
||||
|
||||
`sync.Pool`类型只有两个方法——`Put`和`Get`。Put用于在当前的池中存放临时对象,它接受一个`interface{}`类型的参数;而Get则被用于从当前的池中获取临时对象,它会返回一个`interface{}`类型的值。
|
||||
|
||||
更具体地说,这个类型的`Get`方法可能会从当前的池中删除掉任何一个值,然后把这个值作为结果返回。如果此时当前的池中没有任何值,那么这个方法就会使用当前池的`New`字段创建一个新值,并直接将其返回。
|
||||
|
||||
`sync.Pool`类型的`New`字段代表着创建临时对象的函数。它的类型是没有参数但有唯一结果的函数类型,即:`func() interface{}`。
|
||||
|
||||
这个函数是`Get`方法最后的临时对象获取手段。`Get`方法如果到了最后,仍然无法获取到一个值,那么就会调用该函数。该函数的结果值并不会被存入当前的临时对象池中,而是直接返回给`Get`方法的调用方。
|
||||
|
||||
这里的`New`字段的实际值需要我们在初始化临时对象池的时候就给定。否则,在我们调用它的`Get`方法的时候就有可能会得到`nil`。所以,`sync.Pool`类型并不是开箱即用的。不过,这个类型也就只有这么一个公开的字段,因此初始化起来也并不麻烦。
|
||||
|
||||
举个例子。标准库代码包`fmt`就使用到了`sync.Pool`类型。这个包会创建一个用于缓存某类临时对象的`sync.Pool`类型值,并将这个值赋给一个名为`ppFree`的变量。这类临时对象可以识别、格式化和暂存需要打印的内容。
|
||||
|
||||
```
|
||||
var ppFree = sync.Pool{
|
||||
New: func() interface{} { return new(pp) },
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
临时对象池`ppFree`的`New`字段在被调用的时候,总是会返回一个全新的`pp`类型值的指针(即临时对象)。这就保证了`ppFree`的`Get`方法总能返回一个可以包含需要打印内容的值。
|
||||
|
||||
`pp`类型是`fmt`包中的私有类型,它有很多实现了不同功能的方法。不过,这里的重点是,它的每一个值都是独立的、平等的和可重用的。
|
||||
|
||||
>
|
||||
更具体地说,这些对象既互不干扰,又不会受到外部状态的影响。它们几乎只针对某个需要打印内容的缓冲区而已。由于`fmt`包中的代码在真正使用这些临时对象之前,总是会先对其进行重置,所以它们并不在意取到的是哪一个临时对象。这就是临时对象的平等性的具体体现。
|
||||
|
||||
|
||||
另外,这些代码在使用完临时对象之后,都会先抹掉其中已缓冲的内容,然后再把它存放到`ppFree`中。这样就为重用这类临时对象做好了准备。
|
||||
|
||||
众所周知的`fmt.Println`、`fmt.Printf`等打印函数都是如此使用`ppFree`,以及其中的临时对象的。因此,在程序同时执行很多的打印函数调用的时候,`ppFree`可以及时地把它缓存的临时对象提供给它们,以加快执行的速度。
|
||||
|
||||
而当程序在一段时间内不再执行打印函数调用时,`ppFree`中的临时对象又能够被及时地清理掉,以节省内存空间。
|
||||
|
||||
显然,在这个维度上,临时对象池可以帮助程序实现可伸缩性。这就是它的最大价值。
|
||||
|
||||
我想,到了这里你已经清楚了临时对象池的基本功能、使用方式、适用场景和存在意义。我们下面来讨论一下它的一些内部机制,这样,我们就可以更好地利用它做更多的事。
|
||||
|
||||
首先,我来问你一个问题。这个问题很可能也是你想问的。今天的问题是:为什么说临时对象池中的值会被及时地清理掉?
|
||||
|
||||
这里的典型回答是:因为,Go语言运行时系统中的垃圾回收器,所以在每次开始执行之前,都会对所有已创建的临时对象池中的值进行全面地清除。
|
||||
|
||||
## 问题解析
|
||||
|
||||
我在前面已经向你讲述了临时对象会在什么时候被创建,下面我再来详细说说它会在什么时候被销毁。
|
||||
|
||||
`sync`包在被初始化的时候,会向Go语言运行时系统注册一个函数,这个函数的功能就是清除所有已创建的临时对象池中的值。我们可以把它称为池清理函数。
|
||||
|
||||
一旦池清理函数被注册到了Go语言运行时系统,后者在每次即将执行垃圾回收时就都会执行前者。
|
||||
|
||||
另外,在`sync`包中还有一个包级私有的全局变量。这个变量代表了当前的程序中使用的所有临时对象池的汇总,它是元素类型为`*sync.Pool`的切片。我们可以称之为池汇总列表。
|
||||
|
||||
通常,在一个临时对象池的`Put`方法或`Get`方法第一次被调用的时候,这个池就会被添加到池汇总列表中。正因为如此,池清理函数总是能访问到所有正在被真正使用的临时对象池。
|
||||
|
||||
更具体地说,池清理函数会遍历池汇总列表。对于其中的每一个临时对象池,它都会先将池中所有的私有临时对象和共享临时对象列表都置为`nil`,然后再把这个池中的所有本地池列表都销毁掉。
|
||||
|
||||
最后,池清理函数会把池汇总列表重置为空的切片。如此一来,这些池中存储的临时对象就全部被清除干净了。
|
||||
|
||||
如果临时对象池以外的代码再无对它们的引用,那么在稍后的垃圾回收过程中,这些临时对象就会被当作垃圾销毁掉,它们占用的内存空间也会被回收以备他用。
|
||||
|
||||
以上,就是我对临时对象清理的进一步说明。首先需要记住的是,池清理函数和池汇总列表的含义,以及它们起到的关键作用。一旦理解了这些,那么在有人问到你这个问题的时候,你应该就可以从容地应对了。
|
||||
|
||||
不过,我们在这里还碰到了几个新的词,比如:私有临时对象、共享临时对象列表和本地池。这些都代表着什么呢?这就涉及了下面的问题。
|
||||
|
||||
## 知识扩展
|
||||
|
||||
### 问题1:临时对象池存储值所用的数据结构是怎样的?
|
||||
|
||||
在临时对象池中,有一个多层的数据结构。正因为有了它的存在,临时对象池才能够非常高效地存储大量的值。
|
||||
|
||||
这个数据结构的顶层,我们可以称之为本地池列表,不过更确切地说,它是一个数组。这个列表的长度,总是与Go语言调度器中的P的数量相同。
|
||||
|
||||
还记得吗?Go语言调度器中的P是processor的缩写,它指的是一种可以承载若干个G、且能够使这些G适时地与M进行对接,并得到真正运行的中介。
|
||||
|
||||
这里的G正是goroutine的缩写,而M则是machine的缩写,后者指代的是系统级的线程。正因为有了P的存在,G和M才能够进行灵活、高效的配对,从而实现强大的并发编程模型。
|
||||
|
||||
P存在的一个很重要的原因是为了分散并发程序的执行压力,而让临时对象池中的本地池列表的长度与P的数量相同的主要原因也是分散压力。这里所说的压力包括了存储和性能两个方面。在说明它们之前,我们先来探索一下临时对象池中的那个数据结构。
|
||||
|
||||
在本地池列表中的每个本地池都包含了三个字段(或者说组件),它们是:存储私有临时对象的字段`private`、代表了共享临时对象列表的字段`shared`,以及一个`sync.Mutex`类型的嵌入字段。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/82/22/825cae64e0a879faba34c0a157b7ca22.png" alt=""><br>
|
||||
**sync.Pool中的本地池与各个G的对应关系**
|
||||
|
||||
实际上,每个本地池都对应着一个P。我们都知道,一个goroutine要想真正运行就必须先与某个P产生关联。也就是说,一个正在运行的goroutine必然会关联着某个P。
|
||||
|
||||
在程序调用临时对象池的`Put`方法或`Get`方法的时候,总会先试图从该临时对象池的本地池列表中,获取与之对应的本地池,依据的就是与当前的goroutine关联的那个P的ID。
|
||||
|
||||
换句话说,一个临时对象池的`Put`方法或`Get`方法会获取到哪一个本地池,完全取决于调用它的代码所在的goroutine关联的那个P。
|
||||
|
||||
既然说到了这里,那么紧接着就会有下面这个问题。
|
||||
|
||||
### 问题 2:临时对象池是怎样利用内部数据结构来存取值的?
|
||||
|
||||
临时对象池的`Put`方法总会先试图把新的临时对象,存储到对应的本地池的`private`字段中,以便在后面获取临时对象的时候,可以快速地拿到一个可用的值。
|
||||
|
||||
只有当这个`private`字段已经存有某个值时,该方法才会去访问本地池的`shared`字段。
|
||||
|
||||
相应的,临时对象池的`Get`方法,总会先试图从对应的本地池的`private`字段处获取一个临时对象。只有当这个`private`字段的值为`nil`时,它才会去访问本地池的`shared`字段。
|
||||
|
||||
一个本地池的`shared`字段原则上可以被任何goroutine中的代码访问到,不论这个goroutine关联的是哪一个P。这也是我把它叫做共享临时对象列表的原因。
|
||||
|
||||
相比之下,一个本地池的`private`字段,只可能被与之对应的那个P所关联的goroutine中的代码访问到,所以可以说,它是P级私有的。
|
||||
|
||||
以临时对象池的`Put`方法为例,它一旦发现对应的本地池的`private`字段已存有值,就会去访问这个本地池的`shared`字段。当然,由于`shared`字段是共享的,所以此时必须受到互斥锁的保护。
|
||||
|
||||
还记得本地池嵌入的那个`sync.Mutex`类型的字段吗?它就是这里用到的互斥锁,也就是说,本地池本身就拥有互斥锁的功能。`Put`方法会在互斥锁的保护下,把新的临时对象追加到共享临时对象列表的末尾。
|
||||
|
||||
相应的,临时对象池的`Get`方法在发现对应本地池的`private`字段未存有值时,也会去访问后者的`shared`字段。它会在互斥锁的保护下,试图把该共享临时对象列表中的最后一个元素值取出并作为结果。
|
||||
|
||||
不过,这里的共享临时对象列表也可能是空的,这可能是由于这个本地池中的所有临时对象都已经被取走了,也可能是当前的临时对象池刚被清理过。
|
||||
|
||||
无论原因是什么,`Get`方法都会去访问当前的临时对象池中的所有本地池,它会去逐个搜索它们的共享临时对象列表。
|
||||
|
||||
只要发现某个共享临时对象列表中包含元素值,它就会把该列表的最后一个元素值取出并作为结果返回。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/df/21/df956fe29f35b41a14f941a9efd80d21.png" alt=""><br>
|
||||
**从sync.Pool中获取临时对象的步骤**
|
||||
|
||||
当然了,即使这样也可能无法拿到一个可用的临时对象,比如,在所有的临时对象池都刚被大清洗的情况下就会是如此。
|
||||
|
||||
这时,`Get`方法就会使出最后的手段——调用可创建临时对象的那个函数。还记得吗?这个函数是由临时对象池的`New`字段代表的,并且需要我们在初始化临时对象池的时候给定。如果这个字段的值是`nil`,那么`Get`方法此时也只能返回`nil`了。
|
||||
|
||||
以上,就是我对这个问题的较完整回答。
|
||||
|
||||
## 总结
|
||||
|
||||
今天,我们一起讨论了另一个比较有用的同步工具——`sync.Pool`类型,它的值被我称为临时对象池。
|
||||
|
||||
临时对象池有一个`New`字段,我们在初始化这个池的时候最好给定它。临时对象池还拥有两个方法,即:`Put`和`Get`,它们分别被用于向池中存放临时对象,和从池中获取临时对象。
|
||||
|
||||
临时对象池中存储的每一个值都应该是独立的、平等的和可重用的。我们应该既不用关心从池中拿到的是哪一个值,也不用在意这个值是否已经被使用过。
|
||||
|
||||
要完全做到这两点,可能会需要我们额外地写一些代码。不过,这个代码量应该是微乎其微的,就像`fmt`包对临时对象池的用法那样。所以,在选用临时对象池的时候,我们必须要把它将要存储的值的特性考虑在内。
|
||||
|
||||
在临时对象池的内部,有一个多层的数据结构支撑着对临时对象的存储。它的顶层是本地池列表,其中包含了与某个P对应的那些本地池,并且其长度与P的数量总是相同的。
|
||||
|
||||
在每个本地池中,都包含一个私有的临时对象和一个共享的临时对象列表。前者只能被其对应的P所关联的那个goroutine中的代码访问到,而后者却没有这个约束。从另一个角度讲,前者用于临时对象的快速存取,而后者则用于临时对象的池内共享。
|
||||
|
||||
正因为有了这样的数据结构,临时对象池才能够有效地分散存储压力和性能压力。同时,又因为临时对象池的`Get`方法对这个数据结构的妙用,才使得其中的临时对象能够被高效地利用。比如,该方法有时候会从其他的本地池的共享临时对象列表中,“偷取”一个临时对象。
|
||||
|
||||
这样的内部结构和存取方式,让临时对象池成为了一个特点鲜明的同步工具。它存储的临时对象都应该是拥有较长生命周期的值,并且,这些值不应该被某个goroutine中的代码长期的持有和使用。
|
||||
|
||||
因此,临时对象池非常适合用作针对某种数据的缓存。从某种角度讲,临时对象池可以帮助程序实现可伸缩性,这也正是它的最大价值。
|
||||
|
||||
## 思考题
|
||||
|
||||
今天的思考题是:怎样保证一个临时对象池中总有比较充足的临时对象?
|
||||
|
||||
请从临时对象池的初始化和方法调用两个方面作答。必要时可以参考`fmt`包以及demo70.go文件中使用临时对象池的方式。
|
||||
|
||||
感谢你的收听,我们下次再见。
|
||||
|
||||
[戳此查看Go语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers)
|
||||
|
||||
|
||||
125
极客时间专栏/Go语言核心36讲/模块三:Go语言实战与应用/34 | 并发安全字典sync.Map (上).md
Normal file
125
极客时间专栏/Go语言核心36讲/模块三:Go语言实战与应用/34 | 并发安全字典sync.Map (上).md
Normal file
@@ -0,0 +1,125 @@
|
||||
<audio id="audio" title="34 | 并发安全字典sync.Map (上)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/90/ab/907d94311773ebb569b1b49ed34b07ab.mp3"></audio>
|
||||
|
||||
在前面,我几乎已经把Go语言自带的同步工具全盘托出了。你是否已经听懂了会用了呢?
|
||||
|
||||
无论怎样,我都希望你能够多多练习、多多使用。它们和Go语言独有的并发编程方式并不冲突,相反,配合起来使用,绝对能达到“一加一大于二”的效果。
|
||||
|
||||
当然了,至于怎样配合就是一门学问了。我在前面已经讲了不少的方法和技巧,不过,更多的东西可能就需要你在实践中逐渐领悟和总结了。
|
||||
|
||||
我们今天再来讲一个并发安全的高级数据结构:`sync.Map`。众所周知,Go语言自带的字典类型`map`并不是并发安全的。
|
||||
|
||||
## 前导知识:并发安全字典诞生史
|
||||
|
||||
换句话说,在同一时间段内,让不同goroutine中的代码,对同一个字典进行读写操作是不安全的。字典值本身可能会因这些操作而产生混乱,相关的程序也可能会因此发生不可预知的问题。
|
||||
|
||||
在`sync.Map`出现之前,我们如果要实现并发安全的字典,就只能自行构建。不过,这其实也不是什么麻烦事,使用 `sync.Mutex`或`sync.RWMutex`,再加上原生的`map`就可以轻松地做到。
|
||||
|
||||
GitHub网站上已经有很多库提供了类似的数据结构。我在《Go并发编程实战》的第2版中也提供了一个比较完整的并发安全字典的实现。它的性能比同类的数据结构还要好一些,因为它在很大程度上有效地避免了对锁的依赖。
|
||||
|
||||
尽管已经有了不少的参考实现,Go语言爱好者们还是希望Go语言官方能够发布一个标准的并发安全字典。
|
||||
|
||||
经过大家多年的建议和吐槽,Go语言官方终于在2017年发布的Go 1.9中,正式加入了并发安全的字典类型`sync.Map`。
|
||||
|
||||
这个字典类型提供了一些常用的键值存取操作方法,并保证了这些操作的并发安全。同时,它的存、取、删等操作都可以基本保证在常数时间内执行完毕。换句话说,它们的算法复杂度与`map`类型一样都是`O(1)`的。
|
||||
|
||||
在有些时候,与单纯使用原生`map`和互斥锁的方案相比,使用`sync.Map`可以显著地减少锁的争用。`sync.Map`本身虽然也用到了锁,但是,它其实在尽可能地避免使用锁。
|
||||
|
||||
我们都知道,使用锁就意味着要把一些并发的操作强制串行化。这往往会降低程序的性能,尤其是在计算机拥有多个CPU核心的情况下。
|
||||
|
||||
因此,我们常说,能用原子操作就不要用锁,不过这很有局限性,毕竟原子只能对一些基本的数据类型提供支持。
|
||||
|
||||
无论在何种场景下使用`sync.Map`,我们都需要注意,与原生`map`明显不同,它只是Go语言标准库中的一员,而不是语言层面的东西。也正因为这一点,Go语言的编译器并不会对它的键和值,进行特殊的类型检查。
|
||||
|
||||
如果你看过`sync.Map`的文档或者实际使用过它,那么就一定会知道,它所有的方法涉及的键和值的类型都是`interface{}`,也就是空接口,这意味着可以包罗万象。所以,我们必须在程序中自行保证它的键类型和值类型的正确性。
|
||||
|
||||
好了,现在第一个问题来了。**今天的问题是:并发安全字典对键的类型有要求吗?**
|
||||
|
||||
这道题的典型回答是:有要求。键的实际类型不能是函数类型、字典类型和切片类型。
|
||||
|
||||
**解析一下这个问题。** 我们都知道,Go语言的原生字典的键类型不能是函数类型、字典类型和切片类型。
|
||||
|
||||
由于并发安全字典内部使用的存储介质正是原生字典,又因为它使用的原生字典键类型也是可以包罗万象的`interface{}`;所以,我们绝对不能带着任何实际类型为函数类型、字典类型或切片类型的键值去操作并发安全字典。
|
||||
|
||||
由于这些键值的实际类型只有在程序运行期间才能够确定,所以Go语言编译器是无法在编译期对它们进行检查的,不正确的键值实际类型肯定会引发panic。
|
||||
|
||||
因此,我们在这里首先要做的一件事就是:一定不要违反上述规则。我们应该在每次操作并发安全字典的时候,都去显式地检查键值的实际类型。无论是存、取还是删,都应该如此。
|
||||
|
||||
当然,更好的做法是,把针对同一个并发安全字典的这几种操作都集中起来,然后统一地编写检查代码。除此之外,把并发安全字典封装在一个结构体类型中,往往是一个很好的选择。
|
||||
|
||||
总之,我们必须保证键的类型是可比较的(或者说可判等的)。如果你实在拿不准,那么可以先通过调用`reflect.TypeOf`函数得到一个键值对应的反射类型值(即:`reflect.Type`类型的值),然后再调用这个值的`Comparable`方法,得到确切的判断结果。
|
||||
|
||||
## 知识扩展
|
||||
|
||||
## 问题1:怎样保证并发安全字典中的键和值的类型正确性?(方案一)
|
||||
|
||||
简单地说,可以使用类型断言表达式或者反射操作来保证它们的类型正确性。
|
||||
|
||||
为了进一步明确并发安全字典中键值的实际类型,这里大致有两种方案可选。
|
||||
|
||||
**第一种方案是,让并发安全字典只能存储某个特定类型的键。**
|
||||
|
||||
比如,指定这里的键只能是`int`类型的,或者只能是字符串,又或是某类结构体。一旦完全确定了键的类型,你就可以在进行存、取、删操作的时候,使用类型断言表达式去对键的类型做检查了。
|
||||
|
||||
一般情况下,这种检查并不繁琐。而且,你要是把并发安全字典封装在一个结构体类型里面,那就更加方便了。你这时完全可以让Go语言编译器帮助你做类型检查。请看下面的代码:
|
||||
|
||||
```
|
||||
type IntStrMap struct {
|
||||
m sync.Map
|
||||
}
|
||||
|
||||
func (iMap *IntStrMap) Delete(key int) {
|
||||
iMap.m.Delete(key)
|
||||
}
|
||||
|
||||
func (iMap *IntStrMap) Load(key int) (value string, ok bool) {
|
||||
v, ok := iMap.m.Load(key)
|
||||
if v != nil {
|
||||
value = v.(string)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (iMap *IntStrMap) LoadOrStore(key int, value string) (actual string, loaded bool) {
|
||||
a, loaded := iMap.m.LoadOrStore(key, value)
|
||||
actual = a.(string)
|
||||
return
|
||||
}
|
||||
|
||||
func (iMap *IntStrMap) Range(f func(key int, value string) bool) {
|
||||
f1 := func(key, value interface{}) bool {
|
||||
return f(key.(int), value.(string))
|
||||
}
|
||||
iMap.m.Range(f1)
|
||||
}
|
||||
|
||||
func (iMap *IntStrMap) Store(key int, value string) {
|
||||
iMap.m.Store(key, value)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如上所示,我编写了一个名为`IntStrMap`的结构体类型,它代表了键类型为`int`、值类型为`string`的并发安全字典。在这个结构体类型中,只有一个`sync.Map`类型的字段`m`。并且,这个类型拥有的所有方法,都与`sync.Map`类型的方法非常类似。
|
||||
|
||||
两者对应的方法名称完全一致,方法签名也非常相似,只不过,与键和值相关的那些参数和结果的类型不同而已。在`IntStrMap`类型的方法签名中,明确了键的类型为`int`,且值的类型为`string`。
|
||||
|
||||
显然,这些方法在接受键和值的时候,就不用再做类型检查了。另外,这些方法在从`m`中取出键和值的时候,完全不用担心它们的类型会不正确,因为它的正确性在当初存入的时候,就已经由Go语言编译器保证了。
|
||||
|
||||
稍微总结一下。第一种方案适用于我们可以完全确定键和值的具体类型的情况。在这种情况下,我们可以利用Go语言编译器去做类型检查,并用类型断言表达式作为辅助,就像`IntStrMap`那样。
|
||||
|
||||
## 总结
|
||||
|
||||
我们今天讨论的是`sync.Map`类型,它是一种并发安全的字典。它提供了一些常用的键、值存取操作方法,并保证了这些操作的并发安全。同时,它还保证了存、取、删等操作的常数级执行时间。
|
||||
|
||||
与原生的字典相同,并发安全字典对键的类型也是有要求的。它们同样不能是函数类型、字典类型和切片类型。
|
||||
|
||||
另外,由于并发安全字典提供的方法涉及的键和值的类型都是`interface{}`,所以我们在调用这些方法的时候,往往还需要对键和值的实际类型进行检查。
|
||||
|
||||
这里大致有两个方案。我们今天主要提到了第一种方案,这是在编码时就完全确定键和值的类型,然后利用Go语言的编译器帮我们做检查。
|
||||
|
||||
在下一次的文章中,我们会提到另外一种方案,并对比这两种方案的优劣。除此之外,我会继续探讨并发安全字典的相关问题。
|
||||
|
||||
感谢你的收听,我们下期再见。
|
||||
|
||||
[戳此查看Go语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers)
|
||||
|
||||
|
||||
175
极客时间专栏/Go语言核心36讲/模块三:Go语言实战与应用/35 | 并发安全字典sync.Map (下).md
Normal file
175
极客时间专栏/Go语言核心36讲/模块三:Go语言实战与应用/35 | 并发安全字典sync.Map (下).md
Normal file
@@ -0,0 +1,175 @@
|
||||
<audio id="audio" title="35 | 并发安全字典sync.Map (下)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/cc/b6/ccdbcb5010588e22947e628c382b47b6.mp3"></audio>
|
||||
|
||||
你好,我是郝林,今天我们继续来分享并发安全字典sync.Map的内容。
|
||||
|
||||
我们在上一篇文章中谈到了,由于并发安全字典提供的方法涉及的键和值的类型都是`interface{}`,所以我们在调用这些方法的时候,往往还需要对键和值的实际类型进行检查。
|
||||
|
||||
这里大致有两个方案。我们上一篇文章中提到了第一种方案,在编码时就完全确定键和值的类型,然后利用Go语言的编译器帮我们做检查。
|
||||
|
||||
这样做很方便,不是吗?不过,虽然方便,但是却让这样的字典类型缺少了一些灵活性。
|
||||
|
||||
如果我们还需要一个键类型为`uint32`并发安全字典的话,那就不得不再如法炮制地写一遍代码了。因此,在需求多样化之后,工作量反而更大,甚至会产生很多雷同的代码。
|
||||
|
||||
## 知识扩展
|
||||
|
||||
## 问题1:怎样保证并发安全字典中的键和值的类型正确性?(方案二)
|
||||
|
||||
那么,如果我们既想保持`sync.Map`类型原有的灵活性,又想约束键和值的类型,那么应该怎样做呢?这就涉及了第二个方案。
|
||||
|
||||
**在第二种方案中,我们封装的结构体类型的所有方法,都可以与`sync.Map`类型的方法完全一致(包括方法名称和方法签名)。**
|
||||
|
||||
不过,在这些方法中,我们就需要添加一些做类型检查的代码了。另外,这样并发安全字典的键类型和值类型,必须在初始化的时候就完全确定。并且,这种情况下,我们必须先要保证键的类型是可比较的。
|
||||
|
||||
所以在设计这样的结构体类型的时候,只包含`sync.Map`类型的字段就不够了。
|
||||
|
||||
比如:
|
||||
|
||||
```
|
||||
type ConcurrentMap struct {
|
||||
m sync.Map
|
||||
keyType reflect.Type
|
||||
valueType reflect.Type
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里`ConcurrentMap`类型代表的是:可自定义键类型和值类型的并发安全字典。这个类型同样有一个`sync.Map`类型的字段`m`,代表着其内部使用的并发安全字典。
|
||||
|
||||
另外,它的字段`keyType`和`valueType`,分别用于保存键类型和值类型。这两个字段的类型都是`reflect.Type`,我们可称之为反射类型。
|
||||
|
||||
这个类型可以代表Go语言的任何数据类型。并且,这个类型的值也非常容易获得:通过调用`reflect.TypeOf`函数并把某个样本值传入即可。
|
||||
|
||||
调用表达式`reflect.TypeOf(int(123))`的结果值,就代表了`int`类型的反射类型值。
|
||||
|
||||
**我们现在来看一看`ConcurrentMap`类型方法应该怎么写。**
|
||||
|
||||
**先说`Load`方法**,这个方法接受一个`interface{}`类型的参数`key`,参数`key`代表了某个键的值。
|
||||
|
||||
因此,当我们根据ConcurrentMap在`m`字段的值中查找键值对的时候,就必须保证ConcurrentMap的类型是正确的。由于反射类型值之间可以直接使用操作符`==`或`!=`进行判等,所以这里的类型检查代码非常简单。
|
||||
|
||||
```
|
||||
func (cMap *ConcurrentMap) Load(key interface{}) (value interface{}, ok bool) {
|
||||
if reflect.TypeOf(key) != cMap.keyType {
|
||||
return
|
||||
}
|
||||
return cMap.m.Load(key)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们把一个接口类型值传入`reflect.TypeOf`函数,就可以得到与这个值的实际类型对应的反射类型值。
|
||||
|
||||
因此,如果参数值的反射类型与`keyType`字段代表的反射类型不相等,那么我们就忽略后续操作,并直接返回。
|
||||
|
||||
这时,`Load`方法的第一个结果`value`的值为`nil`,而第二个结果`ok`的值为`false`。这完全符合`Load`方法原本的含义。
|
||||
|
||||
**再来说`Store`方法。**`Store`方法接受两个参数`key`和`value`,它们的类型也都是`interface{}`。因此,我们的类型检查应该针对它们来做。
|
||||
|
||||
```
|
||||
func (cMap *ConcurrentMap) Store(key, value interface{}) {
|
||||
if reflect.TypeOf(key) != cMap.keyType {
|
||||
panic(fmt.Errorf("wrong key type: %v", reflect.TypeOf(key)))
|
||||
}
|
||||
if reflect.TypeOf(value) != cMap.valueType {
|
||||
panic(fmt.Errorf("wrong value type: %v", reflect.TypeOf(value)))
|
||||
}
|
||||
cMap.m.Store(key, value)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里的类型检查代码与`Load`方法中的代码很类似,不同的是对检查结果的处理措施。当参数`key`或`value`的实际类型不符合要求时,`Store`方法会立即引发panic。
|
||||
|
||||
这主要是由于`Store`方法没有结果声明,所以在参数值有问题的时候,它无法通过比较平和的方式告知调用方。不过,这也是符合`Store`方法的原本含义的。
|
||||
|
||||
如果你不想这么做,也是可以的,那么就需要为`Store`方法添加一个`error`类型的结果。
|
||||
|
||||
并且,在发现参数值类型不正确的时候,让它直接返回相应的`error`类型值,而不是引发panic。要知道,这里展示的只一个参考实现,你可以根据实际的应用场景去做优化和改进。
|
||||
|
||||
至于与`ConcurrentMap`类型相关的其他方法和函数,我在这里就不展示了。它们在类型检查方式和处理流程上并没有特别之处。你可以在demo72.go文件中看到这些代码。
|
||||
|
||||
稍微总结一下。第一种方案适用于我们可以完全确定键和值具体类型的情况。在这种情况下,我们可以利用Go语言编译器去做类型检查,并用类型断言表达式作为辅助,就像`IntStrMap`那样。
|
||||
|
||||
在第二种方案中,我们无需在程序运行之前就明确键和值的类型,只要在初始化并发安全字典的时候,动态地给定它们就可以了。这里主要需要用到`reflect`包中的函数和数据类型,外加一些简单的判等操作。
|
||||
|
||||
第一种方案存在一个很明显的缺陷,那就是无法灵活地改变字典的键和值的类型。一旦需求出现多样化,编码的工作量就会随之而来。
|
||||
|
||||
第二种方案很好地弥补了这一缺陷,但是,那些反射操作或多或少都会降低程序的性能。我们往往需要根据实际的应用场景,通过严谨且一致的测试,来获得和比较程序的各项指标,并以此作为方案选择的重要依据之一。
|
||||
|
||||
## 问题2:并发安全字典如何做到尽量避免使用锁?
|
||||
|
||||
`sync.Map`类型在内部使用了大量的原子操作来存取键和值,并使用了两个原生的`map`作为存储介质。
|
||||
|
||||
**其中一个原生`map`被存在了`sync.Map`的`read`字段中,该字段是`sync/atomic.Value`类型的。** 这个原生字典可以被看作一个快照,它总会在条件满足时,去重新保存所属的`sync.Map`值中包含的所有键值对。
|
||||
|
||||
为了描述方便,我们在后面简称它为只读字典。不过,只读字典虽然不会增减其中的键,但却允许变更其中的键所对应的值。所以,它并不是传统意义上的快照,它的只读特性只是对于其中键的集合而言的。
|
||||
|
||||
由`read`字段的类型可知,`sync.Map`在替换只读字典的时候根本用不着锁。另外,这个只读字典在存储键值对的时候,还在值之上封装了一层。
|
||||
|
||||
它先把值转换为了`unsafe.Pointer`类型的值,然后再把后者封装,并储存在其中的原生字典中。如此一来,在变更某个键所对应的值的时候,就也可以使用原子操作了。
|
||||
|
||||
**`sync.Map`中的另一个原生字典由它的`dirty`字段代表。** 它存储键值对的方式与`read`字段中的原生字典一致,它的键类型也是`interface{}`,并且同样是把值先做转换和封装后再进行储存的。我们暂且把它称为脏字典。
|
||||
|
||||
**注意,脏字典和只读字典如果都存有同一个键值对,那么这里的两个键指的肯定是同一个基本值,对于两个值来说也是如此。**
|
||||
|
||||
正如前文所述,这两个字典在存储键和值的时候都只会存入它们的某个指针,而不是基本值。
|
||||
|
||||
`sync.Map`在查找指定的键所对应的值的时候,总会先去只读字典中寻找,并不需要锁定互斥锁。只有当确定“只读字典中没有,但脏字典中可能会有这个键”的时候,它才会在锁的保护下去访问脏字典。
|
||||
|
||||
相对应的,`sync.Map`在存储键值对的时候,只要只读字典中已存有这个键,并且该键值对未被标记为“已删除”,就会把新值存到里面并直接返回,这种情况下也不需要用到锁。
|
||||
|
||||
否则,它才会在锁的保护下把键值对存储到脏字典中。这个时候,该键值对的“已删除”标记会被抹去。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/41/51/418e648a9c370f67dffa70e84c96f451.png" alt="">
|
||||
|
||||
**sync.Map中的read与dirty**
|
||||
|
||||
顺便说一句,只有当一个键值对应该被删除,但却仍然存在于只读字典中的时候,才会被用标记为“已删除”的方式进行逻辑删除,而不会直接被物理删除。
|
||||
|
||||
这种情况会在重建脏字典以后的一段时间内出现。不过,过不了多久,它们就会被真正删除掉。在查找和遍历键值对的时候,已被逻辑删除的键值对永远会被无视。
|
||||
|
||||
对于删除键值对,`sync.Map`会先去检查只读字典中是否有对应的键。如果没有,脏字典中可能有,那么它就会在锁的保护下,试图从脏字典中删掉该键值对。
|
||||
|
||||
最后,`sync.Map`会把该键值对中指向值的那个指针置为`nil`,这是另一种逻辑删除的方式。
|
||||
|
||||
除此之外,还有一个细节需要注意,只读字典和脏字典之间是会互相转换的。在脏字典中查找键值对次数足够多的时候,`sync.Map`会把脏字典直接作为只读字典,保存在它的`read`字段中,然后把代表脏字典的`dirty`字段的值置为`nil`。
|
||||
|
||||
在这之后,一旦再有新的键值对存入,它就会依据只读字典去重建脏字典。这个时候,它会把只读字典中已被逻辑删除的键值对过滤掉。理所当然,这些转换操作肯定都需要在锁的保护下进行。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c5/f2/c5a9857311175ac94451fcefe52c30f2.png" alt=""><br>
|
||||
**sync.Map中read与dirty的互换**
|
||||
|
||||
综上所述,`sync.Map`的只读字典和脏字典中的键值对集合,并不是实时同步的,它们在某些时间段内可能会有不同。
|
||||
|
||||
由于只读字典中键的集合不能被改变,所以其中的键值对有时候可能是不全的。相反,脏字典中的键值对集合总是完全的,并且其中不会包含已被逻辑删除的键值对。
|
||||
|
||||
因此,可以看出,在读操作有很多但写操作却很少的情况下,并发安全字典的性能往往会更好。在几个写操作当中,新增键值对的操作对并发安全字典的性能影响是最大的,其次是删除操作,最后才是修改操作。
|
||||
|
||||
如果被操作的键值对已经存在于`sync.Map`的只读字典中,并且没有被逻辑删除,那么修改它并不会使用到锁,对其性能的影响就会很小。
|
||||
|
||||
## 总结
|
||||
|
||||
这两篇文章中,我们讨论了`sync.Map`类型,并谈到了怎样保证并发安全字典中的键和值的类型正确性。
|
||||
|
||||
为了进一步明确并发安全字典中键值的实际类型,这里大致有两种方案可选。
|
||||
|
||||
<li>
|
||||
其中一种方案是,在编码时就完全确定键和值的类型,然后利用Go语言的编译器帮我们做检查。
|
||||
</li>
|
||||
<li>
|
||||
另一种方案是,接受动态的类型设置,并在程序运行的时候通过反射操作进行检查。
|
||||
</li>
|
||||
|
||||
这两种方案各有利弊,前一种方案在扩展性方面有所欠缺,而后一种方案通常会影响到程序的性能。在实际使用的时候,我们一般都需要通过客观的测试来帮助决策。
|
||||
|
||||
另外,在有些时候,与单纯使用原生字典和互斥锁的方案相比,使用`sync.Map`可以显著地减少锁的争用。`sync.Map`本身确实也用到了锁,但是,它会尽可能地避免使用锁。
|
||||
|
||||
这就要说到`sync.Map`对其持有两个原生字典的巧妙使用了。这两个原生字典一个被称为只读字典,另一个被称为脏字典。通过对它们的分析,我们知道了并发安全字典的适用场景,以及每种操作对其性能的影响程度。
|
||||
|
||||
## 思考题
|
||||
|
||||
今天的思考题是:关于保证并发安全字典中的键和值的类型正确性,你还能想到其他的方案吗?
|
||||
|
||||
[戳此查看Go语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers)
|
||||
|
||||
|
||||
257
极客时间专栏/Go语言核心36讲/模块三:Go语言实战与应用/36 | unicode与字符编码.md
Normal file
257
极客时间专栏/Go语言核心36讲/模块三:Go语言实战与应用/36 | unicode与字符编码.md
Normal file
@@ -0,0 +1,257 @@
|
||||
<audio id="audio" title="36 | unicode与字符编码" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b1/6b/b14d7b224de2a5924a4cb5aa253e246b.mp3"></audio>
|
||||
|
||||
到目前为止,我们已经一起陆陆续续地学完了Go语言中那些最重要也最有特色的概念、语法和编程方式。我对于它们非常喜爱,简直可以用如数家珍来形容了。
|
||||
|
||||
在开始今天的内容之前,我先来做一个简单的总结。
|
||||
|
||||
## Go语言经典知识总结
|
||||
|
||||
基于混合线程的并发编程模型自然不必多说。
|
||||
|
||||
在**数据类型**方面有:
|
||||
|
||||
- 基于底层数组的切片;
|
||||
- 用来传递数据的通道;
|
||||
- 作为一等类型的函数;
|
||||
- 可实现面向对象的结构体;
|
||||
- 能无侵入实现的接口等。
|
||||
|
||||
在**语法**方面有:
|
||||
|
||||
- 异步编程神器`go`语句;
|
||||
- 函数的最后关卡`defer`语句;
|
||||
- 可做类型判断的`switch`语句;
|
||||
- 多通道操作利器`select`语句;
|
||||
- 非常有特色的异常处理函数`panic`和`recover`。
|
||||
|
||||
除了这些,我们还一起讨论了**测试Go程序**的主要方式。这涉及了Go语言自带的程序测试套件,相关的概念和工具包括:
|
||||
|
||||
- 独立的测试源码文件;
|
||||
- 三种功用不同的测试函数;
|
||||
- 专用的`testing`代码包;
|
||||
- 功能强大的`go test`命令。
|
||||
|
||||
另外,就在前不久,我还为你深入讲解了Go语言提供的那些**同步工具**。它们也是Go语言并发编程工具箱中不可或缺的一部分。这包括了:
|
||||
|
||||
- 经典的互斥锁;
|
||||
- 读写锁;
|
||||
- 条件变量;
|
||||
- 原子操作。
|
||||
|
||||
以及**Go语言特有的一些数据类型**,即:
|
||||
|
||||
- 单次执行小助手`sync.Once`;
|
||||
- 临时对象池`sync.Pool`;
|
||||
- 帮助我们实现多goroutine协作流程的`sync.WaitGroup`、`context.Context`;
|
||||
- 一种高效的并发安全字典`sync.Map`。
|
||||
|
||||
毫不夸张地说,如果你真正地掌握了上述这些知识,那么就已经获得了Go语言编程的精髓。
|
||||
|
||||
在这之后,你再去研读Go语言标准库和那些优秀第三方库中的代码的时候,就一定会事半功倍。同时,在使用Go语言编写软件的时候,你肯定也会如鱼得水、游刃有余的。
|
||||
|
||||
我用了大量的篇幅讲解了Go语言中最核心的知识点,真心希望你已经搞懂了这些内容。
|
||||
|
||||
**在后面的日子里,我会与你一起去探究Go语言标准库中最常用的那些代码包,弄清它们的用法、了解它们的机理。当然了,我还会顺便讲一讲那些必备的周边知识。**
|
||||
|
||||
## 前导内容1:Go语言字符编码基础
|
||||
|
||||
首先,让我们来关注字符编码方面的问题。这应该是在计算机软件领域中非常基础的一个问题了。
|
||||
|
||||
我在前面说过,Go语言中的标识符可以包含“任何Unicode编码可以表示的字母字符”。我还说过,虽然我们可以直接把一个整数值转换为一个`string`类型的值。
|
||||
|
||||
但是,被转换的整数值应该可以代表一个有效的Unicode代码点,否则转换的结果就将会是`"<22>"`,即:一个仅由高亮的问号组成的字符串值。
|
||||
|
||||
另外,当一个`string`类型的值被转换为`[]rune`类型值的时候,其中的字符串会被拆分成一个一个的Unicode字符。
|
||||
|
||||
显然,Go语言采用的字符编码方案从属于Unicode编码规范。更确切地说,Go语言的代码正是由Unicode字符组成的。Go语言的所有源代码,都必须按照Unicode编码规范中的UTF-8编码格式进行编码。
|
||||
|
||||
换句话说,Go语言的源码文件必须使用UTF-8编码格式进行存储。如果源码文件中出现了非UTF-8编码的字符,那么在构建、安装以及运行的时候,go命令就会报告错误“illegal UTF-8 encoding”。
|
||||
|
||||
在这里,我们首先要对Unicode编码规范有所了解。不过,在讲述它之前,我先来简要地介绍一下ASCII编码。
|
||||
|
||||
### 前导内容 2: ASCII编码
|
||||
|
||||
ASCII是英文“American Standard Code for Information Interchange”的缩写,中文译为美国信息交换标准代码。它是由美国国家标准学会(ANSI)制定的单字节字符编码方案,可用于基于文本的数据交换。
|
||||
|
||||
它最初是美国的国家标准,后又被国际标准化组织(ISO)定为国际标准,称为ISO 646标准,并适用于所有的拉丁文字字母。
|
||||
|
||||
ASCII编码方案使用单个字节(byte)的二进制数来编码一个字符。标准的ASCII编码用一个字节的最高比特(bit)位作为奇偶校验位,而扩展的ASCII编码则将此位也用于表示字符。ASCII编码支持的可打印字符和控制字符的集合也被叫做ASCII编码集。
|
||||
|
||||
我们所说的Unicode编码规范,实际上是另一个更加通用的、针对书面字符和文本的字符编码标准。它为世界上现存的所有自然语言中的每一个字符,都设定了一个唯一的二进制编码。
|
||||
|
||||
它定义了不同自然语言的文本数据在国际间交换的统一方式,并为全球化软件创建了一个重要的基础。
|
||||
|
||||
Unicode编码规范以ASCII编码集为出发点,并突破了ASCII只能对拉丁字母进行编码的限制。它不但提供了可以对世界上超过百万的字符进行编码的能力,还支持所有已知的转义序列和控制代码。
|
||||
|
||||
我们都知道,在计算机系统的内部,抽象的字符会被编码为整数。这些整数的范围被称为代码空间。在代码空间之内,每一个特定的整数都被称为一个代码点。
|
||||
|
||||
一个受支持的抽象字符会被映射并分配给某个特定的代码点,反过来讲,一个代码点总是可以被看成一个被编码的字符。
|
||||
|
||||
Unicode编码规范通常使用十六进制表示法来表示Unicode代码点的整数值,并使用“U+”作为前缀。比如,英文字母字符“a”的Unicode代码点是U+0061。在Unicode编码规范中,一个字符能且只能由与它对应的那个代码点表示。
|
||||
|
||||
Unicode编码规范现在的最新版本是11.0,并会于2019年3月发布12.0版本。而Go语言从1.10版本开始,已经对Unicode的10.0版本提供了全面的支持。对于绝大多数的应用场景来说,这已经完全够用了。
|
||||
|
||||
Unicode编码规范提供了三种不同的编码格式,即:UTF-8、UTF-16和UTF-32。其中的UTF是UCS Transformation Format的缩写。而UCS又是Universal Character Set的缩写,但也可以代表Unicode Character Set。所以,UTF也可以被翻译为Unicode转换格式。它代表的是字符与字节序列之间的转换方式。
|
||||
|
||||
在这几种编码格式的名称中,“-”右边的整数的含义是,以多少个比特位作为一个编码单元。以UTF-8为例,它会以8个比特,也就是一个字节,作为一个编码单元。并且,它与标准的ASCII编码是完全兼容的。也就是说,在[0x00, 0x7F]的范围内,这两种编码表示的字符都是相同的。这也是UTF-8编码格式的一个巨大优势。
|
||||
|
||||
UTF-8是一种可变宽的编码方案。换句话说,它会用一个或多个字节的二进制数来表示某个字符,最多使用四个字节。比如,对于一个英文字符,它仅用一个字节的二进制数就可以表示,而对于一个中文字符,它需要使用三个字节才能够表示。不论怎样,一个受支持的字符总是可以由UTF-8编码为一个字节序列。以下会简称后者为UTF-8编码值。
|
||||
|
||||
现在,在你初步地了解了这些知识之后,请认真地思考并回答下面的问题。别担心,我会在后面进一步阐述Unicode、UTF-8以及Go语言对它们的运用。
|
||||
|
||||
**问题:一个`string`类型的值在底层是怎样被表达的?**
|
||||
|
||||
**典型回答** 是在底层,一个`string`类型的值是由一系列相对应的Unicode代码点的UTF-8编码值来表达的。
|
||||
|
||||
## 问题解析
|
||||
|
||||
在Go语言中,一个`string`类型的值既可以被拆分为一个包含多个字符的序列,也可以被拆分为一个包含多个字节的序列。
|
||||
|
||||
前者可以由一个以`rune`为元素类型的切片来表示,而后者则可以由一个以`byte`为元素类型的切片代表。
|
||||
|
||||
`rune`是Go语言特有的一个基本数据类型,它的一个值就代表一个字符,即:一个Unicode字符。
|
||||
|
||||
比如,`'G'`、`'o'`、`'爱'`、`'好'`、`'者'`代表的就都是一个Unicode字符。
|
||||
|
||||
我们已经知道,UTF-8编码方案会把一个Unicode字符编码为一个长度在[1, 4]范围内的字节序列。所以,一个`rune`类型的值也可以由一个或多个字节来代表。
|
||||
|
||||
```
|
||||
type rune = int32
|
||||
|
||||
```
|
||||
|
||||
根据`rune`类型的声明可知,它实际上就是`int32`类型的一个别名类型。也就是说,一个`rune`类型的值会由四个字节宽度的空间来存储。它的存储空间总是能够存下一个UTF-8编码值。
|
||||
|
||||
一个`rune`类型的值在底层其实就是一个UTF-8编码值。前者是(便于我们人类理解的)外部展现,后者是(便于计算机系统理解的)内在表达。
|
||||
|
||||
请看下面的代码:
|
||||
|
||||
```
|
||||
str := "Go爱好者"
|
||||
fmt.Printf("The string: %q\n", str)
|
||||
fmt.Printf(" => runes(char): %q\n", []rune(str))
|
||||
fmt.Printf(" => runes(hex): %x\n", []rune(str))
|
||||
fmt.Printf(" => bytes(hex): [% x]\n", []byte(str))
|
||||
|
||||
```
|
||||
|
||||
字符串值`"Go爱好者"`如果被转换为`[]rune`类型的值的话,其中的每一个字符(不论是英文字符还是中文字符)就都会独立成为一个`rune`类型的元素值。因此,这段代码打印出的第二行内容就会如下所示:
|
||||
|
||||
```
|
||||
=> runes(char): ['G' 'o' '爱' '好' '者']
|
||||
|
||||
```
|
||||
|
||||
又由于,每个`rune`类型的值在底层都是由一个UTF-8编码值来表达的,所以我们可以换一种方式来展现这个字符序列:
|
||||
|
||||
```
|
||||
=> runes(hex): [47 6f 7231 597d 8005]
|
||||
|
||||
```
|
||||
|
||||
可以看到,五个十六进制数与五个字符相对应。很明显,前两个十六进制数`47`和`6f`代表的整数都比较小,它们分别表示字符`'G'`和`'o'`。
|
||||
|
||||
因为它们都是英文字符,所以对应的UTF-8编码值用一个字节表达就足够了。一个字节的编码值被转换为整数之后,不会大到哪里去。
|
||||
|
||||
而后三个十六进制数`7231`、`597d`和`8005`都相对较大,它们分别表示中文字符`'爱'`、`'好'`和`'者'`。
|
||||
|
||||
这些中文字符对应的UTF-8编码值,都需要使用三个字节来表达。所以,这三个数就是把对应的三个字节的编码值,转换为整数后得到的结果。
|
||||
|
||||
我们还可以进一步地拆分,把每个字符的UTF-8编码值都拆成相应的字节序列。上述代码中的第五行就是这么做的。它会得到如下的输出:
|
||||
|
||||
```
|
||||
=> bytes(hex): [47 6f e7 88 b1 e5 a5 bd e8 80 85]
|
||||
|
||||
```
|
||||
|
||||
这里得到的字节切片比前面的字符切片明显长了很多。这正是因为一个中文字符的UTF-8编码值需要用三个字节来表达。
|
||||
|
||||
这个字节切片的前两个元素值与字符切片的前两个元素值是一致的,而在这之后,前者的每三个元素值才对应字符切片中的一个元素值。
|
||||
|
||||
注意,对于一个多字节的UTF-8编码值来说,我们可以把它当做一个整体转换为单一的整数,也可以先把它拆成字节序列,再把每个字节分别转换为一个整数,从而得到多个整数。
|
||||
|
||||
这两种表示法展现出来的内容往往会很不一样。比如,对于中文字符`'爱'`来说,它的UTF-8编码值可以展现为单一的整数`7231`,也可以展现为三个整数,即:`e7`、`88`和`b1`。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0d/85/0d8dac40ccb2972dbceef33d03741085.png" alt=""><br>
|
||||
(字符串值的底层表示)
|
||||
|
||||
总之,一个`string`类型的值会由若干个Unicode字符组成,每个Unicode字符都可以由一个`rune`类型的值来承载。
|
||||
|
||||
这些字符在底层都会被转换为UTF-8编码值,而这些UTF-8编码值又会以字节序列的形式表达和存储。因此,一个`string`类型的值在底层就是一个能够表达若干个UTF-8编码值的字节序列。
|
||||
|
||||
## 知识扩展
|
||||
|
||||
**问题 1:使用带有`range`子句的`for`语句遍历字符串值的时候应该注意什么?**
|
||||
|
||||
带有`range`子句的`for`语句会先把被遍历的字符串值拆成一个字节序列,然后再试图找出这个字节序列中包含的每一个UTF-8编码值,或者说每一个Unicode字符。
|
||||
|
||||
这样的`for`语句可以为两个迭代变量赋值。如果存在两个迭代变量,那么赋给第一个变量的值,就将会是当前字节序列中的某个UTF-8编码值的第一个字节所对应的那个索引值。
|
||||
|
||||
而赋给第二个变量的值,则是这个UTF-8编码值代表的那个Unicode字符,其类型会是`rune`。
|
||||
|
||||
例如,有这么几行代码:
|
||||
|
||||
```
|
||||
str := "Go爱好者"
|
||||
for i, c := range str {
|
||||
fmt.Printf("%d: %q [% x]\n", i, c, []byte(string(c)))
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里被遍历的字符串值是`"Go爱好者"`。在每次迭代的时候,这段代码都会打印出两个迭代变量的值,以及第二个值的字节序列形式。完整的打印内容如下:
|
||||
|
||||
```
|
||||
0: 'G' [47]
|
||||
1: 'o' [6f]
|
||||
2: '爱' [e7 88 b1]
|
||||
5: '好' [e5 a5 bd]
|
||||
8: '者' [e8 80 85]
|
||||
|
||||
```
|
||||
|
||||
第一行内容中的关键信息有`0`、`'G'`和`[47]`。这是由于这个字符串值中的第一个Unicode字符是`'G'`。该字符是一个单字节字符,并且由相应的字节序列中的第一个字节表达。这个字节的十六进制表示为`47`。
|
||||
|
||||
第二行展示的内容与之类似,即:第二个Unicode字符是`'o'`,由字节序列中的第二个字节表达,其十六进制表示为`6f`。
|
||||
|
||||
再往下看,第三行展示的是`'爱'`,也是第三个Unicode字符。因为它是一个中文字符,所以由字节序列中的第三、四、五个字节共同表达,其十六进制表示也不再是单一的整数,而是`e7`、`88`和`b1`组成的序列。
|
||||
|
||||
下面要注意了,正是因为`'爱'`是由三个字节共同表达的,所以第四个Unicode字符`'好'`对应的索引值并不是`3`,而是`2`加`3`后得到的`5`。
|
||||
|
||||
这里的`2`代表的是`'爱'`对应的索引值,而`3`代表的则是`'爱'`对应的UTF-8编码值的宽度。对于这个字符串值中的最后一个字符`'者'`来说也是类似的,因此,它对应的索引值是`8`。
|
||||
|
||||
由此可以看出,这样的`for`语句可以逐一地迭代出字符串值里的每个Unicode字符。但是,相邻的Unicode字符的索引值并不一定是连续的。这取决于前一个Unicode字符是否为单字节字符。
|
||||
|
||||
正因为如此,如果我们想得到其中某个Unicode字符对应的UTF-8编码值的宽度,就可以用下一个字符的索引值减去当前字符的索引值。
|
||||
|
||||
初学者可能会对`for`语句的这种行为感到困惑,因为它给予两个迭代变量的值看起来并不总是对应的。不过,一旦我们了解了它的内在机制就会拨云见日、豁然开朗。
|
||||
|
||||
## 总结
|
||||
|
||||
我们今天把目光聚焦在了Unicode编码规范、UTF-8编码格式,以及Go语言对字符串和字符的相关处理方式上。
|
||||
|
||||
Go语言的代码是由Unicode字符组成的,它们都必须由Unicode编码规范中的UTF-8编码格式进行编码并存储,否则就会导致go命令的报错。
|
||||
|
||||
Unicode编码规范中的编码格式定义的是:字符与字节序列之间的转换方式。其中的UTF-8是一种可变宽的编码方案。
|
||||
|
||||
它会用一个或多个字节的二进制数来表示某个字符,最多使用四个字节。一个受支持的字符,总是可以由UTF-8编码为一个字节序列,后者也可以被称为UTF-8编码值。
|
||||
|
||||
Go语言中的一个`string`类型值会由若干个Unicode字符组成,每个Unicode字符都可以由一个`rune`类型的值来承载。
|
||||
|
||||
这些字符在底层都会被转换为UTF-8编码值,而这些UTF-8编码值又会以字节序列的形式表达和存储。因此,一个`string`类型的值在底层就是一个能够表达若干个UTF-8编码值的字节序列。
|
||||
|
||||
初学者可能会对带有`range`子句的`for`语句遍历字符串值的行为感到困惑,因为它给予两个迭代变量的值看起来并不总是对应的。但事实并非如此。
|
||||
|
||||
这样的`for`语句会先把被遍历的字符串值拆成一个字节序列,然后再试图找出这个字节序列中包含的每一个UTF-8编码值,或者说每一个Unicode字符。
|
||||
|
||||
相邻的Unicode字符的索引值并不一定是连续的。这取决于前一个Unicode字符是否为单字节字符。一旦我们清楚了这些内在机制就不会再困惑了。
|
||||
|
||||
对于Go语言来说,Unicode编码规范和UTF-8编码格式算是基础之一了。我们应该了解到它们对Go语言的重要性。这对于正确理解Go语言中的相关数据类型以及日后的相关程序编写都会很有好处。
|
||||
|
||||
## 思考题
|
||||
|
||||
今天的思考题是:判断一个Unicode字符是否为单字节字符通常有几种方式?
|
||||
|
||||
[戳此查看Go语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers)
|
||||
|
||||
|
||||
219
极客时间专栏/Go语言核心36讲/模块三:Go语言实战与应用/37 | strings包与字符串操作.md
Normal file
219
极客时间专栏/Go语言核心36讲/模块三:Go语言实战与应用/37 | strings包与字符串操作.md
Normal file
@@ -0,0 +1,219 @@
|
||||
<audio id="audio" title="37 | strings包与字符串操作" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d8/46/d86e6fe25606592dd8afd5a5da601d46.mp3"></audio>
|
||||
|
||||
在上一篇文章中,我介绍了Go语言与Unicode编码规范、UTF-8编码格式的渊源及运用。
|
||||
|
||||
Go语言不但拥有可以独立代表Unicode字符的类型`rune`,而且还有可以对字符串值进行Unicode字符拆分的`for`语句。
|
||||
|
||||
除此之外,标准库中的`unicode`包及其子包还提供了很多的函数和数据类型,可以帮助我们解析各种内容中的Unicode字符。
|
||||
|
||||
这些程序实体都很好用,也都很简单明了,而且有效地隐藏了Unicode编码规范中的一些复杂的细节。我就不在这里对它们进行专门的讲解了。
|
||||
|
||||
我们今天主要来说一说标准库中的`strings`代码包。这个代码包也用到了不少`unicode`包和`unicode/utf8`包中的程序实体。
|
||||
|
||||
<li>
|
||||
比如,`strings.Builder`类型的`WriteRune`方法。
|
||||
</li>
|
||||
<li>
|
||||
又比如,`strings.Reader`类型的`ReadRune`方法,等等。
|
||||
</li>
|
||||
|
||||
下面这个问题就是针对`strings.Builder`类型的。**我们今天的问题是:与`string`值相比,`strings.Builder`类型的值有哪些优势?**
|
||||
|
||||
这里的**典型回答**是这样的。
|
||||
|
||||
`strings.Builder`类型的值(以下简称`Builder`值)的优势有下面的三种:
|
||||
|
||||
- 已存在的内容不可变,但可以拼接更多的内容;
|
||||
- 减少了内存分配和内容拷贝的次数;
|
||||
- 可将内容重置,可重用值。
|
||||
|
||||
## 问题解析
|
||||
|
||||
**先来说说`string`类型。** 我们都知道,在Go语言中,`string`类型的值是不可变的。 如果我们想获得一个不一样的字符串,那么就只能基于原字符串进行裁剪、拼接等操作,从而生成一个新的字符串。
|
||||
|
||||
- 裁剪操作可以使用切片表达式;
|
||||
- 拼接操作可以用操作符`+`实现。
|
||||
|
||||
在底层,一个`string`值的内容会被存储到一块连续的内存空间中。同时,这块内存容纳的字节数量也会被记录下来,并用于表示该`string`值的长度。
|
||||
|
||||
你可以把这块内存的内容看成一个字节数组,而相应的`string`值则包含了指向字节数组头部的指针值。如此一来,我们在一个`string`值上应用切片表达式,就相当于在对其底层的字节数组做切片。
|
||||
|
||||
另外,我们在进行字符串拼接的时候,Go语言会把所有被拼接的字符串依次拷贝到一个崭新且足够大的连续内存空间中,并把持有相应指针值的`string`值作为结果返回。
|
||||
|
||||
显然,当程序中存在过多的字符串拼接操作的时候,会对内存的分配产生非常大的压力。
|
||||
|
||||
注意,虽然`string`值在内部持有一个指针值,但其类型仍然属于值类型。不过,由于`string`值的不可变,其中的指针值也为内存空间的节省做出了贡献。
|
||||
|
||||
更具体地说,一个`string`值会在底层与它的所有副本共用同一个字节数组。由于这里的字节数组永远不会被改变,所以这样做是绝对安全的。
|
||||
|
||||
**与`string`值相比,`Builder`值的优势其实主要体现在字符串拼接方面。**
|
||||
|
||||
`Builder`值中有一个用于承载内容的容器(以下简称内容容器)。它是一个以`byte`为元素类型的切片(以下简称字节切片)。
|
||||
|
||||
由于这样的字节切片的底层数组就是一个字节数组,所以我们可以说它与`string`值存储内容的方式是一样的。
|
||||
|
||||
实际上,它们都是通过一个`unsafe.Pointer`类型的字段来持有那个指向了底层字节数组的指针值的。
|
||||
|
||||
正是因为这样的内部构造,`Builder`值同样拥有高效利用内存的前提条件。虽然,对于字节切片本身来说,它包含的任何元素值都可以被修改,但是`Builder`值并不允许这样做,其中的内容只能够被拼接或者完全重置。
|
||||
|
||||
这就意味着,已存在于`Builder`值中的内容是不可变的。因此,我们可以利用`Builder`值提供的方法拼接更多的内容,而丝毫不用担心这些方法会影响到已存在的内容。
|
||||
|
||||
>
|
||||
这里所说的方法指的是,`Builder`值拥有的一系列指针方法,包括:`Write`、`WriteByte`、`WriteRune`和`WriteString`。我们可以把它们统称为拼接方法。
|
||||
|
||||
|
||||
我们可以通过调用上述方法把新的内容拼接到已存在的内容的尾部(也就是右边)。这时,如有必要,`Builder`值会自动地对自身的内容容器进行扩容。这里的自动扩容策略与切片的扩容策略一致。
|
||||
|
||||
换句话说,我们在向`Builder`值拼接内容的时候并不一定会引起扩容。只要内容容器的容量够用,扩容就不会进行,针对于此的内存分配也不会发生。同时,只要没有扩容,`Builder`值中已存在的内容就不会再被拷贝。
|
||||
|
||||
除了`Builder`值的自动扩容,我们还可以选择手动扩容,这通过调用`Builder`值的`Grow`方法就可以做到。`Grow`方法也可以被称为扩容方法,它接受一个`int`类型的参数`n`,该参数用于代表将要扩充的字节数量。
|
||||
|
||||
如有必要,`Grow`方法会把其所属值中内容容器的容量增加`n`个字节。更具体地讲,它会生成一个字节切片作为新的内容容器,该切片的容量会是原容器容量的二倍再加上`n`。之后,它会把原容器中的所有字节全部拷贝到新容器中。
|
||||
|
||||
```
|
||||
var builder1 strings.Builder
|
||||
// 省略若干代码。
|
||||
fmt.Println("Grow the builder ...")
|
||||
builder1.Grow(10)
|
||||
fmt.Printf("The length of contents in the builder is %d.\n", builder1.Len())
|
||||
|
||||
```
|
||||
|
||||
当然,`Grow`方法还可能什么都不做。这种情况的前提条件是:当前的内容容器中的未用容量已经够用了,即:未用容量大于或等于`n`。这里的前提条件与前面提到的自动扩容策略中的前提条件是类似的。
|
||||
|
||||
```
|
||||
fmt.Println("Reset the builder ...")
|
||||
builder1.Reset()
|
||||
fmt.Printf("The third output(%d):\n%q\n", builder1.Len(), builder1.String())
|
||||
|
||||
```
|
||||
|
||||
最后,`Builder`值是可以被重用的。通过调用它的`Reset`方法,我们可以让`Builder`值重新回到零值状态,就像它从未被使用过那样。
|
||||
|
||||
一旦被重用,`Builder`值中原有的内容容器会被直接丢弃。之后,它和其中的所有内容,将会被Go语言的垃圾回收器标记并回收掉。
|
||||
|
||||
## 知识扩展
|
||||
|
||||
### 问题1:`strings.Builder`类型在使用上有约束吗?
|
||||
|
||||
答案是:有约束,概括如下:
|
||||
|
||||
- 在已被真正使用后就不可再被复制;
|
||||
- 由于其内容不是完全不可变的,所以需要使用方自行解决操作冲突和并发安全问题。
|
||||
|
||||
我们只要调用了`Builder`值的拼接方法或扩容方法,就意味着开始真正使用它了。显而易见,这些方法都会改变其所属值中的内容容器的状态。
|
||||
|
||||
一旦调用了它们,我们就不能再以任何的方式对其所属值进行复制了。否则,只要在任何副本上调用上述方法就都会引发panic。
|
||||
|
||||
这种panic会告诉我们,这样的使用方式是并不合法的,因为这里的`Builder`值是副本而不是原值。顺便说一句,这里所说的复制方式,包括但不限于在函数间传递值、通过通道传递值、把值赋予变量等等。
|
||||
|
||||
```
|
||||
var builder1 strings.Builder
|
||||
builder1.Grow(1)
|
||||
builder3 := builder1
|
||||
//builder3.Grow(1) // 这里会引发panic。
|
||||
_ = builder3
|
||||
|
||||
```
|
||||
|
||||
虽然这个约束非常严格,但是如果我们仔细思考一下的话,就会发现它还是有好处的。
|
||||
|
||||
正是由于已使用的`Builder`值不能再被复制,所以肯定不会出现多个`Builder`值中的内容容器(也就是那个字节切片)共用一个底层字节数组的情况。这样也就避免了多个同源的`Builder`值在拼接内容时可能产生的冲突问题。
|
||||
|
||||
不过,虽然已使用的`Builder`值不能再被复制,但是它的指针值却可以。无论什么时候,我们都可以通过任何方式复制这样的指针值。注意,这样的指针值指向的都会是同一个`Builder`值。
|
||||
|
||||
```
|
||||
f2 := func(bp *strings.Builder) {
|
||||
(*bp).Grow(1) // 这里虽然不会引发panic,但不是并发安全的。
|
||||
builder4 := *bp
|
||||
//builder4.Grow(1) // 这里会引发panic。
|
||||
_ = builder4
|
||||
}
|
||||
f2(&builder1)
|
||||
|
||||
```
|
||||
|
||||
正因为如此,这里就产生了一个问题,即:如果`Builder`值被多方同时操作,那么其中的内容就很可能会产生混乱。这就是我们所说的操作冲突和并发安全问题。
|
||||
|
||||
`Builder`值自己是无法解决这些问题的。所以,我们在通过传递其指针值共享`Builder`值的时候,一定要确保各方对它的使用是正确、有序的,并且是并发安全的;而最彻底的解决方案是,绝不共享`Builder`值以及它的指针值。
|
||||
|
||||
我们可以在各处分别声明一个`Builder`值来使用,也可以先声明一个`Builder`值,然后在真正使用它之前,便将它的副本传到各处。另外,我们还可以先使用再传递,只要在传递之前调用它的`Reset`方法即可。
|
||||
|
||||
```
|
||||
builder1.Reset()
|
||||
builder5 := builder1
|
||||
builder5.Grow(1) // 这里不会引发panic。
|
||||
|
||||
```
|
||||
|
||||
总之,关于复制`Builder`值的约束是有意义的,也是很有必要的。虽然我们仍然可以通过某些方式共享`Builder`值,但最好还是不要以身犯险,“各自为政”是最好的解决方案。不过,对于处在零值状态的`Builder`值,复制不会有任何问题。
|
||||
|
||||
### 问题2:为什么说`strings.Reader`类型的值可以高效地读取字符串?
|
||||
|
||||
与`strings.Builder`类型恰恰相反,`strings.Reader`类型是为了高效读取字符串而存在的。后者的高效主要体现在它对字符串的读取机制上,它封装了很多用于在`string`值上读取内容的最佳实践。
|
||||
|
||||
`strings.Reader`类型的值(以下简称`Reader`值)可以让我们很方便地读取一个字符串中的内容。在读取的过程中,`Reader`值会保存已读取的字节的计数(以下简称已读计数)。
|
||||
|
||||
已读计数也代表着下一次读取的起始索引位置。`Reader`值正是依靠这样一个计数,以及针对字符串值的切片表达式,从而实现快速读取。
|
||||
|
||||
此外,这个已读计数也是读取回退和位置设定时的重要依据。虽然它属于`Reader`值的内部结构,但我们还是可以通过该值的`Len`方法和`Size`把它计算出来的。代码如下:
|
||||
|
||||
```
|
||||
var reader1 strings.Reader
|
||||
// 省略若干代码。
|
||||
readingIndex := reader1.Size() - int64(reader1.Len()) // 计算出的已读计数。
|
||||
|
||||
```
|
||||
|
||||
`Reader`值拥有的大部分用于读取的方法都会及时地更新已读计数。比如,`ReadByte`方法会在读取成功后将这个计数的值加`1`。
|
||||
|
||||
又比如,`ReadRune`方法在读取成功之后,会把被读取的字符所占用的字节数作为计数的增量。
|
||||
|
||||
不过,`ReadAt`方法算是一个例外。它既不会依据已读计数进行读取,也不会在读取后更新它。正因为如此,这个方法可以自由地读取其所属的`Reader`值中的任何内容。
|
||||
|
||||
除此之外,`Reader`值的`Seek`方法也会更新该值的已读计数。实际上,这个`Seek`方法的主要作用正是设定下一次读取的起始索引位置。
|
||||
|
||||
另外,如果我们把常量`io.SeekCurrent`的值作为第二个参数值传给该方法,那么它还会依据当前的已读计数,以及第一个参数`offset`的值来计算新的计数值。
|
||||
|
||||
由于`Seek`方法会返回新的计数值,所以我们可以很容易地验证这一点。比如像下面这样:
|
||||
|
||||
```
|
||||
offset2 := int64(17)
|
||||
expectedIndex := reader1.Size() - int64(reader1.Len()) + offset2
|
||||
fmt.Printf("Seek with offset %d and whence %d ...\n", offset2, io.SeekCurrent)
|
||||
readingIndex, _ := reader1.Seek(offset2, io.SeekCurrent)
|
||||
fmt.Printf("The reading index in reader: %d (returned by Seek)\n", readingIndex)
|
||||
fmt.Printf("The reading index in reader: %d (computed by me)\n", expectedIndex)
|
||||
|
||||
```
|
||||
|
||||
综上所述,`Reader`值实现高效读取的关键就在于它内部的已读计数。计数的值就代表着下一次读取的起始索引位置。它可以很容易地被计算出来。`Reader`值的`Seek`方法可以直接设定该值中的已读计数值。
|
||||
|
||||
## 总结
|
||||
|
||||
今天,我们主要讨论了`strings`代码包中的两个重要类型,即:`Builder`和`Reader`。前者用于构建字符串,而后者则用于读取字符串。
|
||||
|
||||
与`string`值相比,`Builder`值的优势主要体现在字符串拼接方面。它可以在保证已存在的内容不变的前提下,拼接更多的内容,并且会在拼接的过程中,尽量减少内存分配和内容拷贝的次数。
|
||||
|
||||
不过,这类值在使用上也是有约束的。它在被真正使用之后就不能再被复制了,否则就会引发panic。虽然这个约束很严格,但是也可以带来一定的好处。它可以有效地避免一些操作冲突。虽然我们可以通过一些手段(比如传递它的指针值)绕过这个约束,但这是弊大于利的。最好的解决方案就是分别声明、分开使用、互不干涉。
|
||||
|
||||
`Reader`值可以让我们很方便地读取一个字符串中的内容。它的高效主要体现在它对字符串的读取机制上。在读取的过程中,`Reader`值会保存已读取的字节的计数,也称已读计数。
|
||||
|
||||
这个计数代表着下一次读取的起始索引位置,同时也是高效读取的关键所在。我们可以利用这类值的`Len`方法和`Size`方法,计算出其中的已读计数的值。有了它,我们就可以更加灵活地进行字符串读取了。
|
||||
|
||||
我只在本文介绍了上述两个数据类型,但并不意味着`strings`包中有用的程序实体只有这两个。实际上,`strings`包还提供了大量的函数。比如:
|
||||
|
||||
```
|
||||
`Count`、`IndexRune`、`Map`、`Replace`、`SplitN`、`Trim`,等等。
|
||||
|
||||
```
|
||||
|
||||
它们都是非常易用和高效的。你可以去看看它们的源码,也许会因此有所感悟。
|
||||
|
||||
## 思考题
|
||||
|
||||
今天的思考题是:`*strings.Builder`和`*strings.Reader`都分别实现了哪些接口?这样做有什么好处吗?
|
||||
|
||||
[戳此查看Go语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers)
|
||||
|
||||
|
||||
141
极客时间专栏/Go语言核心36讲/模块三:Go语言实战与应用/38 | bytes包与字节串操作(上).md
Normal file
141
极客时间专栏/Go语言核心36讲/模块三:Go语言实战与应用/38 | bytes包与字节串操作(上).md
Normal file
@@ -0,0 +1,141 @@
|
||||
<audio id="audio" title="38 | bytes包与字节串操作(上)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ba/ba/ba50388910f70a3e9f48adde5e12dbba.mp3"></audio>
|
||||
|
||||
我相信,经过上一次的学习,你已经对`strings.Builder`和`strings.Reader`这两个类型足够熟悉了。
|
||||
|
||||
我上次还建议你去自行查阅`strings`代码包中的其他程序实体。如果你认真去看了,那么肯定会对我们今天要讨论的`bytes`代码包,有种似曾相识的感觉。
|
||||
|
||||
## 前导内容: `bytes.Buffer`基础知识
|
||||
|
||||
`strings`包和`bytes`包可以说是一对孪生兄弟,它们在API方面非常的相似。单从它们提供的函数的数量和功能上讲,差别可以说是微乎其微。
|
||||
|
||||
**只不过,`strings`包主要面向的是Unicode字符和经过UTF-8编码的字符串,而`bytes`包面对的则主要是字节和字节切片。**
|
||||
|
||||
我今天会主要讲`bytes`包中最有特色的类型`Buffer`。顾名思义,`bytes.Buffer`类型的用途主要是作为字节序列的缓冲区。
|
||||
|
||||
与`strings.Builder`类型一样,`bytes.Buffer`也是开箱即用的。
|
||||
|
||||
但不同的是,`strings.Builder`只能拼接和导出字符串,而`bytes.Buffer`不但可以拼接、截断其中的字节序列,以各种形式导出其中的内容,还可以顺序地读取其中的子序列。
|
||||
|
||||
可以说,`bytes.Buffer`是集读、写功能于一身的数据类型。当然了,这些也基本上都是作为一个缓冲区应该拥有的功能。
|
||||
|
||||
在内部,`bytes.Buffer`类型同样是使用字节切片作为内容容器的。并且,与`strings.Reader`类型类似,`bytes.Buffer`有一个`int`类型的字段,用于代表已读字节的计数,可以简称为已读计数。
|
||||
|
||||
不过,这里的已读计数就无法通过`bytes.Buffer`提供的方法计算出来了。
|
||||
|
||||
我们先来看下面的代码:
|
||||
|
||||
```
|
||||
var buffer1 bytes.Buffer
|
||||
contents := "Simple byte buffer for marshaling data."
|
||||
fmt.Printf("Writing contents %q ...\n", contents)
|
||||
buffer1.WriteString(contents)
|
||||
fmt.Printf("The length of buffer: %d\n", buffer1.Len())
|
||||
fmt.Printf("The capacity of buffer: %d\n", buffer1.Cap())
|
||||
|
||||
```
|
||||
|
||||
我先声明了一个`bytes.Buffer`类型的变量`buffer1`,并写入了一个字符串。然后,我想打印出这个`bytes.Buffer`类型的值(以下简称`Buffer`值)的长度和容量。在运行这段代码之后,我们将会看到如下的输出:
|
||||
|
||||
```
|
||||
Writing contents "Simple byte buffer for marshaling data." ...
|
||||
The length of buffer: 39
|
||||
The capacity of buffer: 64
|
||||
|
||||
```
|
||||
|
||||
乍一看这没什么问题。长度`39`和容量`64`的含义看起来与我们已知的概念是一致的。我向缓冲区中写入了一个长度为`39`的字符串,所以`buffer1`的长度就是`39`。
|
||||
|
||||
根据切片的自动扩容策略,`64`这个数字也是合理的。另外,可以想象,这时的已读计数的值应该是`0`,这是因为我还没有调用任何用于读取其中内容的方法。
|
||||
|
||||
可实际上,与`strings.Reader`类型的`Len`方法一样,`buffer1`的`Len`方法返回的也是内容容器中未被读取部分的长度,而不是其中已存内容的总长度(以下简称内容长度)。示例如下:
|
||||
|
||||
```
|
||||
p1 := make([]byte, 7)
|
||||
n, _ := buffer1.Read(p1)
|
||||
fmt.Printf("%d bytes were read. (call Read)\n", n)
|
||||
fmt.Printf("The length of buffer: %d\n", buffer1.Len())
|
||||
fmt.Printf("The capacity of buffer: %d\n", buffer1.Cap())
|
||||
|
||||
```
|
||||
|
||||
当我从`buffer1`中读取一部分内容,并用它们填满长度为`7`的字节切片`p1`之后,`buffer1`的`Len`方法返回的结果值也会随即发生变化。如果运行这段代码,我们会发现,这个缓冲区的长度已经变为了`32`。
|
||||
|
||||
另外,因为我们并没有再向该缓冲区中写入任何内容,所以它的容量会保持不变,仍是`64`。
|
||||
|
||||
**总之,在这里,你需要记住的是,`Buffer`值的长度是未读内容的长度,而不是已存内容的总长度。** 它与在当前值之上的读操作和写操作都有关系,并会随着这两种操作的进行而改变,它可能会变得更小,也可能会变得更大。
|
||||
|
||||
而`Buffer`值的容量指的是它的内容容器(也就是那个字节切片)的容量,它只与在当前值之上的写操作有关,并会随着内容的写入而不断增长。
|
||||
|
||||
再说已读计数。由于`strings.Reader`还有一个`Size`方法可以给出内容长度的值,所以我们用内容长度减去未读部分的长度,就可以很方便地得到它的已读计数。
|
||||
|
||||
然而,`bytes.Buffer`类型却没有这样一个方法,它只有`Cap`方法。可是`Cap`方法提供的是内容容器的容量,也不是内容长度。
|
||||
|
||||
并且,这里的内容容器容量在很多时候都与内容长度不相同。因此,没有了现成的计算公式,只要遇到稍微复杂些的情况,我们就很难估算出`Buffer`值的已读计数。
|
||||
|
||||
一旦理解了已读计数这个概念,并且能够在读写的过程中,实时地获得已读计数和内容长度的值,我们就可以很直观地了解到当前`Buffer`值各种方法的行为了。不过,很可惜,这两个数字我们都无法直接拿到。
|
||||
|
||||
虽然,我们无法直接得到一个`Buffer`值的已读计数,并且有时候也很难估算它,但是我们绝对不能就此作罢,而应该通过研读`bytes.Buffer`和文档和源码,去探究已读计数在其中起到的关键作用。
|
||||
|
||||
否则,我们想用好`bytes.Buffer`的意愿,恐怕就不会那么容易实现了。
|
||||
|
||||
下面的这个问题,如果你认真地阅读了`bytes.Buffer`的源码之后,就可以很好地回答出来。
|
||||
|
||||
**我们今天的问题是:`bytes.Buffer`类型的值记录的已读计数,在其中起到了怎样的作用?**
|
||||
|
||||
这道题的典型回答是这样的。
|
||||
|
||||
`bytes.Buffer`中的已读计数的大致功用如下所示。
|
||||
|
||||
1. 读取内容时,相应方法会依据已读计数找到未读部分,并在读取后更新计数。
|
||||
1. 写入内容时,如需扩容,相应方法会根据已读计数实现扩容策略。
|
||||
1. 截断内容时,相应方法截掉的是已读计数代表索引之后的未读部分。
|
||||
1. 读回退时,相应方法需要用已读计数记录回退点。
|
||||
1. 重置内容时,相应方法会把已读计数置为`0`。
|
||||
1. 导出内容时,相应方法只会导出已读计数代表的索引之后的未读部分。
|
||||
1. 获取长度时,相应方法会依据已读计数和内容容器的长度,计算未读部分的长度并返回。
|
||||
|
||||
## 问题解析
|
||||
|
||||
通过上面的典型回答,我们已经能够体会到已读计数在`bytes.Buffer`类型,及其方法中的重要性了。没错,`bytes.Buffer`的绝大多数方法都用到了已读计数,而且都是非用不可。
|
||||
|
||||
**在读取内容的时候**,相应方法会先根据已读计数,判断一下内容容器中是否还有未读的内容。如果有,那么它就会从已读计数代表的索引处开始读取。
|
||||
|
||||
**在读取完成后**,它还会及时地更新已读计数。也就是说,它会记录一下又有多少个字节被读取了。**这里所说的相应方法包括了所有名称以`Read`开头的方法,以及`Next`方法和`WriteTo`方法。**
|
||||
|
||||
**在写入内容的时候**,绝大多数的相应方法都会先检查当前的内容容器,是否有足够的容量容纳新的内容。如果没有,那么它们就会对内容容器进行扩容。
|
||||
|
||||
**在扩容的时候**,方法会在必要时,依据已读计数找到未读部分,并把其中的内容拷贝到扩容后内容容器的头部位置。
|
||||
|
||||
然后,方法将会把已读计数的值置为`0`,以表示下一次读取需要从内容容器的第一个字节开始。**用于写入内容的相应方法,包括了所有名称以`Write`开头的方法,以及`ReadFrom`方法。**
|
||||
|
||||
**用于截断内容的方法`Truncate`,会让很多对`bytes.Buffer`不太了解的程序开发者迷惑。** 它会接受一个`int`类型的参数,这个参数的值代表了:在截断时需要保留头部的多少个字节。
|
||||
|
||||
不过,需要注意的是,这里说的头部指的并不是内容容器的头部,而是其中的未读部分的头部。头部的起始索引正是由已读计数的值表示的。因此,在这种情况下,已读计数的值再加上参数值后得到的和,就是内容容器新的总长度。
|
||||
|
||||
**在`bytes.Buffer`中,用于读回退的方法有`UnreadByte`和`UnreadRune`。** 这两个方法分别用于回退一个字节和回退一个Unicode字符。调用它们一般都是为了退回在上一次被读取内容末尾的那个分隔符,或者为重新读取前一个字节或字符做准备。
|
||||
|
||||
不过,退回的前提是,在调用它们之前的那一个操作必须是“读取”,并且是成功的读取,否则这些方法就只能忽略后续操作并返回一个非`nil`的错误值。
|
||||
|
||||
`UnreadByte`方法的做法比较简单,把已读计数的值减`1`就好了。而`UnreadRune`方法需要从已读计数中减去的,是上一次被读取的Unicode字符所占用的字节数。
|
||||
|
||||
这个字节数由`bytes.Buffer`的另一个字段负责存储,它在这里的有效取值范围是[1, 4]。只有`ReadRune`方法才会把这个字段的值设定在此范围之内。
|
||||
|
||||
由此可见,只有紧接在调用`ReadRune`方法之后,对`UnreadRune`方法的调用才能够成功完成。该方法明显比`UnreadByte`方法的适用面更窄。
|
||||
|
||||
我在前面说过,`bytes.Buffer`的`Len`方法返回的是内容容器中未读部分的长度,而不是其中已存内容的总长度(即:内容长度)。
|
||||
|
||||
而该类型的`Bytes`方法和`String`方法的行为,与`Len`方法是保持一致的。前两个方法只会去访问未读部分中的内容,并返回相应的结果值。
|
||||
|
||||
在我们剖析了所有的相关方法之后,可以这样来总结:在已读计数代表的索引之前的那些内容,永远都是已经被读过的,它们几乎没有机会再次被读取。
|
||||
|
||||
不过,这些已读内容所在的内存空间可能会被存入新的内容。这一般都是由于重置或者扩充内容容器导致的。这时,已读计数一定会被置为`0`,从而再次指向内容容器中的第一个字节。这有时候也是为了避免内存分配和重用内存空间。
|
||||
|
||||
## 总结
|
||||
|
||||
总结一下,`bytes.Buffer`是一个集读、写功能于一身的数据类型。它非常适合作为字节序列的缓冲区。我们会在下一篇文章中继续对bytes.Buffer的知识进行延展。如果你对于这部分内容有什么样问题,欢迎给我留言,我们一起讨论。
|
||||
|
||||
感谢你的收听,我们下次再见。
|
||||
|
||||
[戳此查看Go语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers)
|
||||
|
||||
|
||||
139
极客时间专栏/Go语言核心36讲/模块三:Go语言实战与应用/39 | bytes包与字节串操作(下).md
Normal file
139
极客时间专栏/Go语言核心36讲/模块三:Go语言实战与应用/39 | bytes包与字节串操作(下).md
Normal file
@@ -0,0 +1,139 @@
|
||||
<audio id="audio" title="39 | bytes包与字节串操作(下)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/1f/c6/1f81df9ff385ae6e343d551f09628fc6.mp3"></audio>
|
||||
|
||||
你好,我是郝林,今天我们继续分享bytes包与字节串操作的相关内容。
|
||||
|
||||
在上一篇文章中,我们分享了`bytes.Buffer`中已读计数的大致功用,并围绕着这个问题做了解析,下面我们来进行相关的知识扩展。
|
||||
|
||||
## 知识扩展
|
||||
|
||||
### 问题 1:`bytes.Buffer`的扩容策略是怎样的?
|
||||
|
||||
`Buffer`值既可以被手动扩容,也可以进行自动扩容。并且,这两种扩容方式的策略是基本一致的。所以,除非我们完全确定后续内容所需的字节数,否则让`Buffer`值自动去扩容就好了。
|
||||
|
||||
在扩容的时候,`Buffer`值中相应的代码(以下简称扩容代码)会**先判断内容容器的剩余容量**,是否可以满足调用方的要求,或者是否足够容纳新的内容。
|
||||
|
||||
**如果可以,那么扩容代码会在当前的内容容器之上,进行长度扩充。**
|
||||
|
||||
更具体地说,如果内容容器的容量与其长度的差,大于或等于另需的字节数,那么扩容代码就会通过切片操作对原有的内容容器的长度进行扩充,就像下面这样:
|
||||
|
||||
```
|
||||
b.buf = b.buf[:length+need]
|
||||
|
||||
```
|
||||
|
||||
**反之,如果内容容器的剩余容量不够了,那么扩容代码可能就会用新的内容容器去替代原有的内容容器,从而实现扩容。**
|
||||
|
||||
不过,这里还有一步优化。
|
||||
|
||||
**如果当前内容容器的容量的一半,仍然大于或等于其现有长度再加上另需的字节数的和**,即:
|
||||
|
||||
```
|
||||
cap(b.buf)/2 >= len(b.buf)+need
|
||||
|
||||
```
|
||||
|
||||
那么,扩容代码就会复用现有的内容容器,并把容器中的未读内容拷贝到它的头部位置。
|
||||
|
||||
这也意味着其中的已读内容,将会全部被未读内容和之后的新内容覆盖掉。
|
||||
|
||||
这样的复用预计可以至少节省掉一次后续的扩容所带来的内存分配,以及若干字节的拷贝。
|
||||
|
||||
**若这一步优化未能达成**,也就是说,当前内容容器的容量小于新长度的二倍。
|
||||
|
||||
那么,扩容代码就只能再创建一个新的内容容器,并把原有容器中的未读内容拷贝进去,最后再用新的容器替换掉原有的容器。这个新容器的容量将会等于原有容量的二倍再加上另需字节数的和。
|
||||
|
||||
>
|
||||
新容器的容量=2*原有容量+所需字节数
|
||||
|
||||
|
||||
通过上面这些步骤,对内容容器的扩充基本上就完成了。不过,为了内部数据的一致性,以及避免原有的已读内容可能造成的数据混乱,扩容代码还会把已读计数置为`0`,并再对内容容器做一下切片操作,以掩盖掉原有的已读内容。
|
||||
|
||||
顺便说一下,对于处在零值状态的`Buffer`值来说,如果第一次扩容时的另需字节数不大于`64`,那么该值就会基于一个预先定义好的、长度为`64`的字节数组来创建内容容器。
|
||||
|
||||
在这种情况下,这个内容容器的容量就是`64`。这样做的目的是为了让`Buffer`值在刚被真正使用的时候就可以快速地做好准备。
|
||||
|
||||
### 问题2:`bytes.Buffer`中的哪些方法可能会造成内容的泄露?
|
||||
|
||||
首先明确一点,什么叫内容泄露?这里所说的内容泄露是指,使用`Buffer`值的一方通过某种非标准的(或者说不正式的)方式,得到了本不该得到的内容。
|
||||
|
||||
比如说,我通过调用`Buffer`值的某个用于读取内容的方法,得到了一部分未读内容。我应该,也只应该通过这个方法的结果值,拿到在那一时刻`Buffer`值中的未读内容。
|
||||
|
||||
但是,在这个`Buffer`值又有了一些新内容之后,我却可以通过当时得到的结果值,直接获得新的内容,而不需要再次调用相应的方法。
|
||||
|
||||
这就是典型的非标准读取方式。这种读取方式是不应该存在的,即使存在,我们也不应该使用。因为它是在无意中(或者说一不小心)暴露出来的,其行为很可能是不稳定的。
|
||||
|
||||
在`bytes.Buffer`中,`Bytes`方法和`Next`方法都可能会造成内容的泄露。原因在于,它们都把基于内容容器的切片直接返回给了方法的调用方。
|
||||
|
||||
我们都知道,通过切片,我们可以直接访问和操纵它的底层数组。不论这个切片是基于某个数组得来的,还是通过对另一个切片做切片操作获得的,都是如此。
|
||||
|
||||
在这里,`Bytes`方法和`Next`方法返回的字节切片,都是通过对内容容器做切片操作得到的。也就是说,它们与内容容器共用了同一个底层数组,起码在一段时期之内是这样的。
|
||||
|
||||
以`Bytes`方法为例。它会返回在调用那一刻其所属值中的所有未读内容。示例代码如下:
|
||||
|
||||
```
|
||||
contents := "ab"
|
||||
buffer1 := bytes.NewBufferString(contents)
|
||||
fmt.Printf("The capacity of new buffer with contents %q: %d\n",
|
||||
contents, buffer1.Cap()) // 内容容器的容量为:8。
|
||||
unreadBytes := buffer1.Bytes()
|
||||
fmt.Printf("The unread bytes of the buffer: %v\n", unreadBytes) // 未读内容为:[97 98]。
|
||||
|
||||
```
|
||||
|
||||
我用字符串值`"ab"`初始化了一个`Buffer`值,由变量`buffer1`代表,并打印了当时该值的一些状态。
|
||||
|
||||
你可能会有疑惑,我只在这个`Buffer`值中放入了一个长度为`2`的字符串值,但为什么该值的容量却变为了`8`。
|
||||
|
||||
虽然这与我们当前的主题无关,但是我可以提示你一下:你可以去阅读`runtime`包中一个名叫`stringtoslicebyte`的函数,答案就在其中。
|
||||
|
||||
接着说`buffer1`。我又向该值写入了字符串值`"cdefg"`,此时,其容量仍然是`8`。我在前面通过调用`buffer1`的`Bytes`方法得到的结果值`unreadBytes`,包含了在那时其中的所有未读内容。
|
||||
|
||||
但是,由于这个结果值与`buffer1`的内容容器在此时还共用着同一个底层数组,所以,我只需通过简单的再切片操作,就可以利用这个结果值拿到`buffer1`在此时的所有未读内容。如此一来,`buffer1`的新内容就被泄露出来了。
|
||||
|
||||
```
|
||||
buffer1.WriteString("cdefg")
|
||||
fmt.Printf("The capacity of buffer: %d\n", buffer1.Cap()) // 内容容器的容量仍为:8。
|
||||
unreadBytes = unreadBytes[:cap(unreadBytes)]
|
||||
fmt.Printf("The unread bytes of the buffer: %v\n", unreadBytes) // 基于前面获取到的结果值可得,未读内容为:[97 98 99 100 101 102 103 0]。
|
||||
|
||||
```
|
||||
|
||||
如果我当时把`unreadBytes`的值传到了外界,那么外界就可以通过该值操纵`buffer1`的内容了,就像下面这样:
|
||||
|
||||
```
|
||||
unreadBytes[len(unreadBytes)-2] = byte('X') // 'X'的ASCII编码为88。
|
||||
fmt.Printf("The unread bytes of the buffer: %v\n", buffer1.Bytes()) // 未读内容变为了:[97 98 99 100 101 102 88]。
|
||||
|
||||
```
|
||||
|
||||
现在,你应该能够体会到,这里的内容泄露可能造成的严重后果了吧?对于`Buffer`值的`Next`方法,也存在相同的问题。
|
||||
|
||||
不过,如果经过扩容,`Buffer`值的内容容器或者它的底层数组被重新设定了,那么之前的内容泄露问题就无法再进一步发展了。我在demo80.go文件中写了一个比较完整的示例,你可以去看一看,并揣摩一下。
|
||||
|
||||
## 总结
|
||||
|
||||
我们结合两篇内容总结一下。与`strings.Builder`类型不同,`bytes.Buffer`不但可以拼接、截断其中的字节序列,以各种形式导出其中的内容,还可以顺序地读取其中的子序列。
|
||||
|
||||
`bytes.Buffer`类型使用字节切片作为其内容容器,并且会用一个字段实时地记录已读字节的计数。
|
||||
|
||||
虽然我们无法直接计算出这个已读计数,但是由于它在`Buffer`值中起到的作用非常关键,所以我们很有必要去理解它。
|
||||
|
||||
无论是读取、写入、截断、导出还是重置,已读计数都是功能实现中的重要一环。
|
||||
|
||||
与`strings.Builder`类型的值一样,`Buffer`值既可以被手动扩容,也可以进行自动的扩容。除非我们完全确定后续内容所需的字节数,否则让`Buffer`值自动去扩容就好了。
|
||||
|
||||
`Buffer`值的扩容方法并不一定会为了获得更大的容量,替换掉现有的内容容器,而是先会本着尽量减少内存分配和内容拷贝的原则,对当前的内容容器进行重用。并且,只有在容量实在无法满足要求的时候,它才会去创建新的内容容器。
|
||||
|
||||
此外,你可能并没有想到,`Buffer`值的某些方法可能会造成内容的泄露。这主要是由于这些方法返回的结果值,在一段时期内会与其所属值的内容容器共用同一个底层数组。
|
||||
|
||||
**如果我们有意或无意地把这些结果值传到了外界,那么外界就有可能通过它们操纵相关联`Buffer`值的内容。**
|
||||
|
||||
这属于很严重的数据安全问题。我们一定要避免这种情况的发生。最彻底的做法是,在传出切片这类值之前要做好隔离。比如,先对它们进行深度拷贝,然后再把副本传出去。
|
||||
|
||||
## 思考题
|
||||
|
||||
今天的思考题是:对比`strings.Builder`和`bytes.Buffer`的`String`方法,并判断哪一个更高效?原因是什么?
|
||||
|
||||
[戳此查看Go语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers)
|
||||
|
||||
|
||||
195
极客时间专栏/Go语言核心36讲/模块三:Go语言实战与应用/40 | io包中的接口和工具 (上).md
Normal file
195
极客时间专栏/Go语言核心36讲/模块三:Go语言实战与应用/40 | io包中的接口和工具 (上).md
Normal file
@@ -0,0 +1,195 @@
|
||||
<audio id="audio" title="40 | io包中的接口和工具 (上)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/30/44/30489fc1c8b7f8b455366a2fffd2bc44.mp3"></audio>
|
||||
|
||||
我们在前几篇文章中,主要讨论了`strings.Builder`、`strings.Reader`和`bytes.Buffer`这三个数据类型。
|
||||
|
||||
## 知识回顾
|
||||
|
||||
还记得吗?当时我还问过你“它们都实现了哪些接口”。在我们继续讲解`io`包中的接口和工具之前,我先来解答一下这个问题。
|
||||
|
||||
**`strings.Builder`类型主要用于构建字符串**,它的指针类型实现的接口有`io.Writer`、`io.ByteWriter`和`fmt.Stringer`。另外,它其实还实现了一个`io`包的包级私有接口`io.stringWriter`(自Go 1.12起它会更名为`io.StringWriter`)。
|
||||
|
||||
**`strings.Reader`类型主要用于读取字符串**,它的指针类型实现的接口比较多,包括:
|
||||
|
||||
1. `io.Reader`;
|
||||
1. `io.ReaderAt`;
|
||||
1. `io.ByteReader`;
|
||||
1. `io.RuneReader`;
|
||||
1. `io.Seeker`;
|
||||
1. `io.ByteScanner`;
|
||||
1. `io.RuneScanner`;
|
||||
1. `io.WriterTo`;
|
||||
|
||||
共有8个,它们都是`io`包中的接口。
|
||||
|
||||
其中,`io.ByteScanner`是`io.ByteReader`的扩展接口,而`io.RuneScanner`又是`io.RuneReader`的扩展接口。
|
||||
|
||||
**`bytes.Buffer`是集读、写功能于一身的数据类型,它非常适合作为字节序列的缓冲区。** 它的指针类型实现的接口就更多了。
|
||||
|
||||
更具体地说,该指针类型实现的读取相关的接口有下面几个。
|
||||
|
||||
1. `io.Reader`;
|
||||
1. `io.ByteReader`;
|
||||
1. `io.RuneReader`;
|
||||
1. `io.ByteScanner`;
|
||||
1. `io.RuneScanner`;
|
||||
1. `io.WriterTo`;
|
||||
|
||||
共有6个。而其实现的写入相关的接口则有这些。
|
||||
|
||||
1. `io.Writer`;
|
||||
1. `io.ByteWriter`;
|
||||
1. `io.stringWriter`;
|
||||
1. `io.ReaderFrom`;
|
||||
|
||||
共4个。此外,它还实现了导出相关的接口`fmt.Stringer`。
|
||||
|
||||
## 前导内容:io包中接口的好处与优势
|
||||
|
||||
那么,这些类型实现了这么多的接口,其动机(或者说目的)究竟是什么呢?
|
||||
|
||||
**简单地说,这是为了提高不同程序实体之间的互操作性。**远的不说,我们就以`io`包中的一些函数为例。
|
||||
|
||||
在`io`包中,有这样几个用于拷贝数据的函数,它们是:
|
||||
|
||||
- `io.Copy`;
|
||||
- `io.CopyBuffer`;
|
||||
- `io.CopyN`。
|
||||
|
||||
虽然这几个函数在功能上都略有差别,但是它们都首先会接受两个参数,即:用于代表数据目的地、`io.Writer`类型的参数`dst`,以及用于代表数据来源的、`io.Reader`类型的参数`src`。这些函数的功能大致上都是把数据从`src`拷贝到`dst`。
|
||||
|
||||
不论我们给予它们的第一个参数值是什么类型的,只要这个类型实现了`io.Writer`接口即可。
|
||||
|
||||
同样的,无论我们传给它们的第二个参数值的实际类型是什么,只要该类型实现了`io.Reader`接口就行。
|
||||
|
||||
一旦我们满足了这两个条件,这些函数几乎就可以正常地执行了。当然了,函数中还会对必要的参数值进行有效性的检查,如果检查不通过,它的执行也是不能够成功结束的。
|
||||
|
||||
下面来看一段示例代码:
|
||||
|
||||
```
|
||||
src := strings.NewReader(
|
||||
"CopyN copies n bytes (or until an error) from src to dst. " +
|
||||
"It returns the number of bytes copied and " +
|
||||
"the earliest error encountered while copying.")
|
||||
dst := new(strings.Builder)
|
||||
written, err := io.CopyN(dst, src, 58)
|
||||
if err != nil {
|
||||
fmt.Printf("error: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("Written(%d): %q\n", written, dst.String())
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我先使用`strings.NewReader`创建了一个字符串读取器,并把它赋给了变量`src`,然后我又`new`了一个字符串构建器,并将其赋予了变量`dst`。
|
||||
|
||||
之后,我在调用`io.CopyN`函数的时候,把这两个变量的值都传了进去,同时把给这个函数的第三个参数值设定为了`58`。也就是说,我想从`src`中拷贝前`58`个字节到`dst`那里。
|
||||
|
||||
虽然,变量`src`和`dst`的类型分别是`strings.Reader`和`strings.Builder`,但是当它们被传到`io.CopyN`函数的时候,就已经分别被包装成了`io.Reader`类型和`io.Writer`类型的值。`io.CopyN`函数也根本不会去在意,它们的实际类型到底是什么。
|
||||
|
||||
为了优化的目的,`io.CopyN`函数中的代码会对参数值进行再包装,也会检测这些参数值是否还实现了别的接口,甚至还会去探求某个参数值被包装后的实际类型,是否为某个特殊的类型。
|
||||
|
||||
但是,从总体上来看,这些代码都是面向参数声明中的接口来做的。`io.CopyN`函数的作者通过面向接口编程,极大地拓展了它的适用范围和应用场景。
|
||||
|
||||
换个角度看,正因为`strings.Reader`类型和`strings.Builder`类型都实现了不少接口,所以它们的值才能够被使用在更广阔的场景中。
|
||||
|
||||
**换句话说,如此一来,Go语言的各种库中,能够操作它们的函数和数据类型明显多了很多。**
|
||||
|
||||
这就是我想要告诉你的,`strings`包和`bytes`包中的数据类型在实现了若干接口之后得到的最大好处。
|
||||
|
||||
也可以说,这就是面向接口编程带来的最大优势。这些数据类型和函数的做法,也是非常值得我们在编程的过程中去效仿的。
|
||||
|
||||
可以看到,前文所述的几个类型实现的大都是`io`代码包中的接口。实际上,`io`包中的接口,对于Go语言的标准库和很多第三方库而言,都起着举足轻重的作用。它们非常基础也非常重要。
|
||||
|
||||
就拿`io.Reader`和`io.Writer`这两个最核心的接口来说,它们是很多接口的扩展对象和设计源泉。同时,单从Go语言的标准库中统计,实现了它们的数据类型都(各自)有上百个,而引用它们的代码更是都(各自)有400多处。
|
||||
|
||||
很多数据类型实现了`io.Reader`接口,是因为它们提供了从某处读取数据的功能。类似的,许多能够把数据写入某处的数据类型,也都会去实现`io.Writer`接口。
|
||||
|
||||
其实,有不少类型的设计初衷都是:实现这两个核心接口的某个,或某些扩展接口,以提供比单纯的字节序列读取或写入,更加丰富的功能,就像前面讲到的那几个`strings`包和`bytes`包中的数据类型那样。
|
||||
|
||||
在Go语言中,对接口的扩展是通过接口类型之间的嵌入来实现的,这也常被叫做接口的组合。
|
||||
|
||||
我在讲接口的时候也提到过,Go语言提倡使用小接口加接口组合的方式,来扩展程序的行为以及增加程序的灵活性。`io`代码包恰恰就可以作为这样的一个标杆,它可以成为我们运用这种技巧时的一个参考标准。
|
||||
|
||||
下面,我就以`io.Reader`接口为对象提出一个与接口扩展和实现有关的问题。如果你研究过这个核心接口以及相关的数据类型的话,这个问题回答起来就并不困难。
|
||||
|
||||
**我们今天的问题是:在`io`包中,`io.Reader`的扩展接口和实现类型都有哪些?它们分别都有什么功用?**
|
||||
|
||||
这道题的**典型回答**是这样的。在`io`包中,`io.Reader`的扩展接口有下面几种。
|
||||
|
||||
1. `io.ReadWriter`:此接口既是`io.Reader`的扩展接口,也是`io.Writer`的扩展接口。换句话说,该接口定义了一组行为,包含且仅包含了基本的字节序列读取方法`Read`,和字节序列写入方法`Write`。
|
||||
1. `io.ReadCloser`:此接口除了包含基本的字节序列读取方法之外,还拥有一个基本的关闭方法`Close`。后者一般用于关闭数据读写的通路。这个接口其实是`io.Reader`接口和`io.Closer`接口的组合。
|
||||
1. `io.ReadWriteCloser`:很明显,此接口是`io.Reader`、`io.Writer`和`io.Closer`这三个接口的组合。
|
||||
1. `io.ReadSeeker`:此接口的特点是拥有一个用于寻找读写位置的基本方法`Seek`。更具体地说,该方法可以根据给定的偏移量基于数据的起始位置、末尾位置,或者当前读写位置去寻找新的读写位置。这个新的读写位置用于表明下一次读或写时的起始索引。`Seek`是`io.Seeker`接口唯一拥有的方法。
|
||||
1. `io.ReadWriteSeeker`:显然,此接口是另一个三合一的扩展接口,它是`io.Reader`、`io.Writer`和`io.Seeker`的组合。
|
||||
|
||||
再来说说`io`包中的`io.Reader`接口的实现类型,它们包括下面几项内容。
|
||||
|
||||
<li>
|
||||
`*io.LimitedReader`:此类型的基本类型会包装`io.Reader`类型的值,并提供一个额外的受限读取的功能。所谓的受限读取指的是,此类型的读取方法`Read`返回的总数据量会受到限制,无论该方法被调用多少次。这个限制由该类型的字段`N`指明,单位是字节。
|
||||
</li>
|
||||
<li>
|
||||
<p>`*io.SectionReader`:此类型的基本类型可以包装`io.ReaderAt`类型的值,并且会限制它的`Read`方法,只能够读取原始数据中的某一个部分(或者说某一段)。<br>
|
||||
<br>这个数据段的起始位置和末尾位置,需要在它被初始化的时候就指明,并且之后无法变更。该类型值的行为与切片有些类似,它只会对外暴露在其窗口之中的那些数据。</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>`*io.teeReader`:此类型是一个包级私有的数据类型,也是`io.TeeReader`函数结果值的实际类型。这个函数接受两个参数`r`和`w`,类型分别是`io.Reader`和`io.Writer`。<br>
|
||||
<br> 其结果值的`Read`方法会把`r`中的数据经过作为方法参数的字节切片`p`写入到`w`。可以说,这个值就是`r`和`w`之间的数据桥梁,而那个参数`p`就是这座桥上的数据搬运者。</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>`*io.multiReader`:此类型也是一个包级私有的数据类型。类似的,`io`包中有一个名为`MultiReader`的函数,它可以接受若干个`io.Reader`类型的参数值,并返回一个实际类型为`io.multiReader`的结果值。<br><br>
|
||||
当这个结果值的`Read`方法被调用时,它会顺序地从前面那些`io.Reader`类型的参数值中读取数据。因此,我们也可以称之为多对象读取器。</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>`*io.pipe`:此类型为一个包级私有的数据类型,它比上述类型都要复杂得多。它不但实现了`io.Reader`接口,而且还实现了`io.Writer`接口。<br><br>
|
||||
实际上,`io.PipeReader`类型和`io.PipeWriter`类型拥有的所有指针方法都是以它为基础的。这些方法都只是代理了`io.pipe`类型值所拥有的某一个方法而已。<br><br>
|
||||
又因为`io.Pipe`函数会返回这两个类型的指针值并分别把它们作为其生成的同步内存管道的两端,所以可以说,`*io.pipe`类型就是`io`包提供的同步内存管道的核心实现。</p>
|
||||
</li>
|
||||
<li>
|
||||
`*io.PipeReader`:此类型可以被视为`io.pipe`类型的代理类型。它代理了后者的一部分功能,并基于后者实现了`io.ReadCloser`接口。同时,它还定义了同步内存管道的读取端。
|
||||
</li>
|
||||
|
||||
注意,我在这里忽略掉了测试源码文件中的实现类型,以及不会以任何形式直接对外暴露的那些实现类型。
|
||||
|
||||
## 问题解析
|
||||
|
||||
我问这个问题的目的主要是评估你对`io`包的熟悉程度。这个代码包是Go语言标准库中所有I/O相关API的根基,所以,我们必须对其中的每一个程序实体都有所了解。
|
||||
|
||||
然而,由于该包包含的内容众多,因此这里的问题是以`io.Reader`接口作为切入点的。通过`io.Reader`接口,我们应该能够梳理出基于它的类型树,并知晓其中每一个类型的功用。
|
||||
|
||||
`io.Reader`可谓是`io`包乃至是整个Go语言标准库中的核心接口,所以我们可以从它那里牵扯出很多扩展接口和实现类型。
|
||||
|
||||
我在本问题的典型回答中,为你罗列和介绍了`io`包范围内的相关数据类型。
|
||||
|
||||
这些类型中的每一个都值得你认真去理解,尤其是那几个实现了`io.Reader`接口的类型。它们实现的功能在细节上都各有不同。
|
||||
|
||||
在很多时候,我们可以根据实际需求将它们搭配起来使用。
|
||||
|
||||
例如,对施加在原始数据之上的(由`Read`方法提供的)读取功能进行多层次的包装(比如受限读取和多对象读取等),以满足较为复杂的读取需求。
|
||||
|
||||
在实际的面试中,只要应聘者能够从某一个方面出发,说出`io.Reader`的扩展接口及其存在意义,或者说清楚该接口的三五个实现类型,那么就可以算是基本回答正确了。
|
||||
|
||||
比如,从读取、写入、关闭这一些列的基本功能出发,描述清楚:
|
||||
|
||||
- `io.ReadWriter`;
|
||||
- `io.ReadCloser`;
|
||||
- `io.ReadWriteCloser;`
|
||||
|
||||
这几个接口。
|
||||
|
||||
又比如,说明白`io.LimitedReader`和`io.SectionReader`这两个类型之间的异同点。
|
||||
|
||||
再比如,阐述`*io.SectionReader`类型实现`io.ReadSeeker`接口的具体方式,等等。不过,这只是合格的门槛,应聘者回答得越全面越好。
|
||||
|
||||
我在示例文件demo82.go中写了一些代码,以展示上述类型的一些基本用法,供你参考。
|
||||
|
||||
## 总结
|
||||
|
||||
我们今天一直在讨论和梳理`io`代码包中的程序实体,尤其是那些重要的接口及其实现类型。
|
||||
|
||||
`io`包中的接口对于Go语言的标准库和很多第三方库而言,都起着举足轻重的作用。其中最核心的`io.Reader`接口和`io.Writer`接口,是很多接口的扩展对象或设计源泉。我们下一节会继续讲解`io`包中的接口内容。
|
||||
|
||||
你用过哪些`io`包中的接口和工具呢,又有哪些收获和感受呢,你可以给我留言,我们一起讨论。感谢你的收听,我们下次再见。
|
||||
|
||||
[戳此查看Go语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers)
|
||||
|
||||
|
||||
103
极客时间专栏/Go语言核心36讲/模块三:Go语言实战与应用/41 | io包中的接口和工具 (下).md
Normal file
103
极客时间专栏/Go语言核心36讲/模块三:Go语言实战与应用/41 | io包中的接口和工具 (下).md
Normal file
@@ -0,0 +1,103 @@
|
||||
<audio id="audio" title="41 | io包中的接口和工具 (下)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/34/38/34f2f69f79cdd97dbf8205e2ee38ab38.mp3"></audio>
|
||||
|
||||
上一篇文章中,我主要讲到了`io.Reader`的扩展接口和实现类型。当然,`io`代码包中的核心接口不止`io.Reader`一个。
|
||||
|
||||
我们基于它引出的一条主线,只是`io`包类型体系中的一部分。我们很有必要再从另一个角度去探索一下,以求对`io`包有更加全面的了解。
|
||||
|
||||
下面的一个问题就与此有关。
|
||||
|
||||
## 知识扩展
|
||||
|
||||
### 问题:`io`包中的接口都有哪些?它们之间都有着怎样的关系?
|
||||
|
||||
我们可以把没有嵌入其他接口并且只定义了一个方法的接口叫做**简单接口**。在`io`包中,这样的接口一共有11个。
|
||||
|
||||
在它们之中,有的接口有着众多的扩展接口和实现类型,我们可以称之为**核心接口**。**`io`包中的核心接口只有3个,它们是:`io.Reader`、`io.Writer`和`io.Closer`。**
|
||||
|
||||
我们还可以把`io`包中的简单接口分为四大类。这四大类接口分别针对于四种操作,即:读取、写入、关闭和读写位置设定。前三种操作属于基本的I/O操作。
|
||||
|
||||
**关于读取操作,我们在前面已经重点讨论过核心接口`io.Reader`。它在`io`包中有5个扩展接口,并有6个实现类型。除了它,这个包中针对读取操作的接口还有不少。我们下面就来梳理一下。**
|
||||
|
||||
首先来看`io.ByteReader`和`io.RuneReader`这两个简单接口。它们分别定义了一个读取方法,即:`ReadByte`和`ReadRune`。
|
||||
|
||||
但与`io.Reader`接口中`Read`方法不同的是,这两个读取方法分别只能够读取下一个单一的字节和Unicode字符。
|
||||
|
||||
我们之前讲过的数据类型`strings.Reader`和`bytes.Buffer`都是`io.ByteReader`和`io.RuneReader`的实现类型。
|
||||
|
||||
不仅如此,这两个类型还都实现了`io.ByteScanner`接口和`io.RuneScanner`接口。
|
||||
|
||||
`io.ByteScanner`接口内嵌了简单接口`io.ByteReader`,并定义了额外的`UnreadByte`方法。如此一来,它就抽象出了一个能够读取和读回退单个字节的功能集。
|
||||
|
||||
与之类似,`io.RuneScanner`内嵌了简单接口`io.RuneReader`,并定义了额外的`UnreadRune`方法。它抽象的是可以读取和读回退单个Unicode字符的功能集。
|
||||
|
||||
再来看`io.ReaderAt`接口。它也是一个简单接口,其中只定义了一个方法`ReadAt`。与我们在前面说过的读取方法都不同,`ReadAt`是一个纯粹的只读方法。
|
||||
|
||||
它只去读取其所属值中包含的字节,而不对这个值进行任何的改动,比如,它绝对不能去修改已读计数的值。这也是`io.ReaderAt`接口与其实现类型之间最重要的一个约定。
|
||||
|
||||
因此,如果仅仅并发地调用某一个值的`ReadAt`方法,那么安全性应该是可以得到保障的。
|
||||
|
||||
另外,还有一个读取操作相关的接口我们没有介绍过,它就是`io.WriterTo`。这个接口定义了一个名为`WriteTo`的方法。
|
||||
|
||||
千万不要被它的名字迷惑,这个`WriteTo`方法其实是一个读取方法。它会接受一个`io.Writer`类型的参数值,并会把其所属值中的数据读出并写入到这个参数值中。
|
||||
|
||||
与之相对应的是`io.ReaderFrom`接口。它定义了一个名叫`ReadFrom`的写入方法。该方法会接受一个`io.Reader`类型的参数值,并会从该参数值中读出数据,并写入到其所属值中。
|
||||
|
||||
值得一提的是,我们在前面用到过的`io.CopyN`函数,在复制数据的时候会先检测其参数`src`的值,是否实现了`io.WriterTo`接口。如果是,那么它就直接利用该值的`WriteTo`方法,把其中的数据拷贝给参数`dst`代表的值。
|
||||
|
||||
类似的,这个函数还会检测`dst`的值是否实现了`io.ReaderFrom`接口。如果是,那么它就会利用这个值的`ReadFrom`方法,直接从`src`那里把数据拷贝进该值。
|
||||
|
||||
实际上,对于`io.Copy`函数和`io.CopyBuffer`函数来说也是如此,因为它们在内部做数据复制的时候用的都是同一套代码。
|
||||
|
||||
你也看到了,`io.ReaderFrom`接口与`io.WriterTo`接口对应得很规整。**实际上,在`io`包中,与写入操作有关的接口都与读取操作的相关接口有着一定的对应关系。下面,我们就来说说写入操作相关的接口。**
|
||||
|
||||
首先当然是核心接口`io.Writer`。基于它的扩展接口除了有我们已知的`io.ReadWriter`、`io.ReadWriteCloser`和`io.ReadWriteSeeker`之外,还有`io.WriteCloser`和`io.WriteSeeker`。
|
||||
|
||||
我们之前提及的`*io.pipe`就是`io.ReadWriter`接口的实现类型。然而,在`io`包中并没有`io.ReadWriteCloser`接口的实现,它的实现类型主要集中在`net`包中。
|
||||
|
||||
除此之外,写入操作相关的简单接口还有`io.ByteWriter`和`io.WriterAt`。可惜,`io`包中也没有它们的实现类型。不过,有一个数据类型值得在这里提一句,那就是`*os.File`。
|
||||
|
||||
这个类型不但是`io.WriterAt`接口的实现类型,还同时实现了`io.ReadWriteCloser`接口和`io.ReadWriteSeeker`接口。也就是说,该类型支持的I/O操作非常的丰富。
|
||||
|
||||
`io.Seeker`接口作为一个读写位置设定相关的简单接口,也仅仅定义了一个方法,名叫`Seek`。
|
||||
|
||||
我在讲`strings.Reader`类型的时候还专门说过这个`Seek`方法,当时还给出了一个与已读计数估算有关的例子。该方法主要用于寻找并设定下一次读取或写入时的起始索引位置。
|
||||
|
||||
`io`包中有几个基于`io.Seeker`的扩展接口,包括前面讲过的`io.ReadSeeker`和`io.ReadWriteSeeker`,以及还未曾提过的`io.WriteSeeker`。`io.WriteSeeker`是基于`io.Writer`和`io.Seeker`的扩展接口。
|
||||
|
||||
我们之前多次提到的两个指针类型`strings.Reader`和`io.SectionReader`都实现了`io.Seeker`接口。顺便说一句,这两个类型也都是`io.ReaderAt`接口的实现类型。
|
||||
|
||||
最后,关闭操作相关的接口`io.Closer`非常通用,它的扩展接口和实现类型都不少。我们单从名称上就能够一眼看出`io`包中的哪些接口是它的扩展接口。至于它的实现类型,`io`包中只有`io.PipeReader`和`io.PipeWriter`。
|
||||
|
||||
## 总结
|
||||
|
||||
我们来总结一下这两篇的内容。在Go语言中,对接口的扩展是通过接口类型之间的嵌入来实现的,这也常被叫做接口的组合。而`io`代码包恰恰就可以作为接口扩展的一个标杆,它可以成为我们运用这种技巧时的一个参考标准。
|
||||
|
||||
在本文中,我根据接口定义的方法的数量以及是否有接口嵌入,把`io`包中的接口分为了简单接口和扩展接口。
|
||||
|
||||
同时,我又根据这些简单接口的扩展接口和实现类型的数量级,把它们分为了核心接口和非核心接口。
|
||||
|
||||
在`io`包中,称得上核心接口的简单接口只有3个,即:`io.Reader`、`io.Writer`和`io.Closer`。这些核心接口在Go语言标准库中的实现类型都在200个以上。
|
||||
|
||||
另外,根据针对的I/O操作的不同,我还把简单接口分为了四大类。这四大类接口针对的操作分别是:读取、写入、关闭和读写位置设定。
|
||||
|
||||
其中,前三种操作属于基本的I/O操作。基于此,我带你梳理了每个类别的简单接口,并讲解了它们在`io`包中的扩展接口,以及具有代表性的实现类型。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e5/0b/e5b4af00105769cdc9f0ab729bb3b30b.png" alt="">
|
||||
|
||||
( io包中的接口体系)
|
||||
|
||||
除此之外,我还从多个维度为你描述了一些重要程序实体的功用和机理,比如:数据段读取器`io.SectionReader`、作为同步内存管道核心实现的`io.pipe`类型,以及用于数据拷贝的`io.CopyN`函数,等等。
|
||||
|
||||
我如此详尽且多角度的阐释,正是为了让你能够记牢`io`代码包中有着网状关系的接口和数据类型。我希望这个目的已经达到了,最起码,本文可以作为你深刻记忆它们的开始。
|
||||
|
||||
最后再强调一下,`io`包中的简单接口共有11个。其中,读取操作相关的接口有5个,写入操作相关的接口有4个,而与关闭操作有关的接口只有1个,另外还有一个读写位置设定相关的接口。
|
||||
|
||||
此外,`io`包还包含了9个基于这些简单接口的扩展接口。你需要在今后思考和实践的是,你在什么时候应该编写哪些数据类型实现`io`包中的哪些接口,并以此得到最大的好处。
|
||||
|
||||
## 思考题
|
||||
|
||||
今天的思考题是:`io`包中的同步内存管道的运作机制是什么?
|
||||
|
||||
[戳此查看Go语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers)
|
||||
|
||||
|
||||
121
极客时间专栏/Go语言核心36讲/模块三:Go语言实战与应用/42 | bufio包中的数据类型 (上).md
Normal file
121
极客时间专栏/Go语言核心36讲/模块三:Go语言实战与应用/42 | bufio包中的数据类型 (上).md
Normal file
@@ -0,0 +1,121 @@
|
||||
<audio id="audio" title="42 | bufio包中的数据类型 (上)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b3/9c/b399e847c0dd8b41b8ebd71f7788419c.mp3"></audio>
|
||||
|
||||
今天,我们来讲另一个与I/O操作强相关的代码包`bufio`。`bufio`是“buffered I/O”的缩写。顾名思义,这个代码包中的程序实体实现的I/O操作都内置了缓冲区。
|
||||
|
||||
`bufio`包中的数据类型主要有:
|
||||
|
||||
1. `Reader`;
|
||||
1. `Scanner`;
|
||||
1. `Writer`和`ReadWriter`。
|
||||
|
||||
与`io`包中的数据类型类似,这些类型的值也都需要在初始化的时候,包装一个或多个简单I/O接口类型的值。(这里的简单I/O接口类型指的就是`io`包中的那些简单接口。)
|
||||
|
||||
下面,我们将通过一系列问题对`bufio.Reader`类型和`bufio.Writer`类型进行讨论(以前者为主)。**今天我的问题是:`bufio.Reader`类型值中的缓冲区起着怎样的作用?**
|
||||
|
||||
**这道题的典型回答是这样的。**
|
||||
|
||||
`bufio.Reader`类型的值(以下简称`Reader`值)内的缓冲区,其实就是一个数据存储中介,它介于底层读取器与读取方法及其调用方之间。所谓的底层读取器,就是在初始化此类值的时候传入的`io.Reader`类型的参数值。
|
||||
|
||||
`Reader`值的读取方法一般都会先从其所属值的缓冲区中读取数据。同时,在必要的时候,它们还会预先从底层读取器那里读出一部分数据,并暂存于缓冲区之中以备后用。
|
||||
|
||||
有这样一个缓冲区的好处是,可以在大多数的时候降低读取方法的执行时间。虽然,读取方法有时还要负责填充缓冲区,但从总体来看,读取方法的平均执行时间一般都会因此有大幅度的缩短。
|
||||
|
||||
## 问题解析
|
||||
|
||||
`bufio.Reader`类型并不是开箱即用的,因为它包含了一些需要显式初始化的字段。为了让你能在后面更好地理解它的读取方法的内部流程,我先在这里简要地解释一下这些字段,如下所示。
|
||||
|
||||
1. `buf`:`[]byte`类型的字段,即字节切片,代表缓冲区。虽然它是切片类型的,但是其长度却会在初始化的时候指定,并在之后保持不变。
|
||||
1. `rd`:`io.Reader`类型的字段,代表底层读取器。缓冲区中的数据就是从这里拷贝来的。
|
||||
1. `r`:`int`类型的字段,代表对缓冲区进行下一次读取时的开始索引。我们可以称它为已读计数。
|
||||
1. `w`:`int`类型的字段,代表对缓冲区进行下一次写入时的开始索引。我们可以称之为已写计数。
|
||||
1. `err`:`error`类型的字段。它的值用于表示在从底层读取器获得数据时发生的错误。这里的值在被读取或忽略之后,该字段会被置为`nil`。
|
||||
1. `lastByte`:`int`类型的字段,用于记录缓冲区中最后一个被读取的字节。读回退时会用到它的值。
|
||||
1. `lastRuneSize`:`int`类型的字段,用于记录缓冲区中最后一个被读取的Unicode字符所占用的字节数。读回退的时候会用到它的值。这个字段只会在其所属值的`ReadRune`方法中才会被赋予有意义的值。在其他情况下,它都会被置为`-1`。
|
||||
|
||||
`bufio`包为我们提供了两个用于初始化`Reader`值的函数,分别叫:
|
||||
|
||||
<li>
|
||||
`NewReader`;
|
||||
</li>
|
||||
<li>
|
||||
`NewReaderSize`;
|
||||
</li>
|
||||
|
||||
它们都会返回一个`*bufio.Reader`类型的值。
|
||||
|
||||
`NewReader`函数初始化的`Reader`值会拥有一个默认尺寸的缓冲区。这个默认尺寸是4096个字节,即:4 KB。而`NewReaderSize`函数则将缓冲区尺寸的决定权抛给了使用方。
|
||||
|
||||
由于这里的缓冲区在一个`Reader`值的生命周期内其尺寸不可变,所以在有些时候是需要做一些权衡的。`NewReaderSize`函数就提供了这样一个途径。
|
||||
|
||||
在`bufio.Reader`类型拥有的读取方法中,`Peek`方法和`ReadSlice`方法都会调用该类型一个名为`fill`的包级私有方法。`fill`方法的作用是填充内部缓冲区。我们在这里就先重点说说它。
|
||||
|
||||
`fill`方法会先检查其所属值的已读计数。如果这个计数不大于`0`,那么有两种可能。
|
||||
|
||||
一种可能是其缓冲区中的字节都是全新的,也就是说它们都没有被读取过,另一种可能是缓冲区刚被压缩过。
|
||||
|
||||
对缓冲区的压缩包括两个步骤。**第一步,把缓冲区中在`[已读计数, 已写计数)`范围之内的所有元素值(或者说字节)都依次拷贝到缓冲区的头部。**
|
||||
|
||||
比如,把缓冲区中与已读计数代表的索引对应字节拷贝到索引`0`的位置,并把紧挨在它后边的字节拷贝到索引`1`的位置,以此类推。
|
||||
|
||||
这一步之所以不会有任何副作用,是因为它基于两个事实。
|
||||
|
||||
**第一事实,**已读计数之前的字节都已经被读取过,并且肯定不会再被读取了,因此把它们覆盖掉是安全的。
|
||||
|
||||
**第二个事实,**在压缩缓冲区之后,已写计数之后的字节只可能是已被读取过的字节,或者是已被拷贝到缓冲区头部的未读字节,又或者是代表未曾被填入数据的零值`0x00`。所以,后续的新字节是可以被写到这些位置上的。
|
||||
|
||||
**在压缩缓冲区的第二步中,`fill`方法会把已写计数的新值设定为原已写计数与原已读计数的差。这个差所代表的索引,就是压缩后第一次写入字节时的开始索引。**
|
||||
|
||||
另外,该方法还会把已读计数的值置为`0`。显而易见,在压缩之后,再读取字节就肯定要从缓冲区的头部开始读了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/68/84/687b56d4137ea4d01e0b20d259f91284.png" alt="">
|
||||
|
||||
(bufio.Reader中的缓冲区压缩)
|
||||
|
||||
实际上,`fill`方法只要在开始时发现其所属值的已读计数大于`0`,就会对缓冲区进行一次压缩。之后,如果缓冲区中还有可写的位置,那么该方法就会对其进行填充。
|
||||
|
||||
在填充缓冲区的时候,`fill`方法会试图从底层读取器那里,读取足够多的字节,并尽量把从已写计数代表的索引位置到缓冲区末尾之间的空间都填满。
|
||||
|
||||
在这个过程中,`fill`方法会及时地更新已写计数,以保证填充的正确性和顺序性。另外,它还会判断从底层读取器读取数据的时候,是否有错误发生。如果有,那么它就会把错误值赋给其所属值的`err`字段,并终止填充流程。
|
||||
|
||||
好了,到这里,我们暂告一个段落。在本题中,我对`bufio.Reader`类型的基本结构,以及相关的一些函数和方法进行了概括介绍,并且重点阐述了该类型的`fill`方法。
|
||||
|
||||
后者是我们在后面要说明的一些读取流程的重要组成部分。你起码要记住的是:这个`fill`方法大致都做了些什么。
|
||||
|
||||
## 知识扩展
|
||||
|
||||
问题1:`bufio.Writer`类型值中缓冲的数据什么时候会被写到它的底层写入器?
|
||||
|
||||
我们先来看一下`bufio.Writer`类型都有哪些字段:
|
||||
|
||||
1. `err`:`error`类型的字段。它的值用于表示在向底层写入器写数据时发生的错误。
|
||||
1. `buf`:`[]byte`类型的字段,代表缓冲区。在初始化之后,它的长度会保持不变。
|
||||
1. `n`:`int`类型的字段,代表对缓冲区进行下一次写入时的开始索引。我们可以称之为已写计数。
|
||||
1. `wr`:`io.Writer`类型的字段,代表底层写入器。
|
||||
|
||||
`bufio.Writer`类型有一个名为`Flush`的方法,它的主要功能是把相应缓冲区中暂存的所有数据,都写到底层写入器中。数据一旦被写进底层写入器,该方法就会把它们从缓冲区中删除掉。
|
||||
|
||||
不过,这里的删除有时候只是逻辑上的删除而已。不论是否成功地写入了所有的暂存数据,`Flush`方法都会妥当处置,并保证不会出现重写和漏写的情况。该类型的字段`n`在此会起到很重要的作用。
|
||||
|
||||
`bufio.Writer`类型值(以下简称`Writer`值)拥有的所有数据写入方法都会在必要的时候调用它的`Flush`方法。
|
||||
|
||||
比如,`Write`方法有时候会在把数据写进缓冲区之后,调用`Flush`方法,以便为后续的新数据腾出空间。`WriteString`方法的行为与之类似。
|
||||
|
||||
又比如,`WriteByte`方法和`WriteRune`方法,都会在发现缓冲区中的可写空间不足以容纳新的字节,或Unicode字符的时候,调用`Flush`方法。
|
||||
|
||||
此外,如果`Write`方法发现需要写入的字节太多,同时缓冲区已空,那么它就会跨过缓冲区,并直接把这些数据写到底层写入器中。
|
||||
|
||||
而`ReadFrom`方法,则会在发现底层写入器的类型是`io.ReaderFrom`接口的实现之后,直接调用其`ReadFrom`方法把参数值持有的数据写进去。
|
||||
|
||||
总之,在通常情况下,只要缓冲区中的可写空间无法容纳需要写入的新数据,`Flush`方法就一定会被调用。并且,`bufio.Writer`类型的一些方法有时候还会试图走捷径,跨过缓冲区而直接对接数据供需的双方。
|
||||
|
||||
你可以在理解了这些内部机制之后,有的放矢地编写你的代码。不过,在你把所有的数据都写入`Writer`值之后,再调用一下它的`Flush`方法,显然是最稳妥的。
|
||||
|
||||
## 总结
|
||||
|
||||
今天我们从“`bufio.Reader`类型值中的缓冲区起着怎样的作用”这道问题入手,介绍了一部分bufio包中的数据类型,在下一次的分享中,我会沿着这个问题继续展开。
|
||||
|
||||
你对今天的内容有什么样的思考,可以给我留言,我们一起讨论。感谢你的收听,我们下期再见。
|
||||
|
||||
[戳此查看Go语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers)
|
||||
|
||||
|
||||
125
极客时间专栏/Go语言核心36讲/模块三:Go语言实战与应用/43 | bufio包中的数据类型(下).md
Normal file
125
极客时间专栏/Go语言核心36讲/模块三:Go语言实战与应用/43 | bufio包中的数据类型(下).md
Normal file
@@ -0,0 +1,125 @@
|
||||
<audio id="audio" title="43 | bufio包中的数据类型(下)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/14/0a/14b60f9568f1135273b78051726a240a.mp3"></audio>
|
||||
|
||||
你好,我是郝林,我今天继续分享bufio包中的数据类型。
|
||||
|
||||
在上一篇文章中,我提到了`bufio`包中的数据类型主要有`Reader`、`Scanner`、`Writer`和`ReadWriter`。并着重讲到了`bufio.Reader`类型与`bufio.Writer`类型,今天,我们继续专注`bufio.Reader`的内容来进行学习。
|
||||
|
||||
## 知识扩展
|
||||
|
||||
### 问题 :`bufio.Reader`类型读取方法有哪些不同?
|
||||
|
||||
`bufio.Reader`类型拥有很多用于读取数据的指针方法,**这里面有4个方法可以作为不同读取流程的代表,它们是:`Peek`、`Read`、`ReadSlice`和`ReadBytes`。**
|
||||
|
||||
**`Reader`值的`Peek`方法**的功能是:读取并返回其缓冲区中的`n`个未读字节,并且它会从已读计数代表的索引位置开始读。
|
||||
|
||||
在缓冲区未被填满,并且其中的未读字节的数量小于`n`的时候,该方法就会调用`fill`方法,以启动缓冲区填充流程。但是,如果它发现上次填充缓冲区的时候有错误,那就不会再次填充。
|
||||
|
||||
如果调用方给定的`n`比缓冲区的长度还要大,或者缓冲区中未读字节的数量小于`n`,那么`Peek`方法就会把“所有未读字节组成的序列”作为第一个结果值返回。
|
||||
|
||||
同时,它通常还把“`bufio.ErrBufferFull`变量的值(以下简称缓冲区已满的错误)”<br>
|
||||
作为第二个结果值返回,用来表示:虽然缓冲区被压缩和填满了,但是仍然满足不了要求。
|
||||
|
||||
只有在上述的情况都没有出现时,`Peek`方法才能返回:“以已读计数为起始的`n`个字节”和“表示未发生任何错误的`nil`”。
|
||||
|
||||
**`bufio.Reader`类型的Peek方法有一个鲜明的特点,那就是:即使它读取了缓冲区中的数据,也不会更改已读计数的值。**
|
||||
|
||||
这个类型的其他读取方法并不是这样。就拿**该类型的`Read`方法来说**,它有时会把缓冲区中的未读字节,依次拷贝到其参数`p`代表的字节切片中,并立即根据实际拷贝的字节数增加已读计数的值。
|
||||
|
||||
<li>
|
||||
在缓冲区中还有未读字节的情况下,该方法的做法就是如此。不过,在另一些时候,其所属值的已读计数会等于已写计数,这表明:此时的缓冲区中已经没有任何未读的字节了。
|
||||
</li>
|
||||
<li>
|
||||
当缓冲区中已无未读字节时,`Read`方法会先检查参数`p`的长度是否大于或等于缓冲区的长度。如果是,那么`Read`方法会索性放弃向缓冲区中填充数据,转而直接从其底层读取器中读出数据并拷贝到`p`中。这意味着它完全跨过了缓冲区,并直连了数据供需的双方。
|
||||
</li>
|
||||
|
||||
需要注意的是,`Peek`方法在遇到类似情况时的做法与这里的区别(这两种做法孰优孰劣还要看具体的使用场景)。
|
||||
|
||||
`Peek`方法会在条件满足时填充缓冲区,并在发现参数`n`的值比缓冲区的长度更大时,直接返回缓冲区中的所有未读字节。
|
||||
|
||||
如果我们当初设定的缓冲区长度很大,那么在这种情况下的方法执行耗时,就有可能会比较长。最主要的原因是填充缓冲区需要花费较长的时间。
|
||||
|
||||
由`fill`方法执行的流程可知,它会尽量填满缓冲区中的可写空间。然而,`Read`方法在大多数的情况下,是不会向缓冲区中写入数据的,尤其是在前面描述的那种情况下,即:缓冲区中已无未读字节,且参数`p`的长度大于或等于缓冲区的长度。
|
||||
|
||||
此时,该方法会直接从底层读取器那里读出数据,所以数据的读出速度就成为了这种情况下方法执行耗时的决定性因素。
|
||||
|
||||
当然了,我在这里说的只是耗时操作在某些情况下更可能出现在哪里,一切的结论还是要以性能测试的客观结果为准。
|
||||
|
||||
说回`Read`方法的内部流程。如果缓冲区中已无未读字节,但其长度比参数`p`的长度更大,那么该方法会先把已读计数和已写计数的值都重置为`0`,然后再尝试着使用从底层读取器那里获取的数据,对缓冲区进行一次从头至尾的填充。
|
||||
|
||||
不过要注意,这里的尝试只会进行一次。无论在这一时刻是否能够获取到数据,也无论获取时是否有错误发生,都会是如此。而`fill`方法的做法与此不同,只要没有发生错误,它就会进行多次尝试,因此它真正获取到一些数据的可能性更大。
|
||||
|
||||
不过,这两个方法有一点是相同,那就是:只要它们把获取到的数据写入缓冲区,就会及时地更新已写计数的值。
|
||||
|
||||
**再来说`ReadSlice`方法和`ReadBytes`方法。** 这两个方法的功能总体上来说,都是持续地读取数据,直至遇到调用方给定的分隔符为止。
|
||||
|
||||
**`ReadSlice`方法**会先在其缓冲区的未读部分中寻找分隔符。如果未能找到,并且缓冲区未满,那么该方法会先通过调用`fill`方法对缓冲区进行填充,然后再次寻找,如此往复。
|
||||
|
||||
如果在填充的过程中发生了错误,那么它会把缓冲区中的未读部分作为结果返回,同时返回相应的错误值。
|
||||
|
||||
注意,在这个过程中有可能会出现虽然缓冲区已被填满,但仍然没能找到分隔符的情况。
|
||||
|
||||
这时,`ReadSlice`方法会把整个缓冲区(也就是`buf`字段代表的字节切片)作为第一个结果值,并把缓冲区已满的错误(即`bufio.ErrBufferFull`变量的值)作为第二个结果值。
|
||||
|
||||
经过`fill`方法填满的缓冲区肯定从头至尾都只包含了未读的字节,所以这样做是合理的。
|
||||
|
||||
当然了,一旦`ReadSlice`方法找到了分隔符,它就会在缓冲区上切出相应的、包含分隔符的字节切片,并把该切片作为结果值返回。无论分隔符找到与否,该方法都会正确地设置已读计数的值。
|
||||
|
||||
比如,在返回缓冲区中的所有未读字节,或者代表全部缓冲区的字节切片之前,它会把已写计数的值赋给已读计数,以表明缓冲区中已无未读字节。
|
||||
|
||||
如果说`ReadSlice`是一个容易半途而废的方法的话,那么可以说`ReadBytes`方法算得上是相当的执着。
|
||||
|
||||
**`ReadBytes`方法**会通过调用`ReadSlice`方法一次又一次地从缓冲区中读取数据,直至找到分隔符为止。
|
||||
|
||||
在这个过程中,`ReadSlice`方法可能会因缓冲区已满而返回所有已读到的字节和相应的错误值,但`ReadBytes`方法总是会忽略掉这样的错误,并再次调用`ReadSlice`方法,这使得后者会继续填充缓冲区并在其中寻找分隔符。
|
||||
|
||||
除非`ReadSlice`方法返回的错误值并不代表缓冲区已满的错误,或者它找到了分隔符,否则这一过程永远不会结束。
|
||||
|
||||
如果寻找的过程结束了,不管是不是因为找到了分隔符,`ReadBytes`方法都会把在这个过程中读到的所有字节,按照读取的先后顺序组装成一个字节切片,并把它作为第一个结果值。如果过程结束是因为出现错误,那么它还会把拿到的错误值作为第二个结果值。
|
||||
|
||||
在`bufio.Reader`类型的众多读取方法中,依赖`ReadSlice`方法的除了`ReadBytes`方法,还有`ReadLine`方法。不过后者在读取流程上并没有什么特别之处,我就不在这里赘述了。
|
||||
|
||||
另外,该类型的`ReadString`方法完全依赖于`ReadBytes`方法,前者只是在后者返回的结果值之上做了一个简单的类型转换而已。
|
||||
|
||||
**最后,我还要提醒你一下,有个安全性方面的问题需要你注意。`bufio.Reader`类型的`Peek`方法、`ReadSlice`方法和`ReadLine`方法都有可能会造成内容泄露。**
|
||||
|
||||
这主要是因为它们在正常的情况下都会返回直接基于缓冲区的字节切片。我在讲`bytes.Buffer`类型的时候解释过什么叫内容泄露。你可以返回查看。
|
||||
|
||||
调用方可以通过这些方法返回的结果值访问到缓冲区的其他部分,甚至修改缓冲区中的内容。这通常都是很危险的。
|
||||
|
||||
## 总结
|
||||
|
||||
我们用比较长的篇幅介绍了`bufio`包中的数据类型,其中的重点是`bufio.Reader`类型。
|
||||
|
||||
`bufio.Reader`类型代表的是携带缓冲区的读取器。它的值在被初始化的时候需要接受一个底层的读取器,后者的类型必须是`io.Reader`接口的实现。
|
||||
|
||||
`Reader`值中的缓冲区其实就是一个数据存储中介,它介于底层读取器与读取方法及其调用方之间。此类值的读取方法一般都会先从该值的缓冲区中读取数据,同时在必要的时候预先从其底层读取器那里读出一部分数据,并填充到缓冲区中以备后用。填充缓冲区的操作通常会由该值的`fill`方法执行。在填充的过程中,`fill`方法有时还会对缓冲区进行压缩。
|
||||
|
||||
在`Reader`值拥有的众多读取方法中,有4个方法可以作为不同读取流程的代表,它们是:`Peek`、`Read`、`ReadSlice`和`ReadBytes`。
|
||||
|
||||
`Peek`方法的特点是即使读取了缓冲区中的数据,也不会更改已读计数的值。而`Read`方法会在参数值的长度过大,且缓冲区中已无未读字节时,跨过缓冲区并直接向底层读取器索要数据。
|
||||
|
||||
`ReadSlice`方法会在缓冲区的未读部分中寻找给定的分隔符,并在必要时对缓冲区进行填充。
|
||||
|
||||
如果在填满缓冲区之后仍然未能找到分隔符,那么该方法就会把整个缓冲区作为第一个结果值返回,同时返回缓冲区已满的错误。
|
||||
|
||||
`ReadBytes`方法会通过调用`ReadSlice`方法,一次又一次地填充缓冲区,并在其中寻找分隔符。除非发生了未预料到的错误或者找到了分隔符,否则这一过程将会一直进行下去。
|
||||
|
||||
`Reader`值的`ReadLine`方法会依赖于它的`ReadSlice`方法,而其`ReadString`方法则完全依赖于`ReadBytes`方法。
|
||||
|
||||
另外,值得我们特别注意的是,`Reader`值的`Peek`方法、`ReadSlice`方法和`ReadLine`方法都可能会造成其缓冲区中的内容的泄露。
|
||||
|
||||
最后再说一下`bufio.Writer`类型。把该类值的缓冲区中暂存的数据写进其底层写入器的功能,主要是由它的`Flush`方法实现的。
|
||||
|
||||
此类值的所有数据写入方法都会在必要的时候调用它的`Flush`方法。一般情况下,这些写入方法都会先把数据写进其所属值的缓冲区,然后再增加该值中的已写计数。但是,在有些时候,`Write`方法和`ReadFrom`方法也会跨过缓冲区,并直接把数据写进其底层写入器。
|
||||
|
||||
请记住,虽然这些写入方法都会不时地调用`Flush`方法,但是在写入所有的数据之后再显式地调用一下这个方法总是最稳妥的。
|
||||
|
||||
## 思考题
|
||||
|
||||
今天的思考题是:`bufio.Scanner`类型的主要功用是什么?它有哪些特点?
|
||||
|
||||
感谢你的收听,我们下期再见。
|
||||
|
||||
[戳此查看Go语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers)
|
||||
|
||||
|
||||
130
极客时间专栏/Go语言核心36讲/模块三:Go语言实战与应用/44 | 使用os包中的API (上).md
Normal file
130
极客时间专栏/Go语言核心36讲/模块三:Go语言实战与应用/44 | 使用os包中的API (上).md
Normal file
@@ -0,0 +1,130 @@
|
||||
<audio id="audio" title="44 | 使用os包中的API (上)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a1/0b/a1215371bd81a30f92e1a0711db47e0b.mp3"></audio>
|
||||
|
||||
我们今天要讲的是`os`代码包中的API。这个代码包可以让我们拥有操控计算机操作系统的能力。
|
||||
|
||||
## 前导内容:os包中的API
|
||||
|
||||
这个代码包提供的都是平台不相关的API。那么说,什么叫平台不相关的API呢?
|
||||
|
||||
它的意思是:这些API基于(或者说抽象自)操作系统,为我们使用操作系统的功能提供高层次的支持,但是,它们并不依赖于具体的操作系统。
|
||||
|
||||
不论是Linux、macOS、Windows,还是FreeBSD、OpenBSD、Plan9,`os`代码包都可以为之提供统一的使用接口。这使得我们可以用同样的方式,来操纵不同的操作系统,并得到相似的结果。
|
||||
|
||||
`os`包中的API主要可以帮助我们使用操作系统中的文件系统、权限系统、环境变量、系统进程以及系统信号。
|
||||
|
||||
其中,操纵文件系统的API最为丰富。我们不但可以利用这些API创建和删除文件以及目录,还可以获取到它们的各种信息、修改它们的内容、改变它们的访问权限,等等。
|
||||
|
||||
说到这里,就不得不提及一个非常常用的数据类型:`os.File`。
|
||||
|
||||
从字面上来看,`os.File`类型代表了操作系统中的文件。但实际上,它可以代表的远不止于此。或许你已经知道,对于类Unix的操作系统(包括Linux、macOS、FreeBSD等),其中的一切都可以被看做是文件。
|
||||
|
||||
除了文本文件、二进制文件、压缩文件、目录这些常见的形式之外,还有符号链接、各种物理设备(包括内置或外接的面向块或者字符的设备)、命名管道,以及套接字(也就是socket),等等。
|
||||
|
||||
因此,可以说,我们能够利用`os.File`类型操纵的东西太多了。不过,为了聚焦于`os.File`本身,同时也为了让本文讲述的内容更加通用,我们在这里主要把`os.File`类型应用于常规的文件。
|
||||
|
||||
下面这个问题,就是以`os.File`类型代表的最基本内容入手。**我们今天的问题是:`os.File`类型都实现了哪些`io`包中的接口?**
|
||||
|
||||
这道题的**典型回答**是这样的。
|
||||
|
||||
`os.File`类型拥有的都是指针方法,所以除了空接口之外,它本身没有实现任何接口。而它的指针类型则实现了很多`io`代码包中的接口。
|
||||
|
||||
首先,对于`io`包中最核心的3个简单接口`io.Reader`、`io.Writer`和`io.Closer`,`*os.File`类型都实现了它们。
|
||||
|
||||
其次,该类型还实现了另外的3个简单接口,即:`io.ReaderAt`、`io.Seeker`和`io.WriterAt`。
|
||||
|
||||
正是因为`*os.File`类型实现了这些简单接口,所以它也顺便实现了`io`包的9个扩展接口中的7个。
|
||||
|
||||
然而,由于它并没有实现简单接口`io.ByteReader`和`io.RuneReader`,所以它没有实现分别作为这两者的扩展接口的`io.ByteScanner`和`io.RuneScanner`。
|
||||
|
||||
总之,`os.File`类型及其指针类型的值,不但可以通过各种方式读取和写入某个文件中的内容,还可以寻找并设定下一次读取或写入时的起始索引位置,另外还可以随时对文件进行关闭。
|
||||
|
||||
但是,它们并不能专门地读取文件中的下一个字节,或者下一个Unicode字符,也不能进行任何的读回退操作。
|
||||
|
||||
不过,单独读取下一个字节或字符的功能也可以通过其他方式来实现,比如,调用它的`Read`方法并传入适当的参数值就可以做到这一点。
|
||||
|
||||
## 问题解析
|
||||
|
||||
这个问题其实在间接地问“`os.File`类型能够以何种方式操作文件?”我在前面的典型回答中也给出了简要的答案。
|
||||
|
||||
在我进一步地说明一些细节之前,我们先来看看,怎样才能获得一个`os.File`类型的指针值(以下简称`File`值)。
|
||||
|
||||
在`os`包中,有这样几个函数,即:`Create`、`NewFile`、`Open`和`OpenFile`。
|
||||
|
||||
**`os.Create`函数用于根据给定的路径创建一个新的文件。** 它会返回一个`File`值和一个错误值。我们可以在该函数返回的`File`值之上,对相应的文件进行读操作和写操作。
|
||||
|
||||
不但如此,我们使用这个函数创建的文件,对于操作系统中的所有用户来说,都是可以读和写的。
|
||||
|
||||
换句话说,一旦这样的文件被创建出来,任何能够登录其所属的操作系统的用户,都可以在任意时刻读取该文件中的内容,或者向该文件写入内容。
|
||||
|
||||
注意,如果在我们给予`os.Create`函数的路径之上,已经存在了一个文件,那么该函数会先清空现有文件中的全部内容,然后再把它作为第一个结果值返回。
|
||||
|
||||
另外,`os.Create`函数是有可能返回非`nil`的错误值的。
|
||||
|
||||
比如,如果我们给定的路径上的某一级父目录并不存在,那么该函数就会返回一个`*os.PathError`类型的错误值,以表示“不存在的文件或目录”。
|
||||
|
||||
**再来看`os.NewFile`函数。** 该函数在被调用的时候,需要接受一个代表文件描述符的、`uintptr`类型的值,以及一个用于表示文件名的字符串值。
|
||||
|
||||
如果我们给定的文件描述符并不是有效的,那么这个函数将会返回`nil`,否则,它将会返回一个代表了相应文件的`File`值。
|
||||
|
||||
注意,不要被这个函数的名称误导了,它的功能并不是创建一个新的文件,而是依据一个已经存在的文件的描述符,来新建一个包装了该文件的`File`值。
|
||||
|
||||
例如,我们可以像这样拿到一个包装了标准错误输出的`File`值:
|
||||
|
||||
```
|
||||
file3 := os.NewFile(uintptr(syscall.Stderr), "/dev/stderr")
|
||||
|
||||
```
|
||||
|
||||
然后,通过这个`File`值向标准错误输出上写入一些内容:
|
||||
|
||||
```
|
||||
if file3 != nil {
|
||||
defer file3.Close()
|
||||
file3.WriteString(
|
||||
"The Go language program writes the contents into stderr.\n")
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**`os.Open`函数会打开一个文件并返回包装了该文件的`File`值。** 然而,该函数只能以只读模式打开文件。换句话说,我们只能从该函数返回的`File`值中读取内容,而不能向它写入任何内容。
|
||||
|
||||
如果我们调用了这个`File`值的任何一个写入方法,那么都将会得到一个表示了“坏的文件描述符”的错误值。实际上,我们刚刚说的只读模式,正是应用在`File`值所持有的文件描述符之上的。
|
||||
|
||||
所谓的文件描述符,是由通常很小的非负整数代表的。它一般会由I/O相关的系统调用返回,并作为某个文件的一个标识存在。
|
||||
|
||||
从操作系统的层面看,针对任何文件的I/O操作都需要用到这个文件描述符。只不过,Go语言中的一些数据类型,为我们隐匿掉了这个描述符,如此一来我们就无需时刻关注和辨别它了(就像`os.File`类型这样)。
|
||||
|
||||
实际上,我们在调用前文所述的`os.Create`函数、`os.Open`函数以及将会提到的`os.OpenFile`函数的时候,它们都会执行同一个系统调用,并且在成功之后得到这样一个文件描述符。这个文件描述符将会被储存在它们返回的`File`值中。
|
||||
|
||||
`os.File`类型有一个指针方法,名叫`Fd`。它在被调用之后将会返回一个`uintptr`类型的值。这个值就代表了当前的`File`值所持有的那个文件描述符。
|
||||
|
||||
不过,在`os`包中,除了`NewFile`函数需要用到它,它也没有什么别的用武之地了。所以,如果你操作的只是常规的文件或者目录,那么就无需特别地在意它了。
|
||||
|
||||
**最后,再说一下`os.OpenFile`函数。** 这个函数其实是`os.Create`函数和`os.Open`函数的底层支持,它最为灵活。
|
||||
|
||||
这个函数有3个参数,分别名为`name`、`flag`和`perm`。其中的`name`指代的就是文件的路径。而`flag`参数指的则是需要施加在文件描述符之上的模式,我在前面提到的只读模式就是这里的一个可选项。
|
||||
|
||||
在Go语言中,这个只读模式由常量`os.O_RDONLY`代表,它是`int`类型的。当然了,这里除了只读模式之外,还有几个别的模式可选,我们稍后再细说。
|
||||
|
||||
`os.OpenFile`函数的参数`perm`代表的也是模式,它的类型是`os.FileMode`,此类型是一个基于`uint32`类型的再定义类型。
|
||||
|
||||
为了加以区别,我们把参数`flag`指代的模式叫做操作模式,而把参数`perm`指代的模式叫做权限模式。可以这么说,操作模式限定了操作文件的方式,而权限模式则可以控制文件的访问权限。关于权限模式的更多细节我们将在后面讨论。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d3/93/d3414376a3343926a2b33cdeeb094893.png" alt=""><br>
|
||||
(获得os.File类型的指针值的几种方式)
|
||||
|
||||
到这里,你需要记住的是,通过`os.File`类型的值,我们不但可以对文件进行读取、写入、关闭等操作,还可以设定下一次读取或写入时的起始索引位置。
|
||||
|
||||
此外,`os`包中还有用于创建全新文件的`Create`函数,用于包装现存文件的`NewFile`函数,以及可被用来打开已存在的文件的`Open`函数和`OpenFile`函数。
|
||||
|
||||
## 总结
|
||||
|
||||
我们今天讲的是`os`代码包以及其中的程序实体。我们首先讨论了`os`包存在的意义,和它的主要用途。代码包中所包含的API,都是对操作系统的某方面功能的高层次抽象,这使得我们可以通过它以统一的方式,操纵不同的操作系统,并得到相似的结果。
|
||||
|
||||
在这个代码包中,操纵文件系统的API最为丰富,最有代表性的就是数据类型`os.File`。`os.File`类型不但可以代表操作系统中的文件,还可以代表很多其他的东西。尤其是在类Unix的操作系统中,它几乎可以代表一切可以操纵的软件和硬件。
|
||||
|
||||
在下一期的文章中,我会继续讲解os包中的API的内容。如果你对这部分的知识有什么问题,可以给我留言,感谢你的收听,我们下期再见。
|
||||
|
||||
[戳此查看Go语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers)
|
||||
|
||||
|
||||
106
极客时间专栏/Go语言核心36讲/模块三:Go语言实战与应用/45 | 使用os包中的API (下).md
Normal file
106
极客时间专栏/Go语言核心36讲/模块三:Go语言实战与应用/45 | 使用os包中的API (下).md
Normal file
@@ -0,0 +1,106 @@
|
||||
<audio id="audio" title="45 | 使用os包中的API (下)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/41/25/413105a082fb333eb4f2f3ce080d2c25.mp3"></audio>
|
||||
|
||||
你好,我是郝林,今天我们继续分享使用os包中的API。
|
||||
|
||||
我们在上一篇文章中。从“`os.File`类型都实现了哪些`io`包中的接口”这一问题出发,介绍了一系列的相关内容。今天我们继续围绕这一知识点进行扩展。
|
||||
|
||||
## 知识扩展
|
||||
|
||||
### 问题1:可应用于`File`值的操作模式都有哪些?
|
||||
|
||||
针对`File`值的操作模式主要有只读模式、只写模式和读写模式。
|
||||
|
||||
这些模式分别由常量`os.O_RDONLY`、`os.O_WRONLY`和`os.O_RDWR`代表。在我们新建或打开一个文件的时候,必须把这三个模式中的一个设定为此文件的操作模式。
|
||||
|
||||
除此之外,我们还可以为这里的文件设置额外的操作模式,可选项如下所示。
|
||||
|
||||
- `os.O_APPEND`:当向文件中写入内容时,把新内容追加到现有内容的后边。
|
||||
- `os.O_CREATE`:当给定路径上的文件不存在时,创建一个新文件。
|
||||
- `os.O_EXCL`:需要与`os.O_CREATE`一同使用,表示在给定的路径上不能有已存在的文件。
|
||||
- `os.O_SYNC`:在打开的文件之上实施同步I/O。它会保证读写的内容总会与硬盘上的数据保持同步。
|
||||
- `os.O_TRUNC`:如果文件已存在,并且是常规的文件,那么就先清空其中已经存在的任何内容。
|
||||
|
||||
对于以上操作模式的使用,`os.Create`函数和`os.Open`函数都是现成的例子。
|
||||
|
||||
```
|
||||
func Create(name string) (*File, error) {
|
||||
return OpenFile(name, O_RDWR|O_CREATE|O_TRUNC, 0666)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
`os.Create`函数在调用`os.OpenFile`函数的时候,给予的操作模式是`os.O_RDWR`、`os.O_CREATE`和`os.O_TRUNC`的组合。
|
||||
|
||||
这就基本上决定了前者的行为,即:如果参数`name`代表路径之上的文件不存在,那么就新建一个,否则,先清空现存文件中的全部内容。
|
||||
|
||||
并且,它返回的`File`值的读取方法和写入方法都是可用的。这里需要注意,多个操作模式是通过按位或操作符`|`组合起来的。
|
||||
|
||||
func Open(name string) (*File, error) {<br>
|
||||
return OpenFile(name, O_RDONLY, 0)<br>
|
||||
}
|
||||
|
||||
我在前面说过,`os.Open`函数的功能是:以只读模式打开已经存在的文件。其根源就是它在调用`os.OpenFile`函数的时候,只提供了一个单一的操作模式`os.O_RDONLY`。
|
||||
|
||||
以上,就是我对可应用于`File`值的操作模式的简单解释。在demo88.go文件中还有少许示例,可供你参考。
|
||||
|
||||
### 问题2:怎样设定常规文件的访问权限?
|
||||
|
||||
我们已经知道,`os.OpenFile`函数的第三个参数`perm`代表的是权限模式,其类型是`os.FileMode`。但实际上,`os.FileMode`类型能够代表的,可远不只权限模式,它还可以代表文件模式(也可以称之为文件种类)。
|
||||
|
||||
由于`os.FileMode`是基于`uint32`类型的再定义类型,所以它的每个值都包含了32个比特位。在这32个比特位当中,每个比特位都有其特定的含义。
|
||||
|
||||
比如,如果在其最高比特位上的二进制数是`1`,那么该值表示的文件模式就等同于`os.ModeDir`,也就是说,相应的文件代表的是一个目录。
|
||||
|
||||
又比如,如果其中的第26个比特位上的是`1`,那么相应的值表示的文件模式就等同于`os.ModeNamedPipe`,也就是说,那个文件代表的是一个命名管道。
|
||||
|
||||
实际上,在一个`os.FileMode`类型的值(以下简称`FileMode`值)中,只有最低的9个比特位才用于表示文件的权限。当我们拿到一个此类型的值时,可以把它和`os.ModePerm`常量的值做按位与操作。
|
||||
|
||||
这个常量的值是`0777`,是一个八进制的无符号整数,其最低的9个比特位上都是`1`,而更高的23个比特位上都是`0`。
|
||||
|
||||
所以,经过这样的按位与操作之后,我们即可得到这个`FileMode`值中所有用于表示文件权限的比特位,也就是该值所表示的权限模式。这将会与我们调用`FileMode`值的`Perm`方法所得到的结果值是一致。
|
||||
|
||||
在这9个用于表示文件权限的比特位中,每3个比特位为一组,共可分为3组。
|
||||
|
||||
**从高到低,这3组分别表示的是文件所有者(也就是创建这个文件的那个用户)、文件所有者所属的用户组,以及其他用户对该文件的访问权限。而对于每个组,其中的3个比特位从高到低分别表示读权限、写权限和执行权限。**
|
||||
|
||||
如果在其中的某个比特位上的是`1`,那么就意味着相应的权限开启,否则,就表示相应的权限关闭。
|
||||
|
||||
因此,八进制整数`0777`就表示:操作系统中的所有用户都对当前的文件有读、写和执行的权限,而八进制整数`0666`则表示:所有用户都对当前文件有读和写的权限,但都没有执行的权限。
|
||||
|
||||
我们在调用`os.OpenFile`函数的时候,可以根据以上说明设置它的第三个参数。但要注意,只有在新建文件的时候,这里的第三个参数值才是有效的。在其他情况下,即使我们设置了此参数,也不会对目标文件产生任何的影响。
|
||||
|
||||
## 总结
|
||||
|
||||
为了聚焦于`os.File`类型本身,我在这两篇文章中主要讲述了怎样把os.File类型应用于常规的文件。该类型的指针类型实现了很多`io`包中的接口,因此它的具体功用也就可以不言自明了。
|
||||
|
||||
通过该类型的值,我们不但可以对文件进行各种读取、写入、关闭等操作,还可以设定下一次读取或写入时的起始索引位置。
|
||||
|
||||
在使用这个类型的值之前,我们必须先要创建它。所以,我为你重点介绍了几个可以创建,并获得此类型值的函数。
|
||||
|
||||
包括:`os.Create`、`os.NewFile`、`os.Open`和`os.OpenFile`。我们用什么样的方式创建`File`值,就决定了我们可以使用它来做什么。
|
||||
|
||||
利用`os.Create`函数,我们可以在操作系统中创建一个全新的文件,或者清空一个现存文件中的全部内容并重用它。
|
||||
|
||||
在相应的`File`值之上,我们可以对该文件进行任何的读写操作。虽然`os.NewFile`函数并不是被用来创建新文件的,但是它能够基于一个有效的文件描述符包装出一个可用的`File`值。
|
||||
|
||||
`os.Open`函数的功能是打开一个已经存在的文件。但是,我们只能通过它返回的`File`值对相应的文件进行读操作。
|
||||
|
||||
`os.OpenFile`是这些函数中最为灵活的一个,通过它,我们可以设定被打开文件的操作模式和权限模式。实际上,`os.Create`函数和`os.Open`函数都只是对它的简单封装而已。
|
||||
|
||||
在使用`os.OpenFile`函数的时候,我们必须要搞清楚操作模式和权限模式所代表的真正含义,以及设定它们的正确方式。
|
||||
|
||||
我在本文的扩展问题中分别对它们进行了较为详细的解释。同时,我在对应的示例文件中也编写了一些代码。
|
||||
|
||||
你需要认真地阅读和理解这些代码,并在运行它们的过程当中悟出这两种模式的真谛。
|
||||
|
||||
我在本文中讲述的东西对于`os`包来说,只是海面上的那部分冰山而已。这个代码包囊括的知识众多,而且延展性都很强。
|
||||
|
||||
如果你想完全理解它们,可能还需要去参看操作系统等方面的文档和教程。由于篇幅原因,我在这里只是做了一个引导,帮助你初识该包中的一些重要的程序实体,并给予你一个可以深入下去的切入点,希望你已经在路上了。
|
||||
|
||||
**思考题**
|
||||
|
||||
今天的思考题是:怎样通过`os`包中的API创建和操纵一个系统进程?
|
||||
|
||||
[戳此查看Go语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers)
|
||||
|
||||
|
||||
169
极客时间专栏/Go语言核心36讲/模块三:Go语言实战与应用/46 | 访问网络服务.md
Normal file
169
极客时间专栏/Go语言核心36讲/模块三:Go语言实战与应用/46 | 访问网络服务.md
Normal file
@@ -0,0 +1,169 @@
|
||||
<audio id="audio" title="46 | 访问网络服务" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a9/fb/a95b0a665b0f5a780748aca96012adfb.mp3"></audio>
|
||||
|
||||
你真的很棒,已经跟着我一起从最开始初识Go语言,一步一步地走到了这里。
|
||||
|
||||
在这之前的几十篇文章中,我向你一点一点地介绍了很多Go语言的核心知识,以及一些最最基础的标准库代码包。我想,你已经完全有能力独立去做一些事情了。
|
||||
|
||||
为了激发你更多的兴趣,我还打算用几篇文章来说说Go语言的网络编程。不过,关于网络编程这个事情,恐怕早已庞大到用一两本专著都无法对它进行完整论述的地步了。
|
||||
|
||||
所以,我在这里说的东西只能算是个引子。只要这样能让你产生想去尝试的冲动,我就很开心了。
|
||||
|
||||
## 前导内容:socket与IPC
|
||||
|
||||
人们常常会使用Go语言去编写网络程序(当然了,这方面也是Go语言最为擅长的事情)。说到网络编程,我们就不得不提及socket。
|
||||
|
||||
socket,常被翻译为套接字,它应该算是网络编程世界中最为核心的知识之一了。关于socket,我们可以讨论的东西太多了,因此,我在这里只围绕着Go语言向你介绍一些关于它的基础知识。
|
||||
|
||||
所谓socket,是一种IPC方法。IPC是Inter-Process Communication的缩写,可以被翻译为进程间通信。顾名思义,IPC这个概念(或者说规范)主要定义的是多个进程之间,相互通信的方法。
|
||||
|
||||
这些方法主要包括:系统信号(signal)、管道(pipe)、套接字 (socket)、文件锁(file lock)、消息队列(message queue)、信号灯(semaphore,有的地方也称之为信号量)等。现存的主流操作系统大都对IPC提供了强有力的支持,尤其是socket。
|
||||
|
||||
你可能已经知道,Go语言对IPC也提供了一定的支持。
|
||||
|
||||
比如,在`os`代码包和`os/signal`代码包中就有针对系统信号的API。
|
||||
|
||||
又比如,`os.Pipe`函数可以创建命名管道,而`os/exec`代码包则对另一类管道(匿名管道)提供了支持。对于socket,Go语言与之相应的程序实体都在其标准库的`net`代码包中。
|
||||
|
||||
**毫不夸张地说,在众多的IPC方法中,socket是最为通用和灵活的一种。**与其他的IPC方法不同,利用socket进行通信的进程,可以不局限在同一台计算机当中。
|
||||
|
||||
实际上,通信的双方无论存在于世界上的哪个角落,只要能够通过计算机的网卡端口以及网络进行互联,就可以使用socket。
|
||||
|
||||
支持socket的操作系统一般都会对外提供一套API。**跑在它们之上的应用程序利用这套API,就可以与互联网上的另一台计算机中的程序、同一台计算机中的其他程序,甚至同一个程序中的其他线程进行通信。**
|
||||
|
||||
例如,在Linux操作系统中,用于创建socket实例的API,就是由一个名为`socket`的系统调用代表的。这个系统调用是Linux内核的一部分。
|
||||
|
||||
>
|
||||
所谓的系统调用,你可以理解为特殊的C语言函数。它们是连接应用程序和操作系统内核的桥梁,也是应用程序使用操作系统功能的唯一渠道。
|
||||
|
||||
|
||||
在Go语言标准库的`syscall`代码包中,有一个与这个`socket`系统调用相对应的函数。这两者的函数签名是基本一致的,它们都会接受三个`int`类型的参数,并会返回一个可以代表文件描述符的结果。
|
||||
|
||||
但不同的是,`syscall`包中的`Socket`函数本身是平台不相关的。在其底层,Go语言为它支持的每个操作系统都做了适配,这才使得这个函数无论在哪个平台上,总是有效的。
|
||||
|
||||
Go语言的`net`代码包中的很多程序实体,都会直接或间接地使用到`syscall.Socket`函数。
|
||||
|
||||
比如,我们在调用`net.Dial`函数的时候,会为它的两个参数设定值。其中的第一个参数名为`network`,它决定着Go程序在底层会创建什么样的socket实例,并使用什么样的协议与其他程序通信。
|
||||
|
||||
下面,我们就通过一个简单的问题来看看怎样正确地调用`net.Dial`函数。
|
||||
|
||||
**今天的问题是:`net.Dial`函数的第一个参数`network`有哪些可选值?**
|
||||
|
||||
这道题的**典型回答**是这样的。
|
||||
|
||||
`net.Dial`函数会接受两个参数,分别名为`network`和`address`,都是`string`类型的。
|
||||
|
||||
参数`network`常用的可选值一共有9个。这些值分别代表了程序底层创建的socket实例可使用的不同通信协议,罗列如下。
|
||||
|
||||
- `"tcp"`:代表TCP协议,其基于的IP协议的版本根据参数`address`的值自适应。
|
||||
- `"tcp4"`:代表基于IP协议第四版的TCP协议。
|
||||
- `"tcp6"`:代表基于IP协议第六版的TCP协议。
|
||||
- `"udp"`:代表UDP协议,其基于的IP协议的版本根据参数`address`的值自适应。
|
||||
- `"udp4"`:代表基于IP协议第四版的UDP协议。
|
||||
- `"udp6"`:代表基于IP协议第六版的UDP协议。
|
||||
- `"unix"`:代表Unix通信域下的一种内部socket协议,以SOCK_STREAM为socket类型。
|
||||
- `"unixgram"`:代表Unix通信域下的一种内部socket协议,以SOCK_DGRAM为socket类型。
|
||||
- `"unixpacket"`:代表Unix通信域下的一种内部socket协议,以SOCK_SEQPACKET为socket类型。
|
||||
|
||||
## 问题解析
|
||||
|
||||
为了更好地理解这些可选值的深层含义,我们需要了解一下`syscall.Socket`函数接受的那三个参数。
|
||||
|
||||
我在前面说了,这个函数接受的三个参数都是`int`类型的。这些参数所代表的分别是想要创建的socket实例通信域、类型以及使用的协议。
|
||||
|
||||
Socket的通信域主要有这样几个可选项:IPv4域、IPv6域和Unix域。
|
||||
|
||||
我想你应该能够猜出**IPv4域、IPv6域**的含义,它们对应的分别是基于IP协议第四版的网络,和基于IP协议第六版的网络。
|
||||
|
||||
现在的计算机网络大都是基于IP协议第四版的,但是由于现有IP地址的逐渐枯竭,网络世界也在逐步地支持IP协议第六版。
|
||||
|
||||
**Unix域**,指的是一种类Unix操作系统中特有的通信域。在装有此类操作系统的同一台计算机中,应用程序可以基于此域建立socket连接。
|
||||
|
||||
以上三种通信域分别可以由`syscall`代码包中的常量`AF_INET`、`AF_INET6`和`AF_UNIX`表示。
|
||||
|
||||
Socket的类型一共有4种,分别是:`SOCK_DGRAM`、`SOCK_STREAM`、`SOCK_SEQPACKET`以及`SOCK_RAW`。`syscall`代码包中也都有同名的常量与之对应。前两者更加常用一些。
|
||||
|
||||
`SOCK_DGRAM`中的“DGRAM”代表的是datagram,即数据报文。它是一种有消息边界,但没有逻辑连接的非可靠socket类型,我们熟知的基于UDP协议的网络通信就属于此类。
|
||||
|
||||
有消息边界的意思是,与socket相关的操作系统内核中的程序(以下简称内核程序)在发送或接收数据的时候是以消息为单位的。
|
||||
|
||||
你可以把消息理解为带有固定边界的一段数据。内核程序可以自动地识别和维护这种边界,并在必要的时候,把数据切割成一个一个的消息,或者把多个消息串接成连续的数据。如此一来,应用程序只需要面向消息进行处理就可以了。
|
||||
|
||||
所谓的有逻辑连接是指,通信双方在收发数据之前必须先建立网络连接。待连接建立好之后,双方就可以一对一地进行数据传输了。显然,基于UDP协议的网络通信并不需要这样,它是没有逻辑连接的。
|
||||
|
||||
只要应用程序指定好对方的网络地址,内核程序就可以立即把数据报文发送出去。这有优势,也有劣势。
|
||||
|
||||
优势是发送速度快,不长期占用网络资源,并且每次发送都可以指定不同的网络地址。
|
||||
|
||||
当然了,最后一个优势有时候也是劣势,因为这会使数据报文更长一些。其他的劣势有,无法保证传输的可靠性,不能实现数据的有序性,以及数据只能单向进行传输。
|
||||
|
||||
而`SOCK_STREAM`这个socket类型,恰恰与`SOCK_DGRAM`相反。**它没有消息边界,但有逻辑连接,能够保证传输的可靠性和数据的有序性,同时还可以实现数据的双向传输。**众所周知的基于TCP协议的网络通信就属于此类。
|
||||
|
||||
>
|
||||
这样的网络通信传输数据的形式是字节流,而不是数据报文。字节流是以字节为单位的。内核程序无法感知一段字节流中包含了多少个消息,以及这些消息是否完整,这完全需要应用程序自己去把控。
|
||||
不过,此类网络通信中的一端,总是会忠实地按照另一端发送数据时的字节排列顺序,接收和缓存它们。所以,应用程序需要根据双方的约定去数据中查找消息边界,并按照边界切割数据,仅此而已。
|
||||
|
||||
|
||||
`syscall.Socket`函数的第三个参数用于表示socket实例所使用的协议。
|
||||
|
||||
通常,只要明确指定了前两个参数的值,我们就无需再去确定第三个参数值了,一般把它置为`0`就可以了。这时,内核程序会自行选择最合适的协议。
|
||||
|
||||
比如,当前两个参数值分别为`syscall.AF_INET`和`syscall.SOCK_DGRAM`的时候,内核程序会选择UDP作为协议。
|
||||
|
||||
又比如,在前两个参数值分别为`syscall.AF_INET6`和`syscall.SOCK_STREAM`时,内核程序可能会选择TCP作为协议。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/99/69/99f8a0405a98ea16495364be352fe969.png" alt=""><br>
|
||||
(syscall.Socket函数一瞥)
|
||||
|
||||
不过,你也看到了,在使用`net`包中的高层次API的时候,我们连那前两个参数值都无需给定,只需要把前面罗列的那些字符串字面量的其中一个,作为`network`参数的值就好了。
|
||||
|
||||
当然,如果你在使用这些API的时候,能够想到我在上面说的这些基础知识的话,那么一定会对你做出正确的判断和选择有所帮助。
|
||||
|
||||
## 知识扩展
|
||||
|
||||
### 问题1:调用`net.DialTimeout`函数时给定的超时时间意味着什么?
|
||||
|
||||
简单来说,这里的超时时间,代表着函数为网络连接建立完成而等待的最长时间。这是一个相对的时间。它会由这个函数的参数`timeout`的值表示。
|
||||
|
||||
开始的时间点几乎是我们调用`net.DialTimeout`函数的那一刻。在这之后,时间会主要花费在“解析参数`network`和`address`的值”,以及“创建socket实例并建立网络连接”这两件事情上。
|
||||
|
||||
不论执行到哪一步,只要在绝对的超时时间达到的那一刻,网络连接还没有建立完成,该函数就会返回一个代表了I/O操作超时的错误值。
|
||||
|
||||
值得注意的是,在解析`address`的值的时候,函数会确定网络服务的IP地址、端口号等必要信息,并在需要时访问DNS服务。
|
||||
|
||||
另外,如果解析出的IP地址有多个,那么函数会串行或并发地尝试建立连接。但无论用什么样的方式尝试,函数总会以最先建立成功的那个连接为准。
|
||||
|
||||
同时,它还会根据超时前的剩余时间,去设定针对每次连接尝试的超时时间,以便让它们都有适当的时间执行。
|
||||
|
||||
再多说一点。在`net`包中还有一个名为`Dialer`的结构体类型。该类型有一个名叫`Timeout`的字段,它与上述的`timeout`参数的含义是完全一致的。实际上,`net.DialTimeout`函数正是利用了这个类型的值才得以实现功能的。
|
||||
|
||||
`net.Dialer`类型值得你好好学习一下,尤其是它的每个字段的功用以及它的`DialContext`方法。
|
||||
|
||||
## 总结
|
||||
|
||||
我们今天提及了使用Go语言进行网络编程这个主题。作为引子,我先向你介绍了关于socket的一些基础知识。socket常被翻译为套接字,它是一种IPC方法。IPC可以被翻译为进程间通信,它主要定义了多个进程之间相互通信的方法。
|
||||
|
||||
Socket是IPC方法中最为通用和灵活的一种。与其他的方法不同,利用socket进行通信的进程可以不局限在同一台计算机当中。
|
||||
|
||||
只要通信的双方能够通过计算机的网卡端口,以及网络进行互联就可以使用socket,无论它们存在于世界上的哪个角落。
|
||||
|
||||
支持socket的操作系统一般都会对外提供一套API。Go语言的`syscall`代码包中也有与之对应的程序实体。其中最重要的一个就是`syscall.Socket`函数。
|
||||
|
||||
不过,`syscall`包中的这些程序实体,对于普通的Go程序来说都属于底层的东西了,我们通常很少会用到。一般情况下,我们都会使用`net`代码包及其子包中的API去编写网络程序。
|
||||
|
||||
`net`包中一个很常用的函数,名为`Dial`。这个函数主要用于连接网络服务。它会接受两个参数,你需要搞明白这两个参数的值都应该怎么去设定。
|
||||
|
||||
尤其是`network`参数,它有很多的可选值,其中最常用的有9个。这些可选值的背后都代表着相应的socket属性,包括通信域、类型以及使用的协议。一旦你理解了这些socket属性,就一定会帮助你做出正确的判断和选择。
|
||||
|
||||
与此相关的一个函数是`net.DialTimeout`。我们在调用它的时候需要设定一个超时时间。这个超时时间的含义你是需要搞清楚的。
|
||||
|
||||
通过它,我们可以牵扯出这个函数的一大堆实现细节。另外,还有一个叫做`net.Dialer`的结构体类型。这个类型其实是前述两个函数的底层实现,值得你好好地学习一番。
|
||||
|
||||
以上,就是我今天讲的主要内容,它们都是关于怎样访问网络服务的。你可以从这里入手,进入Go语言的网络编程世界。
|
||||
|
||||
## 思考题
|
||||
|
||||
今天的思考题也与超时时间有关。在你调用了`net.Dial`等函数之后,如果成功就会得到一个代表了网络连接的`net.Conn`接口类型的值。我的问题是:怎样在`net.Conn`类型的值上正确地设定针对读操作和写操作的超时时间?
|
||||
|
||||
[戳此查看Go语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers)
|
||||
|
||||
|
||||
176
极客时间专栏/Go语言核心36讲/模块三:Go语言实战与应用/47 | 基于HTTP协议的网络服务.md
Normal file
176
极客时间专栏/Go语言核心36讲/模块三:Go语言实战与应用/47 | 基于HTTP协议的网络服务.md
Normal file
@@ -0,0 +1,176 @@
|
||||
<audio id="audio" title="47 | 基于HTTP协议的网络服务" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e5/8f/e5086c42c16596609483acb1e4bfb88f.mp3"></audio>
|
||||
|
||||
我们在上一篇文章中简单地讨论了网络编程和socket,并由此提及了Go语言标准库中的`syscall`代码包和`net`代码包。
|
||||
|
||||
我还重点讲述了`net.Dial`函数和`syscall.Socket`函数的参数含义。前者间接地调用了后者,所以正确理解后者,会对用好前者有很大裨益。
|
||||
|
||||
之后,我们把视线转移到了`net.DialTimeout`函数以及它对操作超时的处理上,这又涉及了`net.Dialer`类型。实际上,这个类型正是`net`包中这两个“拨号”函数的底层实现。
|
||||
|
||||
我们像上一篇文章的示例代码那样用`net.Dial`或`net.DialTimeout`函数来访问基于HTTP协议的网络服务是完全没有问题的。HTTP协议是基于TCP/IP协议栈的,并且它也是一个面向普通文本的协议。
|
||||
|
||||
原则上,我们使用任何一个文本编辑器,都可以轻易地写出一个完整的HTTP请求报文。只要你搞清楚了请求报文的头部(header)和主体(body)应该包含的内容,这样做就会很容易。所以,在这种情况下,即便直接使用`net.Dial`函数,你应该也不会感觉到困难。
|
||||
|
||||
不过,不困难并不意味着很方便。如果我们只是访问基于HTTP协议的网络服务的话,那么使用`net/http`代码包中的程序实体来做,显然会更加便捷。
|
||||
|
||||
其中,最便捷的是使用`http.Get`函数。我们在调用它的时候只需要传给它一个URL就可以了,比如像下面这样:
|
||||
|
||||
```
|
||||
url1 := "http://google.cn"
|
||||
fmt.Printf("Send request to %q with method GET ...\n", url1)
|
||||
resp1, err := http.Get(url1)
|
||||
if err != nil {
|
||||
fmt.Printf("request sending error: %v\n", err)
|
||||
}
|
||||
defer resp1.Body.Close()
|
||||
line1 := resp1.Proto + " " + resp1.Status
|
||||
fmt.Printf("The first line of response:\n%s\n", line1)
|
||||
|
||||
```
|
||||
|
||||
`http.Get`函数会返回两个结果值。第一个结果值的类型是`*http.Response`,它是网络服务给我们传回来的响应内容的结构化表示。
|
||||
|
||||
第二个结果值是`error`类型的,它代表了在创建和发送HTTP请求,以及接收和解析HTTP响应的过程中可能发生的错误。
|
||||
|
||||
`http.Get`函数会在内部使用缺省的HTTP客户端,并且调用它的`Get`方法以完成功能。这个缺省的HTTP客户端是由`net/http`包中的公开变量`DefaultClient`代表的,其类型是`*http.Client`。它的基本类型也是可以被拿来使用的,甚至它还是开箱即用的。下面的这两行代码:
|
||||
|
||||
```
|
||||
var httpClient1 http.Client
|
||||
resp2, err := httpClient1.Get(url1)
|
||||
|
||||
```
|
||||
|
||||
与前面的这一行代码
|
||||
|
||||
```
|
||||
resp1, err := http.Get(url1)
|
||||
|
||||
```
|
||||
|
||||
是等价的。
|
||||
|
||||
`http.Client`是一个结构体类型,并且它包含的字段都是公开的。之所以该类型的零值仍然可用,是因为它的这些字段要么存在着相应的缺省值,要么其零值直接就可以使用,且代表着特定的含义。
|
||||
|
||||
现在,我问你一个问题,是关于这个类型中的最重要的一个字段的。
|
||||
|
||||
**今天的问题是:`http.Client`类型中的`Transport`字段代表着什么?**
|
||||
|
||||
这道题的**典型回答**是这样的。
|
||||
|
||||
`http.Client`类型中的`Transport`字段代表着:向网络服务发送HTTP请求,并从网络服务接收HTTP响应的操作过程。也就是说,该字段的方法`RoundTrip`应该实现单次HTTP事务(或者说基于HTTP协议的单次交互)需要的所有步骤。
|
||||
|
||||
这个字段是`http.RoundTripper`接口类型的,它有一个由`http.DefaultTransport`变量代表的缺省值(以下简称`DefaultTransport`)。当我们在初始化一个`http.Client`类型的值(以下简称`Client`值)的时候,如果没有显式地为该字段赋值,那么这个`Client`值就会直接使用`DefaultTransport`。
|
||||
|
||||
顺便说一下,`http.Client`类型的`Timeout`字段,代表的正是前面所说的单次HTTP事务的超时时间,它是`time.Duration`类型的。它的零值是可用的,用于表示没有设置超时时间。
|
||||
|
||||
## 问题解析
|
||||
|
||||
下面,我们再通过该字段的缺省值`DefaultTransport`,来深入地了解一下这个`Transport`字段。
|
||||
|
||||
`DefaultTransport`的实际类型是`*http.Transport`,后者即为`http.RoundTripper`接口的默认实现。这个类型是可以被复用的,也推荐被复用,同时,它也是并发安全的。正因为如此,`http.Client`类型也拥有着同样的特质。
|
||||
|
||||
`http.Transport`类型,会在内部使用一个`net.Dialer`类型的值(以下简称`Dialer`值),并且,它会把该值的`Timeout`字段的值,设定为`30`秒。
|
||||
|
||||
也就是说,这个`Dialer`值如果在30秒内还没有建立好网络连接,那么就会被判定为操作超时。在`DefaultTransport`的值被初始化的时候,这样的`Dialer`值的`DialContext`方法会被赋给前者的`DialContext`字段。
|
||||
|
||||
`http.Transport`类型还包含了很多其他的字段,其中有一些字段是关于操作超时的。
|
||||
|
||||
- `IdleConnTimeout`:含义是空闲的连接在多久之后就应该被关闭。
|
||||
- `DefaultTransport`会把该字段的值设定为`90`秒。如果该值为`0`,那么就表示不关闭空闲的连接。注意,这样很可能会造成资源的泄露。
|
||||
- `ResponseHeaderTimeout`:含义是,从客户端把请求完全递交给操作系统到从操作系统那里接收到响应报文头的最大时长。`DefaultTransport`并没有设定该字段的值。
|
||||
- `ExpectContinueTimeout`:含义是,在客户端递交了请求报文头之后,等待接收第一个响应报文头的最长时间。在客户端想要使用HTTP的“POST”方法把一个很大的报文体发送给服务端的时候,它可以先通过发送一个包含了“Expect: 100-continue”的请求报文头,来询问服务端是否愿意接收这个大报文体。这个字段就是用于设定在这种情况下的超时时间的。注意,如果该字段的值不大于`0`,那么无论多大的请求报文体都将会被立即发送出去。这样可能会造成网络资源的浪费。`DefaultTransport`把该字段的值设定为了`1`秒。
|
||||
- `TLSHandshakeTimeout`:TLS是Transport Layer Security的缩写,可以被翻译为传输层安全。这个字段代表了基于TLS协议的连接在被建立时的握手阶段的超时时间。若该值为`0`,则表示对这个时间不设限。`DefaultTransport`把该字段的值设定为了`10`秒。
|
||||
|
||||
此外,还有一些与`IdleConnTimeout`相关的字段值得我们关注,即:`MaxIdleConns`、`MaxIdleConnsPerHost`以及`MaxConnsPerHost`。
|
||||
|
||||
无论当前的`http.Transport`类型的值(以下简称`Transport`值)访问了多少个网络服务,`MaxIdleConns`字段都只会对空闲连接的总数做出限定。而`MaxIdleConnsPerHost`字段限定的则是,该`Transport`值访问的每一个网络服务的最大空闲连接数。
|
||||
|
||||
每一个网络服务都会有自己的网络地址,可能会使用不同的网络协议,对于一些HTTP请求也可能会用到代理。`Transport`值正是通过这三个方面的具体情况,来鉴别不同的网络服务的。
|
||||
|
||||
`MaxIdleConnsPerHost`字段的缺省值,由`http.DefaultMaxIdleConnsPerHost`变量代表,值为`2`。也就是说,在默认情况下,对于某一个`Transport`值访问的每一个网络服务,它的空闲连接数都最多只能有两个。
|
||||
|
||||
与`MaxIdleConnsPerHost`字段的含义相似的,是`MaxConnsPerHost`字段。不过,后者限制的是,针对某一个`Transport`值访问的每一个网络服务的最大连接数,不论这些连接是否是空闲的。并且,该字段没有相应的缺省值,它的零值表示不对此设限。
|
||||
|
||||
`DefaultTransport`并没有显式地为`MaxIdleConnsPerHost`和`MaxConnsPerHost`这两个字段赋值,但是它却把`MaxIdleConns`字段的值设定为了`100`。
|
||||
|
||||
换句话说,在默认情况下,空闲连接的总数最大为`100`,而针对每个网络服务的最大空闲连接数为`2`。注意,上述两个与空闲连接数有关的字段的值应该是联动的,所以,你有时候需要根据实际情况来定制它们。
|
||||
|
||||
当然了,这首先需要我们在初始化`Client`值的时候,定制它的`Transport`字段的值。定制这个值的方式,可以参看`DefaultTransport`变量的声明。
|
||||
|
||||
最后,我简单说一下为什么会出现空闲的连接。我们都知道,HTTP协议有一个请求报文头叫做“Connection”。在HTTP协议的1.1版本中,这个报文头的值默认是“keep-alive”。
|
||||
|
||||
在这种情况下的网络连接都是持久连接,它们会在当前的HTTP事务完成后仍然保持着连通性,因此是可以被复用的。
|
||||
|
||||
既然连接可以被复用,那么就会有两种可能。一种可能是,针对于同一个网络服务,有新的HTTP请求被递交,该连接被再次使用。另一种可能是,不再有对该网络服务的HTTP请求,该连接被闲置。
|
||||
|
||||
显然,后一种可能就产生了空闲的连接。另外,如果分配给某一个网络服务的连接过多的话,也可能会导致空闲连接的产生,因为每一个新递交的HTTP请求,都只会征用一个空闲的连接。所以,为空闲连接设定限制,在大多数情况下都是很有必要的,也是需要斟酌的。
|
||||
|
||||
如果我们想彻底地杜绝空闲连接的产生,那么可以在初始化`Transport`值的时候把它的`DisableKeepAlives`字段的值设定为`true`。这时,HTTP请求的“Connection”报文头的值就会被设置为“close”。这会告诉网络服务,这个网络连接不必保持,当前的HTTP事务完成后就可以断开它了。
|
||||
|
||||
如此一来,每当一个HTTP请求被递交时,就都会产生一个新的网络连接。这样做会明显地加重网络服务以及客户端的负载,并会让每个HTTP事务都耗费更多的时间。所以,在一般情况下,我们都不要去设置这个`DisableKeepAlives`字段。
|
||||
|
||||
顺便说一句,在`net.Dialer`类型中,也有一个看起来很相似的字段`KeepAlive`。不过,它与前面所说的HTTP持久连接并不是一个概念,`KeepAlive`是直接作用在底层的socket上的。
|
||||
|
||||
它的背后是一种针对网络连接(更确切地说,是TCP连接)的存活探测机制。它的值用于表示每间隔多长时间发送一次探测包。当该值不大于`0`时,则表示不开启这种机制。`DefaultTransport`会把这个字段的值设定为`30`秒。
|
||||
|
||||
好了,以上这些内容阐述的就是,`http.Client`类型中的`Transport`字段的含义,以及它的值的定制方式。这涉及了`http.RoundTripper`接口、`http.DefaultTransport`变量、`http.Transport`类型,以及`net.Dialer`类型。
|
||||
|
||||
## 知识扩展
|
||||
|
||||
### 问题:`http.Server`类型的`ListenAndServe`方法都做了哪些事情?
|
||||
|
||||
`http.Server`类型与`http.Client`是相对应的。`http.Server`代表的是基于HTTP协议的服务端,或者说网络服务。
|
||||
|
||||
`http.Server`类型的`ListenAndServe`方法的功能是:监听一个基于TCP协议的网络地址,并对接收到的HTTP请求进行处理。这个方法会默认开启针对网络连接的存活探测机制,以保证连接是持久的。同时,该方法会一直执行,直到有严重的错误发生或者被外界关掉。当被外界关掉时,它会返回一个由`http.ErrServerClosed`变量代表的错误值。
|
||||
|
||||
对于本问题,典型回答可以像下面这样。
|
||||
|
||||
这个`ListenAndServe`方法主要会做下面这几件事情。
|
||||
|
||||
1. 检查当前的`http.Server`类型的值(以下简称当前值)的`Addr`字段。该字段的值代表了当前的网络服务需要使用的网络地址,即:IP地址和端口号. 如果这个字段的值为空字符串,那么就用`":http"`代替。也就是说,使用任何可以代表本机的域名和IP地址,并且端口号为`80`。
|
||||
1. 通过调用`net.Listen`函数在已确定的网络地址上启动基于TCP协议的监听。
|
||||
1. 检查`net.Listen`函数返回的错误值。如果该错误值不为`nil`,那么就直接返回该值。否则,通过调用当前值的`Serve`方法准备接受和处理将要到来的HTTP请求。
|
||||
|
||||
可以从当前问题直接衍生出的问题一般有两个,一个是“`net.Listen`函数都做了哪些事情”,另一个是“`http.Server`类型的`Serve`方法是怎样接受和处理HTTP请求的”。
|
||||
|
||||
**对于第一个直接的衍生问题,如果概括地说,回答可以是:**
|
||||
|
||||
1. 解析参数值中包含的网络地址隐含的IP地址和端口号;
|
||||
1. 根据给定的网络协议,确定监听的方法,并开始进行监听。
|
||||
|
||||
从这里的第二个步骤出发,我们还可以继续提出一些间接的衍生问题。这往往会涉及`net.socket`函数以及相关的socket知识。
|
||||
|
||||
**对于第二个直接的衍生问题,我们可以这样回答:**
|
||||
|
||||
在一个`for`循环中,网络监听器的`Accept`方法会被不断地调用,该方法会返回两个结果值;第一个结果值是`net.Conn`类型的,它会代表包含了新到来的HTTP请求的网络连接;第二个结果值是代表了可能发生的错误的`error`类型值。
|
||||
|
||||
如果这个错误值不为`nil`,除非它代表了一个暂时性的错误,否则循环都会被终止。如果是暂时性的错误,那么循环的下一次迭代将会在一段时间之后开始执行。
|
||||
|
||||
如果这里的`Accept`方法没有返回非`nil`的错误值,那么这里的程序将会先把它的第一个结果值包装成一个`*http.conn`类型的值(以下简称`conn`值),然后通过在新的goroutine中调用这个`conn`值的`serve`方法,来对当前的HTTP请求进行处理。
|
||||
|
||||
这个处理的细节还是很多的,所以我们依然可以找出不少的间接的衍生问题。比如,这个`conn`值的状态有几种,分别代表着处理的哪个阶段?又比如,处理过程中会用到哪些读取器和写入器,它们的作用分别是什么?再比如,这里的程序是怎样调用我们自定义的处理函数的,等等。
|
||||
|
||||
诸如此类的问题很多,我就不在这里一一列举和说明了。你只需要记住一句话:“源码之前了无秘密”。上面这些问题的答案都可以在Go语言标准库的源码中找到。如果你想对本问题进行深入的探索,那么一定要去看`net/http`代码包的源码。
|
||||
|
||||
## 总结
|
||||
|
||||
今天,我们主要讲的是基于HTTP协议的网络服务,侧重点仍然在客户端。
|
||||
|
||||
我们在讨论了`http.Get`函数和`http.Client`类型的简单使用方式之后,把目光聚焦在了后者的`Transport`字段。
|
||||
|
||||
这个字段代表着单次HTTP事务的操作过程。它是`http.RoundTripper`接口类型的。它的缺省值由`http.DefaultTransport`变量代表,其实际类型是`*http.Transport`。
|
||||
|
||||
`http.Transport`包含的字段非常多。我们先讲了`DefaultTransport`中的`DialContext`字段会被赋予什么样的值,又详细说明了一些关于操作超时的字段。
|
||||
|
||||
比如`IdleConnTimeout`和`ExpectContinueTimeout`,以及相关的`MaxIdleConns`和`MaxIdleConnsPerHost`等等。之后,我又简单地解释了出现空闲连接的原因,以及相关的定制方式。
|
||||
|
||||
最后,作为扩展,我还为你简要地梳理了`http.Server`类型的`ListenAndServe`方法,执行的主要流程。不过,由于篇幅原因,我没有做深入讲述。但是,这并不意味着没有必要深入下去。相反,这个方法很重要,值得我们认真地去探索一番。
|
||||
|
||||
在你需要或者有兴趣的时候,我希望你能去好好地看一看`net/http`包中的相关源码。一切秘密都在其中。
|
||||
|
||||
## 思考题
|
||||
|
||||
我今天留给你的思考题比较简单,即:怎样优雅地停止基于HTTP协议的网络服务程序?
|
||||
|
||||
[戳此查看Go语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers)
|
||||
|
||||
|
||||
113
极客时间专栏/Go语言核心36讲/模块三:Go语言实战与应用/48 | 程序性能分析基础(上).md
Normal file
113
极客时间专栏/Go语言核心36讲/模块三:Go语言实战与应用/48 | 程序性能分析基础(上).md
Normal file
@@ -0,0 +1,113 @@
|
||||
<audio id="audio" title="48 | 程序性能分析基础(上)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4b/40/4b858ec579e4d63db1cbf7f193058640.mp3"></audio>
|
||||
|
||||
作为拾遗的部分,今天我们来讲讲与Go程序性能分析有关的基础知识。
|
||||
|
||||
Go语言为程序开发者们提供了丰富的性能分析API,和非常好用的标准工具。这些API主要存在于:
|
||||
|
||||
1. `runtime/pprof`;
|
||||
1. `net/http/pprof`;
|
||||
1. `runtime/trace`;
|
||||
|
||||
这三个代码包中。
|
||||
|
||||
另外,`runtime`代码包中还包含了一些更底层的API。它们可以被用来收集或输出Go程序运行过程中的一些关键指标,并帮助我们生成相应的概要文件以供后续分析时使用。
|
||||
|
||||
至于标准工具,主要有`go tool pprof`和`go tool trace`这两个。它们可以解析概要文件中的信息,并以人类易读的方式把这些信息展示出来。
|
||||
|
||||
此外,`go test`命令也可以在程序测试完成后生成概要文件。如此一来,我们就可以很方便地使用前面那两个工具读取概要文件,并对被测程序的性能加以分析。这无疑会让程序性能测试的一手资料更加丰富,结果更加精确和可信。
|
||||
|
||||
在Go语言中,用于分析程序性能的概要文件有三种,分别是:CPU概要文件(CPU Profile)、内存概要文件(Mem Profile)和阻塞概要文件(Block Profile)。
|
||||
|
||||
这些概要文件中包含的都是:在某一段时间内,对Go程序的相关指标进行多次采样后得到的概要信息。
|
||||
|
||||
对于CPU概要文件来说,其中的每一段独立的概要信息都记录着,在进行某一次采样的那个时刻,CPU上正在执行的Go代码。
|
||||
|
||||
而对于内存概要文件,其中的每一段概要信息都记载着,在某个采样时刻,正在执行的Go代码以及堆内存的使用情况,这里包含已分配和已释放的字节数量和对象数量。至于阻塞概要文件,其中的每一段概要信息,都代表着Go程序中的一个goroutine阻塞事件。
|
||||
|
||||
注意,在默认情况下,这些概要文件中的信息并不是普通的文本,它们都是以二进制的形式展现的。如果你使用一个常规的文本编辑器查看它们的话,那么肯定会看到一堆“乱码”。
|
||||
|
||||
这时就可以显现出`go tool pprof`这个工具的作用了。我们可以通过它进入一个基于命令行的交互式界面,并对指定的概要文件进行查阅。就像下面这样:
|
||||
|
||||
```
|
||||
$ go tool pprof cpuprofile.out
|
||||
Type: cpu
|
||||
Time: Nov 9, 2018 at 4:31pm (CST)
|
||||
Duration: 7.96s, Total samples = 6.88s (86.38%)
|
||||
Entering interactive mode (type "help" for commands, "o" for options)
|
||||
(pprof)
|
||||
|
||||
```
|
||||
|
||||
关于这个工具的具体用法,我就不在这里赘述了。在进入这个工具的交互式界面之后,我们只要输入指令`help`并按下回车键,就可以看到很详细的帮助文档。
|
||||
|
||||
我们现在来说说怎样生成概要文件。
|
||||
|
||||
你可能会问,既然在概要文件中的信息不是普通的文本,那么它们到底是什么格式的呢?一个对广大的程序开发者而言,并不那么重要的事实是,它们是通过protocol buffers生成的二进制数据流,或者说字节流。
|
||||
|
||||
概括来讲,protocol buffers是一种数据序列化协议,同时也是一个序列化工具。它可以把一个值,比如一个结构体或者一个字典,转换成一段字节流。
|
||||
|
||||
也可以反过来,把经过它生成的字节流反向转换为程序中的一个值。前者就被叫做序列化,而后者则被称为反序列化。
|
||||
|
||||
换句话说,protocol buffers定义和实现了一种“可以让数据在结构形态和扁平形态之间互相转换”的方式。
|
||||
|
||||
Protocol buffers的优势有不少。比如,它可以在序列化数据的同时对数据进行压缩,所以它生成的字节流,通常都要比相同数据的其他格式(例如XML和JSON)占用的空间明显小很多。
|
||||
|
||||
又比如,它既能让我们自己去定义数据序列化和结构化的格式,也允许我们在保证向后兼容的前提下去更新这种格式。
|
||||
|
||||
正因为这些优势,Go语言从1.8版本开始,把所有profile相关的信息生成工作都交给protocol buffers来做了。这也是我们在上述概要文件中,看不到普通文本的根本原因了。
|
||||
|
||||
Protocol buffers的用途非常广泛,并且在诸如数据存储、数据传输等任务中有着很高的使用率。不过,关于它,我暂时就介绍到这里。你目前知道这些也就足够了。你并不用关心`runtime/pprof`包以及`runtime`包中的程序是如何序列化这些概要信息的。
|
||||
|
||||
继续回到怎样生成概要文件的话题,我们依然通过具体的问题来讲述。
|
||||
|
||||
**我们今天的问题是:怎样让程序对CPU概要信息进行采样?**
|
||||
|
||||
**这道题的典型回答是这样的。**
|
||||
|
||||
这需要用到`runtime/pprof`包中的API。更具体地说,在我们想让程序开始对CPU概要信息进行采样的时候,需要调用这个代码包中的`StartCPUProfile`函数,而在停止采样的时候则需要调用该包中的`StopCPUProfile`函数。
|
||||
|
||||
## 问题解析
|
||||
|
||||
`runtime/pprof.StartCPUProfile`函数(以下简称`StartCPUProfile`函数)在被调用的时候,先会去设定CPU概要信息的采样频率,并会在单独的goroutine中进行CPU概要信息的收集和输出。
|
||||
|
||||
注意,`StartCPUProfile`函数设定的采样频率总是固定的,即:`100`赫兹。也就是说,每秒采样`100`次,或者说每`10`毫秒采样一次。
|
||||
|
||||
赫兹,也称Hz,是从英文单词“Hertz”(一个英文姓氏)音译过来的一个中文词。它是CPU主频的基本单位。
|
||||
|
||||
CPU的主频指的是,CPU内核工作的时钟频率,也常被称为CPU clock speed。这个时钟频率的倒数即为时钟周期(clock cycle),也就是一个CPU内核执行一条运算指令所需的时间,单位是秒。
|
||||
|
||||
例如,主频为`1000`Hz的CPU,它的单个内核执行一条运算指令所需的时间为`0.001`秒,即`1`毫秒。又例如,我们现在常用的`3.2`GHz的多核CPU,其单个内核在`1`个纳秒的时间里就可以至少执行三条运算指令。
|
||||
|
||||
`StartCPUProfile`函数设定的CPU概要信息采样频率,相对于现代的CPU主频来说是非常低的。这主要有两个方面的原因。
|
||||
|
||||
一方面,过高的采样频率会对Go程序的运行效率造成很明显的负面影响。因此,`runtime`包中`SetCPUProfileRate`函数在被调用的时候,会保证采样频率不超过`1`MHz(兆赫),也就是说,它只允许每`1`微秒最多采样一次。`StartCPUProfile`函数正是通过调用这个函数来设定CPU概要信息的采样频率的。
|
||||
|
||||
另一方面,经过大量的实验,Go语言团队发现`100`Hz是一个比较合适的设定。因为这样做既可以得到足够多、足够有用的概要信息,又不至于让程序的运行出现停滞。另外,操作系统对高频采样的处理能力也是有限的,一般情况下,超过`500`Hz就很可能得不到及时的响应了。
|
||||
|
||||
在`StartCPUProfile`函数执行之后,一个新启用的goroutine将会负责执行CPU概要信息的收集和输出,直到`runtime/pprof`包中的`StopCPUProfile`函数被成功调用。
|
||||
|
||||
`StopCPUProfile`函数也会调用`runtime.SetCPUProfileRate`函数,并把参数值(也就是采样频率)设为`0`。这会让针对CPU概要信息的采样工作停止。
|
||||
|
||||
同时,它也会给负责收集CPU概要信息的代码一个“信号”,以告知收集工作也需要停止了。
|
||||
|
||||
在接到这样的“信号”之后,那部分程序将会把这段时间内收集到的所有CPU概要信息,全部写入到我们在调用`StartCPUProfile`函数的时候指定的写入器中。只有在上述操作全部完成之后,`StopCPUProfile`函数才会返回。
|
||||
|
||||
好了,经过这一番解释,你应该已经对CPU概要信息的采样工作有一定的认识了。你可以去看看demo96.go文件中的代码,并运行几次试试。这样会有助于你加深对这个问题的理解。
|
||||
|
||||
## 总结
|
||||
|
||||
我们这两篇内容讲的是Go程序的性能分析,这其中的内容都是你从事这项任务必备的一些知识和技巧。
|
||||
|
||||
首先,我们需要知道,与程序性能分析有关的API主要存在于`runtime`、`runtime/pprof`和`net/http/pprof`这几个代码包中。它们可以帮助我们收集相应的性能概要信息,并把这些信息输出到我们指定的地方。
|
||||
|
||||
Go语言的运行时系统会根据要求对程序的相关指标进行多次采样,并对采样的结果进行组织和整理,最后形成一份完整的性能分析报告。这份报告就是我们一直在说的概要信息的汇总。
|
||||
|
||||
一般情况下,我们会把概要信息输出到文件。根据概要信息的不同,概要文件的种类主要有三个,分别是:CPU概要文件(CPU Profile)、内存概要文件(Mem Profile)和阻塞概要文件(Block Profile)。
|
||||
|
||||
在本文中,我提出了一道与上述几种概要信息有关的问题。在下一篇文章中,我们会继续对这部分问题的探究。
|
||||
|
||||
你对今天的内容有什么样的思考与疑惑,可以给我留言,感谢你的收听,我们下次再见。
|
||||
|
||||
[戳此查看Go语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers)
|
||||
|
||||
|
||||
169
极客时间专栏/Go语言核心36讲/模块三:Go语言实战与应用/49 | 程序性能分析基础(下).md
Normal file
169
极客时间专栏/Go语言核心36讲/模块三:Go语言实战与应用/49 | 程序性能分析基础(下).md
Normal file
@@ -0,0 +1,169 @@
|
||||
<audio id="audio" title="49 | 程序性能分析基础(下)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d3/e6/d3832ef7429c41e1548c6d29c73e72e6.mp3"></audio>
|
||||
|
||||
你好,我是郝林,今天我们继续分享程序性能分析基础的内容。
|
||||
|
||||
在上一篇文章中,我们围绕着“怎样让程序对CPU概要信息进行采样”这一问题进行了探讨,今天,我们再来一起看看它的拓展问题。
|
||||
|
||||
## 知识扩展
|
||||
|
||||
### 问题1:怎样设定内存概要信息的采样频率?
|
||||
|
||||
针对内存概要信息的采样会按照一定比例收集Go程序在运行期间的堆内存使用情况。设定内存概要信息采样频率的方法很简单,只要为`runtime.MemProfileRate`变量赋值即可。
|
||||
|
||||
这个变量的含义是,平均每分配多少个字节,就对堆内存的使用情况进行一次采样。如果把该变量的值设为`0`,那么,Go语言运行时系统就会完全停止对内存概要信息的采样。该变量的缺省值是`512 KB`,也就是`512`千字节。
|
||||
|
||||
注意,如果你要设定这个采样频率,那么越早设定越好,并且只应该设定一次,否则就可能会对Go语言运行时系统的采样工作,造成不良影响。比如,只在`main`函数的开始处设定一次。
|
||||
|
||||
在这之后,当我们想获取内存概要信息的时候,还需要调用`runtime/pprof`包中的`WriteHeapProfile`函数。该函数会把收集好的内存概要信息,写到我们指定的写入器中。
|
||||
|
||||
注意,我们通过`WriteHeapProfile`函数得到的内存概要信息并不是实时的,它是一个快照,是在最近一次的内存垃圾收集工作完成时产生的。如果你想要实时的信息,那么可以调用`runtime.ReadMemStats`函数。不过要特别注意,该函数会引起Go语言调度器的短暂停顿。
|
||||
|
||||
以上,就是关于内存概要信息的采样频率设定问题的简要回答。
|
||||
|
||||
### 问题2:怎样获取到阻塞概要信息?
|
||||
|
||||
我们调用`runtime`包中的`SetBlockProfileRate`函数,即可对阻塞概要信息的采样频率进行设定。该函数有一个名叫`rate`的参数,它是`int`类型的。
|
||||
|
||||
这个参数的含义是,只要发现一个阻塞事件的持续时间达到了多少个纳秒,就可以对其进行采样。如果这个参数的值小于或等于`0`,那么就意味着Go语言运行时系统将会完全停止对阻塞概要信息的采样。
|
||||
|
||||
在`runtime`包中,还有一个名叫`blockprofilerate`的包级私有变量,它是`uint64`类型的。这个变量的含义是,只要发现一个阻塞事件的持续时间跨越了多少个CPU时钟周期,就可以对其进行采样。它的含义与我们刚刚提到的`rate`参数的含义非常相似,不是吗?
|
||||
|
||||
实际上,这两者的区别仅仅在于单位不同。`runtime.SetBlockProfileRate`函数会先对参数`rate`的值进行单位换算和必要的类型转换,然后,它会把换算结果用原子操作赋给`blockprofilerate`变量。由于此变量的缺省值是`0`,所以Go语言运行时系统在默认情况下并不会记录任何在程序中发生的阻塞事件。
|
||||
|
||||
另一方面,当我们需要获取阻塞概要信息的时候,需要先调用`runtime/pprof`包中的`Lookup`函数并传入参数值`"block"`,从而得到一个`*runtime/pprof.Profile`类型的值(以下简称`Profile`值)。在这之后,我们还需要调用这个`Profile`值的`WriteTo`方法,以驱使它把概要信息写进我们指定的写入器中。
|
||||
|
||||
这个`WriteTo`方法有两个参数,一个参数就是我们刚刚提到的写入器,它是`io.Writer`类型的。而另一个参数则是代表了概要信息详细程度的`int`类型参数`debug`。
|
||||
|
||||
`debug`参数主要的可选值有两个,即:`0`和`1`。当`debug`的值为`0`时,通过`WriteTo`方法写进写入器的概要信息仅会包含`go tool pprof`工具所需的内存地址,这些内存地址会以十六进制的形式展现出来。
|
||||
|
||||
当该值为`1`时,相应的包名、函数名、源码文件路径、代码行号等信息就都会作为注释被加入进去。另外,`debug`为`0`时的概要信息,会经由protocol buffers转换为字节流。而在`debug`为`1`的时候,`WriteTo`方法输出的这些概要信息就是我们可以读懂的普通文本了。
|
||||
|
||||
除此之外,`debug`的值也可以是`2`。这时,被输出的概要信息也会是普通的文本,并且通常会包含更多的细节。至于这些细节都包含了哪些内容,那就要看我们调用`runtime/pprof.Lookup`函数的时候传入的是什么样的参数值了。下面,我们就来一起看一下这个函数。
|
||||
|
||||
### 问题 3:`runtime/pprof.Lookup`函数的正确调用方式是什么?
|
||||
|
||||
`runtime/pprof.Lookup`函数(以下简称`Lookup`函数)的功能是,提供与给定的名称相对应的概要信息。这个概要信息会由一个`Profile`值代表。如果该函数返回了一个`nil`,那么就说明不存在与给定名称对应的概要信息。
|
||||
|
||||
`runtime/pprof`包已经为我们预先定义了6个概要名称。它们对应的概要信息收集方法和输出方法也都已经准备好了。我们直接拿来使用就可以了。它们是:`goroutine`、`heap`、`allocs`、`threadcreate`、`block`和`mutex`。
|
||||
|
||||
当我们把`"goroutine"`传入`Lookup`函数的时候,该函数会利用相应的方法,收集到当前正在使用的所有goroutine的堆栈跟踪信息。注意,这样的收集会引起Go语言调度器的短暂停顿。
|
||||
|
||||
当调用该函数返回的`Profile`值的`WriteTo`方法时,如果参数`debug`的值大于或等于`2`,那么该方法就会输出所有goroutine的堆栈跟踪信息。这些信息可能会非常多。如果它们占用的空间超过了`64 MB`(也就是`64`兆字节),那么相应的方法就会将超出的部分截掉。
|
||||
|
||||
如果`Lookup`函数接到的参数值是`"heap"`,那么它就会收集与堆内存的分配和释放有关的采样信息。这实际上就是我们在前面讨论过的内存概要信息。在我们传入`"allocs"`的时候,后续的操作会与之非常的相似。
|
||||
|
||||
在这两种情况下,`Lookup`函数返回的`Profile`值也会极其相像。只不过,在这两种`Profile`值的`WriteTo`方法被调用时,它们输出的概要信息会有细微的差别,而且这仅仅体现在参数`debug`等于`0`的时候。
|
||||
|
||||
`"heap"`会使得被输出的内存概要信息默认以“在用空间”(inuse_space)的视角呈现,而`"allocs"`对应的默认视角则是“已分配空间”(alloc_space)。
|
||||
|
||||
“在用空间”是指,已经被分配但还未被释放的内存空间。在这个视角下,`go tool pprof`工具并不会去理会与已释放空间有关的那部分信息。而在“已分配空间”的视角下,所有的内存分配信息都会被展现出来,无论这些内存空间在采样时是否已被释放。
|
||||
|
||||
此外,无论是`"heap"`还是`"allocs"`,在我们调用`Profile`值的`WriteTo`方法的时候,只要赋予`debug`参数的值大于`0`,那么该方法输出内容的规格就会是相同的。
|
||||
|
||||
参数值`"threadcreate"`会使`Lookup`函数去收集一些堆栈跟踪信息。这些堆栈跟踪信息中的每一个都会描绘出一个代码调用链,这些调用链上的代码都导致新的操作系统线程产生。这样的`Profile`值的输出规格也只有两种,取决于我们传给其`WriteTo`方法的参数值是否大于`0`。
|
||||
|
||||
再说`"block"`和`"mutex"`。`"block"`代表的是,因争用同步原语而被阻塞的那些代码的堆栈跟踪信息。还记得吗?这就是我们在前面讲过的阻塞概要信息。
|
||||
|
||||
与之相对应,`"mutex"`代表的是,曾经作为同步原语持有者的那些代码,它们的堆栈跟踪信息。它们的输出规格也都只有两种,取决于`debug`是否大于`0`。
|
||||
|
||||
这里所说的同步原语,指的是存在于Go语言运行时系统内部的一种底层的同步工具,或者说一种同步机制。
|
||||
|
||||
它是直接面向内存地址的,并以异步信号量和原子操作作为实现手段。我们已经熟知的通道、互斥锁、条件变量、”WaitGroup“,以及Go语言运行时系统本身,都会利用它来实现自己的功能。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/17/a7/17f957efc8fd583e2011c8ace0b7c7a7.png" alt=""><br>
|
||||
runtime/pprof.Lookup函数一瞥
|
||||
|
||||
好了,关于这个问题,我们已经谈了不少了。我相信,你已经对`Lookup`函数的调用方式及其背后的含义有了比较深刻的理解了。demo99.go文件中包含了一些示例代码,可供你参考。
|
||||
|
||||
### 问题4:如何为基于HTTP协议的网络服务添加性能分析接口?
|
||||
|
||||
这个问题说起来还是很简单的。这是因为我们在一般情况下只要在程序中导入`net/http/pprof`代码包就可以了,就像这样:
|
||||
|
||||
```
|
||||
import _ "net/http/pprof"
|
||||
|
||||
```
|
||||
|
||||
然后,启动网络服务并开始监听,比如:
|
||||
|
||||
```
|
||||
log.Println(http.ListenAndServe("localhost:8082", nil))
|
||||
|
||||
```
|
||||
|
||||
在运行这个程序之后,我们就可以通过在网络浏览器中访问`http://localhost:8082/debug/pprof`这个地址看到一个简约的网页。如果你认真地看了上一个问题的话,那么肯定可以快速搞明白这个网页中各个部分的含义。
|
||||
|
||||
在`/debug/pprof/`这个URL路径下还有很多可用的子路径,这一点你通过点选网页中的链接就可以了解到。像`allocs`、`block`、`goroutine`、`heap`、`mutex`、`threadcreate`这6个子路径,在底层其实都是通过`Lookup`函数来处理的。关于这个函数,你应该已经很熟悉了。
|
||||
|
||||
这些子路径都可以接受查询参数`debug`。它用于控制概要信息的格式和详细程度。至于它的可选值,我就不再赘述了。它的缺省值是`0`。另外,还有一个名叫`gc`的查询参数。它用于控制是否在获取概要信息之前强制地执行一次垃圾回收。只要它的值大于`0`,程序就会这样做。不过,这个参数仅在`/debug/pprof/heap`路径下有效。
|
||||
|
||||
一旦`/debug/pprof/profile`路径被访问,程序就会去执行对CPU概要信息的采样。它接受一个名为`seconds`的查询参数。该参数的含义是,采样工作需要持续多少秒。如果这个参数未被显式地指定,那么采样工作会持续`30`秒。注意,在这个路径下,程序只会响应经protocol buffers转换的字节流。我们可以通过`go tool pprof`工具直接读取这样的HTTP响应,例如:
|
||||
|
||||
```
|
||||
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=60
|
||||
|
||||
```
|
||||
|
||||
除此之外,还有一个值得我们关注的路径,即:`/debug/pprof/trace`。在这个路径下,程序主要会利用`runtime/trace`代码包中的API来处理我们的请求。
|
||||
|
||||
更具体地说,程序会先调用`trace.Start`函数,然后在查询参数`seconds`指定的持续时间之后再调用`trace.Stop`函数。这里的`seconds`的缺省值是`1`秒。至于`runtime/trace`代码包的功用,我就留给你自己去查阅和探索吧。
|
||||
|
||||
前面说的这些URL路径都是固定不变的。这是默认情况下的访问规则。我们还可以对它们进行定制,就像这样:
|
||||
|
||||
```
|
||||
mux := http.NewServeMux()
|
||||
pathPrefix := "/d/pprof/"
|
||||
mux.HandleFunc(pathPrefix,
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
name := strings.TrimPrefix(r.URL.Path, pathPrefix)
|
||||
if name != "" {
|
||||
pprof.Handler(name).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
pprof.Index(w, r)
|
||||
})
|
||||
mux.HandleFunc(pathPrefix+"cmdline", pprof.Cmdline)
|
||||
mux.HandleFunc(pathPrefix+"profile", pprof.Profile)
|
||||
mux.HandleFunc(pathPrefix+"symbol", pprof.Symbol)
|
||||
mux.HandleFunc(pathPrefix+"trace", pprof.Trace)
|
||||
|
||||
server := http.Server{
|
||||
Addr: "localhost:8083",
|
||||
Handler: mux,
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可以看到,我们几乎只使用了`net/http/pprof`代码包中的几个程序实体,就完成了这样的定制。这在我们使用第三方的网络服务开发框架时尤其有用。
|
||||
|
||||
我们自定义的HTTP请求多路复用器`mux`所包含的访问规则与默认的规则很相似,只不过URL路径的前缀更短了一些而已。
|
||||
|
||||
我们定制`mux`的过程与`net/http/pprof`包中的`init`函数所做的事情也是类似的。这个`init`函数的存在,其实就是我们在前面仅仅导入"net/http/pprof"代码包就能够访问相关路径的原因。
|
||||
|
||||
在我们编写网络服务程序的时候,使用`net/http/pprof`包要比直接使用`runtime/pprof`包方便和实用很多。通过合理运用,这个代码包可以为网络服务的监测提供有力的支撑。关于这个包的知识,我就先介绍到这里。
|
||||
|
||||
## 总结
|
||||
|
||||
这两篇文章中,我们主要讲了Go程序的性能分析,提到的很多内容都是你必备的知识和技巧。这些有助于你真正地理解以采样、收集、输出为代表的一系列操作步骤。
|
||||
|
||||
我提到的几种概要信息有关的问题。你需要记住的是,每一种概要信息都代表了什么,它们分别都包含了什么样的内容。
|
||||
|
||||
你还需要知道获取它们的正确方式,包括怎样启动和停止采样、怎样设定采样频率,以及怎样控制输出内容的格式和详细程度。
|
||||
|
||||
此外,`runtime/pprof`包中的`Lookup`函数的正确调用方式也很重要。对于除了CPU概要信息之外的其他概要信息,我们都可以通过调用这个函数获取到。
|
||||
|
||||
除此之外,我还提及了一个上层的应用,即:为基于HTTP协议的网络服务,添加性能分析接口。这也是很实用的一个部分。
|
||||
|
||||
虽然`net/http/pprof`包提供的程序实体并不多,但是它却能够让我们用不同的方式,实现性能分析接口的嵌入。这些方式有的是极简的、开箱即用的,而有的则用于满足各种定制需求。
|
||||
|
||||
以上这些,就是我今天为你讲述的Go语言知识,它们是程序性能分析的基础。如果你把Go语言程序运用于生产环境,那么肯定会涉及它们。对于这里提到的所有内容和问题,我都希望你能够认真地去思考和领会。这样才能够让你在真正使用它们的时候信手拈来。
|
||||
|
||||
## 思考题
|
||||
|
||||
我今天留给你的思考题其实在前面已经透露了,那就是:`runtime/trace`代码包的功用是什么?
|
||||
|
||||
感谢你的收听,我们下期再见。
|
||||
|
||||
[戳此查看Go语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user