Mobile wallpaper 1
18765 字
94 分钟
GoLang 语言学习
2025-11-05
2025-11-07
统计加载中...

01 数据类型#

变量声明与赋值#

变量声明#

package main
import "fmt"
func main() {
var i int = 10
fmt.Println(i)
}

Go 语言中的变量定义都是名称在前,类型在后,和 C++ 反着来

变量 i 也可以用如下方式声明,省略定义类型,来自行推导:

var i = 10

也可以一次声明多个变量,放在 () 中,并且同样可以进行自动推导:

var (
j int = 0
k int = 1
)
var (
j = 0
k = 1
)

赋值#

赋值语句的作用是修改变量,使用 = 来进行赋值

变量的简短声明#

Go语言可以使用 变量名:=表达式 来进行简短声明,如果能为变量初始化,那就选择简短声明方式

例如:

i := 10
bf := false
s1 := "Hello"

数据类型#

整形#

有符号整形:int, int8, int16, int32 和 int64

无符号整形:uint, uint8, uint16, uint32 和 uint64

  • 字节 byte 类型等价于 uint8 类型,用于定义一个字节,也属于整形

浮点数#

最常用的是 float64,因为它的精度更高

浮点计算的结果相比于 float32 误差更小

package main
import "fmt"
func main() {
var f32 float32 = 2.2
var f64 float64 = 10.3456
fmt.Print("f32 is ", f32, ", f64 is ", f64)
}

布尔型#

布尔型的值只有两种:true 和 false,使用 bool 定义

package main
import "fmt"
func main() {
var bf bool = false
var bt bool = true
fmt.Print("bf is ", bf, ", bt is ", bt)
}

布尔型可以被用于一元操作符 ! ,二元操作符 &&||

字符串#

package main
import "fmt"
func main() {
var s1 string = "Hello"
var s2 string = "World!"
fmt.Println(s1, s2)
}

可以通过 + 来把字符串连接起来,也可以使用 +=== 等符号,类似 C++

特殊值与常量#

零值#

一个变量的默认值

常量#

关键字是 const ,一旦创建便不能修改,使用 = 来声明,比如

const name = "LANSGANBS"

Go语言中只允许布尔型,字符串,数字类型这种基本类型作为常量

iota#

这是一个常量生成器,初始化相似规则的常量,避免重复初始化

假如需要定义:

const (
one = 1
two = 2
three = 3
four = 4
)

可以写作

const (
one = iota + 1
two
three
four
)

其他重要概念#

指针#

指针对应变量在内存中的存储位置,也就是说指针的值就是变量的内存地址

例如:

pi := &i
fmt.Println(*pi)

pi 就是指向变量 i 的指针,想要获得指针 pi 指向的变量的值,则使用 *pi

变量类型转换#

GO 语言是强类型语言,不同类型的变量无法相互使用计算

不同类型的变量在赋值和计算前,需要先进行类型转换

对于字符串和数字类型,可以使用:

i2s := strconv.Itoa(i)
s2i, err := strconv.Atoi(i2s)

对于数字类型,可以使用:

i2f := float64(i)
f2i := int(i2f)

Strings 包#

strings 包适用于处理字符串的工具包,里面有很多常用函数。比如查找字符串,拆分字符串,去掉字符串的空格等。

例如:

s1 := "Hello World"
// 判断 s1 的前缀是否是 "H"
fmt.Println(strings.HasPrefix(s1, "H"))
// 判断 s1 中查找字符 "o"
fmt.Println(strings.Index(s1, "o"))
// 把 s1 全部转换为大写
fmt.Println(strings.ToUpper(s1))

输出为:

true
4
HELLO WORLD

02 控制结构#

if 条件语句#

例如:

func main() {
i := 10
if i > 10 {
fmt.Println("i > 10")
} else if i > 0 && i < 10 {
fmt.Println("0 < i <= 10")
} else {
fmt.Println("i <= 0")
}
}

和 C++ 不同的是:

  • if 后面的条件表达式不需要使用 ()
  • 每个条件分支中的大括号是必须的
  • if 紧跟的大括号不能独占一行,else 前后的大括号也不能独占一行

上面的代码可以进行折叠,等价于:

func main() {
if i := 10; i > 10 {
fmt.Println("i > 10")
} else if i > 0 && i < 10 {
fmt.Println("0 < i <= 10")
} else {
fmt.Println("i <= 0")
}
}

switch 选择语句#

上面的代码同样可以使用类似 C++ 的 switch 来替代,例如:

func main() {
switch i := 10; {
case i > 10:
fmt.Println("i > 10")
case i > 0 && i <= 10:
fmt.Println("0 < i <= 10")
default:
fmt.Println("i <= 0")
}
}

Go 语言的 switch 的 case 从上到下逐一进行判断,一旦满足条件,立即执行相对应的分支并返回(break),其余分支不进行判断,这与 C++ 不同

for 循环语句#

GO 语言的 for 循环,同样不需要 (),中间使用 ; 进行分隔

func main() {
sum := 0
for i := 1; i <= 100; i++ {
sum += i
}
fmt.Println(sum)
}

Go 语言中没有 while 循环,只能通过 for 循环来进行实现

例如对于上述例子可以写为:

func main() {
sum := 0
i := 1
for i <= 100 {
sum += i
}
fmt.Println(sum)
}

对于多组输入可以写为:

func main() {
t := 100
for t > 0 {
t--
}
}
  • continue 可以跳出本次循环,继续执行下一个循环
  • break 可以跳出整个循环,哪怕 for 循环没有执行完

03 集合类型#

Array 数组#

数组存放的是固定长度、相同类型的数据而且这些存放的元素是连续的,存放的数据类型没有限制

数组声明#

数组的声明和变量类似:

array := [5]string{"a", "b", "c", "d", "e"}

如果数组的所有元素已经确定,那么 [] 可以省略,例如:

array := []string{"a", "b", "c", "d", "e"}

可以对特定元素进行初始化,例如:

array := []string{1: "b", 3: "d"}

对于未初始化的位置,初始都是数组类型的零值

对于访问数组元素,和 C++ 类似,使用 [index] 来访问

数组循环#

大部分情况下,使用 for range 这种 Go 语言的新型循环,例如:

func main() {
array := []string{1: "a", 3: "c"}
for _, v := range array {
println(v)
}
}

类似于 C++ 的结构化绑定,对于上述循环,第一维表示下标,第二位表示数组的值,如果不需要某一维,使用 _ 来丢弃

Slice 切片#

基于数组生成切片#

可以理解动态数组,对任意数组分割,就可以得到一个切片,例如:

func main() {
array := []string{"a", "b", "c", "d", "e"}
slice := array[2:5]
fmt.Println(slice)
}

输出为:

[c d e]

这里的 slice 获取的是左闭右开区间的数组,切片和数组一样,也可以通过索引获取元素

需要注意的是,切片的索引范围对比原数组改变了,也是从 0 开始的

使用 slice 时,数组的 start 和 end 都是可以省略的,如果省略,则默认为 0 和数组的长度

alt text

切片修改#

和数组一样,可以使用 = 来修改

切片声明#

可以使用 make 来声明切片,例如:

slice1 := make([]string, 4, 8)

4 为切片的长度,8 为切片的容量,第三个参数可以省略,默认容量等于长度

同时切片也可以是使用字面量的方式来进行初始化,和数组一样,例如:

slice1 := []string{"a", "b", "c", "d", "e"}

区别只在 [] 中的长度

Append#

新切片 := append(原切片, 要添加的元素/元素列表/另一个切片...)

例如:

func main() {
slice := []string{"Hello World"}
slice1 := []string{"a", "b", "c", "d", "e"}
slice1 = append(slice1, "f", "g", "h")
slice2 := append(slice1, "i", "j", "k")
slice3 := append(slice1, slice2...)
fmt.Println(slice)
fmt.Println(slice1)
fmt.Println(slice2)
fmt.Println(slice3)
}

输出:

[Hello World]
[a b c d e f g h]
[a b c d e f g h i j k]
[a b c d e f g h a b c d e f g h i j k]

切片元素循环#

跟数组是相同的,可以使用 for 循环和 for range

Map 映射#

map是一个无序的K-V键值对集合结构为 map[K]V,其中K对应Key,V对应Value

Map 的声明初始化#

可以使用 make 来声明 map,例如:

func main() {
nameAgeMap := make(map[string]int)
nameAgeMap["LANSGANBS"] = 20
}

同时 map 也可以是使用字面量的方式来进行初始化,和数组一样,例如:

func main() {
nameAgeMap := map[string]int{"LANSGANBS": 20}
println(nameAgeMap["LANSGANBS"])
}

Map 的获取和删除#

获取类似数组的遍历,删除可以使用内置的 delete 函数

func main() {
nameAgeMap := make(map[string]int)
nameAgeMap["LANSGANBS"] = 20
age, ok := nameAgeMap["LANSGANBS"]
if ok {
fmt.Println(age)
}
delete(nameAgeMap, "LANSGANBS")
age, ok = nameAgeMap["LANSGANBS"]
if ok {
fmt.Println(age)
}
}

输出:

20

遍历 Map#

获取类似数组的遍历

func main() {
nameAgeMap := make(map[string]int)
nameAgeMap["LANSGANBS"] = 20
nameAgeMap["LANSGANBS1"] = 21
nameAgeMap["LANSGANBS2"] = 22
for name, age := range nameAgeMap {
fmt.Println("name:", name, " age:", age)
}
}

Map 的大小#

可以使用内置的 len 函数来获取

fmt.Println(len(nameAgeMap))

String 和 []byte#

string 的操作类似 C++,可以使用 [] 来获取元素

Go 语言的 string 是不可变的,类似于 Java 的 string

