README
¶
note
课程地址2: https://time.geekbang.com/column/intro/100093501?tab=catalog
变量声明
如果没有显式为变量赋予初值,Go 编译器会为变量赋予这个类型的零值。 整数类型是0、浮点类型是0.0 、布尔是false、指针、接口、切片、channel、map和函数是nil
区别总结空结构和空接口
- struct{} 是一个空结构体,表示一个不包含任何字段的结构体;而 interface{} 是一个空接口,表示一个不包含任何方法的接口。
- 空结构体通常用于表示不需要存储任何数据的情况,而空接口通常用于表示不限定具体类型的情况。
- 空结构体在并发编程中常用作信号或事件的通知;空接口常用于需要接收任意类型的值的场景。
在实际编程中,空结构体和空接口都有其特定的用途,可以根据具体情况选择使用。
byte类型 => 字节类型
在 gRPC 请求中,byte 类型通常用于表示二进制数据,例如文件内容、图像数据、视频数据等。gRPC 使用 Protocol Buffers 作为默认的序列化和反序列化格式,而 Protocol Buffers 支持将二进制数据表示为字节序列。
在 gRPC 中,您可能会使用 byte 类型来传输二进制数据。例如,如果您有一个 gRPC 服务,需要接收一个文件作为输入,您可以将文件内容表示为 byte 类型,并在 gRPC 请求中传输这些字节。
// 定义 gRPC 请求消息
message UploadFileRequest {
bytes file_content = 1;
}
// 生成的 gRPC 服务端代码
func (s *Server) UploadFile(ctx context.Context, req *pb.UploadFileRequest) (*pb.UploadFileResponse, error) {
fileContent := req.GetFileContent()
// 处理文件内容
return &pb.UploadFileResponse{}, nil
}
go vet
Go 官方提供了 go vet 工具可以用于对 Go 源码做一系列静态检查
go install golang.org/x/tools/go/analysis/passes/shadow/cmd/shadow@latest
go: downloading golang.org/x/tools v0.1.5
go: downloading golang.org/x/mod v0.4.2
我们就可以通过 go vet 扫描代码并检查这里面有没有变量遮蔽的问题
go vet -vettool=$(which shadow) -strict complex.go
./complex.go:13:12: declaration of "err" shadows declaration at line 11
代码块与作用域(避免变量遮蔽的原则)
代码块有显式与隐式之分,显式代码块就是包裹在一对配对大括号内部的语句序列,而隐式代码块则不容易肉眼分辨,它是通过 Go 语言规范明确规定的。隐式代码块有五种,分别是宇宙代码块、包代码块、文件代码块、分支控制语句隐式代码块,以及 switch/select 的子句隐式代码块,理解隐式代码块是理解代码块概念以及后续作用域概念的前提与基础。
作用域的概念是 Go 源码编译过程中标识符(包括变量)的一个属性。Go 编译器会校验每个标识符的作用域,如果它的使用范围超出其作用域,编译器会报错。
不过呢,我们可以使用代码块的概念来划定每个标识符的作用域。划定原则就是声明于外层代码块中的标识符,其作用域包括所有内层代码块。但是,Go 的这种作用域划定也带来了变量遮蔽问题。简单的遮蔽问题,我们通过分析代码可以很快找出,复杂的遮蔽问题,即便是通过 go vet 这样的静态代码分析工具也难于找全。
因此,我们只有了解变量遮蔽问题本质,在日常编写代码时注意同名变量的声明,注意短变量声明与控制语句的结合,才能从根源上尽量避免变量遮蔽问题的发生。
go类型系统
Go 语言的类型大体可分为:
基本数据类型(整型溢出问题、整型符号位采用 2 的补码但是格式化字面值仍是用原码):数值类型(整型、浮点型(包含科学计数)、复数类型(z=a+bi))、字符串
Go 的补码是通过原码逐位取反后再加 1 得到的,比如,我们以 -127 这个值为例,它的补码转换过程就是这样的:
计算机中负数用的是补码表示。 负数的补码是其绝对值取反码,再加1.
float 其实相当复杂,开发中如果能避开就避开,例如金钱单位只有美元或者人民币我建议以分作为单位或者使用decimal
讲讲字符串类型的设计(go原生支持字符串: 注意区分字符串长度(go中string指字节长度)和字符长度)
- 第一点:string 类型的数据是不可变的,提高了字符串的并发安全性和存储利用率。
- 第二点:没有结尾 ’\0’, 而且获取长度的时间复杂度是常数时间,消除了获取字符串长度的开销。
- 第三点:原生支持“所见即所得”的原始字符串,大大降低构造多行字符串时的心智负担。
- 第四点:对非ASCII字符提供原生支持,消除了源码在不同环境下显示乱码的可能。
Go 语言中的字符串值也是一个可空的字节序列,字节序列中的字节个数称为该字符串的长度。
字符串string类型的存储:string 类型其实是一个“描述符”,它本身并不真正存储字符串数据,而仅是由一个指向底层存储的指针和字符串的长度字段组成的。
Go语言源文件默认采用的是Unicode,字符集Unicode 是一个字符编码标准,旨在为世界上所有的字符和符号提供唯一的编号(编码点),使得在不同的计算机系统和语言之间可以一致地表示和处理文本。
总结
- JavaScript 中 str.length 返回的是 UTF-16 码元的数量,对于 BMP 以外的字符会返回大于实际字符数量的值。
- Go 中 len(str) 返回的是字节的数量,对于多字节字符(例如大于 U+007F 的字符)会返回大于实际字符数量的值。
在go语言中:单引号是表示字符、双引号是字符串、模版字符串表示所见即所得的原始字符串(一般用于多行字符串)
如果你需要计算实际的 Unicode 字符数量,而不是字节或码元数量,可以在两种语言中使用对应的方法。可以分别在 JavaScript 中使用 Array.from() 或 split() 方法,在 Go 中使用 []rune 转换。
进制转换
- 10进制 => 八进制、十六进制、二进制 => 整数部分:除以进制数,反向取余数,直到商为0终止。小数部分:乘以进制数,取整顺序输出。
- 二进制、八进制、十六进制 => 十进制 => 二进制、八进制、十六进制转换为十进制当中的整数部分从右往左指数从0开始递增,小数部分从左往右从-1开始递减,原理都是一样的。
位运算
位运算是计算机处理二进制数据的一种基本运算方式。它直接对整数类型的二进制表示进行操作,这种操作通常非常高效。下面是一些常见的位运算及其运算规则:
常见的位运算符
按位与 (&):
规则:对应的位都为1时,结果为1,否则为0。
示例:5 & 3 -> 0101 & 0011 -> 0001 -> 1
按位或 (|):
规则:对应的位有一个为1时,结果为1,否则为0。
示例:5 | 3 -> 0101 | 0011 -> 0111 -> 7
按位异或 (^):
规则:对应的位不同则为1,相同则为0。
示例:5 ^ 3 -> 0101 ^ 0011 -> 0110 -> 6
按位取反 (~):
规则:每个位取反,0变1,1变0。
示例:^5 -> ~0101 -> 1010(按位取反后的值取决于整数的表示方式和位数)
左移 (<<):
规则:将数字的所有位向左移动指定的位数,右侧用0填充。
示例:3 << 2 -> 0011 << 2 -> 1100 -> 12
右移 (>>):
规则:将数字的所有位向右移动指定的位数,左侧用0(对于无符号数)或符号位的值(对于有符号数)填充。
示例:8 >> 2 -> 1000 >> 2 -> 0010 -> 2
复合数据类型
包括数组、切片(slice)、map、结构体,以及像 channel 这类用于并发程序设计的高级复合数据类型。
数组、切片(一组连续存储的同构类型元素集合)
在 Go 语言中,数组(array)是基本类型,而切片(slice)是引用类型。这两个类型在内存分配和参数传递方面有显著的区别。
数组是值类型。将数组赋值给另一个数组或将数组作为参数传递给函数时,会复制整个数组的内容,而不是引用。因此,数组的赋值和传递都是深拷贝。
切片是引用类型。将切片赋值给另一个切片或将切片作为参数传递给函数时,传递的是引用,而不是复制底层数组的内容。因此,对切片的修改会影响到引用的同一个底层数组。切片包含指向底层数组的指针、长度和容量。切片可以引用数组的一部分,并且可以共享相同的底层数组。
切片好比打开了一个访问与修改数组的“窗口”,通过这个窗口,我们可以直接操作底层数组中的部分元素。这有些类似于我们操作文件之前打开的“文件描述符”(Windows 上称为句柄),通过文件描述符我们可以对底层的真实文件进行相关操作。可以说,切片之于数组就像是文件描述符之于文件。
在 Go 语言中,数组更多是“退居幕后”,承担的是底层存储空间的角色。切片就是数组的“描述符”,也正是因为这一特性,切片才能在函数参数传递时避免较大性能开销。因为我们传递的并不是数组本身,而是数组的“描述符”,而这个描述符的大小是固定的(见上面的三元组结构),无论底层的数组有多大,切片打开的“窗口”长度有多长,它都是不变的。此外,我们在进行数组切片化的时候,通常省略 max,而 max 的默认值为数组的长度。
另外,针对一个已存在的数组,我们还可以建立多个操作数组的切片,这些切片共享同一底层数组,切片对底层数组的操作也同样会反映到其他切片中。下面是为数组 arr 建立的两个切片的内存表示:
这里我们要清楚一个概念:切片与数组最大的不同,就在于其长度的不定长,这种不定长需要 Go 运行时提供支持,这种支持就是切片的“动态扩容”。
“动态扩容”指的就是,当我们通过 append 操作向切片追加数据的时候,如果这时切片的 len 值和 cap 值是相等的,也就是说切片底层数组已经没有空闲空间再来存储追加的值了,Go 运行时就会对这个切片做扩容操作,来保证切片始终能存储下追加的新值。
append 操作的这种自动扩容行为,有些时候会给我们开发者带来一些困惑,比如基于一个已有数组建立的切片,一旦追加的数据操作触碰到切片的容量上限(实质上也是数组容量的 上界),切片就会和原数组解除“绑定”,后续对切片的任何修改都不会反映到原数组中了。这种因切片的自动扩容而导致的“绑定关系”解除,有时候会成为你实践道路上的一个小陷阱,你一定要注意这一点。
在大多数场合,我们都会使用切片以替代数组,原因之一是切片作为数组“描述符”的轻量性,无论它绑定的底层数组有多大,传递这个切片花费的开销都是恒定可控的;另外一个原因是切片相较于数组指针也是有优势的,切片可以提供比指针更为强大的功能,比如下标访问、边界 溢出校验、动态扩容等。而且,指针本身在 Go 语言中的功能也受到的限制,比如不支持指针算术运算。
在 Go 语言中,len 函数用于获取数组、切片、字符串、映射(map)或通道(channel)的长度。对于数组和切片,len 函数返回的是元素的数量;对于字符串,len 函数返回的是字符串的字节数,而不是字符数,需要返回字符数,使用[]rune转换。对于映射,len 函数返回的是映射中键值对的数量。
在 Go 语言中,rune 是一种数据类型,表示一个 Unicode 码点。rune 本质上是一个 int32 类型,用于方便地处理 Unicode 字符。
map(map 是 Go 语言提供的一种抽象数据类型,它表示一组无序的键值对。)
- map 类型对 value 的类型没有限制,但是对 key 的类型却有严格要求,因为 map 类型要保证 key 的唯一性。
- Go 语言中要求,key 的类型必须支持“==”和“!=”两种比较操作符。
- 在 Go 语言中,函数类型、map 类型自身,以及切片只支持与 nil 的比较,而不支持同类型两个变量的比较。因此在这里,你一定要注意:函数类型、map 类型自身,以及切片类型是不能作为 map 的 key 类型的。
注意点:和切片类型变量一样,如果我们没有显式地赋予 map 变量初值,map 类型变量的默认值为 nil。
不过切片变量和 map 变量在这里也有些不同。初值为零值 nil 的切片类型变量,可以借助内置的 append 的函数进行操作,这种在 Go 语言中被称为“零值可用”。 但 map 类型,因为它内部实现的复杂性,无法“零值可用”。所以,如果我们对处于零值状态的 map 变量直接进行操作,就会导致运行时异常(panic),从而导致程序进程异常退出
在 Go 语言中,请使用“comma ok”惯用法对 map 进行键查找和键值读取操作。
在 Go 中,遍历 map 的键值对只有一种方法,那就是像对待切片那样通过 for range 语句对 map 数据进行遍历。
和切片类型一样,map 也是引用类型。这就意味着 map 类型变量作为参数被传递给函数或方法的时候,实质上传递的只是一个“描述符”(后面我们再讲这个描述符究竟是什么),而不是整个 map 的数据拷贝,所以这个传递的开销是固定的,而且也很小。
和切片相比,map 类型的内部实现要更加复杂。Go 运行时使用一张哈希表(hash table)来实现抽象的 map 类型。运行时实现了 map 类型操作的所有功能,包括查找、插入、删除等。在编译阶段,Go 编译器会将 Go 语法层面的 map 操作,重写成运行时对应的函数调用。
map 类型在 Go 运行时层实现的示意图:
1. Go语言map的基本结构
Go语言中的map底层由多个bucket组成,每个bucket存储若干个键值对。通过哈希函数将键映射到相应的bucket,然后在bucket中存储具体的键值对。
2. bucket结构
每个bucket可以存储多个键值对。在Go 1.8及之后的版本中,每个bucket最多可以存储8个键值对。一个bucket的结构大致如下:
type bmap struct {
tophash [8]uint8
keys [8]keyType
values [8]valueType
overflow *bmap
}
tophash:是一个数组,存储每个键的哈希值的高8位,用于快速比较和查找。
keys:存储具体的键。
values:存储具体的值。
overflow:指向下一个bucket,用于解决哈希冲突。
3. 哈希函数
当向map中插入一个键值对时,首先会对键进行哈希计算,得到一个哈希值。哈希值的高位决定了键应该插入到哪个bucket中。
hash := hashFunc(key)
bucketIndex := hash & (len(buckets) - 1)
4. 数据存储过程
哈希计算:对键进行哈希计算得到哈希值。
定位bucket:使用哈希值的低位来确定该键值对应该插入到哪个bucket中。
存储数据:将键值对存储到对应的bucket中,并存储哈希值的高8位到tophash数组中。
处理冲突:如果一个bucket已经存满了8个键值对,那么需要分配一个新的bucket,并通过overflow指针将其链接到当前bucket上。
5. 查找过程
查找键值对时:
哈希计算:对键进行哈希计算得到哈希值。
定位bucket:使用哈希值的低位来确定应该在哪个bucket中查找。
查找匹配项:在tophash数组中查找与哈希值高8位匹配的条目,然后依次比较这些条目的键是否与目标键相等。
处理冲突:如果没有在当前bucket中找到匹配的键值对,那么顺着overflow指针查找下一个bucket,直到找到匹配项或bucket链末尾。
6. 动态扩容
当map中的键值对数量增多导致哈希冲突频繁时,map会进行扩容。扩容的步骤包括:
分配一个更大的bucket数组,通常是当前容量的两倍。
重新计算所有键的哈希值并将它们分布到新的bucket数组中。
7. 示例代码
以下是一个简单的示例代码,展示了Go语言中map的基本用法:
package main
import "fmt"
func main() {
m := make(map[string]int)
m["foo"] = 1
m["bar"] = 2
fmt.Println(m["foo"]) // 输出: 1
fmt.Println(m["bar"]) // 输出: 2
}
每个 bucket 的 tophash 区域其实是用来快速定位 key 位置的,这样就避免了逐个 key 进行比较这种代价较大的操作。尤其是当 key 是 size 较大的字符串类型时,好处就更突出了。这是一种以空间换时间的思路。
从上面的实现原理来看,充当 map 描述符角色的 hmap 实例自身是有状态的(hmap.flags),而且对状态的读写是没有并发保护的。所以说 map 实例不是并发写安全的,也不支持并发读写。如果我们对 map 实例进行并发读写,程序运行时就会抛出异常。
不过,如果我们仅仅是进行并发读,map 是没有问题的。而且,Go 1.9 版本中引入了支持并发写安全的 sync.Map 类型,可以用来在并发读写的场景下替换掉 map,如果你有这方面的需求。
另外,你要注意,考虑到 map 可以自动扩容,map 中数据元素的 value 位置可能在这一过程中发生变化,所以 Go 不允许获取 map 中 value 的地址,这个约束是在编译期间就生效的。
思考并实现一个方法,让我们能对 map 的进行稳定次序遍历?
思路:
可以用一个有序结构存储key,如slice,然后for这个slice,用key获取值。资料来源至:https://go.dev/blog/maps
使用链表存储可以得到有序的Map。有了描述符,再也不用担心传递性能问题了!原来多个bucket是为了降低Hash冲突,虽然一个bucket和多个bucket在查找Key时时间复杂度都是O(1),但一个Bucket遇到Hash冲突的可能性要比多个高出很多。
单独对map中的key进行有序存储,然后再依据key的次序依次获取map中key对应的value.
struct 结构体
在 Go 中,提供这种聚合抽象能力的类型是结构体类型,也就是 struct。这一节课,我们就围绕着结构体的使用和内存表示,由外及里来学习 Go 中的结构体类型。
有几种方式:零值初始化、复合字面值初始化,以及使用特定构造函数进行初始化,日常编码中最常见的是第二种。支持零值可用的结构体类型对于简化代码,改善体验具有很好的作用。另外,当复合字面值初始化无法满足要求的情况下,我们需要为结构体类型定义专门的构造函数,这种方式同样有广泛的应用。
结构体类型的内存布局(https://geektutu.com/post/hpg-struct-alignment.html)
结构体类型是既数组类型之后,又一个以平铺形式存放在连续内存块中的类型。不过与数组类型不同,由于内存对齐的要求,结构体类型各个相邻字段间可能存在“填充物”,结构体的尾部同样可能被 Go 编译器填充额外的字节,满足结构体整体对齐的约束。正是因为这点,我们在定义结构体时,一定要合理安排字段顺序,要让结构体类型对内存空间的占用最小。
Go 语言不支持在结构体类型定义中,递归地放入其自身类型字段,但却可以拥有自身类型的指针类型、以自身类型为元素类型的切片类型,以及以自身类型作为 value 类型的 map 类型的字段,你能思考一下其中的原因吗?
一个类型,它所占用的大小是固定的,因此一个结构体定义好的时候,其大小是固定的。 但是,如果结构体里面套结构体,那么在计算该结构体占用大小的时候,就会成死循环。 但如果是指针、切片、map等类型,其本质都是一个int大小(指针,4字节或者8字节,与操作系统有关),因此该结构体的大小是固定的,记得老师前几节课讲类型的时候说过,类型就能决定内存占用的大小。 因此,结构体是可以接口自身类型的指针类型、以自身类型为元素类型的切片类型,以及以自身类型作为 value 类型的 map 类型的字段,而自己本身不行。 => 核心:因为指针、map、切片的变量元数据的内存占用大小是固定的。go是静态语言,对于一个类型,编译器要知道它的大小。如果嵌套T,那么编译器无法知道其大小。但如果是*T或[]T,编译器只需要知道指针大小以及切片这个“描述符”的大小即可。
控制语句(if、switch case、for)
for range 循环形式与 for 语句经典形式差异较大,除了循环体保留了下来,其余组成部分都“不见”了。其实那几部分已经被融合到 for range 的语义中了。
for range 对于 string 类型来说,每次循环得到的 v 值是一个 Unicode 字符码点,也就是 rune 类型值,而不是一个字节,返回的第一个值 i 为该 Unicode 字符码点的内存编码(UTF-8)的第一个字节在字符串内存序列中的位置。使用 for 经典形式与使用 for range 形式,对 string 类型进行循环操作的语义是不同的。
我们要对 map 进行循环操作,for range 是唯一的方法。每次循环,循环变量 k 和 v 分别会被赋值为 map 键值对集合中一个元素的 key 值和 value 值。
channel 是 Go 语言提供的并发设计的原语,它用于多个 Goroutine 之间的通信,我们在后面的课程中还会详细讲解 channel。当 channel 类型变量作为 for range 语句的迭代对象时,for range 会尝试从 channel 中读取数据,使用形式是这样的:
var c = make(chan int)
for v := range c {
// ...
}
带 label 的 continue 语句,通常出现于嵌套循环语句中,被用于跳转到外层循环并继续执行外层循环语句的下一个迭代。
和 continue 语句一样,Go 也 break 语句增加了对 label 的支持。而且,和前面 continue 语句一样,如果遇到嵌套循环,break 要想跳出外层循环,用不带 label 的 break 是不够,因为不带 label 的 break 仅能跳出其所在的最内层循环。要想实现外层循环的跳出,我们还需给 break 加上 label。
for 语句的常见“坑”与避坑方法
问题一:循环变量的重用=>循环变量的值与你之前的“预期”不符
// 看示例 09/bookstore/test/tst.go
func main() {
var m = []int{1, 2, 3, 4, 5}
for i, v := range m {
go func() {
time.Sleep(time.Second * 3)
fmt.Println(i, v)
}
}
time.Sleep(time.Second * 10) // 这里的Sleep作用 => 确保所有 Goroutine 有时间完成输出
// 在并发编程中,Goroutine 是轻量级线程,它们会在后台并发执行。当我们启动多个 Goroutine 时,它们不会阻塞主线程的执行。主线程可能会在 Goroutine 执行完成之前就退出,从而导致 Goroutine 还没来得及输出就被终止。
// time.Sleep(time.Second * 10)作用是让主 Goroutine 休眠一段时间(这里是10秒),以确保在主 Goroutine 退出之前,所有启动的 Goroutine 有足够的时间完成它们的工作(输出内容)。
// 为什么需要等待
// 异步执行:Goroutine 是并发执行的,启动后会在后台运行,而主 Goroutine 会继续执行后续代码。
// 程序退出:如果主 Goroutine 执行完毕,整个程序就会退出,无论后台的 Goroutine 是否已经完成。因此,如果不等待,可能会导致 Goroutine 还没输出结果,程序就已经结束了。
// 解决方法
// 在实际应用中,简单的 time.Sleep 是一种不严谨的做法,因为我们不确定确切需要等待的时间。推荐使用同步机制,如 sync.WaitGroup,确保所有 Goroutine 完成后再退出程序。
// 使用 sync.WaitGroup
// sync.WaitGroup 是一个用于等待一组 Goroutine 完成的同步机制。以下是如何使用它确保所有 Goroutine 完成后再退出
}
问题二:参与循环的是 range 表达式的副本
用切片遍历可以解决数组遍历中由于副本导致修改原数组改变不生效问题?那切片是如何做到的呢?
切片在 Go 内部表示为一个结构体,由(array, len, cap)组成,其中 array 是指向切片对应的底层数组的指针,len 是切片当前长度,cap 为切片的最大容量。 所以,当进行 range 表达式复制时,我们实际上复制的是一个切片,也就是表示切片的结构体。表示切片副本的结构体中的 array,依旧指向原切片对应的底层数组,所以我们对切片副本的修改也都会反映到底层数组 a 上去。 而 v 再从切片副本结构体中 array 指向的底层数组中,获取数组元素,也就得到了被修改后的元素值。
问题三:遍历 map 中元素的随机性
如果我们在循环的过程中,对 map 进行了修改,那么这样修改的结果是否会影响后续迭代呢?这个结果和我们遍历 map 一样,具有随机性。
在“参与循环的是 range 表达式的副本”这一部分中,我们用切片替换了数组,实现了我们预期的输出,我想让你思考一下,除了换成切片这个方案之外,还有什么方案也能实现我们预期的输出呢?
// 数组指针代替数组参与range遍历
func main() {
var a = [5]int{1, 2, 3, 4, 5}
var r [5]int
fmt.Println("original a =", a)
for i, v := range &a { //a 改为&a
if i == 0 {
a[1] = 12
a[2] = 13
}
r[i] = v
}
fmt.Println("after for range loop, r =", r)
fmt.Println("after for range loop, a =", a)
}
switch case
注意点:
- 无论 default 分支出现在什么位置,它都只会在所有 case 都没有匹配上的情况下才会被执行的。
- switch 语句各表达式的求值结果可以为各种类型值,只要它的类型支持比较操作就可以了。
- switch 语句支持声明临时变量。case 语句支持表达式列表。
- 取消了默认执行下一个 case 代码逻辑的语义。 fallthrough可以继续往下执行下一个case的需求。(这里有个坑点:个人感觉fallthrough,执行完 case1 后,继续case2里面的代码,而不用判断case2的条件是否成立,这一点设计的并不好,估计很多人会理解为继续判断case2条件)
- type switch => switch 关键字后面跟着的表达式为x.(type),这种表达式形式是 switch 语句专有的,而且也只能在 switch 语句中使用。我们除了可以获得变量 x 的动态类型信息之外,也能获得其动态类型对应的值信息。
- 跳不出循环的 break => 看test中的案例09/bookstore/test
函数一等公民
package的作用:在 Go 语言中,包(package)是代码组织的基本单元。它不仅用于组织和管理代码,还在代码的模块化、重用、依赖管理、封装等方面发挥了重要作用。
函数定义
如果忽略 Go 包在 Go 代码组织层面的作用,我们可以说 Go 程序就是一组函数的集合。
Go 不直接支持可选参数,但可以通过以下方法实现类似的功能:
- 使用可变参数(variadic parameters)
- 使用结构体封装参数
- 使用函数选项模式(functional options)
我们看到,函数声明中的函数名其实就是变量名,函数声明中的 func 关键字、参数列表和返回值列表共同构成了函数类型。而参数列表与返回值列表的组合也被称为函数签名,它是决定 两个函数类型是否相同的决定因素。因此,函数类型也可以看成是由 func 关键字与函数签名组合而成的。
如果我们把这两个函数类型的参数名与返回值变量名省略,那它们都是func (int, string) ([]string, error),因此它们是相同的函数类型。 到这里,我们可以得到这样一个结论:每个函数声明所定义的函数,仅仅是对应的函数类型的一个实例。
s := T{} // 使用复合类型字面值对结构体类型T的变量进行显式初始化
f := func(){} // 使用变量声明形式的函数声明
// T{}被称为复合类型字面值,那么处于同样位置的 func(){}是什么呢?Go 语言也为它准备了一个名字,叫“函数字面值(Function Literal)”。我们可以看到,函数字面值由函数类型与函数体组成,它特别像一个没有函数名的函数声明,因此我们也叫它匿名函数。匿名函数在Go 中用途很广
函数参数的那些事儿
在函数声明阶段,我们把参数列表中的参数叫做形式参数(Parameter,简称形参),在函数体中,我们使用的都是形参;而在函数实际调用时传入的参数被称为实际参数(Argument,简称实参)。
-
函数参数的那些事儿
当我们实际调用函数的时候,实参会传递给函数,并和形式参数逐一绑定,编译器会根据各个形参的类型与数量,来检查传入的实参的类型与数量是否匹配。只有匹配,程序才能继续执行函数调用,否则编译器就会报错。
- Go 语言中,函数参数传递采用是值传递的方式。所谓“值传递”,就是将实际参数在内存中的表示逐位拷贝(Bitwise Copy)到形式参数中。对于像整型、数组、结构体这类类型,它们的内存表示就是它们自身的数据内容,因此当这些类型作为实参类型时,值传递拷贝的就是它们自身,传递的开销也与它们自身的大小成正比。
- 但是像 string、切片、map 这些类型就不是了,它们的内存表示对应的是它们数据内容的“描述符”。当这些类型作为实参类型时,值传递拷贝的也是它们数据内容的“描述符”,不包括数据内容本身,所以这些类型传递的开销是固定的,与数据内容大小无关。这种只拷贝“描述符”,不拷贝实际数据内容的拷贝过程,也被称为“浅拷贝”。
- 不过函数参数的传递也有两个例外,当函数的形参为接口类型,或者形参是变长参数时,简单的值传递就不能满足要求了,这时 Go 编译器会介入:对于类型为接口类型的形参,Go 编译器会把传递的实参赋值给对应的接口类型形参;对于为变长参数的形参,Go 编译器会将零个或多个实参按一定形式转换为对应的变长形参。
-
函数支持多返回值
Go 语言的错误处理机制很大程度就是建立在多返回值的机制之上
Go 标准库以及大多数项目代码中的函数,都选择了使用普通的非具名返回值形式。但在一些特定场景下,具名返回值也会得到应用。比如,当函数使用 defer,而且还在 defer 函数中修改外部函数返回值时,具名返回值可以让代码显得更优雅清晰。
- 函数作为一等公民的特征(函数也是一种类型)
- Go 函数可以存储在变量中
- 支持在函数内创建并通过返回值返回(当外部外部将一个内部函数作为返回值返回时,内部函数使用了外部函数的变量的现象就称为闭包)
- 作为参数传入函数
- 拥有自己的类型(每个函数都和整型值、字符串值等一等公民一样,拥有自己的类型,也就是函数类型)
如何让函数更健壮
三原则:
原则一:不要相信任何外部输入的参数。
原则二:不要忽略任何一个错误。
原则三:不要假定异常不会发生: 异常不是错误。错误是可预期的,也是经常会发生的,我们有对应的公开错误码和错误处理预案,但异常却是少见的、意料之外的。通常意义上的异常,指的是硬件异常、操作系统异常、语言运行时异常,还有更大可能是代码中潜在 bug 导致的异常,比如代码中出现了以 0 作为分母,或者是数组越界访问等情况。
认识 Go 语言中的异常:panic
不同编程语言表示异常(Exception)这个概念的语法都不相同。在 Go 语言中,异常这个概念由 panic 表示。一些教程或文章会把它译为恐慌,我这里依旧选择不译,保留 panic 的原汁原味。
在 Go 中,panic 主要有两类来源,一类是来自 Go 运行时,另一类则是 Go 开发人员通过 panic 函数主动触发的。无论是哪种,一旦 panic 被触发,后续 Go 程序的执行过程都是一样的,这个过程被 Go 语言称为 panicking。panicking 会沿着函数调用栈向上走,直到被捕获或者recover或者程序退出。
recover函数:recover 是 Go 内置的专门用于恢复 panic 的函数,它必须被放在一个 defer 函数中才能生效。如果 recover 捕捉到 panic,它就会返回以 panic 的具体内容为错误上下文信息的错误值。如果没有 panic 发生,那么 recover 将返回 nil。而且,如果 panic 被 recover 捕捉到, panic 引发的 panicking 过程就会停止。
Go 标准库提供的 http server 采用的是,每个客户端连接都使用一个单独的 Goroutine 进行处理的并发处理模型。也就是说,客户端一旦与 http server 连接成功,http server 就会为这个连接新创建一个 Goroutine,并在这 Goroutine 中执行对应连接(conn)的 serve 方法,来处理这条连接上的客户端请求。无论在哪个 Goroutine 中发生未被恢复的 panic,整个程序都将崩溃退出。所以,为了保证处理某一个客户端连接的 Goroutine 出现 panic 时,不影响到 http server 主 Goroutine 的运行,Go 标准库在 serve 方法中加入了对 panic 的捕捉与恢复,serve 方法在一开始处就设置了 defer 函数,并在该函数中捕捉并恢复了可能出现的 panic。这样,即便处理某个客户端连接的 Goroutine 出现 panic,处理其他连接 Goroutine 以及 http server 自身都不会受到影响。这种局部不要影响整体的异常处理策略,在很多并发程序中都有应用。并且,捕捉和恢复 panic 的位置通常都在子 Goroutine 的起始处,这样设置可以捕捉到后面代码中可能出现的所有 panic
我们可以使用 panic,部分模拟断言对潜在 bug 的提示功能。在 Go 标准库中,大多数 panic 的使用都是充当类似断言的作用的。
不要混淆异常与错误: 作为 API 函数的作者,你一定不要将 panic 当作错误返回给 API 调用者。
使用 defer 简化函数实现 defer 是 Go 语言提供的一种延迟调用机制,defer 的运作离不开函数。
- 在 Go 中,只有在函数(和方法)内部才能使用 defer;
- defer 关键字后面只能接函数(或方法),这些函数被称为 deferred 函数。defer 将它们注册到其所在 Goroutine 中,用于存放 deferred 函数的栈数据结构中,这些 deferred 函数将在执行 defer 的函数退出前,按后进先出(LIFO)的顺序被程序调度执行(如下图所
示)。
- 无论是执行到函数体尾部返回,还是在某个错误处理分支显式 return,又或是出现 panic,已经存储到 deferred 函数栈中的函数,都会被调度执行。
defer 使用的几个注意事项 => 查看testDefer例子
- 第一点:明确哪些函数可以作为 deferred 函数(通过匿名函数的方式可以让不支持defer的内置函数支持)
- 第二点:函数返回前,deferred 函数是按照后入先出(LIFO)的顺序执行的
- 第三点:注意 defer 关键字后面表达式的求值时机(函数注册到defered调用栈时对函数参数进行求值)
- 第四点:知晓 defer 带来的性能损耗(低版本8倍左右差距,1.13版本后不断优化,1.7版本后性能只相差1.5倍左右)
错误处理
基于 Go 错误处理机制、统一的错误值类型以及错误值构造方法的基础上,Go 语言形成了多种错误处理的惯用策略,包括:
- 透明错误处理策略
- “哨兵”错误处理策略
- 错误值类型检视策略
- 错误行为特征检视策略
这些策略都有适用的场合,但没有某种单一的错误处理策略可以适合所有项目或所有场合。
使用建议参考
- 请尽量使用“透明错误”处理策略,降低错误处理方与错误值构造方之间的耦合;
- 如果可以通过错误值类型的特征进行错误检视,那么请尽量使用“错误行为特征检视策略”;
- 在上述两种策略无法实施的情况下,再使用“哨兵”策略和“错误值类型检视”策略;
- Go 1.13 及后续版本中,尽量用errors.Is和errors.As函数替换原先的错误检视比较语句。
接口类型
区分类型、接口、方法
在 Go 语言中,接口、类型和方法是三个核心概念,它们分别扮演不同的角色并具有不同的用途。理解它们的区别和关系对编写清晰、模块化和可维护的代码至关重要。
- 类型(Type)
类型是对一组数据和操作的抽象。在 Go 语言中,类型可以是基础类型(如 int、string)、结构体类型(struct)以及用户定义的类型。
type Person struct {
Name string
Age int
}
- 方法(Method)
方法是与特定类型关联的函数。方法的接收者可以是值类型或指针类型。通过方法,可以为某个类型定义行为。
func (p Person) Greet() string {
return "Hello, my name is " + p.Name
}
- 接口(Interface)
接口是方法的集合,定义了一组方法的签名。任何类型只要实现了接口中的所有方法,就被认为实现了该接口。接口是一种抽象类型,它描述了类型的行为,而不是具体实现。
type Greeter interface {
Greet() string
}
func sayHello(g Greeter) {
fmt.Println(g.Greet())
}
- 区别和关系
类型 vs. 方法
类型 是对数据的抽象和定义。
方法 是与类型关联的函数,定义了类型的行为。
类型 vs. 接口
类型 是具体的数据结构或数据的定义。
接口 是对行为的抽象定义,规定了一组方法。
方法 vs. 接口
方法 是具体类型的行为实现。
接口 是对行为的抽象描述,定义了方法的签名。
// 完整例子
package main
import "fmt"
// 定义一个结构体类型 Person
type Person struct {
Name string
Age int
}
// 为 Person 类型定义一个方法 Greet
func (p Person) Greet() string {
return "Hello, my name is " + p.Name
}
// 定义一个接口 Greeter
type Greeter interface {
Greet() string
}
// 定义一个函数 sayHello,接收一个 Greeter 接口
func sayHello(g Greeter) {
fmt.Println(g.Greet())
}
func main() {
// 创建一个 Person 类型的实例
p := Person{Name: "Alice", Age: 30}
// 由于 Person 类型实现了 Greeter 接口的方法,所以可以作为 Greeter 接口的实现类型传递给 sayHello 函数
sayHello(p)
}
// 看test用例 errorTest
理解go语言中方法的本质:一个以方法的 receiver 参数作为第一个参数的普通函数。
Go 语言从设计伊始,就不支持经典的面向对象语法元素,比如类、对象、继承,等等,但 Go 语言仍保留了名为“方法(method)”的语法元素。当然,Go 语言中的方法和面向对象中的方法并不是一样的。Go 引入方法这一元素,并不是要支持面向对象编程范式,而是 Go 践行组合设计哲学的一种实现层面的需要。
和由五个部分组成的函数声明不同,Go 方法的声明有六个组成部分,多的一个就是图中的 receiver 部分。在 receiver 部分声明的参数,Go 称之为 receiver 参数,这个 receiver 参数也是方法与类型之间的纽带,也是方法与函数的最大不同。
除了 receiver 参数名字要保证唯一外,Go 语言对 receiver 参数的基类型也有约束,那就是 receiver 参数的基类型本身不能为指针类型或接口类型。
Go 对方法声明的位置也是有约束的,Go 要求,方法声明要与 receiver 参数的基类型声明放在同一个包内。
C++ 中的对象在调用方法时,编译器会自动传入指向对象自身的 this 指针作为方法的第一个参数。Go 方法中的原理也是相似的,只不过我们是将 receiver 参数以第一个参数的身份并入到方法的参数列表中(等价转换后的函数的类型就是方法的类型,等价转换有助于理解方法接受者是指针类型和值类型的不同)。
// 案例解析
// https://tonybai.com/2018/03/20/the-analysis-of-output-results-of-a-go-code-snippet/
// https://tonybai.com/2015/09/17/7-things-you-may-not-pay-attation-to-in-go/
如何选择 receiver 参数的类型
在方法中,值类型Receiver传递的是实例的副本(与函数参数的值传递一样),因此在方法内对receiver的任意修改都不会影响原实例。而指针类型Receiver传递的是原实例的地址(与函数参数的值传递一样),因此在方法内对receiver的任意修改都会影响原实例。
选择receiver 参数的类型的原则
- 如果 Go 方法要把对 receiver 参数代表的类型实例的修改,反映到原类型实例上,那么我们应该选择 *T 作为 receiver 参数的类型。
- 无论是 T 类型实例,还是 *T 类型实例,都既可以调用 receiver 为 T 类型的方法,也可以调用 receiver 为 *T 类型的方法。这样,我们在为方法选择 receiver 参数的类型的时候,就不需要担心这个方法不能被与 receiver 参数类型不一致的类型实例调用了。
- 如果 receiver 参数类型的 size 较大,以值拷贝形式传入就会导致较大的性能开销,这时我们选择 *T 作为 receiver 类型可能更好些。
- T 类型是否需要实现某个接口,也就是是否存在将 T 类型的变量赋值给某接口类型变量的情况。 如果 T 类型需要实现某个接口,那我们就要使用 T 作为 receiver 参数的类型,来满足接口类型方法集合中的所有方法。(这里的“T类型是否要实现接口”的含义是是否存在将T类型值赋值给接口类型的情况。)
方法集合
- Go 中任何一个类型都有属于自己的方法集合,或者说方法集合是 Go 类型的一个“属性”。但不是所有类型都有自己的方法呀,比如 int 类型就没有。所以,对于没有定义方法的 Go 类型,我们称其拥有空方法集合。
- 接口类型相对特殊,它只会列出代表接口的方法列表,不会具体定义某个方法,它的方法集合就是它的方法列表中的所有方法,我们可以一目了然地看到。
- 方法集合决定接口实现的含义就是:如果某类型 T 的方法集合与某接口类型的方法集合相同,或者类型 T 的方法集合是接口类型 I 方法集合的超集,那么我们就说这个类型 T 实现了接口 I。或者说,方法集合这个概念在 Go 语言中的主要用途,就是用来判断某个类型是否实现了某个接口。
type T struct{}
func (T) M1()
func (T) M2()
// 基于类型 T 定义了一个新类型 S
type S T
// S 类型 和 *S 类型都没有包含方法,因为type S T 定义了一个新类型。 但是如果用 type S = T 则S和*S类型都包含两个方法。
方法:如何用类型嵌入模拟实现“继承”? 实际是组合的方式 => 通过Go 语言的类型嵌入(Type Embedding)=> 接口类型的组合以及结构体类型的类型嵌入
尤其要注意 *T 类型的方法集合,它包含的可不是 T1 类型的方法集合,而是 *T1 类型的方法集合。这和结构体指针类型的方法集合包含结构体类型方法集合,是一个道理。 类型嵌入分为两种,一种是接口类型的类型嵌入,对于接口类型的类型嵌入我们只要把握好其语义“方法集合并入”就可以了。另外一种是结构体类型的类型嵌入。通过在结构体定义中的嵌入字段,我们可以实现对嵌入类型的方法集合的“继承”。 种“继承”并非经典面向对象范式中的那个继承,Go 中的“继承”实际是一种组合,更具体点是组合思想下代理(delegate)模式的运用,也就是新类型代理了其嵌入类型的所有方法。当外界调用新类型的方法时,Go 编译器会首先查找新类型是否实现了这个方法,如果没有,就会将调用委派给其内部实现了这个方法的嵌入类型的实例去执行,你一定要理解这个原理。
牢记类型嵌入对新类型的方法集合的影响,包括:
- 结构体类型的方法集合包含嵌入的接口类型的方法集合。
- 当结构体类型 T 包含嵌入字段 E 时,*T 的方法集合不仅包含类型 E 的方法集合,还要包含类型 *E 的方法集合。
- 基于非接口类型的 defined 类型创建的新 defined 类型不会继承原类型的方法集合,而通过类型别名定义的新类型则和原类型拥有相同的方法集合。
// 带有类型嵌入的结构体(如 S1)和不带类型嵌入的结构体(如 S2)是不等价的。带有类型嵌入的结构体能够直接调用嵌入类型的方法和访问其字段,而不带类型嵌入的结构体则需要通过字段来调用方法和访问字段。
// 这两个S1与S2是不等价的,区别是:S1结构体能调用代理嵌入类型的所有方法,S2结构体是没有代理嵌入类型方法。
type T1 int
type t2 struct{
n int
m int
}
type I interface {
M1()
}
type S1 struct {
T1
*t2
I
a int
b string
}
type S2 struct {
T1 T1
t2 *t2
I I
a int
b string
}
接口即契约
接口类型是由 type 和 interface 关键字定义的一组方法集合,其中,方法集合唯一确定了这个接口类型所表示的接口。
直接使用interface{}这个类型字面值作为所有空接口类型的代表,接口类型一旦被定义后,它就和其他 Go 类型一样可以用于声明变量
/**
这些类型为接口类型的变量被称为接口类型变量,如果没有被显式赋予初值,接口类型变量的默认值为 nil。
如果要为接口类型变量显式赋予初值,我们就要为接口类型变量选择合法的右值。
*/
*/
var err error // err是一个error接口类型的实例变量
var r io.Reader // r是一个io.Reader接口类型的实例变量
Go 规定:如果一个类型 T 的方法集合是某接口类型 I 的方法集合的等价集合或超集,我们就说类型 T 实现了接口类型 I,那么类型 T 的变量就可以作为合法的右值赋值给接口类型 I 的变量。
// 如果一个变量的类型是空接口类型,由于空接口类型的方法集合为空,这就意味着任何类型都实现了空接口的方法集合,
// 所以我们可以将任何类型的值作为右值,赋值给空接口类型的变量
var i interface{} = 15 // ok
i = "hello, golang" // ok
type T struct{}
var t T
i = t // ok
i = &t // ok
空接口类型的这一可接受任意类型变量值作为右值的特性,让他成为 Go 加入泛型语法之前唯一一种具有“泛型”能力的语法元素,包括 Go 标准库在内的一些通用数据结构与算法的实现,都使用了空类型interface{}作为数据元素的类型,这样我们就无需为每种支持的元素类型单独做一份代码拷贝了。
Go 语言还支持接口类型变量赋值的“逆操作”,也就是通过接口类型变量“还原”它的右值的类型与值信息,这个过程被称为“类型断言(Type Assertion)”。类型断言通常使用下面的语法形式:
接口类型的类型断言还有一个变种,那就是 type switch ,见switch case篇章
尽量定义小接口
接口类型的背后,是通过把类型的行为抽象成契约,建立双方共同遵守的约定,这种契约将双方的耦合降到了最低的程度。
- 隐式契约,无需签署,自动生效(和java中需要implements显式的修饰不同),实现者只需要实现接口方法集合中的全部方法便算是遵守了契约
- 更倾向于“小契约”,尽量定义小接口,即方法个数在 1~3 个之间的接口(也是组合的思想,大的接口类型由小的接口类型组合而成)。
- 第一点:接口越小,抽象程度越高(单一职责原则(SRP))
- 第二点:小接口易于实现和测试
- 第三点:小接口表示的“契约”职责单一,易于复用组合
- 尽管接口不是 Go 独有的,但专注于接口是编写强大而灵活的 Go 代码的关键。因此,在定义小接口之前,我们需要先针对问题领域进行深入理解,聚焦抽象并发现接口。先针对领域对象的行为进行抽象,形成一个接口集合:
- 首先,别管接口大小,先抽象出接口。
- 第二,将大接口拆分为小接口。
- 最后,我们要注意接口的单一契约职责。
Go 接口背后的本质是一种“契约”,通过契约我们可以将代码双方的耦合降至最低。
nil接口不等于nil
Go 接口是构建 Go 应用骨架的重要元素。从语言设计角度来看,Go 语言的接口(interface)和并发(concurrency)原语是我最喜欢的两类 Go 语言语法元素。Go 语言核心团队的技术负责人 Russ Cox 也曾说过这样一句话:“如果要从 Go 语言中挑选出一个特性放入其他语言,我会选择接口”,这句话足以说明接口这一语法特性在这位 Go 语言大神心目中的地位。为什么接口在 Go 中有这么高的地位呢?这是因为接口是 Go 这门静态语言中唯一“动静兼备” 的语法特性。
- 接口的静态特性与动态特性
接口的静态特性体现在接口类型变量具有静态类型,比如var err error中变量 err 的静态类型为 error。拥有静态类型,那就意味着编译器会在编译阶段对所有接口类型变量的赋值操作进行类型检查,编译器会检查右值的类型是否实现了该接口方法集合中的所有方法。而接口的动态特性,就体现在接口类型变量在运行时还存储了右值的真实类型信息,这个右值的真实类型被称为接口类型变量的动态类型。
- nil error 值 != nil
在运行时层面,接口类型变量有两种内部表示:iface和eface,这两种表示分别用于不同的接口类型变量:
- eface 用于表示没有方法的空接口(empty interface)类型变量,也就是 interface{}类型的变量。
- iface 用于表示其余拥有方法的接口 interface 类型变量。
// $GOROOT/src/runtime/runtime2.go
type iface struct {
tab *itab
data unsafe.Pointer
}
type eface struct {
_type *_type
data unsafe.Pointer
}
空接口类型变量 ei 在 Go 运行时
type T struct {
n int
s string
}
func main() {
var t = T {
n: 17,
s: "hello, interface",
}
var ei interface{} = t // Go运行时使用eface结构表示ei
}
非空接口类型变量 NonEmptyInterface 在 Go 运行时
type T struct {
n int
s string
}
func (T) M1() {}
func (T) M2() {}
type NonEmptyInterface interface {
M1()
M2()
}
func main() {
var t = T {
n: 17,
s: "hello, interface",
}
var i NonEmptyInterface = t
}
由上面的这两幅图,我们可以看出,每个接口类型变量在运行时的表示都是由两部分组成的,针对不同接口类型我们可以简化记作:eface(_type, data)和iface(tab, data)。 而且,虽然 eface 和 iface 的第一个字段有所差别,但 tab 和 _type 可以统一看作是动态类型的类型信息。Go 语言中每种类型都会有唯一的 _type 信息,无论是内置原生类型,还是自定义类型都有。 Go 运行时会为程序内的全部类型建立只读的共享 _type 信息表,因此拥有相同动态类型的同类接口类型变量的 _type/tab 信息是相同的。 而接口类型变量的 data 部分则是指向一个动态分配的内存空间,这个内存空间存储的是赋值 给接口类型变量的动态类型变量的值。未显式初始化的接口类型变量的值为nil,也就是这个变量的 _type/tab 和 data 都为 nil。 也就是说,我们判断两个接口类型变量是否相同,只需要判断 _type/tab 是否相同,以及 data 指针指向的内存空间所存储的数据值是否相同就可以了。这里要注意不是 data 指针的值相同噢。
接口类型的装箱(boxing)原理
装箱(boxing)是编程语言领域的一个基础概念,一般是指把一个值类型转换成引用类型,比如在支持装箱概念的 Java 语言中,将一个 int 变量转换成 Integer 对象就是一个装箱操作。 在 Go 语言中,将任意类型赋值给一个接口类型变量也是装箱操作。有了前面对接口类型变量内部表示的学习,我们知道接口类型的装箱实际就是创建一个 eface 或 iface 的过程。装箱之后的数据已经比原数据毫无关系了。
怎么正确使用接口(组合设计)
接口使用原则:在实际真正需要的时候才对程序进行抽象。再通俗一些来讲,就是不要为了抽象而抽象。接口的确可以实现解耦,但它也会引入“抽象”的副作用,或者说接口这种抽象也不是免费的,是有成本的,除了会造成运行效率的下降之外,也会影响代码的可读性。 一切皆组合的设计哲学:Go 语言之父 Rob Pike 曾说过:如果 C++ 和 Java 是关于类型层次结构和类型分类的语言,那么 Go 则是关于组合的语言。如果把 Go 应用程序比作是一台机器的话,那么组合关注的就是如何将散落在各个包中的“零件”关联并组装到一起。我们前面也说过,组合是 Go 语言的重要设计哲学之一,而正交性则为组合哲学的落地提供了更为方便的条件。
- 垂直组合(类型组合 => 比喻为器官):更多应用在新类型的定义方面,可以达到方法实现的复用、接口定义重用等目的。
- 通过嵌入接口构建接口,通过在接口定义中嵌入其他接口类型,实现接口行为聚合,组成大接口。
- 通过嵌入接口构建结构体类型:在结构体中嵌入接口,可以用于快速构建满足某一个接口的结构体类型,来满足某单元测试的需要,之后我们只需要实现少数需要的接口方法就可以了。
- 通过嵌入结构体类型构建新结构体类型:在结构体中嵌入接口类型名和在结构体中嵌入其他结构体,都是“委派模式(delegate)”的一种应用。对新结构体类型的方法调用,可能会被“委派”给该结构体内部嵌入的结构体的实例,通过这种方式构建的新结构体类型就“继承”了被嵌入的结构体的方法的实现。
现在我们可以知道,包括嵌入接口类型在内的各种垂直组合更多用于类型定义层面,本质上它是一种类型组合,也是一种类型之间的耦合方式。 2. 水平组合(接口组合 => 比喻为关节):接口可以将各个类型水平组合(连接)在一起。通过接口的编织,整个应用程序不再是一个个孤立的“器官”,而是一幅完整的、有灵活性和扩展性的静态骨架结构。
接口应用的几种模式
- 基本模式-使用接受接口类型参数的函数或方法
函数 / 方法参数中的接口类型作为“关节(连接点)”,支持将位于多个包中的多个类型与 YourFuncName 函数连接到一起,共同实现某一新特性。 同时,接口类型和它的实现者之间隐式的关系却在不经意间满足了:依赖抽象(DIP)、里氏替换原则(LSP)、接口隔离(ISP)等代码设计原则,这在其他语言中是需要很“刻意”地设计谋划的,但对 Go 接口来看,这一切却是自然而然的。
- 创建模式-接受接口,返回结构体(Accept interfaces, return structs)=> 创建模式通过接口,在 NewXXX 函数所在包与接口的实现者所在包之间建立了一个连接。大多数包含接口类型字段的结构体的实例化,都可以使用创建模式实现。
- 包装器(装饰器)模式 => 实现对输入参数的类型的包装,并在不改变被包装类型(输入参数类型)的定义的情况下,返回具备新功能特性的、实现相同接口类型的新类型。
- 适配器模式 => 适配器模式的核心是适配器函数类型(Adapter Function Type)。适配器函数类型是一个辅助水平组合实现的“工具”类型。(如http.HandlerFunc)
- 中间件(Middleware)=> 中间件就是包装模式和适配器模式结合的产物。
- 尽量避免使用空接口作为函数参数类型 => 建议广大 Gopher 尽可能地抽象出带有一定行为契约的接口,并将它作为函数参数类型,尽量不要使用可以“逃过”编译器类型安全检查的空接口类型(interface{})。在函数或方法参数中使用空接口类型,就意味着你没有为编译器提供关于传 入实参数据的任何信息,所以,你就会失去静态类型语言类型安全检查的“保护屏障”,你需要自己检查类似的错误,并且直到运行时才能发现此类错误。
- 使用interface{}作为参数类型的函数或方法都有一个共同特点,就是它们面对的都是未知类型的数据,所以在这里使用具有“泛型”能力的interface{}类型。我们也可以理解为是在 Go 语言尚未支持泛型的这个阶段的权宜之计。等 Go 泛型落地后,很多场合下 interface{}就可以被泛型替代了。
Go的并发方案实现方案是怎样的?
单进程应用
这个设计下,每个单进程应用对应一个操作系统进程,操作系统内的多个进程按时间片大小,被轮流调度到仅有的一颗单核处理器上执行。换句话说,这颗单核处理器在某个时刻只能执行一个进程对应的程序代码,两个进程不存在并行执行的可能。 这里说的并行(parallelism),指的就是在同一时刻,有两个或两个以上的任务(这里指进程)的代码在处理器上执行。从这个概念我们也可以知道,多个处理器或多核处理器是并行执行的必要条件。 总的来说,单进程应用的设计比较简单,它的内部仅有一条代码执行流,代码从头执行到尾,不存在竞态,无需考虑同步问题。
多进程应用
用户层的另外一种设计方式,就是多进程应用,也就是应用通过 fork 等系统调用创建多个子进程,共同实现应用的功能。 但限于当前仅有一颗单核处理器,这些进程(执行流)依旧无法并行执行,无论是 App1 内部的某个模块对应的进程,还是其他 App 对应的进程,都得逐个按时间片被操作系统调度到处理器上执行。 多进程应用与单进程应用相比并没有什么质的提升。那我们为什么还要将应用设计为多进程呢? 这更多是从应用的结构角度去考虑的,多进程应用由于将功能职责做了划分,并指定专门的模块来负责,所以从结构上来看,要比单进程更为清晰简洁,可读性与可维护性也更好。这种将程序分成多个可独立执行的部分的结构化程序的设计方法,就是并发设计。采用了并发设计的应用也可以看成是一组独立执行的模块的组合。 不过,进程并不适合用于承载采用了并发设计的应用的模块执行流。因为进程是操作系统中资源拥有的基本单位,它不仅包含应用的代码和数据,还有系统级的资源,比如文件描述符、内存地址空间等等。进程的“包袱”太重,这导致它的创建、切换与撤销的代价都很大。
多线程应用
线程就是运行于进程上下文中的更轻量级的执行流。同时随着处理器技术的发展,多核处理器硬件成为了主流,这让真正的并行成为了可能,基于线程的应用通常采用单进程多线程的模型,一个应用对应一个进程,应用通过并发设计将自己划分为多个模块,每个模块由一个线程独立承载执行。多个线程共享这个进程所拥有的资源,但线程作为执行单元可被独立调度到处理器上运行。 线程的创建、切换与撤销的代价相对于进程是要小得多。当这个应用的多个线程同时被调度到不同的处理器核上执行时,我们就说这个应用是并行的。 讲到这里,我们可以对并发与并行两个概念做一些区分了。就像 Go 语言之父 Rob Pike 曾说过那样:并发不是并行,并发关乎结构,并行关乎执行。
在不满足并行必要条件的情况下(也就是仅有一个单核 CPU 的情况下),即便是采用并发设计的程序,依旧不可以并行执行。而在满足并行必要条件的情况下,采用并发设计的程序是可以并行执行的。而那些没有采用并发设计的应用程序,除非是启动多个程序实例,否则是无法并行执行的。 在多核处理器成为主流的时代,即使采用并发设计的应用程序以单实例的方式运行,其中的每个内部模块也都是运行于一个单独的线程中的,多核资源也可以得到充分利用。而且,并发让 并行变得更加容易,采用并发设计的应用可以将负载自然扩展到各个 CPU 核上,从而提升处理器的利用效率。 在传统编程语言(如 C、C++ 等)中,基于多线程模型的应用设计就是一种典型的并发程序设计。
传统支持并发的方式的缺点
- 首先就是复杂, 创建容易退出难。而且,并发执行单元间的通信困难且易错。多个线程之间的通信虽然有多种机制可选,但用起来也是相当复杂。并且一旦涉及共享内存,就会用到各种锁互斥机制,死锁便成为家常便饭。
- 第二就是难于规模化(scale)。线程的使用代价虽然已经比进程小了很多,但我们依然不能大量创建线程,因为除了每个线程占用的资源不小之外,操作系统调度切换线程的代价也不小。
Go 的并发方案:goroutine
Go 并没有使用操作系统线程作为承载分解后的代码片段(模块)的基本执行单元,而是实现了goroutine这一由 Go 运行时(runtime)负责调度的、轻量的用户级线程,为并发程序设计提供原生支持。
使用goroutine用户级线程的优点
- 资源占用小,每个 goroutine 的初始栈大小仅为 2k;
- 由 Go 运行时而不是操作系统调度,goroutine 上下文切换在用户层完成,开销更小;
- 在语言层面而不是通过标准库提供。goroutine 由go关键字创建,一退出就会被回收或销毁,开发体验更佳;
- 语言内置 channel 作为 goroutine 间通信原语,为并发设计提供了强大支撑。
和传统编程语言不同的是,Go 语言是面向并发而生的,所以,在程序的结构设计阶段,Go 的惯例是优先考虑并发设计。这样做的目的更多是考虑随着外界环境的变化,通过并发设计的 Go 应用可以更好地、更自然地适应规模化(scale)。 比如,当应用被分配到更多计算资源,或者计算处理硬件增配后,Go 应用不需要再进行结构调整,就可以充分利用新增的计算资源。而且,经过并发设计后的 Go 应用也会更加契合 Gopher 们的开发分工协作。
goroutine 的基本用法
并发是一种能力,它让你的程序可以由若干个代码片段组合而成,并且每个片段都是独立运行的。goroutine 恰恰就是 Go 原生支持并发的一个具体实现。无论是 Go 自身运行时代码还是用户层 Go 代码,都无一例外地运行在 goroutine 中。
示例在test中
goroutine间的通信
传统的编程语言(比如:C++、Java、Python 等)并非面向并发而生的,所以他们面对并发的逻辑多是基于操作系统的线程。并发的执行单元(线程)之间的通信,利用的也是操作系统 提供的线程或进程间通信的原语,比如:共享内存、信号(signal)、管道(pipe)、消息队列、套接字(socket)等。 在这些通信原语中,使用最多、最广泛的(也是最高效的)是结合了线程同步原语(比如:锁以及更为低级的原子操作)的共享内存方式,因此,我们可以说传统语言的并发模型是基于对内存的共享的。 不过,这种传统的基于共享内存的并发模型很难用,且易错,尤其是在大型或复杂程序中,开发人员在设计并发程序时,需要根据线程模型对程序进行建模,同时规划线程之间的通信方式。如果选择的是高效的基于共享内存的机制,那么他们还要花费大量心思设计线程间的同步机制,并且在设计同步机制的时候,还要考虑多线程间复杂的内存管理,以及如何防止死锁等情况。
Go 语言从设计伊始,就将解决上面这个传统并发模型的问题作为 Go 的一个目标,并在新并发模型设计中借鉴了著名计算机科学家Tony Hoare提出的 CSP (Communicationing Sequential Processes,通信顺序进程)并发模型。 在 Tony Hoare 眼中,一个符合 CSP 模型的并发程序应该是一组通过输入输出原语连接起来的 P 的集合。从这个角度来看,CSP 理论不仅是一个并发参考模型,也是一种并发程序的程序组织方法。它的组合思想与 Go 的设计哲学不谋而合。
CSP通信模型
在 Go 中,与“Process”对应的是 goroutine。为了实现 CSP 并发模型中的输入和输出原语,Go 还引入了 goroutine(P)之 间的通信原语channel。goroutine 可以从 channel 获取输入数据,再将处理后得到的结果数据通过 channel 输出。通过 channel 将 goroutine(P)组合连接在一起,让设计和编写大型并发系统变得更加简单和清晰,我们再也不用为那些传统共享内存并发模型中的问题而伤脑筋了。
虽然 CSP 模型已经成为 Go 语言支持的主流并发模型,但 Go 也支持传统的、基于共享内存的并发模型,并提供了基本的低级别同步原语(主要是 sync 包中的互斥锁、条件变量、读写锁、原子操作等)。
从程序的整体结构来看,Go 始终推荐以 CSP 并发模型风格构建并发程序,尤其是在复杂的业务层面,这能提升程序的逻辑清晰度,大大降低并发设计的复杂性,并让程序更具可读性和可维护性。过,对于局部情况,比如涉及性能敏感的区域或需要保护的结构体数据时,我们可以使用更为高效的低级同步原语(如 mutex),保证 goroutine 对数据的同步访问。
Goroutine 调度器
传统的调度:提到“调度”,我们首先想到的就是操作系统对进程、线程的调度。操作系统调度器会将系统中的多个线程按照一定算法调度到物理 CPU 上去运行。 前面我们也提到,传统的编程语言,比如 C、C++ 等的并发实现,多是基于线程模型的,也就是应用程序负责创建线程(一般通过 libpthread 等库函数调用实现),操作系统负责调度线程。
Go 语言中的并发实现,使用了 Goroutine,代替了操作系统的线程,也不再依靠操作系统调度。
过程如下:
- Goroutine 调度的切换也不用陷入(trap)操作系统内核层完成,代价很低。因此,一个 Go 程序中可以创建成千上万个并发的 Goroutine。
- 一个 Go 程序对于操作系统来说只是一个用户层程序,操作系统眼中只有线程,它甚至不知道有一种叫 Goroutine 的事物存在。所以,Goroutine 的调度全要靠 Go 自己完成。那么,实现 Go 程序内 Goroutine 之间“公平”竞争“CPU”资源的任务,就落到了 Go 运行时(runtime)头上了。
- Goroutine 的调度问题就演变为,Go 运行时如何将程序内的众多 Goroutine,按照一定算法调度到“CPU”资源上运行的问题了。
- 将这些 Goroutine 按照一定算法放到“CPU”上执行的程序,就被称为 Goroutine 调度器(Goroutine Scheduler),注意,这里说的“CPU”打了引号。
- Go 程序是用户层程序,它本身就是整体运行在一个或多个操作系统线程上的。所以这个答案就出来了:Goroutine 们要竞争的“CPU”资源就是操作系统线程。这样,Goroutine 调度器的任务也就明确了:将 Goroutine 按照一定算法放到不同的操作系统线程中去执行。
Goroutine 调度器调度器模型与演化过程
调度器的工作就是将 G 调度到 M 上去运行。为了更好地控制程序中活跃的 M 的数量,调度器引入了 GOMAXPROCS 变量来表示 Go 调度器可见的“处理器”的最大数量。
GM模型的缺点
- 单一全局互斥锁(Sched.Lock) 和集中状态存储的存在,导致所有 Goroutine 相关操作,比如创建、重新调度等,都要上锁;
- Goroutine 传递问题:M 经常在 M 之间传递“可运行”的 Goroutine,这导致调度延迟增大,也增加了额外的性能损耗;
- 每个 M 都做内存缓存,导致内存占用过高,数据局部性较差;
- 由于系统调用(syscall)而形成的频繁的工作线程阻塞和解除阻塞,导致额外的性能损耗。
G-M模型到G-P-M模型:核心是增加了一个中间层去解决 Go 并发程序的伸缩性不足的相关GM模型缺点
P 是一个“逻辑 Proccessor”,每个 G(Goroutine)要想真正运行起来,首先需要被分配一个 P,也就是进入到 P 的本地运行队列(local runq)中。对于 G 来说,P 就是运行它的 “CPU”,可以说:在 G 的眼里只有 P。但从 Go 调度器的视角来看,真正的“CPU”是 M,只有将 P 和 M 绑定,才能让 P 的 runq 中的 G 真正运行起来。
- G: 代表 Goroutine,存储了 Goroutine 的执行栈信息、Goroutine 状态以及 Goroutine 的任务函数等,而且 G 对象是可以重用的;
- P: 代表逻辑 processor,P 的数量决定了系统内最大可并行的 G 的数量,P 的最大作用还是其拥有的各种 G 对象队列、链表、一些缓存和状态;
- M: M 代表着真正的执行计算资源。在绑定有效的 P 后,进入一个调度循环,而调度循环的机制大致是从 P 的本地运行队列以及全局队列中获取 G,切换到 G 的执行栈上并执行 G 的函数,调用 goexit 做清理工作并回到 M,如此反复。M 并不保留 G 状态,这是 G 可以跨 M 调度的基础。
- Go 程序启动时,运行时会去启动一个名为 sysmon 的 M(一般称为监控线程),这个 M 的特殊之处在于它不需要绑定 P 就可以运行(以 g0 这个 G 的形式),
- 也就是如果某个 G 没有进行系统调用(syscall)、没有进行 I/O 操作、没有阻塞在一个 channel 操作上,调度器是如何让 G 停下来并调度下一个可运行的 G 的呢? => G被抢占调度
- 如果一个 G 任务运行 10ms,sysmon 就会认为它的运行时间太久而发出抢占式调度的请求。一旦 G 的抢占标志位被设为 true,那么等到这个 G 下一次调用函数或方法时,运行时就可以将 G 抢占并移出运行状态,放入队列中,等待下一次被调度。
- 释放闲置超过 5 分钟的 span 内存;
- 如果超过 2 分钟没有垃圾回收,强制执行;
- 将长时间未处理的 netpoll 结果添加到任务队列;向长时间运行的 G 任务发出抢占调度;
- 收回因 syscall 长时间阻塞的 P;
- 第一种:channel 阻塞或网络 I/O 情况下的调度。(放入等待队列,等待唤醒:M 可以不被阻塞,这避免了大量创建 M 导致的开销。)
- 第二种:系统调用阻塞情况下的调度。(如果 G 被阻塞在某个系统调用(system call)上,那么不光 G 会阻塞,执行这个 G 的 M 也会解绑 P,与 G 一起进入挂起状态。如果此时有空闲的 M,那么 P 就会和它绑定,并继续执行其他 G;如果没有空闲的 M,但仍然有其他 G 要去执行,那么 Go 运行时就会创建一个新 M(线程)。)
channel深入理解
Go 语言的 CSP 模型的实现包含两个主要组成部分:一个是 Goroutine,它是 Go 应用并发设计的基本构建与执行单元;另一个就是 channel,它在并发模型中扮演着重要的角色。 channel 既可以用来实现 Goroutine 间的通信,还可以实现 Goroutine 间的同步。它就好比 Go 并发设计这门“武功”的秘籍口诀,可以说,学会在 Go 并发设计时灵活运用 channel,才能说真正掌握了 Go 并发设计的真谛。
- 和其他复合数据类型支持使用复合类型字面值作为变量初始值不同,为 channel 类型变量赋初值的唯一方法就是使用 make 这个 Go 预定义的函数
- 通过带有 capacity 参数的make(chan T, capacity)创建的元素类型,为 T、缓冲区长度为 capacity 的 channel 类型,是带缓冲 channel。
- Go 提供了<-操作符用于对 channel 类型变量进行发送与接收操作。
- 在理解 channel 的发送与接收操作时,你一定要始终牢记:channel 是用于 Goroutine 间通信的,所以绝大多数对 channel 的读写都被分别放在了不同的 Goroutine 中。
- 对无缓冲 channel 类型的发送与接收操作,一定要放在两个不同的 Goroutine 中进行,否则会导致 deadlock。
- 和无缓冲 channel 相反,带缓冲 channel 的运行时层实现带有缓冲区,因此,对带缓冲 channel 的发送操作在缓冲区未满、接收操作在缓冲区非空的情况下是异步的(发送或接收不需要阻塞等待)。
- 对一个带缓冲 channel 来说,在缓冲区未满的情况下,对它进行发送操作的 Goroutine 并不会阻塞挂起;在缓冲区有数据的情况下,对它进行接收操作的 Goroutine 也不会阻塞挂起。 但当缓冲区满了的情况下,对它进行发送操作的 Goroutine 就会阻塞挂起;当缓冲区为空的情况下,对它进行接收操作的 Goroutine 也会阻塞挂起。
- 使用操作符<-,我们还可以声明只发送 channel 类型(send-only)和只接收 channel 类型(recv-only)
- 发送端负责关闭 channel,这是因为发送端没有像接受端那样的、可以安全判断 channel 是否被关闭了的方法。
- 当涉及同时对多个 channel 进行操作时,我们会结合 Go 为 CSP 并发模型提供的另外一个原语 select,一起使用。
无缓冲 channel 的惯用法
- 第一种用法:用作信号传递
- 第二种用法:用于替代锁机制
带缓冲 channel 的惯用法
-
第一种用法:用作消息队列
无论是 1 收 1 发还是多收多发,带缓冲 channel 的收发性能都要好于无缓冲 channel;对于带缓冲 channel 而言,发送与接收的 Goroutine 数量越多,收发性能会有所下降;对于带缓冲 channel 而言,选择适当容量会在一定程度上提升收发性能。
Go 支持 channel 的初衷是将它作为 Goroutine 间的通信手段,它并不是专门用于消息队列场景的。如果你的项目需要专业消息队列的功能特性,比如支持优先级、支 持权重、支持离线持久化等,那么 channel 就不合适了,可以使用第三方的专业的消息队列实现。
-
第二种用法:用作计数信号量(counting semaphore)
-
len(channel) 的应用: 当 ch 为无缓冲 channel 时,len(ch) 总是返回 0;当 ch 为带缓冲 channel 时,len(ch) 返回当前 channel ch 中尚未被读取的元素个数。
-
channel 原语用于多个 Goroutine 间的通信,一旦多个 Goroutine 共同对 channel 进行收发操作,len(channel) 就会在多个 Goroutine 间形成“竞态”。单纯地依靠 len(channel) 来判断 channel 中元素状态,是不能保证在后续对 channel 的收发时 channel 状态是不变的。
-
如果一个 channel 类型变量的值为 nil,我们称它为 nil channel。nil channel 有一个特性,那就是对 nil channel 的读写都会发生阻塞。
与 select 结合使用的一些惯用法
- 第一种用法:利用 default 分支避免阻塞
- 第二种用法:实现超时机制
- 第三种用法:实现心跳机制
go的并发原语选择真的是非常精炼:简洁又强大,一个ch就负责了线程通信、同步的多种功能;一个select又实现了对阻塞、非阻塞的控制以及事件循环模式。
如何使用共享变量
Go 也并没有彻底放弃基于共享内存的并发模型,而是在提供 CSP 并发模型原语的同时,还通过标准库的 sync 包,提供了针对传统的、基于共享内存并发模型的低级同步原语,包括:互斥锁(sync.Mutex)、读写锁(sync.RWMutex)、条件变量(sync.Cond)等,并通过 atomic 包提供了原子操作原语等等。
sync 包低级同步原语可以用在哪?(互斥锁(Mutex)是临时区同步原语的首选,它常被用来对结构体对象的内部状态、缓存等进行保护,是使用最为广泛的临界区同步原语。读写锁适合应用在具有一定并发量且读多写少的场合。)
- 首先是需要高性能的临界区(critical section)同步机制场景。(我们可以把 channel 看成是一种高级的同步原语,它自身的实现也是建构在低级同步原语之上的。也正因为如此, channel 自身的性能与低级同步原语相比要略微逊色,开销要更大。三倍左右)
- 第二种就是在不想转移结构体对象所有权,但又要保证结构体内部状态数据的同步访问的场景。(在使用 sync 包中的类型的时候,我们推荐通过闭包方式,或者是传递类型实例(或包裹该类型的类型实例)的地址 (指针)的方式进行。这就是使用 sync 包时最值得我们注意的事项。)
- 互斥锁(Mutex)还是读写锁(RWMutex)?
- 尽量减少在锁中的操作。这可以减少其他因 Goroutine 阻塞而带来的损耗与延迟。
- 一定要记得调用 Unlock 解锁。忘记解锁会导致程序局部死锁,甚至是整个程序死锁,会导致严重的后果。同时,我们也可以结合第 23 讲学习到的 defer,优雅地执行解锁操作。
- 条件变量sync.Cond => (我们可以把一个条件变量理解为一个容器,这个容器中存放着一个或一组等待着某个条件成立的 Goroutine。当条件成立后,这些处于等待状态的 Goroutine 将得到通知,并被唤醒继续进行后续的工作。)
- 和sync.Mutex 、sync.RWMutex等相比,sync.Cond 应用的场景更为有限,只有在需要“等待某个条件成立”的场景下,Cond 才有用武之地。
- atomic 包是 Go 语言给用户提供的原子操作原语的相关接口。原子操作(atomic operations)是相对于普通指令操作而言的。原子操作由底层硬件直接提供支持,是一种硬件实现的指令级的“事务”,因此相对于操作系统层面和 Go 运行时层面提供的同步技术而言,它更为原始。tomic 包封装了 CPU 实现的部分原子操作指令,为用户层提供体验良好的原子操作函数,因此 atomic 包中提供的原语更接近硬件底层,也更为低级,它也常被用于实现更为高级的并发同步技术,比如 channel 和 sync 包中的同步原语。
/**
1. Go 基于 Tony Hoare 的 CSP 并发模型理论,实现了 Goroutine、channel 等并发原语;
2. 使用低级同步原语(标准库的 sync 包以及 atomic 包提供了低级同步原语:Mutex/RWMut ex/Cond 等)的性能可以高出 channel 许多倍
3. 有锁的地方就有江湖,高并发下的性能主要拼的是算法,没有一门语言有压倒性优势
*/
workpool(Goroutine 池) => 携程池
这个方案的核心思想是对 Goroutine 的重用,也就是把 M 个计算任务调度到 N 个 Goroutine 上,而不是为每个计算任务分配一个独享的 Goroutine,从而提高计算资源的利用率。
/**
workerpool 的实现主要分为三个部分:
pool 的创建与销毁;
pool 中 worker(Goroutine)的管理;
task 的提交与调度。
*/
workpool中pool 对 worker 的管理
capacity 是 pool 的一个属性,代表整个 pool 中 worker 的最大容量。我们使用一个带缓冲的 channel:active,作为 worker 的“计数器”,这种 channel 使用模式就是我们在第 33 讲中讲过的计数信号量,如果记不太清了可以复习一下第 33 讲中的相关内容。 当 active channel 可写时,我们就创建一个 worker,用于处理用户通过 Schedule 函数提交的待处理的请求。当 active channel 满了的时候,pool 就会停止 worker 的创建,直到某个 worker 因故退出,active channel 又空出一个位置时,pool 才会创建新的 worker 填补那个空位。 这张图里,我们把用户要提交给 workerpool 执行的请求抽象为一个 Task。Task 的提交与调度也很简单:Task 通过 Schedule 函数提交到一个 task channel 中,已经创建的 worker 将从这个 task channel 中读取 task 并执行。
评论问题
- 这个是不是叫“协程池”,为什么叫做“线程池”?两者有什么区别呢?或者是到底什么是“协程”呢?
- 是不是这节课的实现,也纯粹是为了学习而实现的,个人理解,go实现Goroutine,就是为了解决“线程池”的繁琐,让“并发”实现的不用那么的麻烦,如果是超小“任务”,不用考虑线程频繁切换导致系统资源的浪费。如果再实现“协程池”的话,是不是丢失了这种优点?
- 常驻内存的Goroutine,反复使用,会导致这个Goroutine的内存越来越大,或者其他隐藏的风险么?
回答:
- 传统理解的coroutine一般是面向协作式,而非抢占式。像python中通过yield关键字创建的协程,与主routine之间是在一个线程上实现的切换执行,从设计角度是通过coroutine实现了并发(concurrenc y),但其实它们还是串行执行的,不会真正并行(paralellism),即便在多核处理器上。
基于上面的理解,我们就可以意识到goroutine并非传统意义上的coroutine,是支持抢占的,而且也必须依赖抢占实现runtime对goroutine的调度。它更像thread,可以绑定不同的cpu核并行执行(如果是在多核处理器上的话)。同时基于goroutine的设计也会一种并发的设计。
而goroutine与thread又不同,goroutine是在用户层(相较于os的内核层)调度的,os并不知道其存在,goroutine的切换相对轻量。而thread是os来调度的,切换代价更高一些。 所以文中将goroutine称为“轻量级线程”,而不是协程。 - 你理解的没错。这节课是为了演示goroutine、channel之间的调度与通信机制而“设计”出来的。goroutine使用代价很低,通常不用考虑池化。但是在一些大型网络服务程序时,一旦goroutine数量过多,内存占用以及调度goroutine的代价就不能不考虑了。于是有了“池化”的思路。这与传统的线程池的思路的确是一脉相承的。
- go是gc的,内存不会越来越大。
怎么实现一个TCP服务器?
解决问题思维图:首先是要理解问题。解决实际问题的过程起始于对问题的理解。我们要搞清楚为什么会有这个问题,问题究竟是什么。对于技术人员来说,最终目的是识别出可能要用到的技术点。 然后我们要对识别出的技术点,做相应的技术预研与储备。怎么做技术预研呢?我们至少要了解技术诞生的背景、技术的原理、技术能解决哪些问题以及不能解决哪些问题,还有技术的优点与不足,等等。当然,如果没有新技术点,可以忽略这一步。 最后,我们要基于技术预研和储备的结果,进行解决方案的设计与实现,这个是技术人最擅长的。
网络通信标准模型
OSI模型(开放系统互连模型)和TCP/IP模型(传输控制协议/互联网协议模型)是网络通信的两种标准模型,用于描述和标准化网络协议的功能和设计。
OSI模型
OSI模型由国际标准化组织(ISO)开发,是一个概念模型,用于理解和设计网络系统的通信。它将网络通信划分为七层,每一层都有特定的功能和协议。以下是七层及其功能:
物理层(Physical Layer):
功能:负责传输原始比特流,通过物理媒体(如电缆、光纤)进行传输。 设备:集线器、网卡、接线。
数据链路层(Data Link Layer):
功能:负责帧的传输,处理物理地址(MAC地址)的识别和差错检测。 设备:交换机、桥接器。
网络层(Network Layer):
功能:负责数据包的路由和转发,通过逻辑地址(IP地址)识别网络设备。 设备:路由器。
传输层(Transport Layer):
功能:负责端到端的数据传输,提供可靠的传输服务(如TCP)或不可靠的传输服务(如UDP)。 协议:TCP、UDP。
会话层(Session Layer):
功能:负责建立、管理和终止会话(连接)。 作用:对话控制、同步。
表示层(Presentation Layer):
功能:负责数据的翻译、加密和压缩。 作用:数据格式转换、加密/解密。
应用层(Application Layer):
功能:为应用程序提供网络服务,直接面向用户。 协议:HTTP、FTP、SMTP、DNS等。
TCP/IP模型
TCP/IP模型是互联网的基础协议模型,由DARPA(美国国防高级研究计划局)开发。它比OSI模型简单,将网络通信分为四层。以下是四层及其功能:
网络接口层(Network Interface Layer):
功能:对应OSI模型的物理层和数据链路层,负责在物理网络上传输数据。 协议:以太网、Wi-Fi等。
互联网层(Internet Layer):
功能:对应OSI模型的网络层,负责数据包的路由和传输。 协议:IP、ICMP、ARP。
传输层(Transport Layer):
功能:对应OSI模型的传输层,提供端到端的通信服务(TCP/IP 网络模型,实现了两种传输层协议:TCP 和 UDP。TCP 是面向连接的流协议,为通信的两端提供稳定可靠的数据传输服务;而 UDP 则提供了一种无需建立连接就可以发送数据包的方法。两种协议各有擅长的应用场景。)。 协议:TCP、UDP。
应用层(Application Layer):
功能:对应OSI模型的会话层、表示层和应用层,提供应用程序与网络之间的接口。 协议:HTTP、FTP、SMTP、DNS等。
对比与总结
层次结构:
OSI模型有七层,TCP/IP模型有四层。
OSI模型分层更细致,有助于理解网络通信的各个方面。
TCP/IP模型分层较少,更加实际,直接反映了互联网的实际架构。
实际应用:
OSI模型主要用于教学和理论研究。
TCP/IP模型广泛应用于实际网络中,是互联网的基础协议。
兼容性:
OSI模型的各层具有严格的分界线。
TCP/IP模型的层次之间界线较模糊,更加灵活。
两种模型在实际应用中互为补充,OSI模型提供了详细的理论基础,而TCP/IP模型提供了实际操作框架。了解这两种模型有助于深入理解网络通信的原理和机制。
我们日常开发中使用最多的是 TCP 协议。基于 TCP 协议,我们实现了各种各样的满足用户需求的应用层协议。比如,我们常用的 HTTP 协议就是应用层协议的一种,而且是使用得最广泛的一种。而基于 HTTP 的 Web 编程就是一种针对应用层的网络编程。我们还可以基于传输层暴露给开发者的编程接口,实现应用层的自定义应用协议。
这个传输层暴露给开发者的编程接口,究竟是什么呢?目前各大主流操作系统平台中,最常用的传输层暴露给用户的网络编程接口,就是套接字(socket)。直接基于 socket 编程实现应用层通信业务,也是最常见的一种网络编程形式。
实现一个基于 TCP 的自定义应用层协议的通信服务端
- 第一版见/timegeek/go-first-course/37/tcp-server-demo1
- 优化/timegeek/go-first-course/3738
- Go 程序优化的基本套路
-
首先我们要建立性能基准。要想对程序实施优化,我们首先要有一个初始“参照物”,这样我们才能在执行优化措施后,检验优化措施是否有效,所以这是优化循环的第一步。
- 建立性能基准的方式大概有两种
一种是通过编写 Go 原生提供的性能基准测试 (benchmark test)用例来实现,这相当于对程序的局部热点建立性能基准,常用于一些算法或数据结构的实现,比如分布式全局唯一 ID 生成算法、树的插入 / 查找等。
另外一种是基于度量指标为程序建立起图形化的性能基准,这种方式适合针对程序的整体建立性能基准。而我们的自定义协议服务端程序就十分适合用这种方式,接下来我们就来看一下基于度量指标建立基准的一种可行方案。
基于 Web 的可视化工具、开源监控系统以及时序数据库的兴起,给我们建立性能基准带来了很大的便利,业界有比较多成熟的工具组合可以直接使用。但业界最常用的还是 Prometheus+Grafana 的组合,这也是我日常使用比较多的组合,所以在这里我也使用这个工具组合来为我们的程序建立性能指标观测设施。以 Docker 为代表的轻量级容器(container)的兴起,让这些工具的部署、安装都变得十分简单,这里我们就使用 docker-compose 工具,基于容器安装 Prometheus+Grafana 的组合。
-
第二步是性能剖析。要想优化程序,我们首先要找到可能影响程序性能的“瓶颈点”,这一步的任务,就是通过各种工具和方法找到这些“瓶颈点”。
-
第三步是代码优化。我们要针对上一步找到的“瓶颈点”进行分析,找出它们成为瓶颈的原因,并有针对性地实施优化。
-
第四步是与基准比较,确定优化效果。这一步,我们会采集优化后的程序的性能数据,与第一步的性能基准进行比较,看执行上述的优化措施后,是否提升了程序的性能。
-
- Go 程序优化的基本套路
优化(带缓存的网络I/O + 重用内存对象)
以Docker为代表的轻量级容器(container)的兴起,让这些工具的部署、安装都变得十分简单,这里我们就使用docker-compose工具,基于容器安装Prometheus+Grafana的组合。 我建议你使用一台Linux主机来安装这些工具,因为docker以及docker-compose工具,在Linux平台上的表现最为成熟稳定。我这里不再详细说明docker与docker-compose工具的安装方法了, 你可以参考docker(https://docs.docker.com/get-docker/)安装教程以及docker-compose(https://docs.docker.com/compose/install/)安装教程自行在Linux上安装这两个工具。
pprof(https://github.com/gperftools/gperftools)
Go 是“自带电池”(battery included)的语言,拥有着让其他主流语言羡慕的工具链,Go 同样也内置了对 Go 代码进行性能剖析的工具:pprof。