读[GO语言核心36讲]
一:概要
1.1.参考
【GO编程语言规范】https://golang.google.cn/ref/spec
【GO命令】https://golang.google.cn/cmd/
【环境设置】https://www.liwenzhou.com/posts/Go/install_go_dev_old/
【Unicode】https://home.unicode.org/
二:Go语言基础知识
理解Go语言的开发环境配置、常用源码文件写法,以及程序实体(尤其是变量)及其相关的各种概念和编程技巧(比如类型推断、变量重声明、可重名变量、类型断言、类型转换、别名类型和潜在类型等)。
1.工作区和GOPATH
GOROOT:Go语言安装根目录的路径,也就是GO语言的安装路径。
GOPATH:若干工作区目录的路径。是我们自己定义的工作空间。
你可以把GOPATH简单理解成Go语言的工作目录,它的值是一个目录的路径,也可以是多个目录路径,每个目录都代表Go语言的一个工作区(workspace)
go mod init qiaomingzi.github.io/m/v1
GOBIN:GO程序生成的可执行文件(executable file)的路径。
2.源码文件
2.1 运行程序
使用 go run 命令执行go源码main文件
$ go run hello.go
Hello, World!
此外我们还可以使用 go build 命令来生成二进制文件:
$ go build hello.go
$ ls
hello hello.go
$ ./hello
Hello, World!
2.2 程序实体
是变量、常量、函数、结构体和接口的统称;程序实体的名字被统称为标识符。标识符可以是任何Unicode编码可以表示的字母字符、数字以及下划线“_”,但是其首字母不能是数字。
如:var name string
2.3 源文件包名
package xxx
-
同目录下的源码文件的代码包声明语句要一致。也就是说,它们要同属于一个代码包。这对于所有源码文件都是适用的。
-
源码文件声明的代码包的名称可以与其所在的目录的名称不同。在针对代码包进行构建时,生成的结果文件的主名称与其父目录的名称一致。
-
为了不让该代码包的使用者产生困惑,我们总是应该让声明的包名与其父目录的名称一致
-
名称的首字母为大写的程序实体才可以被当前包外的代码引用,否则它就只能被当前包内的其他代码引用。
-
Go 1.5及后续版本中,我们可以通过创建
internal
代码包让一些程序实体仅仅能被当前模块中的其他代码引用。这被称为Go程序实体的第三种访问权限:模块级私有
3.程序实体的那些事儿
3.1 变量声明
Go语言是静态类型的编程语言,所以我们在声明变量或常量的时候,都需要指定它们的类型,或者给予足够的信息,这样才可以让Go语言能够推导出它们的类型。Go语言是静态类型的,所以一旦在初始化变量时确定了它的类型,之后就不可能再改变。这就避免了在后面维护程序时的一些问题。另外,请记住,这种类型的确定是在编译期完成的,因此不会对程序的运行效率产生任何影响。
在Go语言中,变量的类型可以是其预定义的那些类型,也可以是程序自定义的函数、结构体或接口。
3.2 重构
我们通常把不改变某个程序与外界的任何交互方式和规则,而只改变其内部实现”的代码修改方式,叫做对该程序的重构。重构的对象可以是一行代码、一个函数、一个功能模块,甚至一个软件系统。
3.3 代码块
代码块一般就是一个由花括号括起来的区域,里面可以包含表达式和语句。全域代码块、空代码块。
3.4 变量重声明
对已经声明过的变量再次声明。变量重声明的前提条件如下。
- 由于变量的类型在其初始化时就已经确定了,所以对它再次声明时赋予的类型必须与其原本的类型相同,否则会产生编译错误。
- 变量的重声明只可能发生在某一个代码块中。如果与当前的变量重名的是外层代码块中的变量,那么就是另外一种含义了,我在下一篇文章中会讲到。
- 变量的重声明只有在使用短变量声明时才会发生,否则也无法通过编译。如果要在此处声明全新的变量,那么就应该使用包含关键字
var
的声明语句,但是这时就不能与同一个代码块中的任何变量有重名了。 - 被“声明并赋值”的变量必须是多个,并且其中至少有一个是新的变量。这时我们才可以说对其中的旧变量进行了重声明。
对于同一个代码块而言,声明重名的变量是无法通过编译的,用短变量声明对已有变量进行重声明除外;对于不同的代码块来说,其中的变量重名没什么大不了,照样可以通过编译。
3.5 重名变量查找过程
- 首先,代码引用变量的时候总会最优先查找当前代码块中的那个变量。注意,这里的“当前代码块”仅仅是引用变量的代码所在的那个代码块,并不包含任何子代码块。
- 其次,如果当前代码块中没有声明以此为名的变量,那么程序会沿着代码块的嵌套关系,从直接包含当前代码块的那个代码块开始,一层一层地查找。
- 一般情况下,程序会一直查到当前代码包代表的代码块。如果仍然找不到,那么Go语言的编译器就会报错了。
3.6 语法糖
或者叫便利措施
3.7 作用域
程序实体的访问权限有三种:包级私有的、模块级私有的和公开的,一个程序实体的作用域总是会被限制在某个代码块中,而这个作用域最大的用处,就是对程序实体的访问权限的控制。
3.8 类型断言
类型断言表达式的语法形式是x.(T)
。其中的x
代表要被判断类型的值
var container = []string{"zero", "one", "two"}
value,ok = interface{}(container).([]string)
3.9 类型
-
interface{}
代表了不包含任何方法定义的、空的接口类型。任何类型都是它的实现类型。任何类型的值都可以很方便地被转换成空接口的值。 -
struct{}
,它就代表了不包含任何字段和方法的、空的结构体类型。 -
比如空的切片值
[]string{}
表示元素类型为string的切片类型 -
空的字典值
map[int]string{}
用来表示键类型为int
、值类型为string
的字典类型 -
string
是表示字符串类型的字面量,
-
uint8`是表示8位无符号整数类型的字面量
3.10 类型转换
首先,对于整数类型值、整数常量之间的类型转换,原则上只要源值在目标类型的可表示范围内就是合法的。
第二,虽然直接把一个整数值转换为一个string
类型的值是可行的,但值得关注的是,被转换的整数值应该可以代表一个有效的Unicode代码点,否则转换的结果将会是"�"
(仅由高亮的问号组成的字符串值)。
关于string
类型与各种切片类型之间的互转的。
一个值在从string
类型向[]byte
类型转换时代表着以UTF-8编码的字符串会被拆分成零散、独立的字节。
string([]byte{'\xe4', '\xbd', '\xa0', '\xe5', '\xa5', '\xbd'}) // 你好
比如,UTF-8编码的三个字节\xe4
、\xbd
和\xa0
合在一起才能代表字符'你'
,而\xe5
、\xa5
和\xbd
合在一起才能代表字符'好'
。
一个值在从string
类型向[]rune
类型转换时代表着字符串会被拆分成一个个Unicode字符。
string([]rune{'\u4F60', '\u597D'}) // 你好
3.11 别名类型、类型再定义与潜在类型
潜在类型相同的不同类型的值之间是可以进行类型转换的。但对于集合类的类型[]MyString2
与[]string
来说这样做却是不合法的,因为[]MyString2
与[]string
的潜在类型不同,分别是[]MyString2
和[]string
。
三:Go语言实战与应用
1.数组和切片
数组(array)类型和切片(slice)类型。它们的共同点是都属于集合类的类型,并且,它们的值也都可以用来存储某一种类型的值(或者说元素)。它们最重要的不同是:数组类型的值(以下简称数组)的长度是固定的,而切片类型的值(以下简称切片)是可变长的。
Go语言的切片类型属于引用类型,同属引用类型的还有字典类型、通道类型、函数类型等;而Go语言的数组类型则属于值类型,同属值类型的有基础数据类型以及结构体类型。
注意,Go语言里不存在像Java等编程语言中令人困惑的“传值或传引用”问题。在Go语言中,我们判断所谓的“传值”或者“传引用”只要看被传递的值的类型就好了。
如果传递的值是引用类型的,那么就是“传引用”。如果传递的值是值类型的,那么就是“传值”。从传递成本的角度讲,引用类型的值往往要比值类型的值低很多。
我们在数组和切片之上都可以应用索引表达式,得到的都会是某个元素。我们在它们之上也都可以应用切片表达式,也都会得到一个新的切片。
切片与数组的关系
可以把切片看做是对数组的一层简单的封装,因为在每个切片的底层数据结构中,一定会包含一个数组。数组可以被叫做切片的底层数组,而切片也可以被看作是对数组的某个连续片段的引用。切片的容量代表了它的底层数组的长度,但这仅限于使用make
函数或者切片值字面量初始化切片的情况。
更通用的规则是:一个切片的容量可以被看作是透过这个窗口最多可以看到的底层数组中元素的个数。
例子:
s3 := []int{1, 2, 3, 4, 5, 6, 7, 8}
s4 := s3[3:6]
the length of s4:3
the captcity of s4(8-3):5
切片容量扩容
一旦一个切片无法容纳更多的元素,Go语言就会想办法扩容。但它并不会改变原来的切片,而是会生成一个容量更大的切片,然后将把原有的元素和新元素一并拷贝到新切片中。在一般的情况下,你可以简单地认为新切片的容量(以下简称新容量)将会是原切片容量(以下简称原容量)的2倍。
但是,当原切片的长度(以下简称原长度)大于或等于1024
时,Go语言将会以原容量的1.25
倍作为新容量的基准(以下新容量基准)。新容量基准会被调整(不断地与1.25
相乘),直到结果不小于原长度与要追加的元素数量之和(以下简称新长度)。最终,新容量往往会比新长度大一些,当然,相等也是可能的。
另外,如果我们一次追加的元素过多,以至于使新长度比原容量的2倍还要大,那么新容量就会以新长度为基准。注意,与前面那种情况一样,最终的新容量在很多时候都要比新容量基准更大一些
s5 := make([]int,0)
for i :=1; i < 5;i++{
s5 = append(s5,i)
fmt.Printf("s5(%d): len: %d, cap: %d\n", i, len(s5), cap(s5))
}
s5_1 := append(s5,make([]int,102)...)
fmt.Printf("s6: len: %d, cap: %d\n", len(s5_1), cap(s5_1))
s6 := make([]int, 1024)
s6_1 := append(s6, make([]int, 200)...)
fmt.Printf("s7e1: len: %d, cap: %d\n", len(s6_1), cap(s6_1))
---------------------
s5(1): len: 1, cap: 1
s5(2): len: 2, cap: 2
s5(3): len: 3, cap: 4
s5(4): len: 4, cap: 4
s5_1: len: 106, cap: 112
s6_1: len: 1224, cap: 1280
注意:
-
一个切片的底层数组永远不会被替换,虽然在扩容的时候Go语言一定会生成新的底层数组,但是它也同时生成了新的切片。它只是把新的切片作为了新底层数组的窗口,而没有对原切片,及其底层数组做任何改动。
-
append
函数返回的是指向原底层数组的原切片,而在需要扩容时,append
函数返回的是指向新底层数组的新切片。
2.container包中的那些容器
Go语言的链表实现在标准库的container/list
代码包中,List实现了一个双向链表(以下简称链表),而Element则代表了链表中元素的结构。
链表var l list.List
List
这个结构体类型有两个字段,一个是Element
类型的字段root
,另一个是int
类型的字段len
,它们都是包级私有的,也就是说使用者无法查看和修改它们。
len
的零值是0
,
root
是Element
类型 零值 Element{}。
Element`类型包含了几个包级私有的字段,分别用于存储前一个元素、后一个元素以及所属链表的指针值。
Value
的公开的字段, 它是interface{}
类型的。在Element
类型的零值中,这些字段的值都会是nil
。
延迟初始化
所谓的延迟初始化,你可以理解为把初始化操作延后,仅在实际需要的时候才进行。延迟初始化的优点在于“延后”,它可以分散初始化操作带来的计算量和存储空间消耗。
ring.Ring 与 list.List区别
container/ring
包中的Ring
类型实现的是一个循环链表,也就是我们俗称的环。其实List
在内部就是一个循环链表。它的根元素永远不会持有任何实际的元素值,而该元素的存在就是为了连接这个循环链表的首尾两端。
- Ring
类型的数据结构仅由它自身即可代表,而
List类型则需要由它以及
Element`类型联合表示。这是表示方式上的不同,也是结构复杂度上的不同。 - 一个
Ring
类型的值严格来讲,只代表了其所属的循环链表中的一个元素,而一个List
类型的值则代表了一个完整的链表。这是表示维度上的不同。 - 在创建并初始化一个
Ring
值的时候,我们可以指定它包含的元素的数量,但是对于一个List
值来说却不能这样做(也没有必要这样做)。循环链表一旦被创建,其长度是不可变的。这是两个代码包中的New
函数在功能上的不同,也是两个类型在初始化值方面的第一个不同。 - 仅通过
var r ring.Ring
语句声明的r
将会是一个长度为1
的循环链表,而List
类型的零值则是一个长度为0
的链表。别忘了List
中的根元素不会持有实际元素值,因此计算长度时不会包含它。这是两个类型在初始化值方面的第二个不同。 Ring
值的Len
方法的算法复杂度是O(N)的,而List
值的Len
方法的算法复杂度则是O(1)的。这是两者在性能方面最显而易见的差别。
3.字典的操作和约束
Go语言字典的键类型不可以是函数类型、字典类型和切片类型。Go语言的字典类型其实是一个哈希表(hash table)的特定实现,键的类型是受限的,而元素却可以是任意类型的。
eg: map[keyType]valueType map[string]int
根据键值获取值
先把键值作为参数传给这个哈希表。哈希表会先用哈希函数(hash function)把键值转换为哈希值。哈希值通常是一个无符号的整数。一个哈希表会持有一定数量的桶(bucket),我们也可以叫它哈希桶,这些哈希桶会均匀地储存其所属哈希表收纳的键-元素对。
哈希表会先用这个键哈希值的低几位去定位到一个哈希桶,然后再去这个哈希桶中,查找这个键。由于键-元素对总是被捆绑在一起存储的,所以一旦找到了键,就一定能找到对应的元素值。
键值的约束
Go语言规范规定,在键类型的值之间必须可以施加操作符==
和!=
键类型的值必须要支持判等操作。
哈希碰撞
不同值的哈希值是可能相同的。所以即使哈希值一样,键值也不一定一样。只有键的哈希值和键值都相等,才能说明查找到了匹配的键-元素对。
哪些类型适和做键类型
求哈希和判等操作的速度越快,对应的类型就越适合作为键类型。
对于所有的基本类型、指针类型,以及数组类型、结构体类型和接口类型,Go语言都有一套算法与之对应。这套算法中就包含了哈希和判等。
4.通道的基本操作
作为Go语言最有特色的数据类型,通道(channel)完全可以与goroutine(也可称为go程)并驾齐驱,共同代表Go语言独有的并发编程模式和编程哲学。
Don’t communicate by sharing memory; share memory by communicating. (不要通过共享内存来通信,而应该通过通信来共享内存。)
这是作为Go语言的主要创造者之一的Rob Pike的至理名言,这也充分体现了Go语言最重要的编程理念。通道类型的值本身就是并发安全的,这也是Go语言自带的、唯一一个可以满足并发安全性的类型。
通道特性
一个通道相当于一个先进先出(FIFO)的队列。也就是说,通道中的各个元素值都是严格地按照发送的顺序排列的,先被发送通道的元素值一定会先被接收。元素值的发送和接收都需要用到操作符<-
。我们也可以叫它接送操作符。一个左尖括号紧接着一个减号形象地代表了元素值的传输方向。
基本特性如下。
-
对于同一个通道,发送操作之间是互斥的,接收操作之间也是互斥的。
元素值从外界进入通道时会被复制。更具体地说,进入通道的并不是在接收操作符右边的那个元素值,而是它的副本。元素值从通道进入外界时会被移动。这个移动操作实际上包含了两步,第一步是生成正在通道中的这个元素值的副本,并准备给到接收方,第二步是删除在通道中的这个元素值。
-
发送操作和接收操作中对元素值的处理都是不可分割的。
-
发送操作在完全完成之前会被阻塞。接收操作也是如此。
通道panic
通道一旦关闭,再对它进行发送操作,就会引发panic。如果我们试图关闭一个已经关闭了的通道,也会引发panic。
通道发送和接收
缓冲通道 如果通道已满,那么对它的所有发送操作都会被阻塞,直到通道中有元素值被接收走。这时,通道会优先通知最早因此而等待的、那个发送操作所在的goroutine,后者会再次执行发送操作。它们所在的goroutine会顺序地进入通道内部的发送等待队列,所以通知的顺序总是公平的。
相对的,如果通道已空,那么对它的所有接收操作都会被阻塞,直到通道中有新的元素值出现。这时,通道会通知最早等待的那个接收操作所在的goroutine,并使它再次执行接收操作。因此而等待的、所有接收操作所在的goroutine,都会按照先后顺序被放入通道内部的接收等待队列。
缓冲通道会作为收发双方的中间件。元素值会先从发送方复制到缓冲通道,之后再由缓冲通道复制给接收方。但是,当发送操作在执行的时候发现空的通道中,正好有等待的接收操作,那么它会直接把元素值复制给接收方。
非缓冲通道 无论是发送操作还是接收操作,一开始执行就会被阻塞,直到配对的操作也开始执行,才会继续传递。
非缓冲通道是在用同步的方式传递数据。 缓冲通道则在用异步的方式传递数据。
值为nil
的通道,不论它的具体类型是什么,对它的发送操作和接收操作都会永久地处于阻塞状态。它们所属的goroutine中的任何代码,都不再会被执行。由于通道类型是引用类型,所以它的零值就是nil
。换句话说,当我们只声明该类型的变量但没有用make
函数对它进行初始化时,该变量的值就会是nil
。我们一定不要忘记初始化通道!
单向通道
只能发而不能收 var singleDirectionChan = make(chan<- int, 1)
只能收不能发 `var singleDirectionChan = make(<-chan int, 1)
单向通道最主要的用途就是约束其他代码的行为:
type Notifier interface {
SendInt(ch chan<- int)
}
使用带range
子句的for
语句从通道中获取数据,也可以通过
for range
语句操纵通道
带有range
子句的for
语句操纵通道:
select
语句操纵通道
select
语句是Go语言还有一种专门为了操作通道而存在的语句,规则如下:
-
对于每一个
case
表达式,都至少会包含一个代表发送操作的发送表达式或者一个代表接收操作的接收表达式,同时也可能会包含其他的表达式。它包含的多个表达式总会以从左到右的顺序被求值。 -
select
语句包含的候选分支中的
case`表达式都会在该语句执行开始时先被求值,并且求值的顺序是依从代码编写的顺序从上到下的。 -
对于每一个
case
表达式,如果其中的发送表达式或者接收表达式在被求值时,相应的操作正处于阻塞状态,那么对该case
表达式的求值就是不成功的。在这种情况下,我们可以说,这个case
表达式所在的候选分支是不满足选择条件的。 -
仅当
select
语句中的所有case
表达式都被求值完毕后,它才会开始选择候选分支。这时候,它只会挑选满足选择条件的候选分支执行。如果所有的候选分支都不满足选择条件,那么默认分支就会被执行。如果这时没有默认分支,那么select
语句就会立即进入阻塞状态,直到至少有一个候选分支满足选择条件为止。一旦有一个候选分支满足选择条件,select
语句(或者说它所在的goroutine)就会被唤醒,这个候选分支就会被执行。 -
如果
select
语句发现同时有多个候选分支满足选择条件,那么它就会用一种伪随机的算法在这些分支中选择一个并执行。注意,即使select
语句是在被唤醒时发现的这种情况,也会这样做。 -
一条
select
语句中只能够有一个默认分支。并且,默认分支只在无候选分支可选时才会被执行,这与它的编写位置无关。 -
select
语句的每次执行,包括
case表达式求值和分支选择,都是独立的。不过,至于它的执行是否是并发安全的,就要看其中的
case`表达式以及分支中,是否包含并发不安全的代码了。
5.使用函数的正确姿势
“函数是一等的公民”,函数类型它是一种对一组输入、输出进行模板化的重要工具;函数不但可以用于封装代码、分割功能、解耦逻辑,还可以化身为普通的值,在其他函数间传递、赋予变量、做类型判断和转换等等。
只要两个函数的参数列表和结果列表中的元素顺序及其类型是一致的,我们就可以说它们是一样的函数,各个参数名称、结果的名称、函数的名称(调用函数时给定的标识符)不能算作函数签名的一部分。
函数参数原则:既不要把你程序的细节暴露给外界,也尽量不要让外界的变动影响到你的程序。
所有传给函数的参数值都会被复制,函数在其内部使用的并不是参数值的原值,而是它的副本。
函数类型
声明函数类型type operate func(x, y int) int
它有两个参数和一个结果,都是int
类型。
函数类型属于引用类型,零值是nil。
匿名函数
op := func(x, y int) int {
return x + y
}
高阶函数与闭包
高阶函数条件
-
接受其他的函数作为参数传入
-
把其他的函数作为结果返回
严格来说,函数的名称也不能算作函数签名的一部分,它只是我们在调用函数时,需要给定的标识符而已。
6.结构体及其方法的使用法门
Go语言中根本没有继承的概念,它所做的是通过嵌入字段的方式实现了类型之间的组合,见官方解释 https://golang.org/doc/faq#inheritance
面向对象编程中的继承,其实是通过牺牲一定的代码简洁性来换取可扩展性,而且这种可扩展性是通过侵入的方式来实现的。
结构体接收者
接收者声明就是在关键字func
和方法名称之间的圆括号包裹起来的内容,其中必须包含确切的名称和类型字面量。
func (ac AnimalCategory) String() string {
....
}
上面代码的接收者声明可以看出它隶属于AnimalCategory
类型,方法隶属的类型其实并不局限于结构体类型,但必须是某个自定义的数据类型,并且不能是任何接口类型。在Go语言中,我们可以通过为一个类型编写名为String
的方法,来自定义该类型的字符串表示形式。这个String
方法不需要任何参数声明,但需要有一个string
类型的结果声明。
一个数据类型关联的所有方法,共同组成了该类型的方法集合。同一个方法集合中的方法不能出现重名。并且,如果它们所属的是一个结构体类型,那么它们的名称与该类型中任何字段的名称也不能重复。
我们可以把结构体类型中的一个字段看作是它的一个属性或者一项数据,再把隶属于它的一个方法看作是附加在其中数据之上的一个能力或者一项操作。将属性及其能力(或者说数据及其操作)封装在一起,是面向对象编程(object-oriented programming)的一个主要原则。
Go语言摄取了面向对象编程中的很多优秀特性,同时也推荐这种封装的做法。从这方面看,Go语言其实是支持面向对象编程的,但它选择摒弃了一些在实际运用过程中容易引起程序开发者困惑的特性和规则。
嵌入字段
嵌入字段是其声明中只有类型而没有名称的字段,它可以以一种很自然的方式为被嵌入的类型带来新的属性和能力。但是需要小心可能产生“屏蔽”现象的地方,尤其是当存在多个嵌入字段或者多层嵌入的时候。“屏蔽”现象可能会让你的实际引用与你的预期不符。
type Animal struct {
scientificName string
AnimalCategory //嵌入字段 or 匿名字段,相当于此类型变量的名称后跟“.”,嵌入字段的类型既是类型也是名称。
}
类型组合
类型之间的组合采用的是非声明的方式,类型组合也是非侵入式的,它不会破坏类型的封装或加重类型之间的耦合。类型间的组合也是灵活的,我们总是可以通过嵌入字段的方式把一个类型的属性和能力“嫁接”给另一个类型。组合要比继承更加简洁和清晰;
值方法和指针方法区别
-
值方法的接收者是该方法所属的那个类型值的一个副本。我们在该方法内对该副本的修改一般都不会体现在原值上,除非这个类型本身是某个引用类型(比如切片或字典)的别名类型。
-
而指针方法的接收者,是该方法所属的那个基本类型值的指针值的一个副本。我们在这样的方法内对该副本指向的值进行修改,却一定会体现在原值上。
-
一个自定义数据类型的方法集合中仅会包含它的所有值方法,而该类型的指针类型的方法集合却囊括了前者的所有方法,包括所有值方法和所有指针方法。
7.接口类型的合理运用
在Go语言的语境中,当我们在谈论“接口”的时候,一定指的是接口类型。因为接口类型与其他数据类型不同,它是没法被实例化的,既不能通过调用new
函数或make
函数创建出一个接口类型的值,也无法用字面量来表示一个接口类型的值。通过关键字type
和interface
,我们可以声明出接口类型。
接口类型声明中的这些方法所代表的就是该接口的方法集合。一个接口的方法集合就是它的全部特征。对于任何数据类型,只要它的方法集合中完全包含了一个接口的全部特征(即全部的方法),那么它就一定是这个接口的实现类型。
鸭子类型
在程序设计中,鸭子类型(英语:duck typing)是动态类型的一种风格。在这种风格中,一个对象有效的语义,不是由继承自特定的类或实现特定的接口,由"当前方法和属性的集合"决定。这是一种无侵入式的接口实现方式。详细了解:https://baike.baidu.com/item/%E9%B8%AD%E5%AD%90%E7%B1%BB%E5%9E%8B
判断方法是否被实现
-
两个方法的签名需要完全一致
-
两个方法的名称要一模一样
动态类型
type Pet interface {
SetName(name string)
Name() string
Category() string
}
dog := Dog{"little pig"}
var pet Pet = &dog
比如,我们把取址表达式&dog
的结果值赋给了变量pet
,这时这个结果值就是变量pet
的动态值,而此结果值的类型*Dog
就是该变量的动态类型。变量pet
,我们赋给它的值可以被叫做它的实际值(也称动态值),而该值的类型可以被叫做这个变量的实际类型(也称动态类型)。
动态类型这个叫法是相对于静态类型而言的。对于变量pet
来讲,它的静态类型就是Pet
,并且永远是Pet
,但是它的动态类型却会随着我们赋给它的动态值而变化。在我们给一个接口类型的变量赋予实际的值之前,它的动态类型是不存在的。
当我们给一个接口变量赋值的时候,该变量的动态类型会与它的动态值一起被存储在一个专用的数据结构中。这个专用的数据结构在Go语言的runtime
包叫做iface
。iface
的实例会包含两个指针,一个是指向类型信息的指针,另一个是指向动态值的指针。这里的类型信息是由另一个专用数据结构的实例承载的,其中包含了动态值的类型,以及使它实现了接口的方法和调用它们的途径,等等。总之,接口变量被赋予动态值的时候,存储的是包含了这个动态值的副本的一个结构更加复杂的值。
接口类型本身是无法被值化的。在我们赋予它实际的值之前,它的值一定会是nil
,这也是它的零值。除非我们只声明而不初始化,或者显式地赋给它nil
,否则接口变量的值就不会为nil
。
接口组合
Go语言团队鼓励我们声明体量较小的接口,并建议我们通过接口间的组合来扩展程序、增加程序的灵活性。这是因为相比于包含很多方法的大接口而言,小接口可以更加专注地表达某一种能力或某一类特征,同时也更容易被组合在一起。
接口类型间的嵌入也被称为接口的组合。接口类型间的嵌入会涉及方法间的“屏蔽”。只要组合的接口之间有同名的方法就会产生冲突,从而无法通过编译,即使同名方法的签名彼此不同也会是如此。因此,接口的组合根本不可能导致“屏蔽”现象的出现。
8.关于指针的有限操作
从传统意义上说,指针是一个指向某个确切的内存地址的值。
type struct Dog{
name string
}
func (dog *Dog) SetName(name string) {
dog.name = name
}
func New(name string) Dog {
return Dog{name}
}
【1】New("cat").SetName("miao miao") //我们可以在一个基本类型的值上调用它的指针方法,这是因为Go语言会自动地帮我们转译,但是New`函数所得到的结果值属于临时结果 无法取址导致编译报错
【2】
dog := Dog{"little dog"}
dog.SetName("monster") //Dog类型的变量dog,被自动地转译为(&dog).SetName("monster"),即:先取dog的指针值,再在该指针值上调用SetName方法。
从例子代码看:对于基本类型Dog
来说,*Dog
就是它的指针类型。而对于一个Dog
类型,值不为nil
的变量dog
,取址表达式&dog
的结果就是该变量的值(也就是基本值)的指针值。如果一个方法的接收者是*Dog
类型的,那么该方法就是基本类型Dog
的指针方法。
可以代表指针有哪些
在Go语言中 uintptr`类型可以代表“指针”,该类型实际上是一个数值类型,也是Go语言内建的数据类型之一。根据当前计算机的计算架构的不同,它可以存储32位或64位的无符号整数,可以代表任何指针的位(bit)模式,也就是原始的内存地址。
Go语言标准库中的unsafe
包。``unsafe.Pointer也代表了“指针”,表示任何指向可寻址的值的指针,同时它也是前面提到的指针值和
uintptr`值之间的桥梁。通过它,我们可以在这两种值之上进行双向的转换——可寻址的(addressable)。
哪些值是不可寻址
常量的值总是会被存储到一个确切的内存区域中,并且这种值肯定是不可变的。由于Go语言中的字符串值也是不可变的,所以对于一个字符串类型的变量来说,基于它的索引或切片的结果值也都是不可寻址的。
算术操作的结果值属于一种临时结果,是不可寻址的。针对数组值、切片值或字典值的字面量的表达式会产生临时结果。这主要因为变量的值本身就不是“临时的”。对比而言,值字面量在还没有与任何变量(或者说任何标识符)绑定之前是没有落脚点的,我们无法以任何方式引用到它们。这样的值就是“临时的”。
一个需要特别注意的例外是,对切片字面量的索引结果值是可寻址的。因为不论怎样,每个切片值都会持有一个底层数组,而这个底层数组中的每个元素值都是有一个确切的内存地址的。
如果针对的是数组类型或切片类型的变量,那么索引或切片的结果值就都不属于临时结果了,是可寻址的。
如果我们把临时结果赋给一个变量,那么它就是可寻址的了。如此一来,取得的指针指向的就是这个变量持有的那个值了。
Go语言中以下是不可寻址
- 常量的值。
- 基本类型值的字面量。
- 算术操作的结果值。
- 对各种字面量的索引表达式和切片表达式的结果值。不过有一个例外,对切片字面量的索引结果值却是可寻址的。
- 对字符串变量的索引表达式和切片表达式的结果值。
- 对字典变量的索引表达式的结果值。
- 函数字面量和方法字面量,以及对它们的调用表达式的结果值。
- 结构体字面量的字段值,也就是对结构体字面量的选择表达式的结果值。
- 类型转换表达式的结果值。
- 类型断言表达式的结果值。
- 接收表达式的结果值。
不可寻址的判断方法
- 不可变的值不可寻址。常量、基本类型的值字面量、字符串变量的值、函数以及方法的字面量都是如此。其实这样规定也有安全性方面的考虑。
- 绝大多数被视为临时结果的值都是不可寻址的。算术操作的结果值属于临时结果,针对值字面量的表达式结果值也属于临时结果。但有一个例外,对切片字面量的索引结果值虽然也属于临时结果,但却是可寻址的。
- 若拿到某值的指针可能会破坏程序的一致性,那么就是不安全的,该值就不可寻址。由于字典的内部机制,对字典的索引结果值的取址操作都是不安全的。另外,获取由字面量或标识符代表的函数或方法的地址显然也是不安全的。
不可寻址的值使用限制
-
无法使用取址操作符
&
获取它们的指针了。 对不可寻址的值施加取址操作都会使编译器报错。 -
Go语言中的
++
和--
并不属于操作符,只要在++
或--
的左边添加一个表达式,就可以组成一个自增语句或自减语句,这个表达式的结果值必须是可寻址的。 -
特殊情况
虽然对字典字面量和字典变量索引表达式的结果值都是不可寻址的,但是这样的表达式却可以被用在自增语句和自减语句中。
在赋值语句中,赋值操作符左边的表达式的结果值必须可寻址的,但是对字典的索引结果值也是可以的。
在带有
range
子句的for
语句中,在range
关键字左边的表达式的结果值也都必须是可寻址的,不过对字典的索引结果值同样可以被用在这里。
Go语言中的常用表达式有以下几种。
- 用于获得某个元素的索引表达式。
- 用于获得某个切片(片段)的切片表达式。
- 用于访问某个字段的选择表达式。
- 用于调用某个函数或方法的调用表达式。
- 用于转换值的类型的类型转换表达式。
- 用于判断值的类型的类型断言表达式。
- 向通道发送元素值或从通道那里接收元素值的接收表达式。
使用unsafe.Pointer
操纵可寻址的值
利用unsafe.Pointer
的中转和uintptr
的底层操作来操纵类型值,它可以绕过Go语言的编译器和其他工具的重重检查,并达到潜入内存修改数据的目的。这并不是一种正常的编程手段,很有可能造成安全隐患。
我们总是应该优先使用常规代码包中提供的API去编写程序,当然也可以把像reflect
以及go/ast
这样的代码包作为备选项。作为上层应用的开发者,请谨慎地使用unsafe
包中的任何程序实体。
unsafe.Pointer
的中转和uintptr
的转换规则:
- 一个指针值(比如
*Dog
类型的值)可以被转换为一个unsafe.Pointer
类型的值,反之亦然。 - 一个
uintptr
类型的值也可以被转换为一个unsafe.Pointer
类型的值,反之亦然。 - 一个指针值无法被直接转换成一个
uintptr
类型的值,反过来也是如此。
例:
dog := Dog{"little pig"}
dogP := &dog //取出了它的指针值
dogPtr := uintptr(unsafe.Pointer(dogP))
namePtr := dogPtr + unsafe.Offsetof(dogP.name) //起始存储地址 + 偏移量,以字节为单位
nameP := (*string)(unsafe.Pointer(namePtr)) // 转换成*string`类型的值
9.go语句及其执行规则
Go语言编程提倡:不要通过共享数据来通讯,恰恰相反,要以通讯的方式共享数据。
struct{}
类型值的表示法只有一个,即:
struct{}{}。并且,它占用的内存空间是
0`字节。确切地说,这个值在整个Go程序中永远都只会存在一份。虽然我们可以无数次地使用这个值字面量,但是用到的却都是同一个值。
关键词: sleep、chan struct{}、
sync.WaitGroup
、自旋(spinning)
进程&线程
主线程之外的其他线程都只能由代码显式地创建和销毁。用户级线程的创建、销毁、调度、状态变更以及其中的代码和数据都完全需要我们的程序自己去实现和处理。系统级线程由Go语言的运行时(runtime)系统帮助我们自动地创建和销毁。
Go语言不但有着独特的并发编程模型,以及用户级线程goroutine,还拥有强大的用于调度goroutine、对接系统级线程的调度器。这个调度器是Go语言运行时系统的重要组成部分,它主要负责统筹调配Go并发编程模型中的三个主要元素,即:G(goroutine的缩写)、P(processor的缩写)和M(machine的缩写)。
其中的M指代的就是系统级线程。而P指的是一种可以承载若干个G,且能够使这些G适时地与M进行对接,并得到真正运行的中介。
goroutine
主goroutine的go
函数就是那个作为程序入口的main
函数。
go函数真正被执行的时间,总会与其所属的
go语句被执行的时间不同。当程序执行到一条
go`语句的时候,Go语言的运行时系统,会先试图从某个存放空闲的G的队列中获取一个G(也就是goroutine),队列中的G总是会按照先入先出的顺序,它只有在找不到空闲G的情况下才会去创建一个新的G。这也是为什么我总会说“启用”一个goroutine,而不说“创建”一个goroutine的原因。已存在的goroutine总是会被优先复用。
byte
类型是uint8
类型的别名类型
10.if语句、for语句和switch语句
if语句、
for语句和
switch`语句都属于Go语言的基本流程控制语句。
关键词: 类型
switch
语句
for range
nums := [...]int{1,2,3,4,5,6}
maxIndex := len(nums) - 1
for i,meta := range nums {
if i == maxIndex {
num[0] += meta
} else {
number[i+1] += meta
}
}
fmt.Println(nums)
当for
语句被执行的时候,在range
关键字右边的numbers1
会先被求值。range
表达式的结果值可以是数组、数组的指针、切片、字符串、字典或者允许接收操作的通道中的某一个,并且结果值只能有一个。
这里需要注意两点:
range
表达式只会在for
语句开始执行时被求值一次,无论后边会有多少次迭代;range
表达式的求值结果会被复制,也就是说,被迭代的对象是range
表达式结果值的副本而不是原值。
switch
在Go语言中,只有类型相同的值之间才有可能被允许进行判等操作。如果switch
表达式的结果值是无类型的常量,比如1 + 3
的求值结果就是无类型的常量4
,那么这个常量会被自动地转换为此种常量的默认类型的值,比如整数4
的默认类型是int
,又比如浮点数3.14
的默认类型是float64
。
switch
的case
表达式的所有子表达式的结果值都是要与switch
表达式的结果值判等的,它们的类型必须相同或者能够都统一到switch
表达式的结果类型。
switch
语句会进行有限的类型转换,但肯定不能保证这种转换可以统一它们的类型。还要注意,如果这些表达式的结果类型有某个接口类型,那么一定要小心检查它们的动态值是否都具有可比性(或者说是否允许判等操作),如果不是,虽然不会造成编译错误,但是后果会更加严重:引发panic(也就是运行时恐慌)。
switch
语句在case
子句的选择上是具有唯一性的。 switch
语句不允许case
表达式中的子表达式结果值存在相等的情况,不论这些结果值相等的子表达式,是否存在于不同的case
表达式中,都会是这样的结果。
11.错误处理
error
类型其实是一个接口类型,也是一个Go语言的内建类型。
关键词:卫述语句、最小化访问权限
怎样判断一个错误值具体代表的是哪一类错误?
-
对于类型在已知范围内的一系列错误值,一般使用类型断言表达式或类型
switch
语句来判断;如:
os
包中的几个代表错误的类型os.PathError
、os.LinkError
、os.SyscallError
和os/exec.Error
来说 -
对于已有相应变量且类型相同的一系列错误值,一般直接使用判等操作来判断;
-
对于没有相应变量且类型未知的一系列错误值,只能使用其错误信息的字符串表示形式来做判断。
Go语言中处理错误的最基本方式,这涉及了函数结果列表设计、errors.New
函数、卫述语句以及使用打印函数输出错误值。构建错误值体系的基本方式有两种,即:创建立体的错误类型体系和创建扁平的错误值列表。用类型建立起树形结构的错误体系,用统一字段建立起可追根溯源的链式错误关联。
fmt.Errorf
该函数所做的其实就是先调用fmt.Sprintf
函数,得到确切的错误信息;再调用errors.New
函数,得到包含该错误信息的error
类型值,最后返回该值。
panic
在大多数操作系统中,只要退出状态码不是0
,都意味着程序运行的非正常结束。在Go语言中,因panic导致程序结束运行的退出状态码一般都会是2
。panic详情会在控制权传播的过程中,被逐渐地积累和完善,并且,控制权会一级一级地沿着调用栈的反方向传播至顶端。当一个panic发生时,如果我们不施加任何保护措施,那么导致的直接后果就是程序崩溃
recover
Go语言的内建函数recover
专用于恢复panic,或者说平息运行时恐慌。recover
函数无需任何参数,并且会返回一个空接口类型的值。
func main() {
fmt.Println("Enter function main.")
defer func(){
fmt.Println("Enter defer function.")
if p := recover(); p != nil {
fmt.Printf("panic: %s\n", p)
}
fmt.Println("Exit defer function.")
}()
// 引发panic。
panic(errors.New("something wrong"))
fmt.Println("Exit function main.")
}
defer
当一个函数即将结束执行时,其中的写在最下边的defer
函数调用会最先执行,其次是写在它上边、与它的距离最近的那个defer
函数调用,以此类推,最上边的defer
函数调用会最后一个执行。 同一条defer
语句每被执行一次,其中的defer
函数调用就会产生一次,而且,这些函数调用同样不会被立即执行。
在defer
语句每次执行的时候,Go语言会把它携带的defer
函数及其参数值另行存储到一个链表中。这个链表与该defer
语句所属的函数是对应的,并且,它是先进后出(FILO)的,相当于一个栈。这正是我说“defer
函数调用与其所属的defer
语句的执行顺序完全相反”的原因了。
四:Go语言进阶技术
1.测试的基本规则和流程
对于程序或软件的测试也分很多种,比如:单元测试、API测试、集成测试、灰度测试,等等。单元测试它又称程序员测试。我们可以为Go程序编写三类测试,即:功能测试(test)、基准测试(benchmark,也称性能测试),以及示例测试(example)。
测试源码文件的主名称应该以被测源码文件的主名称为前导,并且必须以“_test”为后缀。例如,demo.go 测试源码文件的名称是demo_test.go。
Go语言对测试函数的名称和签名都有哪些规定?
- 对于功能测试函数来说,其名称必须以
Test
为前缀,并且参数列表中只应有一个*testing.T
类型的参数声明。 - 对于性能测试函数来说,其名称必须以
Benchmark
为前缀,并且唯一参数的类型必须是*testing.B
类型的。 - 对于示例测试函数来说,其名称必须以
Example
为前缀,但对函数的参数列表没有强制规定。
go test
基本规则和主要流程的
只有测试源码文件的名称对了,测试函数的名称和签名也对了,当我们运行go test
命令的时候,其中的测试代码才有可能被运行。
-
go test
命令在开始运行时,会先做一些准备工作,比如,确定内部需要用到的命令,检查我们指定的代码包或源码文件的有效性,以及判断我们给予的标记是否合法,等等。 -
在准备工作顺利完成之后,
go test
命令就会针对每个被测代码包,依次地进行构建、执行包中符合要求的测试函数,清理临时文件,打印测试结果。这就是通常情况下的主要测试流程。为了加快测试速度,它通常会并发地对多个被测代码包进行功能测试,只不过,在最后打印测试结果的时候,它会依照我们给定的顺序逐个进行,这会让我们感觉到它是在完全串行地执行测试流程。
由于并发的测试会让性能测试的结果存在偏差,所以性能测试一般都是串行进行的。更具体地说,只有在所有构建步骤都做完之后,
go test
命令才会真正地开始进行性能测试。并且,下一个代码包性能测试的进行,总会等到上一个代码包性能测试的结果打印完成才会开始,而且性能测试函数的执行也都会是串行的。
功能测试结果解读
$ go test puzzlers/article20/q2
ok puzzlers/article20/q2 0.008s
$ go test puzzlers/article20/q2
ok puzzlers/article20/q2 (cached)
$ go test puzzlers/article20/q2
--- FAIL: TestFail (0.00s)
demo53_test.go:49: Failed.
FAIL
FAIL puzzlers/article20/q2 0.007s
性能测试结果解读
go test高级参数
2.并发&同步编程
2.1.sync.Mutex与sync.RWMutex
共享资源与一致性
相比于Go语言宣扬的“用通讯的方式共享数据”,通过共享数据的方式来传递信息和协调线程运行的做法其实更加主流,毕竟大多数的现代编程语言,都是用后一种方式作为并发编程的解决方案的。
一旦数据被多个线程共享,那么就很可能会产生争用和冲突的情况。这种情况也被称为竞态条件(race condition),这往往会破坏共享数据的一致性。共享数据的一致性代表着某种约定,即:多个线程对共享数据的操作总是可以达到它们各自预期的效果。
同步的用途有两个,一个是避免多个线程在同一时刻操作同一个数据块,另一个是协调多个线程,以避免它们在同一时刻执行同一个代码块。
由于这样的数据块和代码块的背后都隐含着一种或多种资源(比如存储资源、计算资源、I/O资源、网络资源等等),所以我们可以把它们看做是共享资源,或者说共享资源的代表。我们所说的同步其实就是在控制多个线程对共享资源的访问。
临界区
多个并发运行的线程对这个共享资源的访问是完全串行的。只要一个代码片段需要实现对共享资源的串行化访问,就可以被视为一个临界区(critical section),也就是说,由于要访问到资源而必须进入的那个区域。
互斥锁
在Go语言中我们最常用的同步工具当属互斥量(mutual exclusion,简称mutex)。sync
包中的Mutex
就是与其对应的类型,该类型的值可以被称为互斥量或者互斥锁。一个互斥锁可以被用来保护一个临界区或者一组相关临界区。我们可以通过它来保证,在同一时刻只有一个goroutine处于该临界区之内。
互斥锁可以看作是针对某一个临界区或某一组相关临界区的唯一访问令牌。
使用互斥锁的注意事项如下:
- 不要重复锁定互斥锁;
- 不要忘记解锁互斥锁,必要时使用
defer
语句; - 不要对尚未锁定或者已解锁的互斥锁解锁;
- 不要在多个函数之间直接传递互斥锁。
死锁:指的就是当前程序中的主goroutine,以及我们启用的那些goroutine都已经被阻塞。这些goroutine可以被统称为用户级的goroutine。这就相当于整个程序都已经停滞不前了。对一个已经被锁定的互斥锁进行锁定,是会立即阻塞当前的goroutine的。这个goroutine所执行的流程,会一直停滞在调用该互斥锁的
Lock
方法的那行代码上。直到该互斥锁的Unlock
方法被调用我们一定要尽量避免这种情况的发生。而最简单、有效的方式就是让每一个互斥锁都只保护一个临界区或一组相关临界区。在这个前提之下,我们还需要注意,对于同一个goroutine而言,既不要重复锁定一个互斥锁,也不要忘记对它进行解锁。
读写锁
读写锁是读/写互斥锁的简称。在Go语言中,读写锁由sync.RWMutex
类型的值代表。与sync.Mutex
类型一样,这个类型也是开箱即用的。
读写锁是把对共享资源的“读操作”和“写操作”区别对待了。它可以对这两种操作施加不同程度的保护。换句话说,相比于互斥锁,读写锁可以实现更加细腻的访问控制。
一个读写锁中实际上包含了两个锁,即:读锁和写锁。sync.RWMutex
类型中的Lock
方法和Unlock
方法分别用于对写锁进行锁定和解锁,而它的RLock
方法和RUnlock
方法则分别用于对读锁进行锁定和解锁。
另外,对于同一个读写锁来说有如下规则。
- 在写锁已被锁定的情况下再试图锁定写锁,会阻塞当前的goroutine。
- 在写锁已被锁定的情况下试图锁定读锁,也会阻塞当前的goroutine。
- 在读锁已被锁定的情况下试图锁定写锁,同样会阻塞当前的goroutine。
- 在读锁已被锁定的情况下再试图锁定读锁,并不会阻塞当前的goroutine。
2.2.sync.Cond
条件变量是基于互斥锁的,它必须有互斥锁的支撑才能发挥作用。
条件变量并不是被用来保护临界区和共享资源的,它是用于协调想要访问共享资源的那些线程的。当共享资源的状态发生变化时,它可以被用来通知被互斥锁阻塞的线程。
条件变量的初始化离不开互斥锁,并且它的方法有的也是基于互斥锁的。
条件变量提供的方法有三个:等待通知(wait)、单发通知(signal)和广播通知(broadcast)。
var mailbox uint8
var lock sync.RWMutex
sendCond := sync.NewCond(&lock)
recvCond := sync.NewCond(lock.RLocker())
发送
lock.Lock()
for mailbox == 1 {
sendCond.Wait()
}
mailbox = 1
lock.Unlock()
recvCond.Signal()
接收
lock.RLock()
for mailbox == 0 {
recvCond.Wait()
}
mailbox = 0
lock.RUnlock()
sendCond.Signal()
条件变量的Wait
方法主要做了四件事。
- 把调用它的goroutine(也就是当前的goroutine)加入到当前条件变量的通知队列中。
- 解锁当前的条件变量基于的那个互斥锁。
- 让当前的goroutine处于等待状态,等到通知到来时再决定是否唤醒它。此时,这个goroutine就会阻塞在调用这个
Wait
方法的那行代码上。 - 如果通知到来并且决定唤醒这个goroutine,那么就在唤醒它之后重新锁定当前条件变量基于的互斥锁。自此之后,当前的goroutine就会继续执行后面的代码了。
条件变量的Signal
方法和Broadcast
方法都是被用来发送通知的,不同的是,前者的通知只会唤醒一个因此而等待的goroutine,而后者的通知却会唤醒所有为此等待的goroutine。
条件变量的Wait
方法总会把当前的goroutine添加到通知队列的队尾,而它的Signal
方法总会从通知队列的队首开始,查找可被唤醒的goroutine。所以,因Signal
方法的通知,而被唤醒的goroutine一般都是最早等待的那一个。
2.3.sync.atomic
Go语言运行时系统中的调度器会恰当地安排其中所有的goroutine的运行。不过,在同一时刻,只可能有少数的goroutine真正地处于运行状态,并且这个数量只会与M的数量一致,而不会随着G的增多而增长。互斥锁虽然可以保证临界区中代码的串行执行,但却不能保证这些代码执行的原子性(atomicity)。
在众多的同步工具中,真正能够保证原子性执行的只有原子操作(atomic operation),读音 atomic [əˈtɑːmɪk],int [ɪnt]。原子操作在进行的过程中是不允许中断的。在底层,这会由CPU提供芯片级别的支持,所以绝对有效。即使在拥有多CPU核心,或者多CPU的计算机系统中,原子操作的保证也是不可撼动的。这使得原子操作可以完全地消除竞态条件,并能够绝对地保证并发安全性。
sync/atomic
包中提供了几种原子操作?可操作的数据类型又有哪些?
sync/atomic
包中的函数可以做的原子操作有:加法(add)、比较并交换(compare and swap,简称CAS)、加载(load)、存储(store)和交换(swap)。
数据类型有:int32
、int64
、uint32
、uint64
、uintptr
,atomic.Value,以及unsafe
包中的Pointer
。不过,针对unsafe.Pointer
类型,该包并未提供进行原子加法操作的函数。
原子值使用建议。
- 不要把内部使用的原子值暴露给外界。比如,声明一个全局的原子变量并不是一个正确的做法。这个变量的访问权限最起码也应该是包级私有的。
- 如果不得不让包外,或模块外的代码使用你的原子值,那么可以声明一个包级私有的原子变量,然后再通过一个或多个公开的函数,让外界间接地使用到它。注意,这种情况下不要把原子值传递到外界,不论是传递原子值本身还是它的指针值。
- 如果通过某个函数可以向内部的原子值存储值的话,那么就应该在这个函数中先判断被存储值类型的合法性。若不合法,则应该直接返回对应的错误值,从而避免panic的发生。
- 如果可能的话,我们可以把原子值封装到一个数据类型中,比如一个结构体类型。这样,我们既可以通过该类型的方法更加安全地存储值,又可以在该类型中包含可存储值的合法类型信息。
怎样用好sync/atomic.Value
- 一旦
atomic.Value
类型的值(以下简称原子值)被真正使用,它就不应该再被复制了。 - 不能用原子值存储
nil
。也就是说,我们不能把nil
作为参数值传入原子值的Store
方法,否则就会引发一个panic。 - 我们向原子值存储的第一个值,决定了它今后能且只能存储哪一个类型的值。
2.4.sync.WaitGroup
sync.WaitGroup
类型(以下简称WaitGroup
类型)是开箱即用的,是并发安全的。它一旦被真正使用就不能被复制了。它拥有三个指针方法:Add
、Done
和Wait
。你可以想象该类型中有一个计数器,它的默认值是0
。我们可以通过调用该类型值的Add
方法来增加、Done
方法减一操作、Wait
方法阻塞当前的goroutine,直到其所属值中的计数器归零。
WaitGroup
值的使用禁忌,即:不要把增加其计数器值的操作和调用其Wait
方法的代码,放在不同的goroutine中执行。换句话说,要杜绝对同一个WaitGroup
值的两种操作的并发执行。
2.5.sync.Onece
sync.Once
类型(以下简称Once
类型)也属于结构体类型,是开箱即用和并发安全的。由于这个类型中包含了一个sync.Mutex
类型的字段,复制该类型的值也会导致功能的失效。
Once
类型的Do
方法只接受一个参数,这个参数的类型必须是func()
,即:无参数声明和结果声明的函数。该方法的功能是只执行“首次被调用时传入的”那个函数,并且之后不会再执行任何参数函数。
Once
类型中还有一个名叫done
的uint32
类型的字段。它的作用是记录其所属值的Do
方法被调用的次数。不过,该字段的值只可能是0
或者1
。一旦Do
方法的首次调用完成,它的值就会从0
变为1
。
这个Do
方法在功能方面的两个特点
第一个特点,由于Do
方法只会在参数函数执行结束之后把done
字段的值变为1
,因此,如果参数函数的执行需要很长时间或者根本就不会结束(比如执行一些守护任务),那么就有可能会导致相关goroutine的同时阻塞。
第二个特点,Do
方法在参数函数执行结束后,对done
字段的赋值用的是原子操作,并且,这一操作是被挂在defer
语句中的。因此,不论参数函数的执行会以怎样的方式结束,done
字段的值都会变为1
。
2.6.context.Context
context.Context
类型(以下简称Context
类型)是在Go 1.7发布时才被加入到标准库的。而后,标准库中的很多其他代码包都为了支持它而进行了扩展,包括:os/exec
包、net
包、database/sql
包,以runtime/pprof包和
runtime/trace`包,等等。
Context
类型它是一种非常通用的同步工具。它的值不但可以被任意地扩散,而且还可以被用来传递额外的信息和信号。Context
类型可以提供一类代表上下文的值。此类值是并发安全的,也就是说它可以被传播给多个goroutine。
context.Background
、context.WithCancel
、context.WithDeadline
、context.WithTimeout
和context.WithValue
。
Context
类型的实际值大体上分为三种,即:根Context
值、可撤销的Context
值和含数据的Context
值。所有的Context
值共同构成了一颗上下文树。这棵树的作用域是全局的,而根Context
值就是这棵树的根。它是全局唯一的,并且不提供任何额外的功能。
可撤销的Context
值又分为:只可手动撤销的Context
值,和可以定时撤销的Context
值。
2.7.sync.Pool
sync.Pool
类型可以被称为临时对象池,它的值可以被用来存储临时的对象。与Go语言的很多同步工具一样,sync.Pool
类型也属于结构体类型,它的值在被真正使用之后,就不应该再被复制了。
我们可以把临时对象池当作针对某种数据的缓存来用。sync.Pool
类型只有两个方法——Put
和Get
。Put用于在当前的池中存放临时对象,它接受一个interface{}
类型的参数;而Get则被用于从当前的池中获取临时对象,它会返回一个interface{}
类型的值。
为什么说临时对象池中的值会被及时地清理掉?
因为,Go语言运行时系统中的垃圾回收器,在每次开始执行之前,都会对所有已创建的临时对象池中的值进行全面地清除。
临时对象池存储值所用的数据结构
在临时对象池中,有一个多层的数据结构。这个数据结构的顶层,我们可以称之为本地池列表,不过更确切地说,它是一个数组。这个列表的长度,总是与Go语言调度器中的P的数量相同,原因是为了分散并发程序的执行压力,这里所说的压力包括了存储和性能两个方面。
在每个本地池中,都包含一个私有的临时对象和一个共享的临时对象列表。前者只能被其对应的P所关联的那个goroutine中的代码访问到,而后者却没有这个约束。从另一个角度讲,前者用于临时对象的快速存取,而后者则用于临时对象的池内共享。
从sync.Pool中获取临时对象的步骤
2.8.sync.Map
Go语言官方终于在2017年发布的Go 1.9中,正式加入了并发安全的字典类型sync.Map
。这个字典类型提供了一些常用的键值存取操作方法,并保证了这些操作的并发安全。它们的算法复杂度与map
类型一样都是O(1)
的。
使用锁就意味着要把一些并发的操作强制串行化。这往往会降低程序的性能,尤其是在计算机拥有多个CPU核心的情况下。因此,能用原子操作就不要用锁,不过这很有局限性,毕竟原子只能对一些基本的数据类型提供支持。
并发安全字典对键的类型有要求吗?
有要求。键的实际类型不能是函数类型、字典类型和切片类型。我们必须保证键的类型是可比较的(或者说可判等的)。我们可以先通过调用reflect.TypeOf
函数得到一个键值对应的反射类型值(即:reflect.Type
类型的值),然后再调用这个值的Comparable
方法,得到确切的判断结果。
保证并发安全字典中的键和值的类型正确性
-
方案一,在编码时就完全确定键和值的类型,然后利用Go语言的编译器帮我们做检查。
type IntStrMap struct { m sync.Map }
-
方案二,接受动态的类型设置,并在程序运行的时候通过反射操作进行检查。
type ConcurrentMap struct { m sync.Map keyType reflect.Type valueType reflect.Type }
并发安全字典如何做到尽量避免使用锁?
sync.Map
类型在内部使用了大量的原子操作来存取键和值,并使用了两个原生的map
作为存储介质。
其中一个原生map
被存在了sync.Map
的read
字段中,该字段是sync/atomic.Value
类型的。 这个原生字典可以被看作一个快照,它总会在条件满足时,去重新保存所属的sync.Map
值中包含的所有键值对。
另一个原生字典由它的dirty
字段代表。 它存储键值对的方式与read
字段中的原生字典一致,它的键类型也是interface{}
,并且同样是把值先做转换和封装后再进行储存的。我们暂且把它称为脏字典。
注意,脏字典和只读字典如果都存有同一个键值对,那么这里的两个键指的肯定是同一个基本值,对于两个值来说也是如此。
**只读字典和脏字典之间是会互相转换的。**在脏字典中查找键值对次数足够多的时候,sync.Map
会把脏字典直接作为只读字典,保存在它的read
字段中,然后把代表脏字典的dirty
字段的值置为nil
。在这之后,一旦再有新的键值对存入,它就会依据只读字典去重建脏字典。这个时候,它会把只读字典中已被逻辑删除的键值对过滤掉。
3.unicode与字符编码
当一个string
类型的值被转换为[]rune
类型值的时候,其中的字符串会被拆分成一个一个的Unicode字符。
Go语言的代码正是由Unicode字符组成的。Go语言的源码文件必须使用UTF-8编码格式进行存储,否则go命令就会报告错误“illegal UTF-8 encoding”。
一个string
类型的值在底层是怎样被表达的?
一个string
类型的值是由一系列相对应的Unicode代码点的UTF-8编码值来表达的。一个string
类型的值会由若干个Unicode字符组成,每个Unicode字符都可以由一个rune
类型的值来承载。
这些字符在底层都会被转换为UTF-8编码值,而这些UTF-8编码值又会以字节序列的形式表达和存储。因此,一个string
类型的值在底层就是一个能够表达若干个UTF-8编码值的字节序列。
带有range
子句的for
语句遍历字符串值
带有range
子句的for
语句会先把被遍历的字符串值拆成一个字节序列,然后再试图找出这个字节序列中包含的每一个UTF-8编码值,或者说每一个Unicode字符。
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编码规范通常使用十六进制表示法来表示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转换格式。它代表的是字符与字节序列之间的转换方式。
rune类型
rune
类型实际上就是int32
类型的一个别名类型。也就是说,一个rune
类型的值会由四个字节宽度的空间来存储。它的存储空间总是能够存下一个UTF-8编码值。一个rune
类型的值在底层其实就是一个UTF-8编码值。
4.strings包与字符串操作
在Go语言中,string
类型的值是不可变的。我们可以通过裁剪、拼接等操作,从而生成一个新的字符串。
-
裁剪操作可以使用切片表达式;
-
拼接操作可以用操作符
+
实现。
与string
值相比,strings.Builder
类型的值有哪些优势?
- 已存在的内容不可变,但可以拼接更多的内容;
- 减少了内存分配和内容拷贝的次数;
- 可将内容重置,可重用值。
Builde值拥有的一系列指针方法,包括:Write、WriteByte、WriteRune和WriteString。我们可以把它们统称为拼接方法。
strings.Builder
类型在使用上有约束吗?
有约束,概括如下:
- 在已被真正使用后就不可再被复制;
- 由于其内容不是完全不可变的,所以需要使用方自行解决操作冲突和并发安全问题。
我们在通过传递其指针值共享
Builder
值的时候,一定要确保各方对它的使用是正确、有序的,并且是并发安全的;而最彻底的解决方案是,绝不共享Builder
值以及它的指针值。
为什么说strings.Reader
类型的值可以高效地读取字符串?
strings.Reader
类型的值(以下简称Reader
值)可以让我们很方便地读取一个字符串中的内容。在读取的过程中,Reader
值会保存已读取的字节的计数(以下简称已读计数)。
Reader
值实现高效读取的关键就在于它内部的已读计数。计数的值就代表着下一次读取的起始索引位置。它可以很容易地被计算出来。Reader
值的Seek
方法可以直接设定该值中的已读计数值。
5.bytes包与字节串操作
strings
包主要面向的是Unicode字符和经过UTF-8编码的字符串,而bytes
包面对的则主要是字节和字节切片。Buffer
值的长度是未读内容的长度,而不是已存内容的总长度。
bytes.Buffer
类型的值记录的已读计数,在其中起到了怎样的作用?
bytes.Buffer
中的已读计数的大致功用如下所示。
- 读取内容时,相应方法会依据已读计数找到未读部分,并在读取后更新计数。
- 写入内容时,如需扩容,相应方法会根据已读计数实现扩容策略。
- 截断内容时,相应方法截掉的是已读计数代表索引之后的未读部分。
- 读回退时,相应方法需要用已读计数记录回退点。
- 重置内容时,相应方法会把已读计数置为
0
。 - 导出内容时,相应方法只会导出已读计数代表的索引之后的未读部分。
- 获取长度时,相应方法会依据已读计数和内容容器的长度,计算未读部分的长度并返回。
bytes.Buffer
的扩容策略是怎样的?
Buffer
值既可以被手动扩容,也可以进行自动扩容。 在扩容的时候,Buffer
值中相应的代码(以下简称扩容代码)会先判断内容容器的剩余容量,是否足够容纳新的内容。
- 如果可以,那么扩容代码会在当前的内容容器之上,进行长度扩充。
- 如果内容容器的剩余容量不够了,那么扩容代码可能就会用新的内容容器去替代原有的内容容器,从而实现扩容。
如果当前内容容器的容量的一半,仍然大于或等于其现有长度(即未读字节数)再加上另需的字节数的和,即:
cap(b.buf)/2 >= b.Len() + need
那么,扩容代码就会复用现有的内容容器,并把容器中的未读内容拷贝到它的头部位置。这也意味着其中的已读内容,将会全部被未读内容和之后的新内容覆盖掉。
若这一步优化未能达成,也就是说,当前内容容器的容量小于新长度的二倍。那么,扩容代码就只能再创建一个新的内容容器,并把原有容器中的未读内容拷贝进去,最后再用新的容器替换掉原有的容器。这个新容器的容量将会等于原有容量的二倍再加上另需字节数的和,即:新容器的容量=2*原有容量+所需字节数
6.io
io
包中的核心接口只有3个,它们是:io.Reader
、io.Writer
和io.Closer
。
我们还可以把io
包中的简单接口分为四大类。这四大类接口分别针对于四种操作,即:读取、写入、关闭和读写位置设定。前三种操作属于基本的I/O操作。
io
包中的简单接口共有11个。其中,读取操作相关的接口有5个,写入操作相关的接口有4个,而与关闭操作有关的接口只有1个,另外还有一个读写位置设定相关的接口。
io.Reader
的扩展接口和实现类型都有哪些?它们分别都有什么功用?
这道题的典型回答是这样的。在io
包中,io.Reader
的扩展接口有下面几种。
io.ReadWriter
:此接口既是io.Reader
的扩展接口,也是io.Writer
的扩展接口。换句话说,该接口定义了一组行为,包含且仅包含了基本的字节序列读取方法Read
,和字节序列写入方法Write
。io.ReadCloser
:此接口除了包含基本的字节序列读取方法之外,还拥有一个基本的关闭方法Close
。后者一般用于关闭数据读写的通路。这个接口其实是io.Reader
接口和io.Closer
接口的组合。io.ReadWriteCloser
:很明显,此接口是io.Reader
、io.Writer
和io.Closer
这三个接口的组合。io.ReadSeeker
:此接口的特点是拥有一个用于寻找读写位置的基本方法Seek
。更具体地说,该方法可以根据给定的偏移量基于数据的起始位置、末尾位置,或者当前读写位置去寻找新的读写位置。这个新的读写位置用于表明下一次读或写时的起始索引。Seek
是io.Seeker
接口唯一拥有的方法。io.ReadWriteSeeker
:显然,此接口是另一个三合一的扩展接口,它是io.Reader
、io.Writer
和io.Seeker
的组合。
再来说说io
包中的io.Reader
接口的实现类型,它们包括下面几项内容。
-
*io.LimitedReader
:此类型的基本类型会包装io.Reader
类型的值,并提供一个额外的受限读取的功能。所谓的受限读取指的是,此类型的读取方法Read
返回的总数据量会受到限制,无论该方法被调用多少次。这个限制由该类型的字段N
指明,单位是字节。 -
*io.SectionReader
:此类型的基本类型可以包装io.ReaderAt
类型的值,并且会限制它的Read
方法,只能够读取原始数据中的某一个部分(或者说某一段)。这个数据段的起始位置和末尾位置,需要在它被初始化的时候就指明,并且之后无法变更。该类型值的行为与切片有些类似,它只会对外暴露在其窗口之中的那些数据。
-
*io.teeReader
:此类型是一个包级私有的数据类型,也是io.TeeReader
函数结果值的实际类型。这个函数接受两个参数r
和w
,类型分别是io.Reader
和io.Writer
。其结果值的
Read
方法会把r
中的数据经过作为方法参数的字节切片p
写入到w
。可以说,这个值就是r
和w
之间的数据桥梁,而那个参数p
就是这座桥上的数据搬运者。 -
*io.multiReader
:此类型也是一个包级私有的数据类型。类似的,io
包中有一个名为MultiReader
的函数,它可以接受若干个io.Reader
类型的参数值,并返回一个实际类型为io.multiReader
的结果值。当这个结果值的
Read
方法被调用时,它会顺序地从前面那些io.Reader
类型的参数值中读取数据。因此,我们也可以称之为多对象读取器。 -
*io.pipe
:此类型为一个包级私有的数据类型,它比上述类型都要复杂得多。它不但实现了io.Reader
接口,而且还实现了io.Writer
接口。实际上,
io.PipeReader
类型和io.PipeWriter
类型拥有的所有指针方法都是以它为基础的。这些方法都只是代理了io.pipe
类型值所拥有的某一个方法而已。又因为
io.Pipe
函数会返回这两个类型的指针值并分别把它们作为其生成的同步内存管道的两端,所以可以说,*io.pipe
类型就是io
包提供的同步内存管道的核心实现。 -
*io.PipeReader
:此类型可以被视为io.pipe
类型的代理类型。它代理了后者的一部分功能,并基于后者实现了io.ReadCloser
接口。同时,它还定义了同步内存管道的读取端。
bufio
bufio.Reader
类型的值(以下简称Reader
值)内的缓冲区,其实就是一个数据存储中介,它介于底层读取器与读取方法及其调用方之间。所谓的底层读取器,就是在初始化此类值的时候传入的io.Reader
类型的参数值。
bufio
包中的数据类型主要有:
Reader
;Scanner
;Writer
和ReadWriter
。
bufio.Reader
bufio.Reader`类型并不是开箱即用的,因为它包含了一些需要显式初始化的字段
buf
:[]byte
类型的字段,即字节切片,代表缓冲区。虽然它是切片类型的,但是其长度却会在初始化的时候指定,并在之后保持不变。rd
:io.Reader
类型的字段,代表底层读取器。缓冲区中的数据就是从这里拷贝来的。r
:int
类型的字段,代表对缓冲区进行下一次读取时的开始索引。我们可以称它为已读计数。w
:int
类型的字段,代表对缓冲区进行下一次写入时的开始索引。我们可以称之为已写计数。err
:error
类型的字段。它的值用于表示在从底层读取器获得数据时发生的错误。这里的值在被读取或忽略之后,该字段会被置为nil
。lastByte
:int
类型的字段,用于记录缓冲区中最后一个被读取的字节。读回退时会用到它的值。lastRuneSize
:int
类型的字段,用于记录缓冲区中最后一个被读取的Unicode字符所占用的字节数。读回退的时候会用到它的值。这个字段只会在其所属值的ReadRune
方法中才会被赋予有意义的值。在其他情况下,它都会被置为-1
。
fill压缩
bufio.Writer
bufio.Writer
类型字段:
err
:error
类型的字段。它的值用于表示在向底层写入器写数据时发生的错误。buf
:[]byte
类型的字段,代表缓冲区。在初始化之后,它的长度会保持不变。n
:int
类型的字段,代表对缓冲区进行下一次写入时的开始索引。我们可以称之为已写计数。wr
:io.Writer
类型的字段,代表底层写入器。
7.os包
os
包中的API主要可以帮助我们使用操作系统中的文件系统、权限系统、环境变量、系统进程以及系统信号。对于类Unix的操作系统(包括Linux、macOS、FreeBSD等),其中的一切都可以被看做是文件。除了文本文件、二进制文件、压缩文件、目录这些常见的形式之外,还有符号链接、各种物理设备(包括内置或外接的面向块或者字符的设备)、命名管道,以及套接字(也就是socket),等等。
在os
包中,有这样几个函数,即:Create
、NewFile
、Open
和OpenFile
。
os.Create
函数用于根据给定的路径创建一个新的文件。
os.NewFile
函数。 它的功能并不是创建一个新的文件,而是依据一个已经存在的文件的描述符,来新建一个包装了该文件的File
值。
os.Open
函数会打开一个文件并返回包装了该文件的File
值。 然而,该函数只能以只读模式打开文件。
os.OpenFile
函数。 这个函数有3个参数,分别名为name
、flag
和perm
。其中的name
指代的就是文件的路径。而flag
参数指的则是需要施加在文件描述符之上的模式, 参数perm
代表的也是模式,它的类型是os.FileMode
,此类型是一个基于uint32
类型的再定义类型。我们把参数flag
指代的模式叫做操作模式,而把参数perm
指代的模式叫做权限模式
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.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
。
在这9个用于表示文件权限的比特位中,每3个比特位为一组,共可分为3组。
从高到低,这3组分别表示的是文件所有者(也就是创建这个文件的那个用户)、文件所有者所属的用户组,以及其他用户对该文件的访问权限。而对于每个组,其中的3个比特位从高到低分别表示读权限、写权限和执行权限。
8.网络
socket
socket,常被翻译为套接字, 所谓socket,是一种IPC方法。IPC是Inter-Process Communication的缩写,可以被翻译为进程间通信。顾名思义,IPC这个概念(或者说规范)主要定义的是多个进程之间,相互通信的方法。
这些方法主要包括:系统信号(signal)、管道(pipe)、套接字 (socket)、文件锁(file lock)、消息队列(message queue)、信号灯(semaphore,有的地方也称之为信号量)等。现存的主流操作系统大都对IPC提供了强有力的支持,尤其是socket。
net.Dial
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
syscall.Socket
函数接受三个参数,这些参数所代表的分别是想要创建的socket实例通信域、类型以及使用的协议。
-
Socket的通信域主要有这样几个可选项:IPv4域、IPv6域和Unix域。以上三种通信域分别可以由
syscall
代码包中的常量AF_INET
、AF_INET6
和AF_UNIX
表示 -
Socket的类型一共有4种,分别是:
SOCK_DGRAM
、SOCK_STREAM
、SOCK_SEQPACKET
以及SOCK_RAW
。SOCK_DGRAM
中的“DGRAM”代表的是datagram,即数据报文。它是一种有消息边界,但没有逻辑连接的非可靠socket类型,我们熟知的基于UDP协议的网络通信就属于此类。SOCK_STREAM
这个socket类型,恰恰与SOCK_DGRAM
相反。**它没有消息边界,但有逻辑连接,能够保证传输的可靠性和数据的有序性,同时还可以实现数据的双向传输。**众所周知的基于TCP协议的网络通信就属于此类。 -
syscall.Socket
函数的第三个参数用于表示socket实例所使用的协议。
http.Client
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.Client
类型中的Transport
字段代表着什么?
http.Client
类型中的Transport
字段代表着:向网络服务发送HTTP请求,并从网络服务接收HTTP响应的操作过程。也就是说,该字段的方法RoundTrip
应该实现单次HTTP事务(或者说基于HTTP协议的单次交互)需要的所有步骤。
http.Server
http.Server
代表的是基于HTTP协议的服务端,或者说网络服务。
http.Server
类型的ListenAndServe
方法的功能是:监听一个基于TCP协议的网络地址,并对接收到的HTTP请求进行处理。这个方法会默认开启针对网络连接的存活探测机制,以保证连接是持久的。同时,该方法会一直执行,直到有严重的错误发生或者被外界关掉。当被外界关掉时,它会返回一个由http.ErrServerClosed
变量代表的错误值。
这个ListenAndServe
方法主要会做下面这几件事情。
- 检查当前的
http.Server
类型的值(以下简称当前值)的Addr
字段。该字段的值代表了当前的网络服务需要使用的网络地址,即:IP地址和端口号. 如果这个字段的值为空字符串,那么就用":http"
代替。也就是说,使用任何可以代表本机的域名和IP地址,并且端口号为80
。 - 通过调用
net.Listen
函数在已确定的网络地址上启动基于TCP协议的监听。 - 检查
net.Listen
函数返回的错误值。如果该错误值不为nil
,那么就直接返回该值。否则,通过调用当前值的Serve
方法准备接受和处理将要到来的HTTP请求。
9.程序性能分析基础
Go语言为程序开发者们提供了丰富的性能分析API,和非常好用的标准工具。这些API主要存在于:
runtime/pprof
;net/http/pprof
;runtime/trace
;
另外,
runtime
代码包中还包含了一些更底层的API。它们可以被用来收集或输出Go程序运行过程中的一些关键指标,并帮助我们生成相应的概要文件以供后续分析时使用。至于标准工具,主要有
go tool pprof
和go tool trace
这两个。
在Go语言中,用于分析程序性能的概要文件有三种,分别是:CPU概要文件(CPU Profile)、内存概要文件(Mem Profile)和阻塞概要文件(Block Profile)。
这些概要文件中包含的都是:在某一段时间内,对Go程序的相关指标进行多次采样后得到的概要信息。
protocol buffers
protocol buffers定义和实现了一种“可以让数据在结构形态和扁平形态之间互相转换”的方式。它可以在序列化数据的同时对数据进行压缩,所以它生成的字节流,通常都要比相同数据的其他格式(例如XML和JSON)占用的空间明显小很多。
又比如,它既能让我们自己去定义数据序列化和结构化的格式,也允许我们在保证向后兼容的前提下去更新这种格式。正因为这些优势,Go语言从1.8版本开始,把所有profile相关的信息生成工作都交给protocol buffers来做了。
对CPU采样
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
函数才会返回。
对Mem采样
设定采样频率 runtime.MemProfileRate`变量赋值。
最后调用runtime/pprof
包中的WriteHeapProfile
函数。该函数会把收集好的内存概要信息,写到我们指定的写入器中。
注意,我们通过WriteHeapProfile
函数得到的内存概要信息并不是实时的,它是一个快照,是在最近一次的内存垃圾收集工作完成时产生的。如果你想要实时的信息,那么可以调用runtime.ReadMemStats
函数。不过要特别注意,该函数会引起Go语言调度器的短暂停顿。
获取阻塞概要信息
设定采样频率 调用runtime
包中的SetBlockProfileRate
函数,该函数有一个名叫rate
的参数,它是int
类型的。这个参数的含义是,只要发现一个阻塞事件的持续时间达到了多少个纳秒,就可以对其进行采样。如果这个参数的值小于或等于0
,那么就意味着Go语言运行时系统将会完全停止对阻塞概要信息的采样。
其次调用runtime/pprof
包中的Lookup
函数并传入参数值"block"
,从而得到一个*runtime/pprof.Profile
类型的值(以下简称Profile
值)。
最后调用这个Profile
值的WriteTo
方法,以驱使它把概要信息写进我们指定的写入器中。
pprof.Lookup函数
runtime/pprof.Lookup
函数(以下简称Lookup
函数)的功能是,提供与给定的名称相对应的概要信息。
runtime/pprof
包已经为我们预先定义了6个概要名称。它们是:goroutine
、heap
、allocs
、threadcreate
、block
和mutex
。
当我们把"goroutine"
传入Lookup
函数的时候,该函数会利用相应的方法,收集到当前正在使用的所有goroutine的堆栈跟踪信息。注意,这样的收集会引起Go语言调度器的短暂停顿。
五:附录
1.go命令
go get
命令go get
会自动从一些主流公用代码仓库(比如GitHub)下载目标代码包,并把它们安装到环境变量GOPATH
包含的第1工作区的相应目录中。如果存在环境变量GOBIN
,那么仅包含命令源码文件的代码包会被安装到GOBIN
指向的那个目录。
最常用的几个标记有下面几种。
-u
:下载并安装代码包,不论工作区中是否已存在它们。-d
:只下载代码包,不安装代码包。-fix
:在下载代码包后先运行一个用于根据当前Go语言版本修正代码的工具,然后再安装代码包。-t
:同时下载测试所需的代码包。-insecure
:允许通过非安全的网络协议下载和安装代码包。HTTP就是这样的协议。
go build
默认不会编译目标代码包所依赖的那些代码包。当然,如果被依赖的代码包的归档文件不存在,或者源码文件有了变化,那它还是会被编译。
-a 强制编译,不但目标代码包总是会被编译,它依赖的代码包也总会被编译
-i 不但要编译依赖的代码包,还要安装它们的归档文件
-v 可以看到go build
命令编译的代码包的名称
-x 这样可以看到go build
命令具体都执行了哪些操作
-n 可以只查看具体操作而不执行它们
go install
1.go基础知识
2.github优秀项目导图
【clash】https://github.com/Dreamacro/clash
3.问题记录
1)Main file has non-main package or doesn’t contain main function
如果为程序入口的main方法文件,则包应为package main,注意与目录无关。