如果需要修改字符串,可以通过以下方式间接实现:

  • 将字符串转换为字节切片([]byte 或字符切片([]rune,修改切片后再转换回字符串
s := "hello"
bytes := []byte(s)
bytes[0] = 'H'
s = string(bytes)
fmt.Println(s) // 输出 "Hello"
  • 利用字符串拼接、切片操作等方式生成新的字符串
s := "hello"
s = "H" + s[1:]
fmt.Println(s) // 输出 "Hello"

04 函数和方法#

通过函数,可以把开发任务分解成一个个小的单元,这些小单元可以被其他单元复用,进而提高开发效率、降低代码重合度

函数#

  • 任何一个函数的定义都有一个 func 的关键字,用于声明一个函数
  • 定义一个合法的函数名字
  • 函数后的 () 是不可省略的,大括号 {} 也不可省略

函数的定义如下:

func funcName(params) result {
body
}
  • func 关键字
  • funcName 函数名
  • params 函数的参数
  • result 函数的返回值
  • body 函数体

例如:

func sum(a int, b int) int {
return a + b
}

多值返回#

Go语言的函数可以返回多个值,也就是多值返回

例如对于一个函数,第一个值返回函数的结果,第二个值返回函数出错的信息

例如,对于上述 sum 函数,我们不允许传参为负数,可以按照如下改造:

func sum(a int, b int) (int, error) {
if a < 0 || b < 0 {
return 0, errors.New("a 或者 b 不能为负数")
}
return a + b, nil
}
func main() {
result, err := sum(1, 2)
if err != nil {
fmt.Println("错误:", err)
} else {
fmt.Println("结果:", result)
}
}

需要注意的是,如果函数有多值返回,那么需要有多个变量来接受函数的值

同样如果接受的值不需要,可以使用 _ 来忽略掉

例如:result, _ := sum(1, 2)

命名返回参数#

函数的返回值也可以有变量名称,例如:

func sum(a int, b int) (sum int, err error) {
if a < 0 || b < 0 {
return 0, errors.New("a 或者 b 不能为负数")
}
sum = a + b
err = nil
return
}
func main() {
result, err := sum(114, 514)
if err != nil {
fmt.Println("错误:", err)
} else {
fmt.Println("结果:", result)
}
}

这样我们就可以在函数体内使用它们,这样只需要写一个 return 即可,因为sum = a + berr = nil 就相当于 return 了这两个数

但这种方法并不常用

可变参数#

函数的参数数量是可变的,类似 C++ 的折叠表达式,例如:

func sum(a ...int) int {
sum := 0
for _, v := range a {
sum += v
}
return sum
}
func main() {
result := sum(114, 514)
fmt.Println("结果:", result)
}

这里相当于 sum 接受了一个切片 a,类型为 int,所以可以使用 for range 进行循环

如果传入的参数中,同时有可变参数和普通参数,那么可变参数一定要写在参数列表的最后

包级函数#

不管是自定义的函数 sum、sum1,还是我们使用到的函数 Println,都会从属于一个包也就是 package

不同包的函数要被调用,那么函数的作用域必须是公有的也就是函数名称的首字母要大写

  • 函数名称首字母小写代表私有函数,只有在同一个包内才能调用
  • 函数名称首字母大写表示共有函数,不同的包也可以调用
  • 任何一个函数都会从属于一个包

Go 语言中,没有类似 C++ 的 private,public来修饰,是通过首字母大小写来辨别的

匿名函数和闭包#

匿名函数就是没有名称的函数,类似于 C++ 的 lambda 表达式,例如:

func main() {
sum := func(a ...int) int {
sum := 0
for _, v := range a {
sum += v
}
return sum
}
fmt.Println(sum(114, 514))
}

在函数中再定义函数(函数嵌套),定义的这个匿名函数,也可以称为内部函数

更重要的是,在函数内定义的内部函数,可以使用外部函数的变量等,这种方式也称为闭包

方法#

Go 的语法规定:方法的接收者类型必须是 “自定义类型”

方法必须要有一个接收者,这个接收者是一个类型,这样方法就和这个类型绑定在一起,称为这个类型的方法,例如:

type Age uint
func (age Age) String() {
fmt.Println("the age is", age)
}

和函数不同,定义方法是会在关键字 func 和方法名 String() 之间加一个接受者,接受者使用 () 包围

接收者的定义和普通变量、函数参数等一样,前面是变量名,后面是接收者类型

此时 String() 就是类型 Age 的方法,定义之后,就可以通过 . 来调用方法,这类似 C++ 的 class,例如:

func main() {
age := Age(20)
age.String()
}

值类型接受者和指针类型接收者#

方法的接受者也可以传入指针类型,例如:

type Age uint
func (age *Age) String() {
*age++
fmt.Println("the age is", *age)
}
func main() {
age := Age(20)
age.String()
}
  • 如果使用一个值类型变量调用指针类型接收者的方法,Go语言编译器会自动帮我们取指针调用

于是上面的方法等价于:

type Age uint
func (age *Age) String() {
*age++
fmt.Println("the age is", *age)
}
func main() {
age := Age(20)
(&age).String()
}
  • 如果使用一个指针类型变量调用值类型接收者的方法,Go语言编译器会自动帮我们解引用调用

方法表达式#

方法赋值给变量,称之为方法表达式,例如:

type Age uint
func (age Age) String() {
fmt.Println("the age is", age)
}
func main() {
age := Age(20)
sm := Age.String
sm(age)
}

不管方法是否有参数,通过方法表达式调用,第一个参数必须是接受者,然后才是方法自身的参数,例如:

type Age uint
func (age Age) String(name string) {
fmt.Println("Age is:", age, ", Name is:", name)
}
func main() {
age := Age(20)
sm := Age.String
name := "LANSGANBS"
sm(age, name)
}

对于 sm 必须先传入 age,再传入 name

05 strust 和 interface#

整形,字符串只能描述单一对象,如果是聚合对象,就无法描述了,这时需要用到结构体,接口,工厂函数等

strust 结构体#

结构体是一种聚合类型,里面可以包含任意类型的值,这些值就是我们定义的结构体的成员,也称为字段

定义结构体需要用到 type + struct 关键词组合,例如:

type person struct {
name string
age int
}

结构体的声明使用#

定义之后,就可以通过 . 来调用方法,这类似 C++ 的 struct

type person struct {
name string
age int
}
func main() {
var p1 person
fmt.Println(p1.name, p1.age)
p2 := person{name: "Alice", age: 30}
fmt.Println(p2.name, p2.age)
p3 := person{"Bob", 25}
fmt.Println(p3.name, p3.age)
}

如果在定义时明确结构体的字段,那么定义的顺序可以调换;否则需要严格对应

字段结构体#

可以理解为结构体嵌套结构体,例如:

type person struct {
name string
age int
addr address
}
type address struct {
city string
state string
}

这里 person 嵌套了一个 address,初始化时对对应字段初始化即可,例如对于上述结构体初始化:

func main() {
p1 := person{
name: "LANSGANBS",
age: 20,
addr: address{
city: "HRB",
state: "HLJ",
},
}
fmt.Println(p1)
}

interface 接口#

接口是和调用方的一种约定它是一个高度抽象的类型,不用和具体的实现细节绑定在一起,就是给不同类型定的统一标准,只要类型符合标准(实现了接口的所有方法),就能被需要这个标准的地方使用,不用管类型本身是什么。

定义结构体需要用到 type + interface 关键词组合,例如:

package main
import "fmt"
type person struct {
age int
name string
addr address
}
type address struct {
city string
country string
}
type Stringer interface {
String() string
}
func (addr address) String() string {
return fmt.Sprintf("the city is %s, country is %s", addr.city, addr.country)
}
func (p person) String() string {
return fmt.Sprintf("the name is %s, age is %d", p.name, p.age)
}
func printString(s fmt.Stringer) {
fmt.Println(s.String())
}
func main() {
addr := address{city: "Beijing", country: "China"}
printString(addr)
p := person{
name: "Alice",
age: 20,
addr: addr,
}
printString(p)
}

对于上述代码做如下解释:

核心接口:fmt.Stringer#

代码中显式写出了 fmt.Stringer 接口的定义(它本质是 Go 标准库的内置接口,这里显式声明是为了更清晰):

type Stringer interface {
String() string // 接口仅包含一个方法签名:无参数,返回 string 类型
}
  • 接口是方法签名的集合,它不关心类型的具体实现,只规定 “必须实现哪些方法”。
  • fmt.Stringer 的核心作用是:为类型提供 “自定义字符串描述” 的标准方式,很多标准库函数(如 fmt.Print 系列)会自动调用该接口的 String() 方法。

接口的 “隐式实现” 规则#

Go 语言中,类型实现接口无需显式声明(比如 type person struct implements Stringer),只要满足一个条件:该类型实现了接口中所有的方法,就默认实现了这个接口。

address 类型实现了 Stringer 接口#

代码中为 address 定义了 String() string 方法:

func (addr address) String() string {
return fmt.Sprintf("the city is %s, country is %s", addr.city, addr.country)
}
  • 该方法的签名(方法名、参数、返回值)与 Stringer 接口要求的完全一致。
  • 因此,address 类型隐式实现了 Stringer 接口,address 实例可以被当作 Stringer 接口类型使用。

person 类型也实现了 Stringer 接口#

同样,person 类型也定义了符合要求的 String() string 方法:

func (p person) String() string {
return fmt.Sprintf("the name is %s, age is %d", p.name, p.age)
}
  • 方法签名与 Stringer 接口完全匹配,person 类型也隐式实现了 Stringer 接口。

接口的 “多态” 特性#

多态的核心是:同一接口类型的变量,可以接收不同的实现类型实例,调用方法时会执行对应类型的具体实现

代码中的 printString 函数体现了这一点:

func printString(s fmt.Stringer) { // 参数类型是 Stringer 接口
fmt.Println(s.String()) // 调用接口的 String() 方法
}
  • 函数参数 s 是 Stringer 接口类型,它不绑定具体的实现类型,只要是实现了 Stringer 接口的类型,都能作为参数传入。
  • 调用 s.String() 时,会根据传入的实际实例类型,执行该类型对应的 String() 方法(而非接口的 “默认实现”,因为接口本身没有实现)。

06 错误处理#

错误是可以预期的,并且不是非常严重,不会影响程序的进行

error 接口#

在 Go 语言中,错误是通过内置的 error 接口表示的,例如:

type error interface {
Error() string
}

error 工厂函数#

也可以在函数内部返回错误信息给调用者,例如之前提到的:

func sum(a int, b int) (int, error) {
if a < 0 || b < 0 {
return 0, errors.New("a 或者 b 不能为负数")
}
return a + b, nil
}

自定义 error#

采用工厂返回错误信息的方式只能传递一个字符串,如果想返回更多信息,可以自定义 error,先定义一个新类型(比如结构体),然后让这个类型实现 error 接口,如下面的代码所示:

package main
import "fmt"
type commonError struct {
errorCode int
errorMsg string
}
func (ce *commonError) Error() string {
return ce.errorMsg
}
func add(a, b int) (int, error) {
if a < 0 || b < 0 {
return 0, &commonError{
errorCode: 1,
errorMsg: "a 或 b 不能为负数",
}
}
return a + b, nil
}
func main() {
result, err := add(-1, 2)
if err != nil {
fmt.Println("Error:", err)
} else {
fmt.Println("Result:", result)
}
}

error 断言 / 类型断言#

有了自定义的 error,并且携带了更多的错误信息后就可以使用这些信息了,先把返回的 error 接口转换为自定义的错误类型,例如:

func main() {
sum, err := add(1, -2)
if cm, ok := err.(*commonError); ok {
println("错误代码:", cm.errorCode)
println("错误信息:", cm.errorMsg)
} else {
println("计算结果:", sum)
}
}

Error Wrapping#

Error Wrapping(错误包装)是一种在 Go 1.13 中引入的机制,它允许你基于一个已存在的 error 生成一个新的 error,同时保留对原始错误的引用。这样做的好处是可以在不丢失原始错误信息的情况下,为错误添加更多的上下文信息,非常便于调试。

实现错误包装通常使用 fmt.Errorf 函数配合 %w 占位符,例如:

package main
import (
"errors"
"fmt"
"os"
)
func readFile(path string) error {
_, err := os.Open(path)
if err != nil {
return err
}
return nil
}
func processFile(path string) error {
err := readFile(path)
if err != nil {
return fmt.Errorf("处理文件失败: %w", err)
}
return nil
}
func main() {
err := processFile("non_existent_file.txt")
if err != nil {
fmt.Println("捕获到错误:", err)
}
}

输出:

捕获到错误: 处理文件失败: open non_existent_file.txt: no such file or directory

在上面的例子中,processFile 函数捕获到 readFile 返回的原始错误,并使用 fmt.Errorf%w 将其包装起来,形成了一个更具描述性的新错误。

errors.Unwrap#

Go 语言提供了 errors.Unwrap 函数,用于获取被包装(wrapped)的内部错误。如果一个错误没有包装其他错误,errors.Unwrap 会返回 nil

我们可以利用这个函数来层层解析被包装的错误,直到找到最原始的那个,例如:

package main
import (
"errors"
"fmt"
"os"
)
func main() {
originalErr := os.ErrNotExist
wrappedErr1 := fmt.Errorf("读取配置时出错: %w", originalErr)
wrappedErr2 := fmt.Errorf("启动应用失败: %w", wrappedErr1)
err := wrappedErr2
for err != nil {
fmt.Println("当前错误:", err)
err = errors.Unwrap(err)
}
}

输出:

当前错误: 启动应用失败: 读取配置时出错: file does not exist
当前错误: 读取配置时出错: file does not exist
当前错误: file does not exist

errors.Is#

errors.Is 函数用于判断一个错误链(error chain)中是否包含特定的目标错误。它会遍历被包装的错误,检查链上的任何一个错误是否与目标错误匹配。这比简单的 err == targetErr 更强大,因为它能处理错误包装的情况。

package main
import (
"errors"
"fmt"
)
var ErrDataAccess = errors.New("数据访问失败")
func fetchData() error {
return fmt.Errorf("数据库连接错误: %w", ErrDataAccess)
}
func main() {
err := fetchData()
if errors.Is(err, ErrDataAccess) {
fmt.Println("错误是数据访问类型错误")
} else {
fmt.Println("未知类型错误")
}
if err == ErrDataAccess {
fmt.Println("直接比较成功")
} else {
fmt.Println("直接比较失败")
}
}

输出:

错误是数据访问类型错误
直接比较失败

errors.As#

errors.As 函数用于检查错误链中是否有特定类型的错误。如果找到,它会将该错误赋值给一个目标变量并返回 true。这类似于类型断言,但同样可以穿透错误包装。

这在我们使用自定义错误结构体时非常有用,因为它允许我们获取到自定义错误并访问其字段,例如:

package main
import (
"errors"
"fmt"
)
type commonError struct {
errorCode int
errorMsg string
}
func (ce *commonError) Error() string {
return ce.errorMsg
}
func doSomething() error {
baseErr := &commonError{
errorCode: 1001,
errorMsg: "权限不足",
}
return fmt.Errorf("操作失败: %w", baseErr)
}
func main() {
err := doSomething()
var ce *commonError
if errors.As(err, &ce) {
fmt.Printf("捕获到自定义错误! 错误码: %d, 错误信息: %s\n", ce.errorCode, ce.errorMsg)
} else {
fmt.Println("未捕获到自定义错误")
}
}

输出:

捕获到自定义错误! 错误码: 1001, 错误信息: 权限不足

defer#

defer 关键字用于延迟一个函数或方法的执行。被 defer 的函数调用会在其所在的函数执行完毕即将返回之前被执行。defer 通常用于执行清理操作,如关闭文件、释放锁、关闭数据库连接等。

一个函数中可以有多个 defer 语句,它们的执行顺序是后进先出(LIFO, Last-In-First-Out),例如:

package main
import (
"fmt"
"os"
)
func main() {
file, err := os.Create("test.txt")
if err != nil {
fmt.Println("创建文件失败:", err)
return
}
defer file.Close()
fmt.Println("defer 示例开始")
defer fmt.Println("这是第一个 defer (最后执行)")
defer fmt.Println("这是第二个 defer (先执行)")
file.WriteString("Hello, defer!")
fmt.Println("文件写入成功")
}

输出:

defer 示例开始
文件写入成功
这是第二个 defer (先执行)
这是第一个 defer (最后执行)

Panic 异常#

Panic 是一种 Go 语言内置的函数,用于表示程序遇到了无法处理的、灾难性的错误,导致程序无法继续正常运行。当 panic 被调用时:

  1. 程序的正常执行流程会立即停止。
  2. 开始执行当前 Goroutine 中所有被延迟(defer)的函数。
  3. 执行完所有 defer 后,程序崩溃并打印出 panic 信息和堆栈跟踪。

Panic 应该被非常谨慎地使用,它不应用于常规的错误处理。常规错误(如文件未找到、网络超时)应该通过返回 error 值来处理,严重错误例如:

package main
import "fmt"
func riskyOperation() {
fmt.Println("开始执行危险操作...")
panic("灾难发生了!")
fmt.Println("这行代码永远不会被执行")
}
func main() {
fmt.Println("程序开始")
riskyOperation()
fmt.Println("程序结束 (这行也不会被执行)")
}

输出:

程序开始
开始执行危险操作...
panic: 灾难发生了!
...(堆栈跟踪信息)...

Recover 捕获 Panic 异常#

recover 是一个内置函数,用于重新获得对一个正在 panic 的 Goroutine 的控制权。recover 只有在 defer 函数内部被直接调用时才有效。

defer 函数中的 recover 被调用时:

  • 它会捕获当前的 panic 值。
  • 停止 panic 的继续传播。
  • 函数的执行流程会从 defer 语句所在函数的返回点继续。

这个 panic/recover 机制通常用于防止单个请求处理失败导致整个服务崩溃,或者用于将库的内部 panic 转换为一个 error 返回给调用者,例如:

package main
import "fmt"
func protector() {
defer func() {
fmt.Println("进入 defer...")
if r := recover(); r != nil {
fmt.Println("成功捕获到 panic:", r)
}
fmt.Println("defer 执行完毕")
}()
fmt.Println("执行 protector 函数")
panic("触发一个 panic!")
fmt.Println("这行代码不会执行")
}
func main() {
fmt.Println("主函数开始")
protector()
fmt.Println("主函数结束 (因为 panic 被捕获,所以这行会执行)")
}

输出:

主函数开始
执行 protector 函数
进入 defer...
成功捕获到 panic: 触发一个 panic!
defer 执行完毕
主函数结束 (因为 panic 被捕获,所以这行会执行)

panicrecover 提供了一种处理真正异常情况的机制,但不应滥用它来代替常规的 error 处理。最佳实践是,只在包的边界或者程序的最高层(如 main 函数或 HTTP 服务器的 handler)使用 recover 来防止程序崩溃。

07 并发基础#

什么是并发#

同一时刻做了两件事,在编程中这就是并发,并发可以让你编写的程序,在同一时刻做多几件事情

进程和线程#

操作系统会为这个软件创建一个进程,这个进程是该软件的工作空间,它包含了软件运行所需的所有资源

线程是进程的执行空间,一个进程可以有多个线程,线程被操作系统调度执行

Goroutine 协程#

Go 语言没有线程的概念,只有协程

Go 语言的并发是由 Go 自己调度的,自己决定同时执行多少个 Goroutine,什么时候执行几个

协程使用 go function() 来启动,function 是一个方法或者函数的调用,例如:

func main() {
go fmt.Println("LANSGANBS")
fmt.Println("我是 main goroutine")
time.Sleep(time.Second)
}

输出:

我是 main goroutine
LANSGANBS

Channel 通道#

如果启动了多个协程,它们之间可以通过 Channel 通道来进行通信

声明一个 Channel#

在 Go 语言中,声明一个通道很简单,使用 make 关键字,例如:

ch := make(chan string)

chan 是一个关键字,表示是 channel 类型,string 表示 channel 里的数据是 string 类型,chan 是一个集合类型

一个通道的操作只有两种,接受和发送

  • 接收:获取 chan 中的值,操作符为 <-chan
  • 发送:向 chan 发送值,把值放在 chan 中,操作符为 chan<-

例如:

func main() {
ch := make(chan string)
go func() {
fmt.Println("LANSGANBS")
ch <- "goroutine 完成"
}()
fmt.Println("我是 main goroutine")
v := <-ch
fmt.Println("接收到的 chan 中的值为:", v)
}

输出:

我是 main goroutine
LANSGANBS
接收到的 chan 中的值为: goroutine 完成

之所以输出顺序是这样的,是因为通过 make 创建的 chan 中没有值,而 maingoroutine 又想从 chan 中获取值获取不到就一直等待,等到另一个 goroutine 向 chan 发送值为止

无缓冲 Channel#

容量是 0,不能存储任何数据,所以无缓冲 channel 只起到传输数据的作用,数据并不会在 channel 中做任何停留,也可以称为同步 channel

有缓冲 Channel#

alt text

  • 有缓冲 Channel 的内部有一个缓冲队列
  • 发送操作是向队列的尾部插入元素如果队列已满,则阻塞等待,直到另一个 goroutine 执行,接收操作释放队列的空间
  • 接收操作是从队列的头部获取元素并把它从队列中删除如果队列为空,则阻塞等待,直到另一个 goroutine 执行发送操作插入新的元素

例如:

func main() {
cacheCh := make(chan int, 5)
cacheCh <- 1
cacheCh <- 2
cacheCh <- 3
fmt.Println("cacheCh容量:", cap(cacheCh), "元素个数:", len(cacheCh))
}

输出:

cacheCh容量: 5 元素个数: 3

关闭 Channel#

通道可以被创建,同时可以被关闭,使用 close() 来关闭

例如:

close(cacheCh)

如果一个通道被关闭,就不能再向其中发送数据,如果发送就会引起异常;但是可以接收数据,如果通道里没有数据,那么接收到的数据就是 channel 存储类型的零值

单向 Channel#

限制一个 channel 只可以接收但是不能发送,或者限制一个 channel 只能发送但不能接收,这种 channel 称为单向 channel

在声明单向 Channel 的时候,只需要加上箭头操作符即可,例如:

onlySend := make(chan<- int, 5)
onlyReceive := make(<-chan int, 5)

select + channel 示例#

启动了 3 个 goroutine 进行下载,并把结果发送到 3 个 channel 中,哪个先下载好,就会使用哪个 channel 的结果

这时可以使用多路复用,声明如下所示:

select {
case i1 = <-c1:
// todo
case i2 = <-c2:
// todo
case c3 <- i3:
// todo
default:
}

可以理解为, n 个通道中任意一个通道有数据产生,select 就可以监听到,然后执行对应分支,例如:

func main() {
firstCh := make(chan string)
secondCh := make(chan string)
thirdCh := make(chan string)
go func() {
firstCh <- downloadFile("firstCh")
}()
go func() {
secondCh <- downloadFile("secondCh")
}
go func() {
thirdCh <- downloadFile("thirdCh")
}
select {
case filePath := <-firstCh:
fmt.Println(filePath)
case filePath := <-secondCh:
fmt.Println(filePath)
case filePath := <-thirdCh:
fmt.Println(filePath)
}
}

提倡通过通信来共享内存,而不是通过共享内存来通信

08 同步原语#

在某些场景下,我们仍然需要通过共享内存的方式来完成并发任务,例如多个 Goroutine 需要同时读写一个共享的变量

如果不加任何控制,就会产生“竞态条件”(Race Condition),导致程序结果不可预测

同步原语就是用来解决这类问题的,它们位于 sync 包中,可以帮助我们协调 Goroutine,安全地访问共享资源

Mutex 互斥锁#

Mutex 是 “Mutual Exclusion”(互斥)的缩写。当一个 Goroutine 获取了锁之后,其他尝试获取该锁的 Goroutine 就会被阻塞,直到锁被释放。这确保了同一时刻只有一个 Goroutine 可以访问被保护的共享资源。

sync.Mutex 提供了两个方法:

  • Lock():获取锁
  • Unlock():释放锁

我们来看一个未使用锁导致问题的例子:

var count int
func add() {
count++
}
func main() {
// 启动 1000 个 goroutine 来增加 count
for i := 0; i < 1000; i++ {
go add()
}
time.Sleep(2 * time.Second)
fmt.Println("count 的值为:", count)
}

输出:

count 的值为: 941

多次运行,你会发现 count 的值基本都不是 1000。这是因为 count++ 不是一个原子操作,导致了数据竞争

现在我们使用 Mutex 来修复它:

var count int
var lock sync.Mutex // 声明一个互斥锁
func addWithLock() {
lock.Lock() // 操作前加锁
count++
lock.Unlock() // 操作后解锁
}
func main() {
for i := 0; i < 1000; i++ {
go addWithLock()
}
time.Sleep(2 * time.Second)
fmt.Println("加锁后 count 的值为:", count)
}

输出:

加锁后 count 的值为: 1000

通过加锁,我们保证了 count++ 操作的原子性,得到了正确的结果

注意:必须在 Lock() 后使用 defer lock.Unlock() 来确保锁一定会被释放,即使函数发生 panic

RWMutex 读写锁#

sync.RWMutex(读写锁)是对 Mutex 的一种优化。它将访问分为“读操作”和“写操作”

  • 多个 Goroutine 可以同时获取读锁
  • 只有一个 Goroutine 可以获取写锁
  • 当写锁被持有时,所有其他读锁和写锁的获取都会被阻塞

读写锁适用于“读多写少”的场景,可以大大提高并发性能

它提供了以下方法:

  • RLock() / RUnlock():获取/释放读锁
  • Lock() / Unlock():获取/释放写锁
var (
count int
rwLock sync.RWMutex
)
// 写操作
func write() {
rwLock.Lock() // 加写锁
defer rwLock.Unlock()
count++
fmt.Println("正在执行写操作...")
time.Sleep(time.Millisecond * 10)
}
// 读操作
func read(i int) {
rwLock.RLock() // 加读锁
defer rwLock.RUnlock()
fmt.Printf("Goroutine %d 正在读取数据: count = %d\n", i, count)
time.Sleep(time.Millisecond)
}
func main() {
// 启动一个写 goroutine
go write()
// 启动多个读 goroutine
for i := 0; i < 5; i++ {
go read(i)
}
time.Sleep(time.Second * 2)
}

在上面的例子中,多个 read Goroutine 可以并发执行,而 write Goroutine 会等待所有读操作完成后再执行,执行期间会阻塞其他所有读写操作

WaitGroup 等待组#

sync.WaitGroup 用于等待一组 Goroutine 全部完成。主 Goroutine 可以调用 Wait() 方法来阻塞自己,直到所有其他 Goroutine 都完成了任务

它有三个主要方法:

  • Add(n int):等待组的计数器加 n,表示有 n 个任务需要等待
  • Done():计数器减 1,通常在 Goroutine 完成任务时通过 defer 调用。它等价于 Add(-1)
  • Wait():阻塞当前 Goroutine,直到计数器归零
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done() // 完成后通知 WaitGroup
fmt.Printf("工人 %d 开始工作\n", id)
time.Sleep(time.Second)
fmt.Printf("工人 %d 完成工作\n", id)
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1) // 每启动一个 goroutine,计数器加 1
go worker(i, &wg)
}
fmt.Println("Main Goroutine 等待所有工人完成工作...")
wg.Wait() // 等待所有 goroutine 完成
fmt.Println("所有工人都已完成工作,Main Goroutine 退出")
}

