go语言核心知识点-面试突击宝典
又是一年校招季,常常有牛友抱怨go语言的学习资料和面经太少。为了帮助广大牛友顺利斩获offer,我精心整理了50道面试中常问到的go语言核心知识点。既帮助想学习go语言的同学夯实基础,也帮助需要用go语言参加面试的牛友查缺补漏。整理的过程比较仓促,如果大家发现有什么不对的地方,欢迎随时指正。
下面正式进入go语言核心知识点50讲。
1. 切片是值传递还是引用传递
结论:Go语言中所有的传参都是值传递(传值),都是一个副本,一个拷贝。因为拷贝的内容有时候是非引用类型(int、string、struct等),这样就在函数中就无法修改原内容数据;有的是引用类型(指针、map、slice、chan等),这样就可以修改原内容数据。因此切片是值传递,但是切片是引用类型,所以在函数内修改切片会影响到切片本身(注意:引用类型和引用传递是两个概念)。
2. 切片的深拷贝和浅拷贝
结论: 切片是一个引用类型,它实际并不存储元素,但它内部的指针指向了一个数组。将切片s1赋值给s2,s1和s2内部的指针指向了相同的底层数组,因此修改s1指向的数组的值,会同时影响到s2指向的数组的值,这一现象被称之为浅拷贝。使用copy函数,copy(s2,s1),s2和s1是两个完全不同的切片,其内部指针也将指向两个不同的数组,这样任意一方的修改都不会影响到另一方,这一现象被称之为深拷贝。
3. 切片的底层实现
结论:切片的数据结构是一个结构体,结构体内由三个参数。
- Pointer 指向数组中它要表示的片段的起始元素;
- len 长度
- cap 最大容量
type slice struct { array unsafe.Pointer len int cap int }
4. 切片的扩容机制
结论:Go 1.17切片扩容时会进行内存对齐,这个和内存分配策略相关。进行内存对齐之后,新 slice 的容量是要大于等于老 slice 容量的2倍或者1.25倍。
Go1.18不再以1024为临界点,而是设定了一个值为256的 threshold,以256为临界点;超过256,不再是每次扩容1/4,而是每次增加(旧容量+3*256)/4;
- 当新切片需要的容量cap大于两倍扩容的容量,则直接按照新切片需要的容量扩容;
- 当原 slice 容量 < threshold 的时候,新 slice 容量变成原来的 2 倍;
- 当原 slice 容量 > threshold,进入一个循环,每次容量增加(旧容量+3*threshold)/4。
5. map线程安全吗
结论:线程安全是指程序在并发执行或者多个线程同时操作的情况下,执行结果还是正确的。Go语言中的map在并发情况下,只读是线程安全的,同时读写是线程不安全的。因为map变量为指针类型变量,并发写时,多个协程同时操作一个内存,类似于多线程操作同一个资源会发生竞争关系,共享资源会遭到破坏, go语言出于安全的考虑,抛出致命错误:fatal error: concurrent map writes。
6. 哪些类型可以作为map的key
结论:在Go语言中,可比较的类型都可以作为map key,包括boolean、数字类型、string、指针、channel、interface接口、struct、数组。不可以作为map的key的类型有:slice、map、函数。
7. map删除一个key内存会不会释放
结论:在 Go 语言中使用delete函数从 map 中删除一个 key 时,对应的 key-value 对会被移除,但是内存并不会立刻释放。Go 的垃圾回收器负责管理内存的分配和释放。当一个 key-value 对从 map 中删除后,只要没有其他对象引用该 value,这部分内存最终会被垃圾回收器释放。
8. map为什么是无序的
结论: map 在扩容后,可能会将部分 key 移至新内存,那么这一部分实际上就已经是无序的了。而遍历的过程,其实就是按顺序遍历内存地址,同时按顺序遍历内存地址中的 key。但这时已经是无序的了。同时,为了防止有人认为只要不对 map 进行修改删除等操作,使其不发生扩容,顺序就不会改变,go在源码中加上随机的元素,将遍历 map 的顺序随机化,用来防止使用者顺序遍历map。
9. map的底层实现
结论:Go map的底层实现是一个哈希表(数组 + 链表),使用拉链法消除哈希冲突,因此实现map的过程实际上就是实现哈希表的过程(具体细节可以看B站幼麒实验室的讲解)。
type hmap struct { count int // 元素个数,调用len(map)返回这个值 B uint8 // bucket数量是2^B, 最多可以放 loadFactor * 2^B 个元素,再多就要扩容了 hash0 uint32 // hash seed buckets unsafe.Pointer // 指向bucket数组的指针(存储key val);大小:2^B oldbuckets unsafe.Pointer // 扩容时,buckets 长度是 oldbuckets 的两倍 // ... } type bmap struct { topbits [8]uint8 // 高位哈希值数组 keys [8]keytype // 存储key的数组 values [8]valuetype // 存储val的数组 overflow uintptr // 指向当前bucket的溢出桶 // 为缓解当存在多个key计算后的哈希值低8位相同的个数大于一个bucket所能存放的数目8个时,且这个map还没达到扩容条件时,做的一种存储设计。 }
10. map如何顺序读写
结论:首先将 map 中的键存储到一个切片中,然后对切片进行排序,最后,按照排序后的顺序遍历 map。就可以按照特定顺序输出键值对。
11. sync.Map的底层实现
结论: 通过 read 和 dirty 两个字段将读写分离,读的数据存在只读字段 read 上,将最新写入的数据则存在 dirty 字段上。读取时会先查询 read,不存在再查询 dirty,写入时则只写入 dirty。读取 read 并不需要加锁,而读或写 dirty 都需要加锁。另外有 misses 字段来统计 read 被穿透的次数(被穿透指需要读 dirty 的情况),超过一定次数则将 dirty 数据同步到 read 上。对于删除数据则直接通过标记来延迟删除。
type Map struct { // 加锁作用,保护 dirty 字段 mu Mutex // 只读的数据,实际数据类型为 readOnly read atomic.Value // 最新写入的数据 dirty map[interface{}]*entry // 计数器,每次需要读 dirty 则 +1 misses int }
12. rune和byte的区别
结论:在 Go 语言中支持两个字符类型:byte数组和rune类型,主要区别就是rune能够表达的字符范围更大, byte(实际上是 uint8 的别名),代表 UTF-8 字符串的单个字节的值,用来储存ASCII码,表示一个ASCII码字符; rune(实际上是int32),代表单个 Unicode字符,常用来处理unicode或utf-8字符,就是rune的使用范围更大。当需要切割类似于"go语言"这样带有汉字的字符串时, 需要将字符串转成[]rune数组,而不是[]byte数组。
func main() { strs := "go语言" fmt.Println(string([]byte(strs)[0:3])) // 输出:go� fmt.Println(string([]rune(strs)[0:3])) // 输出:go语 }
13. struct能否进行比较
结论:可以。在Go 中,两个结构体( struct )可以进行比较的条件是它们的字段类型都是可比较的。 可比较的类型包括基本数据类型(如整数、浮点数、字符串等)以及指针、数组、结构体等,只要它们的元素或字段类型也是可比较的。但一般不建议直接比较两个结构体,如果有需要,可以使用reflect.DeepEqual()函数。
14. 结构体的tag如何获取
结论:利用反射可以获取结构体的tag。
func main() { type S struct { F string species:"gopher" color:"blue" } s := S{} st := reflect.TypeOf(s) field := st.Field(0) fmt.Println(field.Tag.Get("color"), field.Tag.Get("species")) // blue gopher }
15. go实现单例模式
结论:使用sync.Once()函数。
package singleton import ( "sync" ) type singleton struct {} var instance *singleton var once sync.Once func GetInstance() *singleton { once.Do(func() { instance = &singleton{} }) return instance }
16. go不用sync.Once实现单例模式
结论:可以使用加锁的方式实现。
var mu Sync.Mutex func GetInstance() *singleton { mu.Lock() // 如果实例存在没有必要加锁 defer mu.Unlock() if instance == nil { instance = &singleton{} } return instance }
17. make和new的区别
结论:make和new都是golang用来分配内存的函数,make 即分配内存,也初始化内存。new只是将内存清零,并没有初始化内存。make返回的还是引用类型本身, 用于切片、map、channel的初始化,而new返回的是指向类型的指针。
18. go项目引用包为什么用_和init()函数
结论:在Go语言中,init()函数是一个特殊的函数,它在程序开始执行时被调用,用来初始化程序。init()函数不能被主动调用,而是由Go运行时系统自动执行。当我们导入一个包时,如果我们想仅仅执行包的init()函数,而不引用包中的其他函数或变量,我们可以使用下划线 _ 来重命名导入的包,这样Go编译器就不会报错说我们导入了包却没有使用。这种方式常用于只希望调用包中的init()函数时,而不需要包的其他内容。
19. 如何判断一个结构体是否实现了某接口
结论:可以利用反射接口类型reflect.Type的Implements方法可以做判断。
type SayHello interface { Hello() } type Person struct { Name string } func (p *Person) Hello() { fmt.Printf("Hello, %s! ", p.Name) } func main() { p := &Person{} rv := reflect.TypeOf(p) if rv.Implements(reflect.TypeOf((*SayHello)(nil)).Elem()) { fmt.Println("实现了SayHello接口") } }
20. 空interface{}和interface的底层数据结构
结论:go语言中的接口分为带方法的接口和空接口。 带方法的接口在底层用iface表示,空接口的底层则是eface表示。(具体细节可以看B站幼麒实验室的讲解)。
//空接口 type eface struct { _type *_type //接口内部存储的具体数据的真实类型 data unsafe.Pointer //data是指向真实数据的指针 } //非空接口 type iface struct { tab *itab data unsafe.Pointer //data是指向真实数据的指针 }
21. 不同结构体或者不同切片怎么进行比较
结论:reflect.DeepEqual函数可实现不同结构体或者不同切片之间的比较。
22. GMP调度原理
结论:GMP 模型是 Go 的运行时系统采用的一种并发模型,它将 M (machine)、P (processor) 和 G (goroutine) 三者分离开来,通过一些特殊的机制协调它们之间的关系。M 是线程,它负责执行 G。而 P 是 M 的管理者 ,它维护一个运行队列,其中保存了等待执行的 G。当 M 完成任务后,P 会从队列中取出一个 G,然后将该 G 绑定到当前的 M 上,最后调度 M 执行该 G 的代码。G 则是 Go 语言并发的最小单位,它包含了一个函数和运行该函数所需要的资源,比如堆栈和上下文。GMP 模型可以根据应用程序的需要自动创建或者销毁线程,以实现并行处理,同时,通过 P 的抽象,可以在不同的 M 上运行多个 G,从而让 Go 程序在多核 CPU 上发挥出更好的性能。
23. go的GC
结论:Go语言的垃圾回收采用标记——清扫算法,支持主体并发增量式回收,使用插入与删除两种写屏障结合的混合写屏障 (这一块比较绕,我把最重要的几个关键词列举了出来,建议大家结合网上的一些视频资料进行理解,面试的时候按照自己的理解把这句话解释得通就行)。
24. 闭包
结论:对闭包来说,函数在该语言中得是一等公民。一般来说,一个函数返回另外一个函数,这个被返回的函数可以引用外层函数的局部变量,这形成了一个闭包。通常,闭包通过一个结构体来实现,它存储一个函数和一个关联的上下文环境。但 Go 语言中,匿名函数就是一个闭包,它可以直接引用外部函数的局部变量。
25. go语言函数是一等公民是什么意思
结论:一等公民,是指支持所有操作的实体, 这些操作通常包括作为参数传递,从函数返回,修改并分配给变量等。比如 int 类型,它支持作为参数传递,可以从函数返回,也可以赋值给变量,因此它是一等公民。函数是一等公民,意味着可以把函数赋值给变量或存储在数据结构中,也可以把函数作为其它函数的参数或者返回值。Go语言的函数就支持这些操作。
26. sync.Mutex和sync.RWMutex
结论:
Mutex 也称为互斥锁,互斥锁就是互相排斥的锁,它可以用作保护临界区的共享资源,保证同一时刻只有一个 goroutine 操作临界区中的共享资源。互斥锁 Mutex类型有两个方法,Lock和 Unlock。
type Mutex struct { state int32 // 互斥锁的状态 sema uint32 // 信号量,用于控制互斥锁的状态 }
使用互斥锁的注意事项
- Mutex 类型变量的零值是一个未锁定状态的互斥锁
- Mutex 在首次被使用之后就不能再被拷贝(Mutex 是值类型,拷贝会同时拷贝互斥锁的状态)
- Mutex 在未锁定状态(还未锁定或已被解锁),调用 Unlock方法,将会引发运行时错误
- Mutex 的锁定状态与特定 goroutine 没有关联,Mutex 被一个 goroutine 锁定, 可以被另外一个 goroutine 解锁。(不建议使用,必须使用时需要格外小心)
- Mutex 的 Lock方法和 Unlock方法要成对使用,不要忘记将锁定的互斥锁解锁,一般做法是使用 defer
RWMutex 也称为读写互斥锁,读写互斥锁就是读取/写入互相排斥的锁。它可以由任意数量的读取操作的 goroutine 或单个写入操作的 goroutine 持有。读写互斥锁 RWMutex 类型有五个方法,Lock,Unlock,Rlock,RUnlock 和 RLocker。其中,RLocker 返回一个 Locker 接口,该接口通过调用rw.RLock 和 rw.RUnlock 来实现 Lock 和 Unlock 方法。
type RWMutex struct { w Mutex // held if there are pending writers writerSem uint32 // semaphore for writers to wait for completing readers readerSem uint32 // semaphore for readers to wait for completing writers readerCount atomic.Int32 // number of pending readers readerWait atomic.Int32 // number of departing readers }
使用读写互斥锁的注意事项
- RWMutex 类型变量的零值是一个未锁定状态的互斥锁
- RWMutex 在首次被使用之后就不能再被拷贝
- RWMutex 的读锁或写锁在未锁定状态,解锁操作都会引发 panic
- RWMutex 的一个写锁 Lock 去锁定临界区的共享资源,如果临界区的共享资源已被(读锁或写锁)锁定,这个写锁操作的 goroutine 将被阻塞直到解锁
- RWMutex 的读锁不要用于递归调用,比较容易产生死锁
- RWMutex 的锁定状态与特定的 goroutine 没有关联。一个 goroutine 可以 RLock(Lock),另一个 goroutine 可以 RUnlock(Unlock)
- 写锁被解锁后,所有因操作锁定读锁而被阻塞的 goroutine 会被唤醒,并都可以成功锁定读锁
- 读锁被解锁后,在没有被其他读锁锁定的前提下,所有因操作锁定写锁而被阻塞的 goroutine,其中等待时间最长的一个 goroutine 会被唤醒
Mutex 和 RWMutex 的区别
- RWMutex 将对临界区的共享资源的读写操作做了区分,RWMutex 可以针对读写操作做不同级别的锁保护。
- RWMutex 读写锁中包含读锁和写锁,它的 Lock和 Unlock 方法用作写锁保护,它的 Rlock和 RUnlock 方法用作读锁保护。
- RWMutex 读写锁中的读锁和写锁关系如下:
- 在写锁处于锁定状态时,操作锁定读锁的 goroutine 会被阻塞。
- 在写锁处于锁定状态时,操作锁定写锁的 goroutine 会被阻塞。
- 在读锁处于锁定状态时,操作锁定写锁的 goroutine 会被阻塞。
- 但是,在读锁处于锁定状态时,操作锁定读锁的 goroutine 不会被阻塞。我们可以理解为读锁保护的临界区的共享资源,多个读操作可以同时执行
27. sync.WaitGroup
结论:sync.WaitGroup 用于阻塞等待一组 Go 程的结束。如果有一个任务可以分解成多个子任务进行处理,同时每个子任务没有先后执行顺序的限制,等到全部子任务执行完毕后,再进行下一步处理。这时每个子任务的执行可以并发处理,这种情景下适合使用sync.WaitGroup。
标准用法
- 启动 Go 程时调用 Add()
- 在 Go 程结束时调用 Done()
- 最后调用 Wait()
package main import ( "fmt" "sync" ) func main() { var wg sync.WaitGroup wg.Add(3) go handlerTask1(&wg) go handlerTask2(&wg) go handlerTask3(&wg) wg.Wait() fmt.Println("全部任务执行完毕.") } func handlerTask1(wg *sync.WaitGroup) { defer wg.Done() fmt.Println("执行任务 1") } func handlerTask2(wg *sync.WaitGroup) { defer wg.Done() fmt.Println("执行任务 2") } func handlerTask3(wg *sync.WaitGroup) { defer wg.Done() fmt.Println("执行任务 3") }
28. sync.Cond
结论:sync.Cond 条件变量用来协调想要访问共享资源的那些 goroutine,当共享资源的状态发生变化的时候,它可以用来通知被互斥锁阻塞的 goroutine。假设一个场景:有一个协程在异步地接收数据,剩下的多个协程必须等待这个协程接收完数据,才能读取到正确的数据。在这种情况下,如果单纯使用 chan 或互斥锁,那么只能有一个协程可以等待,并读取到数据,没办法通知其他的协程也读取数据。这个时候,就需要有个全局的变量来标志第一个协程数据是否接受完毕,剩下的协程,反复检查该变量的值,直到满足要求。或者创建多个 channel,每个协程阻塞在一个 channel 上,由接收数据的协程在数据接收完毕后,逐个通知。总之,需要额外的复杂度来完成这件事。Go 语言在标准库 sync 中内置一个sync.Cond 用来解决这类问题。和sync.Cond相关的有4个方法:
- NewCond创建实例
- Broadcast广播唤醒所有
- Signal唤醒一个协程
- Wait等待
package main import ( "log" "sync" "time" ) var done bool func main() { // 1. 定义一个互斥锁,用于保护共享数据 mu := sync.Mutex{} // 2. 创建一个sync.Cond对象,关联这个互斥锁 cond := sync.NewCond(&mu) go read("reader1", cond) go read("reader2", cond) go read("reader3", cond) write("writer", cond) time.Sleep(time.Second * 3) } func read(name string, c *sync.Cond) { // 3. 在需要等待条件变量的地方,获取这个互斥锁,并使用Wait方法等待条件变量被通知; c.L.Lock() for !done { c.Wait() } log.Println(name, "starts reading") c.L.Unlock() } func write(name string, c *sync.Cond) { // 4. 在需要通知等待的协程时,使用Signal或Broadcast方法通知等待的协程。 log.Println(name, "starts writing") time.Sleep(time.Second) c.L.Lock() done = true c.L.Unlock() log.Println(name, "wakes all") c.Broadcast() // 如果不广播, read()方法的 log.Println(name, "starts reading")不会执行 } /* 输出: 2024/10/02 21:27:50 writer starts writing 2024/10/02 21:27:51 writer wakes all 2024/10/02 21:27:51 reader3 starts reading 2024/10/02 21:27:51 reader1 starts reading 2024/10/02 21:27:51 reader2 starts reading */
29. sync.Pool
结论:sync.pool用于保存和复用临时对象,减少内存分配,降低 GC 压力。使用方式:
- 声明对象池
- Get & Put
package main import ( "encoding/json" "fmt" "sync" ) type Student struct { Name string json:"name" } var studentPool = sync.Pool{ New: func() interface{} { return new(Student) }, } func main() { buf := {"name":"Mike"} stu := studentPool.Get().(*Student) fmt.Println(*stu) // {} err := json.Unmarshal([]byte(buf), stu) if err != nil { fmt.Printf("err:%s,err") return } fmt.Println(*stu) studentPool.Put(stu) // {Mike} stu2 := studentPool.Get().(*Student) fmt.Println(*stu2) // {Mike} }
30. go的错误处理
结论:Go 语言中把错误当成一种特殊的值来处理,不支持其他语言中使用try/catch捕获异常的方式。
Go 语言中使用一个名为 error接口来表示错误类型。
type error interface { Error() string }
如果需要自定义 error,最简单的方式是使用errors包提供的New函数创建一个错误。
// New returns an error that formats as the given text. // Each call to New returns a distinct error value even if the text is identical. func New(text string) error { return &errorString{text} } // errorString is a trivial implementation of error. type errorString struct { s string } func (e *errorString) Error() string { return e.s }
我们还可以自己定义结构体类型,实现error接口
// WrapError 自定义结构体类型 type WrapError struct { s string } // WrapError 类型实现error接口 func (e *WrapError) Error() string { return fmt.Sprintf("err:%s", e.s) }
31. goroutine
结论:在Go语言中,goroutine(Go routine)是一种轻量级的执行单元。可以将其理解为一个函数的并发执行,类似于线程,但比线程更轻量级。与传统的线程相比,创建和销毁goroutine的开销非常小。在Go程序中,可以轻松地创建成千上万个goroutine,每个goroutine都能够独立执行,而不需要手动管理线程和锁。这使得在Go语言中实现并发变得非常容易。要创建一个goroutine,只需要在函数调用前加上关键字"go"即可。Go语言的运行时系统(runtime)负责调度和管理goroutine的执行。运行时系统在多个逻辑处理器上调度goroutine,使得它们可以并发执行。以下是goroutine的底层实现原理的一些关键点:
- 栈的动态增长:每个goroutine有一个固定大小的栈空间,初始大小一般很小(几KB)。当需要更多的栈空间时,运行时系统会动态地扩展栈的大小。这种栈的动态增长使得goroutine可以有效地处理深度递归或者大型数据结构。
- 上下文切换:当一个goroutine遇到阻塞操作(如等待I/O、等待通道的数据等)时,运行时系统会自动将该goroutine切换出执行,并让其他可运行的goroutine继续执行。这种上下文切换是协作式的,是在运行时系统控制下完成的,而不是由操作系统的调度器决定。这使得goroutine的切换非常高效。
- 调度器:Go语言的运行时系统有一个调度器(scheduler),负责在逻辑处理器上调度和管理goroutine的执行。调度器会根据一些策略(如工作窃取)来决定将goroutine分配给哪个逻辑处理器执行。调度器还会处理阻塞的goroutine,并在其可以继续执行时将其重新调度。
- 垃圾回收:运行时系统中的垃圾回收器(garbage collector)负责自动管理内存的分配和回收。垃圾回收器会追踪和收集不再使用的对象,并回收其占用的内存空间。垃圾回收器与goroutine的协作非常紧密,确保在回收内存时不会影响正在执行的goroutine。
- 同步和通信:在多个goroutine之间进行同步和通信通常使用通道(channel)。通道提供了一种安全可靠的方式来传递数据和同步操作。通道的使用能够确保在goroutine之间的数据传递和同步操作的正确性和可靠性。
32. go的panic是什么?怎么捕获处理?不捕获会发生什么?go的panic是什么?怎么捕获处理?不捕获会发生什么?
结论:在go语言中,panic是一个运行时错误,类似于其他编程语言中的异常。当程序发生严重错误时,比如数组越界、空指针解引用等情况,Go程序就会引发panic,导致程序崩溃。要捕获和处理panic,可以使用内置函数recover()。在defer中调用recover()函数可以捕获panic,并允许程序继续执行,而不是立即退出。这样就可以在程序崩溃之前执行一些清理工作或者记录错误信息。如果不捕获panic,程序会在发生错误时直接终止运行,输出panic信息以及堆栈跟踪,这可能会导致程序状态不一致或数据丢失。总的来说,处理panic可以优雅地处理错误,确保程序可以在意外情况下继续稳定运行。
33. 子协程panic,父协程会panic吗?
结论:在 Go语言中,当子协程(goroutine)中出现 panic 时,如果没有恢复机制(例如defer/recover),整个程序可能会崩溃并终止。为了避免影响父协程的继续执行,可以在子协程中使用 defer/recover 来捕获 panic,并在子协程内部处理它,以避免影响父协程。这样可以保证父协程继续执行,而不受子协程 panic 的影响。(注:在生产实践中,开goroutine执行一些异步操作时,最好都预先处理一下可能发生的panic,避免影响主流程)。
package main import ( "fmt" "time" ) func childRoutine() { defer func() { if r := recover(); r != nil { fmt.Println("Recovered from panic in childRoutine:", r) } }() // 注释到这段defer则会立马退出程序,不会打印"Main goroutine continues after childRoutine" // Simulate panic in childRoutine panic("Panic in childRoutine") } func main() { go childRoutine() // Allow some time for childRoutine to panic time.Sleep(1 * time.Second) fmt.Println("Main goroutine continues after childRoutine") } // output /* Recovered from panic in childRoutine: Panic in childRoutine Main goroutine continues after childRoutine */
34. go程和线程有什么区别
结论:Go协程和线程是两种并发执行的机制,它们有以下几个主要区别:
- 调度器:Go协程由Go语言的运行时调度器(Goroutine Scheduler)调度,而线程由操作系统的调度器(Thread Scheduler)调度。Go调度器使用了类似于M:N的模型,将多个协程映射到更少的OS线程上,使得协程的调度更加轻量级和高效。
- 创建和销毁的代价:创建和销毁协程的代价远远低于线程。协程的创建和销毁只需几个栈帧和几个字节的内存,而线程的创建和销毁需要较大的栈空间、寄存器、内存等资源。
- 内存占用:协程的栈空间可以根据需要动态地伸缩,因此占用的内存相对较小。而线程的栈空间是固定的,因此占用的内存相对较大。
- 同步通信:协程之间可以通过channel进行同步通信,而线程通常需要借助于锁和条件变量等机制来实现同步。
- 异常处理:协程的异常可以被其所在的协程捕获和处理,而线程的异常通常需要通过线程外的机制来处理。
总的来说,Go协程相比于线程具有更低的创建和销毁代价、更小的内存占用,以及更高效的调度和同步通信机制,适合于高并发和高并行的场景。
35. channel的底层结构
type hchan struct { qcount uint // total data in the queue dataqsiz uint // size of the circular queue buf unsafe.Pointer // points to an array of dataqsiz elements elemsize uint16 closed uint32 elemtype *_type // element type sendx uint // send index recvx uint // receive index recvq waitq // list of recv waiters sendq waitq // list of send waiters // lock protects all fields in hchan, as well as several // fields in sudogs blocked on this channel. // // Do not change another G's status while holding this lock // (in particular, do not ready a G), as this can deadlock // with stack shrinking. lock mutex }
36. channel的使用场景,一般用来做什么
结论:Go 语言的 channel 用于协调并发程序中不同 Goroutines 之间的通信,以及控制并发代码的执行顺序。通过 channel,一个 Goroutine 可以向另一个 Goroutine 发送数据,也可以接收数据。这种通信方式让不同 Goroutines 之间能够安全地共享数据,避免了数据竞争等并发问题。
37. channel什么时候会panic
结论:在下述 4 种情况关闭 channel 会引发 panic
- 关闭为nil的channel
- 关闭一个已经关闭的通道
- 向一个已经关闭的通道写数据
- 关闭通道导致发送阻塞的协程panic
package main func main() { //closeNilChan() // 报错:panic: close of nil channel / *-----------------* / //closeHasClosedChan() // 报错: panic: close of closed channel / *-----------------* / //ch := make(chan int, 1) //sendToCloseChan(ch) // 报错: panic: send on closed channel / *-----------------* / } func closeNilChan() { ch := new(chan int) close(*ch) } func closeHasClosedChan() { ch := make(chan int, 0) close(ch) close(ch) } func sendToCloseChan(ch chan int) { close(ch) // 注释掉:报错信息 panic: send on closed channel 将消失 ch <- 1 }
38. golang不关闭channel会怎样
结论:
- 遍历一个未关闭的channel会造成死循环
- 即使关闭了一个非空通道,我们仍然可以从通道里面接收到未读取的数据
- 可以这样理解,close()函数会往channel中压入一条特殊的通知消息,可以用来通知channel接收者不会再收到数据。所以即使channel中有数据也可以close()而不会导致接收者收不到残留的数据
- channel不需要通过close释放资源,只要没有goroutine持有channel,相关资源会自动释放
package main import "fmt" func main() { ch := make(chan string, 10) ch <- "Hello" ch <- "Golang" ch <- "language" // 关闭函数非常重要,若不执行close(),那么range将无法结束,造成死循环 //close(ch) for v := range ch { fmt.Println(v) } }
39. 容量为1的channel在什么情况下会阻塞
结论:当一个容量为1的 channel 已经包含一个数据时,再次尝试向该 channel 发送数据会导致发送方阻塞,直到有接收方来接收数据。
40. panic和recover
结论:在Go语言中,panic和recover构成了处理程序运行时错误的两个基本机制。它们用于在出现严重错误时,能够优雅地终止程序或恢复程序的执行。
- panic机制
panic是一个内建函数,用于在程序运行时抛出一个错误。当panic被调用时,当前的函数会立即停止执行,并开始逐层向上"冒泡",直到被recover捕获或到达程序的顶层,导致程序崩溃并输出错误信息。
panic通常用于处理那些无法恢复的错误,比如空指针引用、数组越界等。这些错误如果不加以处理,将会导致程序崩溃。
- recover机制
recover是一个内建函数,用于在defer函数中捕获由panic抛出的错误。当panic发生时,程序会立即停止当前函数的执行,并开始逐层向上查找是否有defer语句。如果在defer函数中调用了recover,那么panic会被捕获,程序会恢复正常的执行流程,继续执行defer函数之后的代码。
需要注意的是,recover只有在defer函数中直接调用时才有效。在其他地方调用recover是无效的,它将返回nil并且不会终止panic。
- 原因和解决方案
panic和recover的引入,主要是为了处理Go语言中的运行时错误。这些错误可能由于编程错误、外部输入错误或其他原因panic。通过panic和recover,我们可以更优雅地处理这些错误,避免程序直接崩溃。
解决方案通常是在可能出现错误的代码中使用defer和recover来捕获和处理panic。这样,即使出现了错误,我们也能保持程序的稳定性,并给出合适的错误提示。
41. select
结论:select是一种go可以处理多个通道之间的机制,看起来和switch语句很相似,但是select其实和IO机制中的select一样,多路复用通道,随机选取一个进行执行,如果说通道(channel)实现了多个goroutine之前的同步或者通信,那么select则实现了多个通道(channel)的同步或者通信,并且select具有阻塞的特性。
- 每个 case 必须是一个通信操作,要么是发送要么是接收
- select 随机执行一个可运行的 case。如果没有 case 可运行,它将阻塞,直到有 case 可运行。
select { case <-ch1: // 如果从 ch1 信道成功接收数据,则执行该分支代码 case ch2 <- 1: // 如果成功向 ch2 信道成功发送数据,则执行该分支代码 default: // 如果上面都没有成功,则进入 default 分支处理流程 }
42. select的用途
结论
- 多路非阻塞通信:通过select 语句可以同时监听多个channel,一旦其中一个满足条件,即可执行相应操作,而不像普通的channel操作一样会发生阻塞。
package main import ( "fmt" ) func main() { channel1 := make(chan string) channel2 := make(chan int64) go func() { channel1 <- "Message from channel" }() go func() { channel2 <- 100 }() // 使用 select 同时监听两个通道,响应第一个就绪的通道 select { case msg1 := <-channel1: fmt.Println("Received from channel 1:", msg1) case msg2 := <-channel2: fmt.Println("Received from channel 2:", msg2) } }
- 超时控制:你可以在select语句中结合time.After来设置超时机制,确保在一定时间内执行操作,避免无限阻塞。
package main import ( "fmt" "time" ) func main() { ch := make(chan struct{}) go func() { //time.Sleep(2 * time.Second) // 模拟一个耗时操作 time.Sleep(500 * time.Millisecond) // 模拟一个耗时操作 ch <- struct{}{} }() select { case <-ch: fmt.Println("成功执行耗时操作") case <-time.After(1 * time.Second): fmt.Println("超时") } }
- 在通道上进行非阻塞读写:通过在select语句中使用default语句,在没有任何channel操作就绪时执行默认操作,可以避免死锁情况的发生。
package main import ( "fmt" "time" ) func main() { channel := make(chan string) // 向通道中发送数据的 Goroutine go func() { channel <- "Hello, Concurrent World!" }() //time.Sleep(time.Second) // 使用 select 在通道上进行非阻塞读写 select { case <-channel: // 试图从通道中接收数据 fmt.Println("Received message from channel.") default: // 当通道没有数据时执行默认操作 fmt.Println("No message received. Performing default operation.") } // 这里假设有其他操作,让程序继续运行 time.Sleep(3 * time.Second) fmt.Println("Program continues to run.") }
43. for-select的使用
结论:当在 Go 语言中结合使用 for和 select时,通常是为了持续监听多个通道并执行相应的操作。这种结合使用可以在一个循环中处理多个通道上的非阻塞操作,实现更复杂的并发逻辑。
package main import ( "fmt" "time" ) func main() { forSelectUsage() } func forSelectUsage() { done := make(chan struct{}, 0) dataChan := make(chan int) go func() { for i := 0; i < 20; i++ { dataChan <- i if i == 18 { // 结束条件 done <- struct{}{} break } } }() ticker := time.NewTicker(time.Nanosecond) defer ticker.Stop() for { select { case <-ticker.C: fmt.Printf("tick ") case data := <-dataChan: fmt.Printf("%d ", data) case <-done: fmt.Println("结束循环") return } } } // Output // 0 1 2 tick tick tick tick tick tick tick tick tick tick 3 tick 4 tick tick tick tick tick tick 5 tick 6 tick tick 7 8 9 tick 10 11 tick 12 13 tick tick 14 15 16 17 18 结束循环
44. context
结论:上下文 context.Context是Go 语言中用来设置截止日期、同步信号,传递请求相关值的结构体。上下文与 Goroutine 有比较密切的关系,是 Go 语言中独特的设计,在其他编程语言中很少见到类似的概念。
context.Context是 Go 语言在 1.7 版本中引入标准库的接口,该接口定义了四个需要实现的方法,其中包括:
- Deadline — 返回context.Context 被取消的时间,也就是完成工作的截止日期;
- Done — 返回一个 Channel,这个 Channel 会在当前工作完成或者上下文被取消后关闭,多次调用 Done 方法会返回同一个 Channel;
- Err — 返回 context.Context结束的原因,它只会在 Done 方法对应的 Channel 关闭时返回非空的值; 如果context.Context 被取消,会返回 Canceled 错误;如果context.Context超时,会返回 DeadlineExceeded 错误;
- Value — 从 context.Context中获取键对应的值,对于同一个上下文来说,多次调用 Value 并传入相同的 Key 会返回相同的结果,该方法可以用来传递请求特定的数据;
type Context interface{ Deadline() (deadline time.Time, ok bool) Done() <-chan struct{} Err() error Value(key any) any }
45. context的用途
结论:Go 语言中的 context 包提供了一种在进程中跨 API 和包传递截止时间、取消信号和其他请求范围的值的机制。 context.Context 类型是 Go 语言中用于控制请求的取消操作、截止时间、以及其他跨API和包的请求范围的值的核心接口。以下是 context 的主要用途:
- 取消操作(Cancellation): context.Context 提供了一个取消信号,可以用于通知启动的goroutine停止工作。这在处理长时运行的或阻塞型操作时非常有用,比如网络请求或数据库操作。
- 截止时间(Deadlines): context.Context 可以设置截止时间,当截止时间到达时,可以触发取消操作。这有助于避免长时间挂起的请求,提高系统的响应性。
- 传递请求范围的值(Values): context.Context 可以存储请求相关的值,这些值可以跨API和包传递,而不需要在每个函数调用中显式传递。这使得代码更加清晰,减少了参数列表。
- 控制并发(Concurrency control): 当你有多个goroutine在并发执行时, context.Context 可以帮助你优雅地管理这些goroutine的生命周期。
- 超时控制(Timeouts): context.Context 可以用于设置操作的超时时间,这比使用单独的计时器和取消操作更加方便。
- 父子关系(Parent-child relationships): context.Context 可以创建父子关系,当父上下文被取消时,所有从该父上下文派生的子上下文也会被取消。
- 错误处理(Error handling): 在某些情况下, context.Context 可以用来传递错误信息,比如 context.Canceled 和 context.DeadlineExceeded 错误,这些错误可以用来指示操作被取消或超时。
- 资源管理(Resource management): context.Context 可以用来管理资源,比如数据库连接或文件句柄,确保在请求结束时释放资源。
- 请求隔离(Request isolation): 在Web服务器或API服务中,每个请求可以有自己的 context.Context ,这样可以隔离不同请求的状态,防止请求之间的干扰。
- 日志记录(Logging): context.Context 可以用来传递日志记录相关的信息,比如请求ID,这样可以在日志中保持请求的上下文信息。
context.Context 是 Go 语言中处理并发和请求生命周期的强大工具,它提供了一种优雅的方式来处理取消、超时和跨API传递数据,使得代码更加简洁和健壮。
46. go程序启动时发生了什么
结论:当启动一个 Go 程序时,会发生一系列初始化和启动过程。步骤如下:
- 启动运行时系统(runtime)。在程序开始执行之前,Go 运行时系统会初始化。这包括内存分配器、垃圾回收器、栈管理、goroutine 调度器等。
- 初始化全局变量。程序中的全局变量会被初始化。对于基本数据类型,会初始化为零值(例如,int 类型的零值是 0)。对于更复杂的类型,如切片、映射和通道,它们会被初始化为 nil。
- 注册信号处理器。Go 运行时会注册一些信号处理器来处理如 SIGINT(通常由 Ctrl+C 发送)这样的信号,以便能够适当地处理程序的中断。
- 初始化内建包。标准库中的一些包,如 runtime 和 syscall ,会进行初始化,以确保程序能够使用它们提供的功能。
- 调用 init 函数。程序中每个包的 init 函数会被调用。如果一个包中包含多个 init 函数,它们会按照它们在代码中出现的顺序被调用。如果程序包含多个包,每个包的 init 函数都会被调用。
- 执行 main 函数。 程序的入口点是 main 包中的 main 函数。当 main 函数开始执行时,程序正式启动。
- 启动 goroutines。如果程序中有使用 go 关键字启动的 goroutines,它们会在此时开始执行。
- 执行程序逻辑。程序按照代码逻辑执行,直到遇到阻塞操作、调用 time.Sleep 、或者执行到 main 函数的末尾。
- 退出程序。当 main 函数返回时,程序开始退出过程。这包括清理资源,如关闭文件描述符、网络连接等,以及运行任何注册的 defer 语句。
- 调用 exit 函数。如果程序正常退出, os 包会调用 exit 函数,该函数会终止程序并返回状态码给操作系统。
- 垃圾回收和内存清理。在程序退出之前,Go 的垃圾回收器会运行,以回收未引用的内存。此外,任何分配的内存也会被操作系统回收。
值得注意的是,Go 程序的启动速度通常非常快,因为 Go 运行时系统进行了大量优化,以减少启动时的开销。此外,Go 的编译器也会对代码进行优化,以提高程序的执行效率。
47. 数据竞争 go race
结论:在 Go 语言中,数据竞争(data race)是指在并发程序中,多个 goroutine 同时访问同一变量,并且至少有一个是写操作的情况。这种情况可能导致程序行为不可预测、难以调试和修复。Go 提供了一些机制来帮助开发者检测和解决数据竞争问题。 如何检测数据竞争 Go 语言从版本 1.1 开始内置了数据竞争检测器。要使用这个检测器,可以在编译或运行程序时添加 -race 标志。例如: go build -race -o myapp
或者在运行时: go run -race main.go
使用
-race 选项时,编译器会插入特殊的检测代码,这些代码在运行时监视内存访问模式。如果检测到数据竞争,程序会输出详细的竞争信息,包括相关 goroutine 的堆栈跟踪,以帮助开发者定位和修复问题。 如何解决数据竞争 解决数据竞争的常用方案包括:
- 使用 WaitGroup:通过 sync.WaitGroup 等待所有 goroutine 完成,确保对共享变量的写操作完成后再进行读操作。
func main() { var wg sync.WaitGroup var i int wg.Add(1) go func() { i = 5 wg.Done() }() wg.Wait() }
- 使用通道阻塞:使用通道(channel)来同步 goroutine,确保写操作完成后再进行读操作。
func main() { var i int done := make(chan struct{}) go func() { i = 5 done <- struct{}{} }() <-done }
- 使用 Mutex 锁:使用 sync.Mutex 来保护共享变量,确保同一时间只有一个 goroutine 可以访问共享资源。
func main() { var mutex sync.Mutex var i int go func() { mutex.Lock() i = 5 mutex.Unlock() }() }
- 使用原子操作:对于基本数据类型的操作,可以使用 sync/atomic 包中的原子操作函数,确保操作的原子性。
func main() { var i int64 atomic.AddInt64(&i, 1) }
- 使用通道传递数据:避免在 goroutine 之间共享内存,而是通过通道传递数据,这样可以避免直接的内存访问冲突。
func main() { ch := make(chan int) go func() { ch <- 5 }() i := <-ch }
通过这些方法,可以有效地避免和解决 Go 程序中的数据竞争问题。开发时应该根据具体的应用场景选择合适的同步机制,以确保程序的正确性和性能。
48. go语言实现优雅退出
结论:在计算机术语中,优雅关机(Graceful Shutdown)通常指的是在关闭系统或程序时,确保所有的操作都被正确地完成,资源得到释放,数据保持一致性,不会导致数据丢失或损坏。对于操作系统来说,优雅关机意味着结束所有运行的程序,关闭所有服务,然后安全地关闭硬件。Go语言一个基本的优雅关闭的实现示例:
package main import ( "fmt" "os" "os/signal" "syscall" "time" ) func main() { // 定义一个通道用于接收系统信号 sigs := make(chan os.Signal, 1) // 监听指定的信号,例如中断信号(Ctrl+C)和终止信号 signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) // 模拟一个长时间运行的任务 go func() { for { fmt.Println("服务正在运行...") time.Sleep(2 * time.Second) } }() // 等待接收到信号 <-sigs fmt.Println("接收到关闭信号,开始执行优雅关机...") // 执行关机前的清理操作,例如关闭数据库连接、文件句柄等 // 这里我们只是打印一条消息 fmt.Println("正在关闭服务...") time.Sleep(2 * time.Second) // 模拟关闭服务需要的时间 // 退出程序 fmt.Println("服务已关闭,程序退出。") os.Exit(0) }
这个示例中,首先创建了一个HTTP服务器,并在一个新的goroutine中启动它。然后定义了一个 gracefulShutdown 函数,该函数会监听中断信号(如Ctrl+C),并在接收到信号时开始优雅关闭流程。优雅关闭的步骤如下:
- 监听中断信号。使用 signal.Notify 函数监听 os.Interrupt 和 syscall.SIGTERM 信号。
- 等待信号。使用 <-stopChan 阻塞,直到接收到中断信号。
- 创建上下文。使用 context.WithTimeout 创建一个带有超时的上下文,以确保服务器在指定时间内关闭。
- 关闭服务器。调用 server.Shutdown(ctx) 优雅地关闭服务器。这个方法会停止接收新的请求,并为当前活动的连接提供短暂的宽限期,以便它们可以完成。
- 处理关闭失败。如果 server.Shutdown 返回错误,打印错误信息。
- 取消上下文。在 defer 块中调用 cancel 函数,以确保上下文被正确取消。通过这种方式,你可以确保你的Go程序在接收到中断信号时能够优雅地关闭,释放资源,并正确处理当前活动的请求。
49. uintptr和unsafe.Pointer的区别
结论:uintptr和unsafe.Pointer在go源码中随处可见。unsafe.Pointer只是单纯的通用指针类型,用于转换不同类型指针,它不可以参与指针运算;而uintptr是用于指针运算的,GC 不把 uintptr 当指针,也就是说 uintptr 无法持有对象, uintptr 类型的目标会被回收;unsafe.Pointer 可以和 普通指针 进行相互转换;unsafe.Pointer 可以和 uintptr 进行相互转换。
- unsafe.Pointer只是单纯的通用指针类型,用于转换不同类型指针,它不可以参与指针运算;
- 而uintptr是用于指针运算的,GC 不把 uintptr 当指针,也就是说 uintptr 无法持有对象, uintptr 类型的目标会被回收;
- unsafe.Pointer 可以和 普通指针 进行相互转换;
- unsafe.Pointer 可以和 uintptr 进行相互转换。
package main import ( "fmt" "unsafe" ) type User struct { UserID int64 UserName string } func main() { var user = new(User) // 打印一下user.UserName的地址 fmt.Printf("%p ", &user.UserName) // 0x1400000c020 // 获取user.UserName通用指针 userNameptr := unsafe.Pointer(uintptr(unsafe.Pointer(user)) + unsafe.Offsetof(user.UserName)) userNamePtr := (*string)(userNameptr) // 通用指针转为string指针 fmt.Printf("%p ", userNamePtr) // 0x1400000c020 // 设置user.userName -> Mike *userNamePtr = "Mike" // 利用指针给user.UserName赋值 fmt.Println(user.UserName) // Mike }
50. go语言的限流
结论:限流器是后台服务中的非常重要的组件,可以用来限制请求速率,保护服务,以免服务过载。Golang 标准库中自带了限流算法的实现,即 golang.org/x/time/rate
。该限流器基于令牌桶算法(Token Bucket Algorithm)。这个算法的核心思想是有一个令牌桶,以固定的速率填充令牌,每个请求必须消耗一个令牌才能被处理。如果桶中没有令牌,请求就会等待直到桶中有令牌可用,或者直接被拒绝,取决于具体的处理逻辑。下面是go限流器的代码示例。
package main import ( "context" "fmt" "golang.org/x/time/rate" "time" ) func main() { // 创建一个新的限流器,每秒2个请求,最多10个请求的突发 limiter := rate.NewLimiter(2, 10) // 模拟100个请求 for i := 0; i < 100; i++ { // Wait阻塞直到获取一个令牌或者上下文超时 if err := limiter.Wait(context.Background()); err != nil { fmt.Println("请求被拒绝或超时") continue } // 模拟请求处理 fmt.Printf("处理请求 %d ", i+1) // 假设每个请求处理需要一些时间 time.Sleep(100 * time.Millisecond) } }
在这个例子中,rate.NewLimiter
函数创建了一个新的限流器,第一个参数是每秒可以处理的请求数(令牌填充速率),第二个参数是桶的大小,也就是可以突发的最大请求数。limiter.Wait(context.Background())
会阻塞当前goroutine直到获取一个令牌或者上下文超时。如果成功获取令牌,代码会输出正在处理的请求编号,然后模拟请求处理时间。
以上便是go语言核心知识点的全部内容。如果对你有帮助,欢迎点赞+收藏,你的鼓励是我创作的动力。后续我会持续分享有关go语言或后端开发的相关内容,帮助更多牛友们斩获心仪的offer。