一.基础部分
Go 语言的设计哲学
简单、显式、组合、并发和面向工程。
go语言的值类型和引用类型?
值类型:int、float、bool、string 和 数组。
值类型的变量直接指向存在内存中的值,值类型的变量的值存储在栈
中。
引用类型:切片(slice)、映射(map)、通道(chan)、接口(interface)和指针(pointer)。
引用类型的变量存储在堆
上
调用函数传入结构体时,应该传值还是指针?
go 里面只存在只存在值传递
(要么是该值的副本,要么是指针的副本),不存在引用传递。
之所以对于引用类型的传递可以修改原内容数据,是因为在底层默认使用该引用类型的指针进行传递,但是也是使用指针的副本,依旧是值传递。
Go 只有值传递
Go语言中所有函数参数传递都是值传递,这意味着:
- 当你传递一个变量给函数时,Go会创建该变量的一个副本传入函数
- 这个副本可能是:
• 原始值的副本(对于基本类型如int、float、struct等)
• 指针的副本(对于指针类型或引用类型)
为什么能修改"引用类型"?
对于 slice、map、channel 等"引用类型",虽然看起来像是引用传递,但实际上:
- 这些类型在底层都是结构体,包含指向实际数据的指针
- 当你传递它们时,传递的是这个结构体的副本(值传递)
- 但由于副本中的指针指向相同的内存,所以通过副本也能修改原数据
指针也是值传递
即使你显式使用指针:
func modify(p *int) {
*p = 10
}
func main() {
x := 5
modify(&x)
}
这里传递的是指针&x
的副本,而不是原始指针本身。但因为副本指针指向相同内存,所以能修改原变量。
与引用传递的区别
真正的引用传递(如C++的&
参数)会:
• 直接操作原始变量,不创建副本
• 对参数的修改直接影响调用者
而 Go 的值传递(包括指针副本):
• 总是创建副本
• 只是通过副本中的指针间接访问原数据
副本复制时,是深拷贝还是浅拷贝?
在 Go 语言中,参数传递时的"复制"是浅拷贝(Shallow Copy),而不是深拷贝(Deep Copy)。具体区别如下:
-
基本类型(int, float, bool, string 等)
浅拷贝:直接复制值本身(等同于深拷贝,因为不涉及嵌套结构)
a := 10 b := a // 完全复制值,a 和 b 完全独立
-
引用类型(slice, map, channel)
浅拷贝:复制的是底层数据结构的引用(如 slice 的
ptr
、len
、cap
),但不会复制底层数据s1 := []int{ 1, 2, 3} s2 := s1 // 复制的是 slice 的 header(ptr, len, cap),但底层数组仍然是同一个 s2[0] = 99 fmt.Println(s1) // [99 2 3],因为 s1 和 s2 共享底层数组
-
结构体(struct)
浅拷贝:复制结构体的所有字段,但如果字段是指针或引用类型,只会复制指针/引用,不会复制指向的数据
type Person struct { Name string Friends []string // 引用类型(slice) } p1 := Person{ Name: "Alice", Friends: []string{ "Bob"}} p2 := p1 // 浅拷贝,Friends 的底层数组仍然是同一个 p2.Friends[0] = "Charlie" fmt.Println(p1.Friends) // ["Charlie"],因为 p1 和 p2 共享 Friends 的底层数组
-
指针(
*T
)浅拷贝:复制的是指针的值(内存地址),不会复制指针指向的数据
x := 10 p1 := &x p2 := p1 // 复制的是指针的值(地址),p1 和 p2 指向同一个 x *p2 = 20 fmt.Println(x) // 20,因为 p1 和 p2 都指向 x
总结
类型 | 复制方式 | 是否共享底层数据 | 示例 |
---|---|---|---|
基本类型 | 直接复制值(等同于深拷贝) | ❌ 不共享 | a := 10; b := a |
引用类型(slice, map, channel) | 浅拷贝(复制引用) | ✅ 共享 | s1 := []int{1}; s2 := s1; s2[0] = 2 |
结构体 | 浅拷贝(字段逐值复制) | 如果字段是引用类型,则共享 | p2 := p1 (p1.Friends 是 slice) |
指针 | 浅拷贝(复制地址) | ✅ 共享 | p2 := p1 (p1 是指针) |
关键区别
- 浅拷贝:只复制最外层的值,不会递归复制嵌套的数据(如 slice 的底层数组、map 的哈希表、指针指向的数据)。
- 深拷贝:会递归复制所有数据,完全独立(Go 默认不提供,需要手动实现或使用
encoding/json
、gob
等方式)。
如何实现深拷贝?
-
切片:
如何实现切片的深拷贝,避免共享底层数组?
方法 1:
copy()
(推荐)s1 := []int{ 1, 2, 3} s2 := make([]int, len(s1)) // 先创建新 slice copy(s2, s1) // 复制元素(底层数组不同) s2[0] = 99 fmt.Println(s1) // [1, 2, 3](不受影响) fmt.Println(s2) // [99, 2, 3]
方法 2:手动创建新 slice
s1 := []int{ 1, 2, 3} s2 := append([]int{ }, s1...) // 通过 append 创建新 slice s2[0] = 99 fmt.Println(s1) // [1, 2, 3](不受影响) fmt.Println(s2) // [99, 2, 3]
方法 3: json 序列化/反序列化
// 使用 json 序列化/反序列化(适用于可序列化类型) func deepCopy(src, dest interface{ }) error { bytes, err := json.Marshal(src) if err != nil { return err } return json.Unmarshal(bytes, dest) } // 示例 var s1 = []int{ 1, 2, 3} var s2 []int deepCopy(s1, &s2) // s2 现在是完全独立的副本
-
结构体
方法 1. 手动实现深拷贝(推荐用于简单结构体)
type Person struct { Name string Age int Friends []string } func (p *Person) DeepCopy() *Person { // 创建新结构体 newPerson := &Person{ Name: p.Name, Age: p.Age, } // 对引用类型字段进行深拷贝 if p.Friends != nil { newPerson.Friends = make([]string, len(p.Friends)) copy(newPerson.Friends, p.Friends) } return newPerson } // 使用示例 p1 := &Person{ Name: "Alice", Age: 30, Friends: []string{ "Bob", "Charlie"}, } p2 := p1.DeepCopy() p2.Friends[0] = "David" fmt.Println(p1.Friends) // ["Bob", "Charlie"] fmt.Println(p2.Friends) // ["David", "Charlie"]
方法 2. json 序列化/反序列化
适用于可JSON序列化的结构体:
import "encoding/json" func DeepCopyJSON(src, dst interface{ }) error { bytes, err := json.Marshal(src) if err != nil { return err } return json.Unmarshal(bytes, dst) } // 使用示例 p1 := &Person{ Name: "Alice", Age: 30, Friends: []string{ "Bob", "Charlie"}, } var p2 Person err := DeepCopyJSON(p1, &p2) if err != nil { panic(err) }
Go 中不可序列化的类型
类型 | 不可序列化的原因 | 替代方案 |
---|---|---|
chan |
绑定运行时状态,跨进程无意义 | 传递数据而非通道对象 |
func |
依赖代码段和闭包环境,存在安全风险 | 传递函数标识符和参数 |
Go 的设计哲学强调明确性和安全性,因此禁止对这类具有运行时依赖的类型进行序列化。
struct 结构体能不能比较
- 如果 struct 中
含有不能被比较的字段类型
,就不能被比较 - 如果 struct 中
所有的字段类型都支持比较
,那么就可以被比较。
不可被比较的类型:
- slice,因为 slice 是引用类型,除非是和nil比较
- map,和 slice 同理,如果要比较两个 map 只能通过循环遍历实现
- 函数类型
为什么引用类型不能比较 ?
引用类型,是想去比较值
还是地址
?会有歧义,因此 Go 从语言层面上直接杜绝了引用类型的比较;
当然引用类型可以和 nil 进行比较。
golang 中 make 和 new 的区别?
共同点:
- 给变量分配内存;
- make 与 new 对堆栈分配处理是相同的,编译器优先进行逃逸分析,逃逸的才分配到堆上
不同点:
- 作用变量类型不同,new 给 string、int、数组 分配内存;make给 slice、map、channel 分配内存;
- 返回类型不一样,new 返回指向变量的指针,make 返回变量本身;
- new 分配的空间被清零。make 分配空间后,会进行初始化;
for range 的时候它的地址会发生变化么?
在 for a,b := range c
遍历中, a 和 b 在内存中只会存在一份
,即之后每次循环时遍历到的数据都是以值覆盖的方式赋给 a 和 b,a,b 的内存地址始终不变。
由于有这个特性,for 循环里面如果开协程,不要直接把 a 或者 b 的地址传给协程。
解决办法:在每次循环时,创建一个临时变量。
rune 类型
rune 是类型 int32
的别名,在所有方面都等价于它,用来区分字符值跟整数值。
在 Go 语言中,字符可以被分成两种类型处理:
- 对占 1 个字节的英文类字符,可以使用 byte(或者unit8);
- 对占 1 ~ 4 个字节的其他字符,可以使用 rune(或者int32),如中文、特殊符号等。
s := "Go语言编程"
// byte
fmt.Println([]byte(s)) // 输出:[71 111 232 175 173 232 168 128]
// rune
fmt.Println([]rune(s)) // 输出:[71 111 35821 35328]
获取变量类型?
类型开关(Type Switch
)是在运行时检查变量类型的最佳方式。
switch v := variable.(type) {
case Type1:
// 当 variable 的类型是 Type1 时执行的代码
case Type2:
// 当 variable 的类型是 Type2 时执行的代码
default:
// 当 variable 的类型不在上述 case 中时执行的代码
}
反射
Golang 的反射(reflection)机制允许程序在运行时获取和操作
变量的类型
和值
。
package main
import (
"fmt"
"reflect"
)
func main() {
author := "draven"
fmt.Println("TypeOf author:", reflect.TypeOf(author))
fmt.Println("ValueOf author:", reflect.ValueOf(author))
}
// 结果
// TypeOf author: string
// ValueOf author: draven
反射优点:
- 反射就是在程序运行的过程中,可以对一个未知类型的数据进行操作的过程
- 可以减少重复代码
缺点:
- 反射会消耗性能,使程序运行缓慢
Select 底层
go 的 select 为 golang 提供了多路 IO 复用
机制,用于处理多个通道(channel)操作。
以下是针对源码 selectgo
函数的关键部分注释:
1. 初始化阶段
// 将输入的 scase 数组和 order 数组转换为固定大小的切片(最大 65536 个 case)
cas1 := (*[1 << 16]scase)(unsafe.Pointer(cas0))
order1 := (*[1 << 17]uint16)(unsafe.Pointer(order0))
// 计算总 case 数(发送 + 接收)
ncases := nsends + nrecvs
scases := cas1[:ncases:ncases] // scase 切片
pollorder := order1[:ncases:ncases] // 轮询顺序(随机化)
lockorder := order1[ncases:][:ncases:ncases] // 加锁顺序(按 channel 地址排序)
2. 生成轮询顺序(Poll Order)
// 遍历所有 case,过滤掉 channel 为 nil 的 case
for i := range scases {
cas := &scases[i]
if cas.c == nil {
cas.elem = nil // 允许 GC 回收
continue
}
// 使用随机算法打乱轮询顺序(避免饥饿)
j := fastrandn(uint32(norder + 1))
pollorder[norder] = pollorder[j]
pollorder[j] = uint16(i)
norder++
}
pollorder = pollorder[:norder] // 最终轮询顺序
3. 生成加锁顺序(Lock Order)
// 使用堆排序按 channel 地址排序,避免死锁
for i := range lockorder {
j := i
c := scases[pollorder[i]].c
for j > 0 && scases[lockorder[(j-1)/2]].c.sortkey() < c.sortkey() {
k := (j - 1) / 2
lockorder[j] = lockorder[k]
j = k
}
lockorder[j] = pollorder[i]
}
// ...(后续堆排序调整逻辑)
关键点:
• 按 channel 内存地址排序,确保全局加锁顺序一致,防止死锁。
4. 加锁所有 channel
sellock(scases, lockorder) // 按 lockorder 顺序加锁
5. 第一轮轮询(检查就绪的 case)
for _, casei := range pollorder {
casi = int(casei)
cas = &scases[casi]
c = cas.c
if casi >= nsends {
// 接收 case
if sg := c.sendq.dequeue(); sg != nil {
goto recv // 有发送者等待,直接接收
}
if c.qcount > 0 {
goto bufrecv // 缓冲区有数据,从缓冲区接收
}
if c.closed != 0 {
goto rclose // channel 已关闭
}
} else {
// 发送 case
if c.closed != 0 {
goto sclose // 不能向已关闭 channel 发送
}
if sg := c.recvq.dequeue(); sg != nil {
goto send // 有接收者等待,直接发送
}
if c.qcount < c.dataqsiz {
goto bufsend // 缓冲区未满,写入缓冲区
}
}
}
逻辑:
• 优先检查是否有可直接处理的 case(如已有 goroutine 阻塞在对面操作)。
6. 第二轮处理(阻塞等待)
if !block {
selunlock(scases, lockorder)
casi = -1 // 非阻塞且无 case 就绪,直接返回
goto retc
}
// 将当前 goroutine 加入所有 channel 的等待队列
gp := getg()
for _, casei := range lockorder {
casi = int(casei)
cas = &scases[casi]
c = cas.c
sg := acquireSudog() // 创建 sudog 并加入队列
sg.g = gp
sg.isSelect = true
sg.c = c
if casi < nsends {
c.sendq.enqueue(sg) // 加入发送队列
} else {
c.recvq.enqueue(sg) // 加入接收队列
}
}
// 挂起当前 goroutine,等待被唤醒
gopark(selparkcommit, nil, waitReasonSelect, traceBlockSelect, 1)
7. 被唤醒后的处理
// 从 gp.param 获取唤醒的 sudog(即成功的 case)
sg := (*sudog)(gp.param)
casi = -1
cas = nil
// 清理其他未成功的 case 的等待队列
for sg1 := gp.waiting; sg1 != nil; sg1 = sg1.waitlink {
if sg == sg1 {
casi = int(casei)
cas = &scases[casi]
} else {
c := scases[casei].c
if casei < nsends {
c.sendq.dequeueSudoG(sg1)
} else {
c.recvq.dequeueSudoG(sg1)
}
}
releaseSudog(sg1) // 释放 sudog
}
8. 返回结果
retc:
return casi, recvOK // 返回选中的 case 索引和接收状态
关键路径总结
- 初始化:转换输入参数,生成轮询和加锁顺序。
- 第一轮轮询:检查是否有立即就绪的 case。
- 第二轮阻塞:若无就绪 case,将 goroutine 加入所有 channel 的等待队列。
- 唤醒处理:被唤醒后确定成功的 case,清理其他队列。
- 返回结果:返回执行的 case 索引和状态。
select 的特性
-
随机选择
当多个 case 同时可以执行时,select 会随机选择一个执行。这有助于避免某些 case 长时间得不到执行的情况(饥饿问题)。 -
非阻塞选择
使用 default 子句可以实现非阻塞的选择。如果没有任何 case 可以立即执行,select 会执行 default 子句,从而避免阻塞。 -
多路复用
select 语句允许在一个 goroutine 中同时等待多个通道操作,类似于多路复用器(multiplexer)。
select 的场景
- 竞争选举
这个是最常见的使用场景,多个通道,有一个满足条件可以读取,就可以“竞选成功”
select {
case i := <-ch1:
fmt.Printf("从ch1读取了数据%d", i)
case j := <-ch2:
fmt.Printf("从ch2读取了数据%d", j)
case m := <- ch3
fmt.Printf("从ch3读取了数据%d", m)
...
}
- 超时处理(保证不阻塞)
因为select是阻塞的,我们有时候就需要搭配超时处理来处理这种情况,超过某一个时间就要进行处理,保证程序不阻塞。
select {
case str := <- ch1
fmt.Println("receive str", str)
case <- time.After(time.Second * 5):
fmt.Println("timeout!!")
}
- 阻塞main函数
有时候我们会让main函数阻塞不退出,如http服务,我们会使用空的select{}来阻塞main goroutine
package main
import (
"fmt"
"time"
)
func main() {
bufChan := make(chan int)
go func() {
for{
bufChan <-1
time.Sleep(time.Second)
}
}()
go func() {
for{
fmt.Println(<-bufChan)
}
}()
select{
}
}
Golang 中 select
和 switch
的区别
select
和 switch
是 Go 语言中两个不同的控制结构,虽然它们语法上有些相似,但用途和机制完全不同。
特性 | select |
switch |
---|---|---|
用途 | 用于多通道操作 | 用于条件分支 |
执行方式 | 随机选择一个可执行的 case | 从上到下顺序匹配第一个 true case |
默认行为 | 有 default 分支时不会阻塞 |
有 default 分支时作为默认情况 |
适用场景 | 通道通信 | 值比较或类型判断 |
// switch 示例
value := 2
switch value {
case 1:
fmt.Println("Case 1")
case 2:
fmt.Println("Case 2") // 这个会被执行
case 3:
fmt.Println("Case 3")
}
// select 示例
ch := make(chan int, 1)
ch <- 1
select {
case <-ch:
fmt.Println("Received from ch") // 可能执行这个
case ch <- 2:
fmt.Println("Sent to ch") // 也可能执行这个
}
总结:switch
是条件分支结构,而 select
是专门为通道设计的 I/O 多路复用结构。
context 结构是什么样的?context 使用场景和用途?
Go 1.7 标准库引入 context,它是 goroutine 的上下文,包含 goroutine 的运行状态、环境等信息。
Go 的 Context 的数据结构包含 Deadline,Done,Err,Value
// Conetext 包介绍 : 通常context携带截止时间,**和取消信号**,以及其他跨越API边界的值,Context的方法可以被多个协程同时调用。
package context
type Context interface {
// 返回截止的日期,如果无截止日期,ok返回false
Deadline() (deadline time.Time, ok bool)
// 返回一个channel,当工作已完成或者上下文被取消时关闭。如果是一个不会被取消的上下文,Done会返回nil
// WithCancel方法,会在被调用cancel时,关闭Done
// WithDeadline方法,会在过截止时间时,关闭Done
// WithTimeout方法,会在超时结束时,关闭Done
Done() <-chan struct{
}
// Done没有被关闭时,会返回nil
// 如果Done关闭了,将会返回关闭的原因(取消、超时)
Err() error
// 返回与当前上下文关联的键值或nil。如果没有值与键关联,使用相同键连续调用 Value 会返回相同的结果
Value(key interface{
}) interface{
}
}
context 主要用来:
- 在 goroutine 之间传递上下文信息,包括:取消信号、超时时间(context.WithTimeout )、截止时间、k-v 等
- 上下文控制
- 多个 goroutine 之间的数据交互等
- 超时控制:到某个时间点超时,过多久超时
Go语言中的单引号、双引号和反引号
- 单引号
单引号表示 rune(int32) 类型,单引号里面是单个字符,对应的值为改字符的ASCII值。
func main() {
a := 'A'
fmt.Println(a)
}
// 输出:
// 65
- 双引号
双引号里面可以是字符串和转义字符,如\n、\r等,对应 go 语言中的 string 类型。
func main() {
a := "Hello golang\nI am random_wz."
fmt.Println(a)
}
// 输出:
// Hello golang
// I am random_wz.
- 反引号
多行内容,不支持转义。
func main() {
a := `Hello golang\n:
I am random_wz.
Good.`
fmt.Println(a)
}
// 输出:
// Hello golang\n:
// I am random_wz.
// Good.
// 可以看到 `\n` 并没有被转义,而是被直接作为字符串输出。
Go 语言触发 panic 的情况?
在 Go 语言中,panic
是一种用于处理程序无法继续执行的严重错误的机制。
当程序遇到无法恢复的错误时,会触发 panic
,导致程序立即停止当前函数的执行,并开始逐层向上回溯调用栈,执行每个函数的 defer
语句,最后退出程序。
以下是 Go 中常见的触发 panic
的情况:
- 数组或切片越界访问
- 当访问数组或切片的索引超出其范围时,会触发
panic
。
arr := [3]int{ 1, 2, 3} fmt.Println(arr[5]) // panic: runtime error: index out of range [5] with length 3
- 当访问数组或切片的索引超出其范围时,会触发
- 空指针解引用
- 当解引用一个
nil
指针时,会触发panic
。
var ptr *int fmt.Println(*ptr) // panic: runtime error: invalid memory address or nil pointer dereference
- 当解引用一个
- 向已关闭的通道发送数据
- 当向一个已关闭的通道发送数据时,会触发
panic
。
ch := make(chan int) close(ch) ch <- 1 // panic: send on closed channel
- 当向一个已关闭的通道发送数据时,会触发
- 类型断言失败
- 当类型断言失败且未使用
ok
接收返回值时,会触发panic
。
var i interface{ } = "hello" num := i.(int) // panic: interface conversion: interface {} is string, not int
- 当类型断言失败且未使用
- 除零操作
- 当进行整数除零操作时,会触发
panic
。
a := 10 b := 0 fmt.Println(a / b) // panic: runtime error: integer divide by zero
- 当进行整数除零操作时,会触发
- 递归调用栈溢出
- 当递归调用过深,导致调用栈溢出时,会触发
panic
。
func infiniteRecursion() { infiniteRecursion() } infiniteRecursion() // panic: runtime error: stack overflow
- 当递归调用过深,导致调用栈溢出时,会触发
- 手动调用
panic
- 开发者可以手动调用
panic
来中止程序执行。
panic("something went wrong") // panic: something went wrong
- 开发者可以手动调用
- 使用未初始化的 map
- 当向一个未初始化的
map
插入数据时,会触发panic
。
var m map[string]int m["key"] = 1 // panic: assignment to entry in nil map
- 当向一个未初始化的
- 调用
sync.WaitGroup
的Done
方法次数过多- 当调用
sync.WaitGroup
的Done
方法次数超过Add
方法设置的值时,会触发panic
。
var wg sync.WaitGroup wg.Add(1) wg.Done() wg.Done() // panic: sync: negative WaitGroup counter
- 当调用
- 并发读写
map
- 当多个 goroutine 并发读写
map
时,会触发panic
。
m := make(map[string]int)
go func() {
m["key"] = 1
}()
fmt.Println(m["key"]) // 可能触发 panic: concurrent map read and map write
- 调用
close
关闭nil
通道
- 当尝试关闭一个
nil
通道时,会触发panic
。
var ch chan int
close(ch) // panic: close of nil channel
- 使用
sync.Mutex
未正确加锁
- 当尝试解锁一个未加锁的
sync.Mutex
时,会触发panic
。
var mu sync.Mutex
mu.Unlock() // panic: sync: unlock of unlocked mutex
Go 语言中字符串拼接的方法
Go 语言中有多种字符串拼接方式,各有优缺点和适用场景。以下是主要的字符串拼接方法:
-
使用
+
运算符最简单的拼接方式:
str1 := "Hello" str2 := "World"