输出(不唯一):

工人 1 开始工作
工人 2 开始工作
Main Goroutine 等待所有工人完成工作...
工人 3 开始工作
工人 1 完成工作
工人 3 完成工作
工人 2 完成工作
所有工人都已完成工作,Main Goroutine 退出

Once#

sync.Once 可以保证某个函数在程序的整个生命周期中只被执行一次,即使在多个 Goroutine 中被并发调用。这对于单例对象的初始化或者只需要执行一次的设置非常有用

它只有一个方法:

  • Do(f func()):如果 f 从未被调用过,则执行 f
var once sync.Once
func initialize() {
fmt.Println("初始化操作只执行一次。")
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
once.Do(initialize)
}()
}
wg.Wait()
fmt.Println("所有 Goroutine 执行完毕。")
}

输出:

初始化操作只执行一次。
所有 Goroutine 执行完毕。

无论多少个 Goroutine 调用 once.Do(initialize)initialize 函数都只会被执行一次

Cond 条件变量#

sync.Cond 用于在 Goroutine 之间同步和通信。它使得一个 Goroutine 可以等待某个条件成立,而另一个 Goroutine 在条件成立时通知等待者

Cond 必须和一个 Locker(通常是 *sync.Mutex)关联使用

主要方法:

  • Wait():自动释放持有的锁并阻塞等待。当被唤醒时,它会重新获取锁
  • Signal():唤醒一个正在等待的 Goroutine
  • Broadcast():唤醒所有正在等待的 Goroutine

下面是一个简单的生产者-消费者模型示例:

func main() {
var lock sync.Mutex
cond := sync.NewCond(&lock)
queue := make([]int, 0, 10)
// 生产者
for i := 0; i < 10; i++ {
go func(i int) {
cond.L.Lock()
defer cond.L.Unlock()
queue = append(queue, i)
fmt.Printf("生产者放入: %d\n", i)
cond.Signal() // 通知一个消费者
}(i)
}
// 消费者
for i := 0; i < 10; i++ {
go func(i int) {
cond.L.Lock()
defer cond.L.Unlock()
// 如果队列为空,则等待
for len(queue) == 0 {
fmt.Printf("消费者 %d 等待中...\n", i)
cond.Wait()
}
item := queue[0]
queue = queue[1:]
fmt.Printf("消费者 %d 取出: %d\n", i, item)
}(i)
}
time.Sleep(2 * time.Second)
}

在这个例子中,消费者在队列为空时调用 cond.Wait() 等待,生产者在放入数据后调用 cond.Signal() 唤醒一个等待的消费者。for len(queue) == 0 循环是必要的,因为 Wait() 被唤醒时不一定代表条件就满足了(可能被其他 Goroutine 抢先)

09 Context 上下文#

Context 是 Go 语言中用于在 API 边界和 Goroutine 之间传递请求范围值、取消信号和超时/截止日期的一种标准机制。它对于构建健壮、可控的网络服务和并发程序至关重要

Context 本质上是不可变的,每次派生(如添加超时或值)都会创建一个新的 Context 实例,形成一个树状结构

Context 接口#

Context 是一个接口类型,只有四个方法:

type Context interface {
// Done 返回一个只读的 channel,当 context 被取消或超时时,该 channel 会被关闭
Done() <-chan struct{}
// Err 在 Done() channel 关闭后,返回 context 被取消的原因
// 如果是因取消操作,返回 Canceled;如果是因超时,返回 DeadlineExceeded
Err() error
// Deadline 返回 context 的截止时间。如果没有设置截止时间,ok 会是 false
Deadline() (deadline time.Time, ok bool)
// Value 返回与此 context 关联的键的值,或在没有关联值时返回 nil
Value(key interface{}) interface{}
}

创建根 Context #

alt text

所有的 Context 都派生自一个根 Context。Go 提供了两个函数来创建根 Context

  • context.Background():通常用于 main 函数、初始化和测试中,作为最顶层的 Context,它永远不会被取消
  • context.TODO():当你不确定要使用哪个 Context 或者函数将来可能需要接收一个 Context 时,可以使用它。它本质上是一个占位符
// 示例:创建根 Context
bgCtx := context.Background()
todoCtx := context.TODO()
fmt.Printf("background context: %v\n", bgCtx)
fmt.Printf("todo context: %v\n", todoCtx)

WithCancel:可取消的 Context#

context.WithCancel 函数创建一个可被显式取消的 Context

  • 函数签名func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
  • 它返回一个新的 Context 和一个 CancelFunc 函数。调用这个 cancel 函数会向所有派生自此 Context 的 Goroutine 发送取消信号
  • 重要:必须调用 cancel 函数以释放资源,通常使用 defer
package main
import (
"context"
"fmt"
"time"
)
func worker(ctx context.Context, name string) {
for {
select {
case <-ctx.Done(): // 监听取消信号
fmt.Printf("%s 收到取消信号,停止工作。\n", name)
return
default:
fmt.Printf("%s 正在工作中...\n", name)
time.Sleep(500 * time.Millisecond)
}
}
}
func main() {
// 创建一个可取消的 context
ctx, cancel := context.WithCancel(context.Background())
// 启动一个 worker goroutine
go worker(ctx, "Worker-1")
// 让 worker 运行 2 秒
time.Sleep(2 * time.Second)
// 发送取消信号
fmt.Println("Main Goroutine: 发送取消信号!")
cancel()
// 等待一小段时间,确保 worker 收到信号并退出
time.Sleep(1 * time.Second)
fmt.Println("Main Goroutine: 退出。")
}

输出:

Worker-1 正在工作中...
Worker-1 正在工作中...
Worker-1 正在工作中...
Worker-1 正在工作中...
Main Goroutine: 发送取消信号!
Worker-1 收到取消信号,停止工作。
Main Goroutine: 退出。

WithTimeout:定时取消的 Context / WithDeadline:带超时的 Context#

WithTimeout 和 WithDeadline 用于创建一个在指定时间后会自动取消的 Context

  • WithTimeout(parent Context, timeout time.Duration):在 timeout 之后自动取消
  • WithDeadline(parent Context, d time.Time):在绝对时间 d 到达时自动取消

这对于控制数据库查询、API 调用等有时间限制的操作非常有用

package main
import (
"context"
"fmt"
"time"
)
func longRunningTask(ctx context.Context) {
fmt.Println("任务开始...")
select {
case <-time.After(3 * time.Second): // 模拟一个需要 3 秒才能完成的任务
fmt.Println("任务正常完成。")
case <-ctx.Done(): // 监听超时/取消信号
fmt.Printf("任务被中断: %v\n", ctx.Err())
}
}
func main() {
// 创建一个 2 秒后超时的 context
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel() // 及时释放资源
longRunningTask(ctx)
}

输出:

任务开始...
任务被中断: context deadline exceeded

WithValue:传递请求范围的值#

WithValue 用于在 Context 中附加键值对数据,这些数据可以在整个调用链中被传递和访问

  • 适用场景:传递请求 ID、用户身份信息、追踪 ID 等
  • 注意WithValue 应谨慎使用。不要用它来传递函数的可选参数,这会使代码可读性变差。其 key 应该使用自定义的、不可导出的类型,以避免键冲突
package main
import (
"context"
"fmt"
)
// 使用自定义类型作为 key,防止命名冲突
type contextKey string
const userIDKey = contextKey("userID")
func processRequest(ctx context.Context) {
// 从 context 中获取值
userID := ctx.Value(userIDKey)
if userID != nil {
fmt.Printf("处理请求,用户 ID 是: %d\n", userID)
} else {
fmt.Println("处理请求,但未找到用户 ID。")
}
}
func main() {
// 创建一个包含 userID 的 context
ctx := context.WithValue(context.Background(), userIDKey, 12345)
processRequest(ctx)
}

输出:

处理请求,用户 ID 是: 12345

10 并发模式#

Go 的并发以 Goroutine 与 Channel 为核心,结合 contextsynctimeerrgroup 等标准库,可以拼装出稳定、可控、可观测的并发方案

for select#

场景:持续从多个输入通道读取数据,或同时监听数据与退出信号

要点

  • for { select { ... } } 持续处理
  • default 分支可避免阻塞,但可能忙等;多数情况下不需要 default
  • 当任一输入通道被关闭时,按需退出或忽略该分支
package main
import (
"context"
"fmt"
"time"
)
func main() {
data := make(chan int)
quit := make(chan struct{})
// 生产者
go func() {
for i := 0; i < 5; i++ {
time.Sleep(120 * time.Millisecond)
data <- i
}
close(data)
}()
// 取消者
go func() {
time.Sleep(700 * time.Millisecond)
close(quit)
}()
for {
select {
case v, ok := <-data:
if !ok {
fmt.Println("data closed")
return
}
fmt.Println("recv:", v)
case <-quit:
fmt.Println("quit")
return
}
}
}

输出:

recv: 0
recv: 1
recv: 2
recv: 3
quit

select timeout 模式#

如果可以使用 select timeout 模式进行超时取消,应该优先使用

场景:等待某个通道事件,但又不想无限期阻塞;超时则采取降级或返回错误

要点

  • case <-time.After(d) 是最轻量的超时手段
  • 频繁调用时建议预创建 time.Timer 循环复用,避免大量临时对象
  • 业务上层仍建议用 context 做总时限控制
package main
import (
"errors"
"fmt"
"time"
)
func recvWithTimeout(in <-chan int, d time.Duration) (int, error) {
select {
case v := <-in:
return v, nil
case <-time.After(d):
return 0, errors.New("timeout")
}
}
func main() {
ch := make(chan int)
// 异步晚点送达
go func() {
time.Sleep(300 * time.Millisecond)
ch <- 42
}()
if v, err := recvWithTimeout(ch, 100*time.Millisecond); err != nil {
fmt.Println("first:", err)
}
if v, err := recvWithTimeout(ch, 500*time.Millisecond); err == nil {
fmt.Println("second:", v)
}
}

输出:

first: timeout
second: 42

Pipeline 模式#

Pipeline 模式模拟现实流水线:每道工序用一个函数封装,通过 channel 连接,最终由组织者把工序串起来

以打包手机为例,假设分为三步:采购->组装->打包,例如:

  • 流水线由一道道工序构成,每道工序通过 channel 把数据传递到下一个工序每道工序一般会对应一个函数,函数里有协程和 channel
  • 协程一般用于处理数据并把它放入 channel 中整个函数会返回这个 channel 以供下一道工序使用
  • 最终要有一个组织者(示例中的main函数)把这些工序串起来

要点

  • 每个阶段函数只关闭自己创建的输出通道
  • 阶段内部用 for-range 消费上游通道
  • 任何阶段出错/取消应快速退出并向下游传播收敛
package main
import (
"context"
"fmt"
"strings"
"time"
)
func purchase(ctx context.Context, parts []string) <-chan string {
out := make(chan string)
go func() {
defer close(out)
for _, p := range parts {
select {
case <-ctx.Done():
return
case out <- "raw:" + p:
time.Sleep(50 * time.Millisecond) // 采购耗时
}
}
}()
return out
}
func assemble(ctx context.Context, in <-chan string) <-chan string {
out := make(chan string)
go func() {
defer close(out)
for v := range in {
select {
case <-ctx.Done():
return
case out <- strings.ReplaceAll(v, "raw:", "assembled:"):
time.Sleep(80 * time.Millisecond) // 组装耗时
}
}
}()
return out
}
func pack(ctx context.Context, in <-chan string) <-chan string {
out := make(chan string)
go func() {
defer close(out)
for v := range in {
select {
case <-ctx.Done():
return
case out <- strings.ReplaceAll(v, "assembled:", "packed:"):
time.Sleep(40 * time.Millisecond) // 打包耗时
}
}
}()
return out
}
func main() {
ctx := context.Background()
parts := []string{"phone-A", "phone-B", "phone-C"}
stage1 := purchase(ctx, parts)
stage2 := assemble(ctx, stage1)
stage3 := pack(ctx, stage2)
for v := range stage3 {
fmt.Println(v)
}
}

输出:

packed:phone-A
packed:phone-B
packed:phone-C

扇出和扇入模式#

当某个阶段处理较慢,组织者可以“扇出”多个并行工人处理同类任务,并在末端“扇入”汇聚结果以提升吞吐

例如刚才的组装手机的例子中:经过一段时间的运转,组织者发现产能提不上去,工序2过慢,导致上游工序1配件采购速度不得不降下来,下游工序3没太多事做,不得不闲下来。于是可以采用扇出和扇入模式,图示如下:

alt text

要点

  • 扇出:为同一输入流开多个 worker,从同一个输入通道并发读取
  • 扇入:用 sync.WaitGroup 等待所有 worker 完成后统一关闭聚合通道
  • 注意每个任务只能被一个 worker 消费,如需广播要复制流
package main
import (
"context"
"fmt"
"sync"
"time"
)
func gen(nums ...int) <-chan int {
out := make(chan int)
go func() {
for _, n := range nums {
out <- n
time.Sleep(20 * time.Millisecond)
}
close(out)
}()
return out
}
func worker(ctx context.Context, id int, in <-chan int, out chan<- string, wg *sync.WaitGroup) {
defer wg.Done()
for n := range in {
select {
case <-ctx.Done():
return
default:
time.Sleep(60 * time.Millisecond) // 模拟慢阶段
out <- fmt.Sprintf("w%d:%d*2=%d", id, n, n*2)
}
}
}
func fanIn(ctx context.Context, in <-chan int, workers int) <-chan string {
out := make(chan string, 16)
var wg sync.WaitGroup
wg.Add(workers)
for i := 0; i < workers; i++ {
go worker(ctx, i, in, out, &wg)
}
go func() {
wg.Wait()
close(out)
}()
return out
}
func main() {
ctx := context.Background()
input := gen(1, 2, 3, 4, 5, 6)
results := fanIn(ctx, input, 3) // 扇出 3 个 worker,并最终扇入
for r := range results {
fmt.Println(r)
}
}

输出:

w0:1*2=2
w1:2*2=4
w2:3*2=6
w0:4*2=8
w1:5*2=10
w2:6*2=12

Futures 模式#

在实际需求中,很多任务相互独立,可并发执行;主协程无需同步等待每个返回,而是在需要时再取结果。可用“结果通道 + 占位句柄”实现

比如打算自已做顿火锅吃,那么就需要洗菜、烧水。洗菜、烧水这两个步骤相互之间没有依赖关系,是独立的,那么就可以同时做,但是最后做火锅这个步骤就需要洗好菜、烧好水之后才能进行。这个做火锅的场景就适用 Futures 模式

主写成不用等待子协程返回的结果,可以执行其他语句,等到某个时间点需要的时候再来取;如果此时没有返回结果,则需要等待

要点

  • Future 本质是“只读结果通道 + 取消控制”
  • 主协程先发起所有任务,做其他工作;当用到结果时从对应通道读取
  • 读取时可加 select 超时或 context 控制
package main
import (
"context"
"fmt"
"time"
)
// 一个简单的 Future 句柄
type Future[T any] struct {
C <-chan T
Ctx context.Context
}
func Async[T any](ctx context.Context, f func(context.Context) T) Future[T] {
out := make(chan T, 1)
go func() {
defer close(out)
out <- f(ctx)
}()
return Future[T]{C: out, Ctx: ctx}
}
func main() {
ctx := context.Background()
// 并发:洗菜、烧水(互不依赖)
wash := Async(ctx, func(context.Context) string {
time.Sleep(300 * time.Millisecond)
return "washed veggies"
})
boil := Async(ctx, func(context.Context) string {
time.Sleep(500 * time.Millisecond)
return "boiled water"
})
// 主流程可做别的事
fmt.Println("prepare sauce...")
// 需要结果时再取;可套超时/取消
select {
case v := <-wash.C:
fmt.Println(v)
case <-time.After(400 * time.Millisecond):
fmt.Println("wash timeout")
}
select {
case v := <-boil.C:
fmt.Println(v)
case <-time.After(400 * time.Millisecond):
fmt.Println("boil timeout")
}
// 如果任意一步超时,可以整体取消或降级
fmt.Println("make hotpot")
}

输出:

prepare sauce...
washed veggies
boil timeout
make hotpot

由于超时设置为 400ms,而烧水耗时 500ms,因此出现超时示例。把第二个 time.After(400ms) 调大即可正常拿到 boiled water

11 指针详解#

什么是指针#

程序运行时的数据是存放在内存中的,那么每一个存储在内存中的数据都会有一个编号,这个编号就是内存地址,而内存地址可以被赋值给一个指针

在编程语言中,指针是一种数据类型,用来存储一个内存地址,该地址指向存储在该内存中的对象

以一个现实生活中的例子举例:每本书中都有目录,目录上会有相应章节的页码,你可以把页码理解为一系列的内存地址,通过页码你可以快速地定位到具体的章节

指针的声明和定义#

通过 & 获取变量的地址;通过 * 来获取指针指向的元素的值,即解引用指针,例如:

func main() {
name := "LANSGANBS"
nameP := &name
println("name 的值是:", name)
println("name 的地址是:", nameP)
println("name 的地址的值是:", *nameP)
}

输出:

Value of name: LANSGANBS
Address of name: 0xc000067f28
Value at address nameP: LANSGANBS

但是对于一个数据类型,需要使用  * 来表示这个数据类型的指针,例如:*int *string

不同的指针类型无法相互赋值

比如不能将 string 类型的指针取地址之后赋值给 *int 指针类型

除了使用简短声明,指针仍然可以使用 var 关键字来进行声明,例如:

var intP *int
int P = &name // 指针类型不同无法赋值

和普通类型不同的是,指针类型还可以通过内置的 new 函数来进行声明,例如:

intP1 := new(int)

指针的操作#

指针的操作无非是两种:

  • 获取指针的值
  • 修改指针的值

通过 & 获取变量的地址;通过 * 来获取指针指向的元素的值,即解引用指针,例如:

func main() {
name := "LANSGANBS"
nameP := &name
nameV := *nameP
fmt.Println("nameP 指针的值:", nameP)
fmt.Println("nameV 变量的值:", nameV)
*nameP = "LANSGANBS Go"
fmt.Println("nameP 指针指向的值:", *nameP)
fmt.Println("name 变量的值:", name)
}

输出

nameP 指针的值: 0xc00008e060
nameV 变量的值: LANSGANBS
nameP 指针指向的值: LANSGANBS Go
name 变量的值: LANSGANBS Go

对于下面的代码会报错:

var intP *int
*intP = 10
panic: runtime error: invalid memory address or nil pointer dereference
[signal 0xc0000005 code=0x1 addr=0x0 pc=0x6a8270]
goroutine 1 [running]:
main.main()
D:/gotour/ch04/main.go:7 +0x10

这是因为最开始声明的 intP 为 NULL,这是可以通过内置的 new 函数提前分配好内存即可,例如:

var intP *int = new(int)
// intP := new(int) 效果相同

指针参数#

对于下面代码:

func modifyAge(age int) {
age = 30
}
func main() {
age := 20
modifyAge(age)
fmt.Println(age)
}

运行后发现输出:

20

这是因为,modifyAge 的实参 age 参数只是一份拷贝,所以修改并不会真正改变 age 的值,如果想要真正的修改,那就需要使用指针,例如:

func modifyAge(age *int) {
*age = 30
}
func main() {
age := 20
modifyAge(&age)
fmt.Println(age)
}

指针接收者#

对于是否使用指针类型作为接受者,有以下几点参考:

  1. 如果接受者是 map、slice、channel 这类引用类型,不使用指针
  2. 如果需要修改接收者,那么需要使用指针
  3. 如果接收者是比较大的类型,可以使用指针

指针的两大好处#

  1. 可以修改指向数据的值
  2. 在变量赋值,参数传值的时候可以节省内存

什么情况下使用指针#

  • 不要对 map、slice、channel 这类引用类型使用指针
  • 如果需要修改方法接收者内部的数据或者状态时,需要使用指针
  • 如果需要修改参数的值或者内部数据时,也需要使用指针类型的参数
  • 如果是比较大的结构体这时候可以考虑使用指针
  • 像 int、bool 这样的小数据类型没必要使用指针
  • 如果需要并发安全,则尽可能地不要使用指针,使用指针一定要保证并发安全
  • 指针最好不要嵌套,也就是不要使用一个指向指针的指针

12 参数传递#

在 Go 语言中,所有参数传递都是值传递。 也就是说,当函数被调用时,实参的值会被复制一份传入函数。 如果拷贝的是值类型,函数中修改不会影响原始数据;
如果拷贝的是引用类型或指针,则可以通过引用或指针间接修改原始数据

修改参数#

当你把变量传入函数时,函数拿到的是它的拷贝,如果函数需要修改外部变量,就必须通过指针或引用类型实现

package main
import "fmt"
func changeValue(x int) {
x = 100
}
func changePointer(x *int) {
*x = 100
}
func main() {
a := 10
changeValue(a)
fmt.Println("after changeValue:", a) // 输出 10(未修改)
changePointer(&a)
fmt.Println("after changePointer:", a) // 输出 100(已修改)
}

输出:

after changeValue: 10
after changePointer: 100

值类型#

值类型在传参时,会复制整个变量的值,在函数中修改值类型参数,不会影响原始变量

Go 中的常见值类型包括:

  • 基本类型:intfloat64boolstring
  • 复合类型:arraystruct

例如对于下面代码:

package main
import "fmt"
type User struct {
name string
age int
}
func modify(u User) {
u.name = "Alice"
u.age = 30
}
func main() {
u := User{"Bob", 20}
modify(u)
fmt.Println("after modify:", u)
}

输出:

after modify: {Bob 20}

说明:User 是值类型,modify 拿到的是一份拷贝,修改不影响原始数据

指针类型#

指针保存的是变量的地址,当传递指针时,复制的是这个地址值,所以可以通过指针间接修改原始数据

package main
import "fmt"
type User struct {
name string
age int
}
func modifyPtr(u *User) {
u.name = "Alice"
u.age = 30
}
func main() {
u := &User{"Bob", 20}
modifyPtr(u)
fmt.Println("after modifyPtr:", *u)
}

输出:

after modifyPtr: {Alice 30}

说明:这里传入的是指针,函数内部修改了指针指向的对象内容

引用类型#

Go 中有三种内置的引用类型:slicemapchannel

虽然传参依然是“值传递”,但拷贝的值中包含对底层数据结构的引用, 因此函数内部可以通过这个引用修改原始数据

map#

map 是引用类型,传参时复制的是对底层哈希表的引用,因此在函数中修改 map 会直接影响原始 map

package main
import "fmt"
func modifyMap(m map[string]int) {
m["age"] = 30
}
func main() {
user := map[string]int{"age": 20}
modifyMap(user)
fmt.Println("after modifyMap:", user)
}

输出:

after modifyMap: map[age:30]

map 的底层结构包含一个指向哈希表的指针,复制 map 变量只是复制了这个指针

channel#

channel 也是引用类型,传参时会复制底层通道的引用, 因此在函数中发送或接收数据会直接作用在原通道上

package main
import "fmt"
func sendData(ch chan int) {
ch <- 10
}
func main() {
ch := make(chan int, 1)
sendData(ch)
fmt.Println(<-ch)
}

输出:

10

函数中 ch <- 10 实际操作的是原通道,因此主函数能接收到数据

类型的零值#

在 Go 中,每种类型都有默认的零值(Zero Value),当变量声明但未初始化时,会自动赋予零值

类型类别示例类型零值
数值类型int, float640
布尔类型boolfalse
字符串string""(空字符串)
指针、接口、函数*T, interface{}nil
引用类型map, slice, channil
结构体struct每个字段都是对应类型的零值
package main
import "fmt"
func main() {
var (
a int
b bool
c string
d []int
e map[string]int
f chan int
)
fmt.Println(a, b, c, d, e, f)
}

输出:

0 false [] map[] <nil>

总结:

类型类别参数传递时是否复制底层数据函数内修改是否影响原值
值类型 (int, array, struct)
指针类型 (*T)否(复制指针地址)
引用类型 (map, slice, chan)否(复制引用)

Go 的所有参数传递都是值传递。 能否修改原值,取决于被复制的内容是纯值、指针还是引用

遵命,我将按照您提供的全新标题和要求,以“同步原语”部分的风格重写这些内容,并确保包含更多的小标题和丰富的示例


13 内存分配#

Go 语言在内存分配时提供了两个内置函数:newmake。它们都用于在堆上分配内存,但用途截然不同,是初学者常见的困惑点

new(T):只分配内存#

new 是一个内置函数,它接受一个类型 T 作为参数,只负责为该类型的零值分配内存,并返回一个指向这块内存的指针(*T

package main
import "fmt"
type Person struct {
Name string
Age int
}
func main() {
// 使用 new 分配一个 Person 结构体
// p 是一个指针 *Person,指向一个 Person 结构体的零值
p := new(Person)
fmt.Printf("p 的类型: %T\n", p)
fmt.Printf("p 的值: %+v\n", *p) // 零值: {Name:"" Age:0}
// new(int)
// i 是一个指针 *int,指向 int 的零值 (0)
i := new(int)
fmt.Printf("i 的类型: %T\n", i)
fmt.Printf("i 的值: %d\n", *i)
*i = 100
fmt.Printf("i 修改后的值: %d\n", *i)
}

输出:

p 的类型: *main.Person
p 的值: {Name: Age:0}
i 的类型: *int
i 的值: 0
i 修改后的值: 100

new 几乎等价于 var v T; return &v

make(T, ...):初始化内置类型#

make 仅用于 Go 的三种内置引用类型:slice(切片)、map(映射)和 channel(通道)

make 不仅会分配内存,还会初始化这些数据结构内部的描述符(例如 slicelencap),并返回这个类型的实例,而不是指针

package main
import "fmt"
func main() {
// 制作一个 slice,长度为 0,容量为 10
s := make([]int, 0, 10)
fmt.Printf("s: len=%d, cap=%d, value=%v\n", len(s), cap(s), s)
// 制作一个 map
m := make(map[string]int)
m["apple"] = 1
fmt.Printf("m: %v\n", m)
// 制作一个 channel
ch := make(chan string)
fmt.Printf("ch 的类型: %T\n", ch)
}

输出:

s: len=0, cap=10, value=[]
m: map[apple:1]
ch 的类型: chan string

为什么不能混用?#

如果你尝试用 new 来创建 slice,你会得到一个指向 nil 切片的指针,这通常不是你想要的

s := new([]int) // s 是 *[]int 类型
fmt.Printf("s: %v, len=%d\n", s, len(*s))
// *s = append(*s, 1) // 运行时 panic: nil slice

栈 (Stack) 与堆 (Heap)#

Go 语言的内存分为栈(Stack)和堆(Heap)

  • :用于存储函数局部变量和函数调用信息。栈上分配和回收速度极快,函数返回时自动清理
  • :用于存储生命周期更长的变量(例如被多个 Goroutine 共享或在函数返回后仍需存在的变量)。堆内存由垃圾回收器(GC)管理

逃逸分析 (Escape Analysis)#

newmake 分配的内存在栈上还是堆上,并不是固定的,而是由 Go 编译器在编译时通过逃逸分析(Escape Analysis)决定的

如果一个变量的生命周期只在当前函数内,它通常会被分配在栈上;如果它被函数返回(作为指针或被闭包引用),它就会“逃逸”到堆上

func createOnStack() int {
x := 10 // x 在栈上
return x
}
func createOnHeap() *int {
y := 20 // y "逃逸"到堆上
return &y
}

14 运行时反射#

反射(Reflection)允许程序在运行时检查自身的结构、类型和值。reflect 包是 Go 反射机制的核心,它在序列化(如 JSON)、ORM 和依赖注入框架中被广泛使用

什么是反射#

Go 语言的反射建立在“类型”和“值”这两个概念之上。reflect 包提供了两个核心类型:

  • reflect.Type:表示一个 Go 变量的类型信息
  • reflect.Value:表示一个 Go 变量的实际值

我们可以使用 reflect.TypeOf()reflect.ValueOf() 来获取它们

通过反射获取结构体信息#

反射可以让我们在运行时遍历一个结构体的所有字段和方法

package main
import (
"fmt"
"reflect"
)
type User struct {
Name string `json:"name" info:"用户名"`
Age int `json:"age"`
}
func main() {
u := User{Name: "Alice", Age: 30}
t := reflect.TypeOf(u)
fmt.Println("类型:", t.Name())
fmt.Println("字段数量:", t.NumField())
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
fmt.Printf(" 字段名: %s, 类型: %v\n", field.Name, field.Type)
// 获取 Tag
fmt.Printf(" JSON Tag: %s\n", field.Tag.Get("json"))
fmt.Printf(" Info Tag: %s\n", field.Tag.Get("info"))
}
}

输出:

类型: User
字段数量: 2
字段名: Name, 类型: string
JSON Tag: name
Info Tag: 用户名
字段名: Age, 类型: int
JSON Tag: age
Info Tag:

通过字段名 (字符串) 访问#

这是反射最强大的功能之一:使用字符串动态地访问或修改结构体字段

package main
import (
"fmt"
"reflect"
)
type Config struct {
Host string
Port int
Tags []string
}
func main() {
c := Config{Host: "localhost", Port: 8080}
// 必须传递指针才能修改
v := reflect.ValueOf(&c).Elem() // Elem() 获取指针指向的值
// 1. 通过字符串获取字段
hostField := v.FieldByName("Host")
fmt.Printf("Host 字段值: %s\n", hostField.String())
// 2. 通过字符串修改字段
portField := v.FieldByName("Port")
if portField.CanSet() { // 检查是否可设置
portField.SetInt(9090)
}
fmt.Printf("修改后的 Config: %+v\n", c)
}

输出:

Host 字段值: localhost
修改后的 Config: {Host:localhost Port:9090 Tags:[]}

反射的代价#

反射非常强大,但也带来了显著的性能开销,因为它绕过了编译器的静态类型检查,转而在运行时进行动态查找。应在必要时(如框架开发)才使用,避免在性能敏感的核心业务逻辑中滥用


15 非类型安全#

Go 是一门强类型、内存安全的语言。但它提供了一个特殊的包 unsafe,它允许开发者绕过类型系统和内存安全检查,直接操作内存。这就像一把锋利的“手术刀”,功能强大但极其危险

unsafe 包的魔力#

unsafe 包只提供了几个核心功能,最常用的是:

  • unsafe.Pointer:一种特殊的指针类型,它可以指向任意类型的数据,是连接 Go 指针和 uintptr 的桥梁
  • uintptr:一个整数类型,其大小足以容纳一个指针的位模式。它可以用于指针的算术运算
  • unsafe.Sizeof:返回一个类型实例所占用的字节数

危险的操作:类型转换#

unsafe 允许我们在不兼容的类型之间强行转换,例如将 float64 的内存位模式解释为 uint64

package main
import (
"fmt"
"unsafe"
)
func main() {
f := 1.234
// &f (*float64) -> unsafe.Pointer -> *uint64
// 最后解引用 *(*uint64)
bits := *(*uint64)(unsafe.Pointer(&f))
fmt.Printf("Float: %f\n", f)
// %x 用于以十六进制格式化整数
fmt.Printf("Memory bits (hex): %x\n", bits)
}

输出:

Float: 1.234000
Memory bits (hex): 3ff3be76c8b43958

危险的操作:指针运算#

Go 语言不允许直接对指针进行 +- 运算,但 unsafeuintptr 结合可以做到这一点

package main
import (
"fmt"
"unsafe"
)
func main() {
// 假设一个数组
arr := [3]int{10, 20, 30}
// 1. 获取第一个元素的指针
p0 := unsafe.Pointer(&arr[0])
// 2. 获取 int 类型的大小
intSize := unsafe.Sizeof(arr[0]) // 通常是 8 字节 (64位系统)
// 3. 计算第二个元素的地址
// unsafe.Pointer -> uintptr (进行数学运算) -> unsafe.Pointer
p1 := unsafe.Pointer(uintptr(p0) + intSize)
// 4. 将 p1 转为 *int 并修改
*(*int)(p1) = 200
fmt.Println("修改后的数组:", arr)
}

输出:

修改后的数组: [10 200 30]

使用 unsafe 的时机#

使用 unsafe 意味着你向编译器保证你的操作是安全的,但编译器无法验证。这可能导致程序崩溃、内存泄露或不可预测的行为。它的主要用途是:

  1. 极端的性能优化(如 string[]byte 的零拷贝转换)
  2. 与 C 语言交互(CGo)
  3. 探究 Go 语言的底层数据结构(如 SliceHeader

16 SliceHeader#

slice(切片)是 Go 中最常用的数据结构之一。它之所以高效,关键在于它的内部结构:slice 只是一个指向底层数组的轻量级描述符

slice 的内部结构#

slice 变量本身并不存储数据,它只包含三个字段。这个结构在 reflect 包中被定义为 SliceHeader

type SliceHeader struct {
Data uintptr // 指向底层数组的指针
Len int // 切片的长度 (当前元素个数)
Cap int // 切片的容量 (底层数组的总大小)
}

slice 如何实现高效切片#

当你对一个 slice 进行“切片操作”时(例如 s[2:5]),Go 并不复制数据。它只是创建了一个新的 SliceHeader,将其 Data 指针指向原始 slice 的相应位置,并设置新的 LenCap

package main
import (
"fmt"
"reflect"
"unsafe"
)
func main() {
s1 := []int{10, 20, 30, 40, 50}
// 对 s1 进行切片
s2 := s1[2:4] // s2 包含 {30, 40}
// 使用 unsafe 查看它们的头部
hdr1 := (*reflect.SliceHeader)(unsafe.Pointer(&s1))
hdr2 := (*reflect.SliceHeader)(unsafe.Pointer(&s2))
fmt.Printf("s1: Data=%v, Len=%d, Cap=%d\n", hdr1.Data, hdr1.Len, hdr1.Cap)
fmt.Printf("s2: Data=%v, Len=%d, Cap=%d\n", hdr2.Data, hdr2.Len, hdr2.Cap)
// s2 的 Data 指针 = s1 的 Data 指针 + 2 * (int的大小)
// 修改 s2 会影响 s1
s2[0] = 300
fmt.Println("修改 s2 后, s1 变为:", s1)
}

输出(Data 地址会变化):

s1: Data=824633884688, Len=5, Cap=5
s2: Data=824633884704, Len=2, Cap=3
修改 s2 后, s1 变为: [10 20 300 40 50]

slice 的扩容机制#

当你使用 appendslice 添加元素时,如果 Len 超过了 Cap,Go 会自动触发扩容:

  1. 分配一个新的、更大的底层数组
  2. 将旧数组的数据复制到新数组
  3. 更新 SliceHeader 指向新数组

这就是为什么 append 可能会很昂贵,也是为什么 append 必须返回一个新的 slice(因为 SliceHeader 可能已改变)

string[]byte 的零拷贝转换#

string 的内部结构 StringHeader 类似于 SliceHeader,但只有 DataLen(因为 string 是不可变的)

在性能敏感的场景下,可以使用 unsafe 在它们之间进行零拷贝转换,避免内存分配和复制。但这非常危险:如果将 string 转为 []byte 并修改了字节,将破坏 string 的不可变性

func stringToBytes(s string) []byte {
strHdr := (*reflect.StringHeader)(unsafe.Pointer(&s))
// 构造一个 SliceHeader,共用 Data 指针
sliceHdr := reflect.SliceHeader{
Data: strHdr.Data,
Len: strHdr.Len,
Cap: strHdr.Len,
}
// 将 SliceHeader 转为 []byte
return *(*[]byte)(unsafe.Pointer(&sliceHdr))
}
// 注意:这是一个危险的只读转换

17 质量保证#

Go 语言在设计时就深度集成了质量保证工具。go test 命令是 Go 开发者最常用的工具之一,它支持多种测试类型,确保代码的健壮性和正确性

单元测试 (Unit Testing)#

单元测试是最基础的测试,用于验证特定函数或模块的功能

  • 文件必须以 _test.go 结尾
  • 函数必须以 Test 开头,并接受 *testing.T 参数

math.go (被测试代码):

package mathops
func Add(a, b int) int {
return a + b
}

math_test.go (测试代码):

package mathops
import "testing"
func TestAdd(t *testing.T) {
result := Add(2, 3)
expected := 5
if result != expected {
// t.Errorf 会记录错误并继续执行
t.Errorf("Add(2, 3) = %d; want %d", result, expected)
}
}

运行测试:

$ go test ./...
PASS
ok example.com/mathops 0.002s

基准测试 (Benchmark Testing)#

基准测试用于衡量代码的性能

  • 函数必须以 Benchmark 开头,并接受 *testing.B 参数
  • b.N 是测试框架动态调整的循环次数

math_test.go:

func BenchmarkAdd(b *testing.B) {
for i := 0; i < b.N; i++ {
Add(100, 200)
}
}

运行基准测试 (使用 -bench=.):

$ go test -bench=.
goos: darwin
goarch: arm64
pkg: example.com/mathops
BenchmarkAdd-10 1000000000 0.2825 ns/op
PASS
ok example.com/mathops 0.315s

示例测试 (Example Tests)#

示例测试既是文档(会显示在 Godoc 中),也是可执行的测试

  • 函数以 Example 开头
  • 使用 // Output: 注释来指定预期的标准输出

example_test.go:

package mathops
import "fmt"
func ExampleAdd() {
sum := Add(5, 10)
fmt.Println(sum)
// Output: 15
}

代码覆盖率 (Code Coverage)#

Go 提供了测量测试覆盖率的工具,帮助我们识别哪些代码没有被测试到

$ go test -cover
PASS
coverage: 100.0% of statements
ok example.com/mathops 0.014s

竞争检测 (Race Detector)#

Go 的并发特性很容易引入数据竞争。Race Detector 是一个在运行时检测数据竞争的工具

# 假设我们有一个并发不安全的测试
$ go test -race
==================
WARNING: DATA RACE
Read at 0x000109f3e1b0 by goroutine 8
...
Previous write at 0x000109f3e1b0 by goroutine 7
==================
--- FAIL: TestUnsafeCounter (1.01s)
FAIL

模糊测试 (Fuzz Testing)#

Go 1.18 引入了模糊测试(Fuzz Testing),它会自动生成随机输入来测试代码,以发现边缘情况和安全漏洞

  • 函数必须以 Fuzz 开头,并接受 *testing.F 参数

18 性能优化#

Go 语言的性能优化不是靠猜,而是靠测量。Go 提供了强大的静态检查和运行时分析工具(pprof),帮助开发者定位瓶颈

静态检查:go vet#

go vet 是 Go 内置的静态分析工具,它在编译前检查代码,寻找可疑的构造或常见的错误,这些错误可能会导致 bug 或性能问题

// 示例:一个常见的错误
func main() {
// ...
// 错误:在循环中使用了 defer
for i := 0; i < 1000; i++ {
mu.Lock()
defer mu.Unlock() // vet 会警告这个
}
}

运行 go vet

$ go vet .
# command-line-arguments
./main.go:10:10: defer in loop flows out of loop

Linter 聚合器:golangci-lint#

golangci-lint 是一个流行的第三方 Linter 聚合器,它集成了数十种检查工具(包括 vet),提供了更全面的代码质量和潜在性能问题的分析

性能分析:pprof#

pprof 是 Go 语言的性能分析(Profiling)工具集,是性能优化的基石。它可以在运行时收集数据,分析程序的 CPU、内存、Goroutine 等使用情况

集成 pprof

对于 Web 服务器,只需匿名导入 net/http/pprof

import (
"net/http"
_ "net/http/pprof" // 匿名导入,自动注册 pprof 的 handler
)
func main() {
// ... 你的业务 handler
// pprof 会在 /debug/pprof/ 路径下提供服务
http.ListenAndServe(":8080", nil)
}

CPU 分析 (CPU Profiling)#

我们可以使用 go tool pprof 来分析 CPU 瓶颈:

# 采样 30 秒的 CPU 数据
$ go tool pprof http://localhost:8080/debug/pprof/profile?seconds=30
(pprof) top
# (pprof) 终端中输入 top,显示占用 CPU 最多的函数
Showing top 10 nodes out of 120
flat flat% sum% cum cum%
5.10s 20.00% 20.00% 8.20s 32.16% main.busyLoop
...

内存分析 (Memory Profiling)#

pprof 也可以分析堆内存分配:

# 分析当前堆内存使用
$ go tool pprof http://localhost:8080/debug/pprof/heap
(pprof) top
Showing top 10 nodes out of 80
flat flat% sum% cum cum%
1024MB 33.33% 33.33% 1024MB 33.33% main.bigAllocation
...

火焰图 (Flame Graphs)#

pprof 还能生成可视化的火焰图,帮助我们直观地看到函数调用链和性能热点

(pprof) web

sync.Pool:复用对象#

减少内存分配是 Go 性能优化的关键手段之一。对于那些频繁创建和销毁的临时对象,使用 sync.Pool 可以复用它们,大大减轻 GC 压力

var bigBufferPool = sync.Pool{
New: func() interface{} {
// 池中没有时,创建一个新的
return make([]byte, 1024*64) // 64KB
},
}
func ProcessRequest(w http.ResponseWriter, r *http.Request) {
// 从池中获取
buf := bigBufferPool.Get().([]byte)
// 使用 buf ...
// 归还到池中
// 注意:需要重置 buf 的状态(如果需要)
bigBufferPool.Put(buf)
}

19 协作开发#

在 Go 1.11 之前,Go 语言使用 GOPATH 来管理依赖,导致了“依赖地狱”问题。Go Modules(模块化管理)的出现彻底解决了这个问题,极大地提升了协作开发的效率和项目的可维护性

GOPATH 的困境#

GOPATH 要求所有项目和依赖都存放在同一个工作空间。这导致:

  1. 版本冲突:项目 A 依赖 lib v1.0,项目 B 依赖 lib v2.0GOPATH 下只能存一个版本
  2. 无法复现:新开发者拉取代码,go get 下载的是最新依赖,可能导致构建失败

Go Modules 简介#

Go Modules 允许项目独立于 GOPATH 存在,并实现了可复现的依赖管理

go.mod 文件

go.mod 是 Go Modules 的核心,它定义了模块的路径、Go 版本以及所有依赖项及其精确版本

module example.com/myproject
go 1.21
require (
github.com/gin-gonic/gin v1.9.1 // 直接依赖
)
require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
// ... 其他间接依赖
)

go.sum 文件

go.sum 文件记录了每个依赖包的哈希校验和,确保你下载的依赖没有被篡改,保证了构建的安全性

模块化管理如何提升协作#

  1. 可复现构建go.modgo.sum 锁定了所有依赖(包括间接依赖)的精确版本。任何开发者、任何 CI/CD 平台,只要克隆了代码并执行 go build,都会下载完全相同的依赖,保证了构建的一致性
  2. 语义化版本:Go Modules 遵循语义化版本(SemVer),允许开发者明确地升级(go get lib@v1.2.0)或降级依赖
  3. 摆脱 GOPATH:项目可以放在磁盘的任何位置,不再受单一工作空间的限制

统一的代码风格:gofmt#

Go 通过 gofmt 工具强制推行统一的代码风格。它会自动格式化代码,消除了团队中关于“花括号放哪里”的无休止争论

goimportsgofmt 的超集,它在格式化代码的同时,还会自动管理(添加/删除)import 声明

协作开发流程中,通常会要求所有提交的代码必须通过 gofmt 检查

# 格式化当前目录所有文件
$ gofmt -w .
# (推荐) 使用 goimports
$ goimports -w .

20 网络编程#

Go 语言在网络编程方面拥有“天生”的优势,其 net/http 包足以构建高性能的 RESTful API,同时其内置的 net/rpc 包和流行的 gRPC 框架也使其成为 RPC 开发的利器

net/http 构建 RESTful API#

Go 的标准库 net/http 非常强大,无需任何框架即可构建生产级的 Web 服务

package main
import (
"encoding/json"
"fmt"
"net/http"
)
// 定义数据结构
type Article struct {
ID int `json:"id"`
Title string `json:"title"`
}
var articles = []Article{
{ID: 1, Title: "Hello Go"},
{ID: 2, Title: "RESTful API"},
}
// 路由与 Handler
func articlesHandler(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
// 处理 JSON 数据
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(articles)
case http.MethodPost:
// (此处应添加 Post 逻辑)
w.WriteHeader(http.StatusCreated)
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
func main() {
http.HandleFunc("/api/articles", articlesHandler)
fmt.Println("Starting RESTful API server on :8080")
http.ListenAndServe(":8080", nil)
}

什么是 RPC#

RPC (Remote Procedure Call,远程过程调用) 允许一个程序调用另一个地址空间(通常是另一台机器)的函数或方法,就像调用本地函数一样。它隐藏了底层的网络通信细节

Go 的 net/rpc#

Go 标准库提供了 net/rpc 包,用于实现简单、高效的 RPC

net/rpc 服务端

package main
import (
"fmt"
"net"
"net/rpc"
)
// RPC 服务结构体
type Arith struct{}
// RPC 方法(必须导出,参数和返回值符合规范)
func (t *Arith) Multiply(args *Args, reply *int) error {
*reply = args.A * args.B
return nil
}
type Args struct {
A, B int
}
func main() {
arith := new(Arith)
rpc.Register(arith) // 注册服务
listener, _ := net.Listen("tcp", ":1234")
fmt.Println("RPC server listening on :1234")
for {
conn, _ := listener.Accept()
go rpc.ServeConn(conn) // 为每个连接提供服务
}
}

net/rpc 客户端

package main
import (
"fmt"
"net/rpc"
)
type Args struct {
A, B int
}
func main() {
client, err := rpc.Dial("tcp", "localhost:1234")
if err != nil {
panic(err)
}
args := &Args{A: 7, B: 8}
var reply int
// 像调用本地函数一样调用远程方法
// "Arith.Multiply" 是 "服务名.方法名"
err = client.Call("Arith.Multiply", args, &reply)
if err != nil {
panic(err)
}
fmt.Printf("Arith: %d * %d = %d\n", args.A, args.B, reply)
}

客户端输出:

Arith: 7 * 8 = 56

gRPC:现代 RPC 框架#

虽然 net/rpc 简单易用,但它只支持 Go 语言。在现代微服务架构中,gRPC(Google 开源的 RPC 框架)更为流行

gRPC 使用 Protocol Buffers 作为接口定义语言(IDL),可以自动生成多语言(Java, Python, C++, Go…)的客户端和服务端代码,并提供了流式传输、认证、负载均衡等高级功能

GoLang 语言学习
https://lansganbs.cn/posts/编程语言/golang-语言学习/
作者
LANSGANBS
发布于
2025-11-05
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时