moox2020
2022/09/19阅读:52主题:橙心
120 Go面试加强版之自虐篇
120 Go面试加强版之自虐篇
1、协程池、函数执行超时,nil判断
1. 限制协程执行的基本方法
-
使用的概念,限制池的大小,写入
package main
import (
"fmt"
"sync"
"time"
)
var pool chan struct{} // 空结构体不占用内存空间
func job(index int) {
time.Sleep(time.Second * 1)
fmt.Printf("执行完毕,序号:%d\n", index)
}
func main() {
maxNum := 10
pool = make(chan struct{}, maxNum)
wg := sync.WaitGroup{}
for i := 0; i < 100; i++ {
pool <- struct{}{} // 往pool中写入,到达最大长度时,阻塞
wg.Add(1)
go func(index int) {
defer wg.Done()
defer func() {
<-pool // 从pool中取值
}()
job(index)
}(i)
}
wg.Wait()
}
2. 函数执行超时控制
-
假设程序执行时间很长,需要进行超时控制,如执行超过3s,就退出程序 -
思路:函数执行时,不是等待函数执行完成再返回,而是直接返回channel,使用channel来控制 -
把业务过程变成协程(因为业务没处理完,就可以返回,所以使用goroutine) -
把业务执行结果放入channel (因为执行结果是异步的,所以执行结果先放入channel) -
将耗时的过程放入channel管道
-
package main
import (
"fmt"
"time"
)
// 1.不需要等待执行完成就要返回时,使用goroutine
// 2.将业务执行结果写入管道,chan
func job() chan string {
ret := make(chan string)
go func() {
time.Sleep(time.Second * 2) // 模拟业务处理逻辑
ret <- "success"
}()
return ret
}
// 3. 超时判断
func run() (interface{}, error) {
c := job() // 执行业务逻辑
// select持续判断
select {
// 取到值了,业务逻辑正常处理成功
case r := <-c:
return r, nil
// 计时器,3秒的chan time
case <-time.After(time.Second * 3):
return nil, fmt.Errorf("time out")
}
}
func main() {
fmt.Println(run())
}
3. 明明是nil,却 !=nil 的问题
示例:
package main
import "fmt"
func main() {
var f func()
var a *struct{}
fmt.Println(f) // nil
fmt.Println(a) // nil
// 放入interface切片中时,每一项都是interface{}
// interface 包含类型和值,只有类型和值都一样时,才会相等
list := []interface{}{f, a}
for _, item := range list {
if item == nil {
fmt.Println("nil")
} else {
fmt.Println("not nil") // 打印了这个,说明item!=nil ????
}
fmt.Printf("%T,%v\n", item, item)
/*
类型:func(), 值:<nil>
类型:*struct {}, 值:<nil>
*/
}
}
原因:因为interface包含类型和值,只有类型和值都是nil时,才能与nil相等
-
判断方式一:断言
func main() {
var f func()
var a *struct{}
list := []interface{}{f, a}
for _, item := range list {
if v, ok := item.(func()); ok && v == nil {
fmt.Println("nil func()")
}
if v, ok := item.(*struct{}); ok && v == nil {
fmt.Println("nil struct")
}
}
}
-
判断方式二:反射
func main() {
var f func()
var a *struct{}
list := []interface{}{f, a}
for _, item := range list {
if reflect.ValueOf(item).IsNil() {
fmt.Println("nil")
}
}
}
2、变态的defer
4. defer 定义函数时的参数问题
题目:输出结果为1
func main() {
a := 1
defer fmt.Println(a) // 1
a++
}
原因:defer机制是:defer在定义时,如果defer后紧跟的语句中有参数,参数在定义的时候就确定了,所以定义时a=1
-
如果defer后是匿名函数不带参数,则会在函数结束时执行
func main() {
a := 1
defer func() {
fmt.Println(a) // 2
}()
a++
}
-
如果defer后是匿名函数带参数,则参数会在定义时确定下来
func main() {
a := 1
defer func(input int) {
fmt.Println(input) // 1
}(a)
a++
}
解决:如果函数中带有参数,又想使用defer,如何最后得到最终变量的值? ==> 传指针
func show(a *int) {
fmt.Println(*a)
}
func main() {
a := 1
defer show(&a) // 传指针,指针能取到最终变化后的值
a++
}
5. defer 里使用链式调用、循环执行defer等
-
defer链式调用时,以最后一个调用作为defer的执行,其他前面的安装顺序执行
// 链式调用
func (t *Test) Do(i int) *Test {
fmt.Println(i)
return t
}
func main() {
t := NewTest()
defer t.Do(1).Do(2).Do(4).Do(5) // 最后一个Do()做为defer的执行
t.Do(3)
}
// 输出结果:1,2,4,3,5
-
如果想要让defer最后执行,使用匿名函数包裹
func main() {
t := NewTest()
defer func() {
t.Do(1).Do(2).Do(4).Do(5)
}()
t.Do(3) // 先执行
}
// 执行结果:3,1,2,4,5
循环执行defer
-
defer属于栈结构,先defer,先压栈;后执行defer,先出栈;defer会保存变量的值
func main() {
for i := 0; i < 3; i++ {
defer func() {
fmt.Println(i)
}()
}
}
// 输出333,因为0:defer func压栈i=1,1:defer func压栈1=2,2:defer func压栈i=3 ->
// 出栈2 (i=3)-> 出栈1 (i=3) -> 出栈0(i=3)
-
输出210
func main() {
for i := 0; i < 3; i++ {
defer func(input int) {
fmt.Println(input)
}(i)
}
}
// 或
func main() {
for i := 0; i < 3; i++ {
defer fmt.Println(i)
}
}
6. defer和panic哪个先执行、嵌套panic
-
先执行defer,后抛出异常
func main() {
defer func() { fmt.Println("打印前") }()
defer func() { fmt.Println("打印中") }()
defer func() { fmt.Println("打印后") }()
panic("触发异常1")
}
//打印后
//打印中
//打印前
//panic: 触发异常1
-
变种1:先执行defer,再panic1
func main() {
func() {
defer func() { fmt.Println("打印前") }()
defer func() { fmt.Println("打印中") }()
defer func() { fmt.Println("打印后") }()
panic("触发异常1")
}()
panic("触发异常2") // 不会执行
}
//打印后
//打印中
//打印前
//panic: 触发异常1
-
变种2:先执行defer,再panic2,再panic1
func main() {
defer func() {
defer func() { fmt.Println("打印前") }()
defer func() { fmt.Println("打印中") }()
defer func() { fmt.Println("打印后") }()
panic("触发异常1")
}()
panic("触发异常2")
}
//打印后
//打印中
//打印前
//panic: 触发异常2
// panic: 触发异常1
7. n++原来也是不可靠的
-
n=1000000
func main() {
n := 0
for i := 0; i < 1000000; i++ {
func() {
n++
}()
}
fmt.Println(n) // 1000000
}
-
变种:加入go routine
func main() {
n := 0
wg := sync.WaitGroup{}
for i := 0; i < 1000000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
n++
}()
}
wg.Wait()
fmt.Println(n) // 不确定的值
}
原因:n++的过程不是原子的,
-
从内存读出n -
执行++ -
再赋值结果
这个过程中,其他协程也会进入内存读取n,因此不是原子的。
原子是说:执行过程中,不会发生上下文(线程的切换)的切换
-
解决方法:1. 加锁
func main() {
n := 0
loker := sync.Mutex{} // 互斥锁
wg := sync.WaitGroup{}
for i := 0; i < 1000000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
defer loker.Unlock() // 解锁
loker.Lock() // 计算前加锁
n++
}()
}
wg.Wait()
fmt.Println(n)
}
-
解决方法2:atomic,只支持int32和int64
func main() {
var n int32 = 0
wg := sync.WaitGroup{}
for i := 0; i < 1000000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
atomic.AddInt32(&n, 1) // AddInt32 atomically adds delta to *addr and returns the new value.
// n++
}()
}
wg.Wait()
fmt.Println(n)
}
3、GO的并发模式
8. Go的常见并发模式(1)基础模式
go的并发模式有哪些:
-
go有协程以及CSP调度模型,是它可以进行并发运行的基础,可以使用协程来完成“并发编程” -
Go有一句并发编程的哲学口号:不要通过共享内存来通信,而应通过通信来共享内存(channel) -
并发编程的重点在于:如何精准的控制“共享数据”
最基本的并发编程:
-
使用waitGroup控制协程并发
func main() {
wg := sync.WaitGroup{}
for i := 0; i < 5; i++ {
wg.Add(1)
// 协程是并发的,无序的
go func(i int) {
defer wg.Done()
fmt.Println(i * 2) // 这里是业务处理逻辑,这里的数据如何拿出来=> channel
}(i)
}
wg.Wait()
}
-
使用协程做并发,使用channel通信,可以不使用锁
func main() {
c := make(chan int, 5) // 创建channel,容器时5
for i := 0; i < 5; i++ {
// 协程是并发的,无序的
go func(i int) {
c <- (i * 2) // 将业务逻辑处理结果保存到channel中
}(i)
}
for i := 0; i < cap(c); i++ {
fmt.Println(<-c)
}
}
9. Go的常见并发模式(2)生产者模式(队列模式)、多种写法
-
生产者(往外抛) -> channel -> 消费者(往里读) -
生产者和消费者共用一个channel,类似于MQ
生产者和消费者:第一种写法
func Producer(out chan int) {
defer close(out) // 写完后关闭管道
for i := 0; i < 5; i++ {
out <- i * 2 // 生产者往channel中写入数据
time.Sleep(time.Second * 1)
}
}
func Comsumer(out chan int) {
for item := range out {
fmt.Println(item)
}
}
func main() {
c := make(chan int) // 为生产者和消费者创建共用的channel
go Producer(c) // 1个协程生产数据
Comsumer(c) // 消费数据,没有数据时等待
}
// 输出 0 2 4 6 8
// 如果消费者使用协程,因为c容量为0,主进程结束,消费者和生产者协程可能还没执行就结束了
// 没有数据输出
func main() {
c := make(chan int) // 0 容量的channel
go Producer(c) // 1个协程生产数据
go Comsumer(c) // 1个协程消费数据,因为消费时没有数据,主进程结束了,所以结束了。
}
-
第二种写法:使用开关当做消费者入参
func Producer(out chan int) {
defer close(out)
for i := 0; i < 5; i++ {
out <- i * 2 // 生产者往channel中写入数据
time.Sleep(time.Second * 1)
}
}
func Comsumer(out chan int, r chan struct{}) {
for item := range out {
fmt.Println(item)
}
// 保证遍历channel out完成后,往channel r中写入空结构体
r <- struct{}{}
}
func main() {
c := make(chan int) // 0 容量的channel
r := make(chan struct{}) // 创建一个空结构体管道作为开关
go Producer(c) // 1个协程生产数据
go Comsumer(c, r) // 1个协程消费数据,因为消费时没有数据,所以结束了。
<-r // 这里会读取channel,没有数据时等待
}
-
第三种写法:消费完成返回开关信息,推荐使用
func Producer(out chan int) {
defer close(out)
for i := 0; i < 5; i++ {
out <- i * 2 // 生产者往channel中写入数据
time.Sleep(time.Second * 1)
}
}
func Comsumer(out chan int) chan struct{} {
r := make(chan struct{})
go func() {
// 保证遍历channel out完成后,往channel r中写入空结构体
defer func() {
r <- struct{}{}
}()
for item := range out {
fmt.Println(item)
}
}()
return r
}
func main() {
c := make(chan int) // 0 容量的channel
go Producer(c) // 1个协程生产数据
r := Comsumer(c)
<-r
}
10. Go的常见并发模式(3)优胜劣汰模式
-
多个协程,每个协程都执行相同的job,将执行的结果保存到channel -
消费端只消费channel一次,也就是最快进入channel的,谁先到就消费谁,优胜劣汰模式
// 模拟延时,每次延迟时间不一样
func job() int {
rand.Seed(time.Now().Unix())
result := rand.Intn(5)
time.Sleep(time.Second * time.Duration(result))
return result
}
func main() {
c := make(chan int, 5) // chan 中最多保存5个int
for i := 0; i < 5; i++ { // 同时有5个协程执行job
go func() {
c <- job() // 写入chan
}()
}
// 只消费一次:
fmt.Printf("最快用了:%d 秒\n", <-c) // 等待写入chan,只要消费到chan,就结束,
// 如果是全部消费:
// for item := range c{...}
}
11. 协程为什么总是先输出倒数第一个
-
协程默认是在多核上运行,不能保证哪一个协程先执行
func main() {
wg := sync.WaitGroup{}
for i := 0; i < 5; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
fmt.Println(i)
}(i)
}
wg.Wait()
}
// 无序输出
-
设置为单核执行时
func main() {
runtime.GOMAXPROCS(1) // 单核,单线程
wg := sync.WaitGroup{}
for i := 0; i < 5; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
fmt.Println(i)
}(i)
}
wg.Wait()
}
// 输出:4,0,1,2,3, 为什么是这样的顺序?
先扯一个废话:
-
runtime.GOMAXPROCS() ,GOLANG默认使用所有的核执行 -
runtime.GOMAXPROCS(1),就变成单核运行 -
单核情况下,所有goroutine运行在同一个线程M中,线程维护一个上线文P -
MPG -
M: 工作线程 -
P: processor(上下文)或cpu -
G:goroutine,协程只是一个调度机制,真正干活的是线程
-
-
为什么输出是4,0,1,2,3的原因:
-
程序中,我们循环创建了若干个协程,且是单核的,只有一个线程在干所有的活 -
等待P(processor/cpu)上下文就绪后,才开始执行。默认先执行的是最后一个创建的协程,所以输出4 -
然后才按顺序执行协程,输出0,1,2,3
如果要解决最后创建的协程第一个执行的问题:让最后创建出的协程单独出来
func main() {
runtime.GOMAXPROCS(1) // 单核,单线程
wg := sync.WaitGroup{}
for i := 0; i < 5; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
fmt.Println(i) // 单核情况下业务逻辑顺序执行
}(i)
}
go func() {
defer wg.Done()
fmt.Println("我要开始执行了...") // 单核情况下最后创建的协程第一个执行
}()
wg.Wait()
}
12. 写一个带过期机制的kv获取map
map问题:
-
原始的map不是线程安全的 -
应该使用线程安全的sync.map或自己加锁实现安全
过期机制:time.AfterFun()
package main
import (
"fmt"
"sync"
"time"
)
var kv sync.Map
func Set(key string, value interface{}, expire time.Duration) {
kv.Store(key, value)
// 超过多少秒后会执行的函数
time.AfterFunc(expire, func() {
kv.Delete(key)
})
}
func main() {
Set("id", 101, time.Second*5)
Set("name", "zhangsan", time.Second*8)
for {
fmt.Println(kv.Load("id"))
fmt.Println(kv.Load("name"))
time.Sleep(time.Second * 1)
}
}
13. 谈一谈GO的链表操作
-
go自带一个双向链表 -
单向链表,查询时间不如双向链表,但空间占用小 -
双向链表,查询时间优于单向链表,但占用空间大
package main
import (
"container/list" // Go自带的双向链表包list
"fmt"
)
func main() {
data := list.New() // 初始化一个双向链表
e8 := data.PushBack(8) // 队尾插入
e9 := data.PushBack(9) // 队尾插入
data.PushBack(10) // 队尾插入
data.PushFront(7) // 队首插入
e85 := data.InsertAfter(8.5, e8) // 在e8元素之后插入8.5,成为一个新元素
data.MoveAfter(e85, e9) // 将元素e85移动到元素e9的后面
for ele := data.Front(); ele != nil; ele = ele.Next() {
fmt.Printf("%v ", ele.Value)
}
}
14. 如何使用Golang定义枚举
-
首先,go里面没有枚举关键字enum -
替代方法是使用iota, -
iota是go语言的常量计数器,只能在常量的表达式中使用(const) -
iota在const关键字出现时将被重置为0(const内部的第一行之前), -
const中每新增一行常量声明,将使用iota计数一次
package main
import (
"fmt"
)
type UserType int
// 重写了String方法,fmt调用时会执行这个方法
func (u UserType) String() string {
switch u {
case 0:
return "学生"
case 1:
return "教师"
case 3:
return "领导"
case 7:
return "工作者"
default:
return "教职工"
}
}
const (
student UserType = iota // 0
teacher // 1
_ // 2
leader // 3
master = "a" // a ,可以设置为自定义值,但占用了数值4
master2 // a ,与上一个值相同,但占用了数值5
worker = iota // 6 ,继续iota值
worker2 // 7
)
func main() {
// 强制转换一下类型
fmt.Println(UserType(student),
UserType(teacher),
UserType(leader),
master,
master2,
UserType(worker),
UserType(worker2))
}
// 学生 教师 领导 a a 教职工 工作者
4、高频面试题
15. Go的Struct能不能比较
答案:可以比较,也可以不能比较
-
相同结构,只要成员类型都可以比较,则就能比较
-
结构体不包含map,slice,func时,可以比较 -
结构体包含map,slice,func时不能比较
-
-
不相同的结构,如果能互相转换,也能比较。前提是成员是可以比较的
示例:
-
同一struct不包含map
type User struct {
id int
}
func main() {
u1 := User{101}
u2 := User{101}
u3 := &User{101}
fmt.Println(u1 == u2, &u1 == u3) // 能比较,true,false
}
-
同一struct包含map,不能被比较
type User struct {
id int
m map[string]string
}
func main() {
u1 := User{id: 101}
u2 := User{id: 101}
fmt.Println(u1 == u2) // u1 == u2 (struct containing map[string]string cannot be compared)
}
-
不相同的结构,如果能互相转换,也能比较
type User1 struct {
id int
}
type User2 struct {
id int
}
func main() {
u1 := User1{id: 101}
u2 := User2{id: 101}
fmt.Println(u1 == u2) // cannot compare u1 == u2 (mismatched types User1 and User2)
fmt.Println(u1 == User1(u2)) // 不相同的结构,如果能互相转换,也能比较
}
-
不相同的结构,不能互相转换,不能比较
type User1 struct {
id1 int // 成员不同,不能相互转换
}
type User2 struct {
id2 int // 成员不同,不能相互转换
}
func main() {
u1 := User1{id1: 101}
u2 := User2{id2: 101}
fmt.Println(u1 == u2) // cannot compare u1 == u2 (mismatched types User1 and User2)
fmt.Println(u1 == User1(u2)) // cannot convert u2 (variable of type User2) to type User1
}
16. 请用Go实现一个简单的set
-
目前go标准库中没有set -
所谓set,简单来说就是一个集合,集合中的内容不能重复 -
类比于切片,[]int{1,2,3,3,4},但是切片可以重复 -
思考:可以使用map实现,因为map的key是不能重复的,基于这一特性,可以来设置Set -
type Set map[interface{}]struct{};这里的key类型为interface{},value不重要,所以类型为struct{}空结构体,不占用空间
示例:
-
使用Map实现的简单Set是无序的,如果需要有序Set,可以考虑使用ASCII排序 -
如果考虑多线程安全,这里的Map可以使用sync.Map
package main
import (
"bytes"
"fmt"
)
type Empty struct{}
type Set map[interface{}]Empty
func NewSet() Set {
return make(Set)
}
func (s Set) Add(vs ...interface{}) Set {
for _, v := range vs {
s[v] = Empty{}
}
return s
}
// 重写了String(),fmt在传入Set时,会调用这里的String
func (s Set) String() string {
var buf bytes.Buffer
for k, _ := range s {
// 第一个字符不写入
if buf.Len() > 0 {
buf.WriteString(",")
}
buf.WriteString(fmt.Sprintf("%v", k))
}
return buf.String()
}
func main() {
s := NewSet().Add(1, 2, 3, "a", "a", 2, 3)
fmt.Println(s) // 这里的Set是无序的map
}
17. go的切片浅拷贝和深拷贝的写法和区别
-
浅拷贝:共享同一底层数组空间
-
不同的切片指针指向同一个底层数组空间,其中一个修改,其余也会被迫修改了
-
-
深拷贝:指针指向不同的底层数组空间
示例:
package main
import "fmt"
func main() {
// 浅拷贝,a,b指针指向同一个底层数组空间
a := []int{1, 2, 3}
b := a
a[1] = 100
fmt.Println(a, b) // [1 100 3] [1 100 3]
// 深拷贝,c,d指针指向不同的底层数组
c := []int{11, 21, 31}
d := make([]int, len(a), cap(a))
copy(d, c)
c[1] = 200
fmt.Println(c, d) // [11 200 31] [11 21 31]
}
18. go的内存逃逸分析
逃逸分析是:Go在编译阶段确定内存是分配在栈上还是堆上的一种行为。
几个底层知识点:
-
栈内存分配和释放非常快 -
堆内存需要依靠Go垃圾回收(占CPU) -
通过逃逸分析,可以尽量把那些不需要分配到堆上的变量直接分配到栈上
Go的主要目的并不希望程序员关注分配,而是通过编译时的代码分析自动决定
-
Go在编译时可以设置参数去分析那些变量发生逃逸 -
逃逸就是本身应该被分配到栈上的变量,实际被分配到了堆上
逃逸场景:
-
-
局部变量原本应该在栈中分配,在栈中回收,但由于返回时被外部引用,所以被分配到堆中 如何分析:分析代码是否真实的外部引用,如果没有引用,就不需要返回
-
-
-
参数为interface类型,如fmt.Println(a ...interface{}),编译期间很难决定其参数的具体类型,也能产生逃逸
-
-
-
结构体使用时,使用指针的不同情况可能发生逃逸
-
示例:
-
没有发生逃逸
package main
func test() {
a := []int{1, 2, 2}
a[1] = 4
}
func main() {
test()
}
// 使用go build -gcflags=-m src/main.go
/*
go build -gcflags=-m src/main.go
# command-line-arguments
src\main.go:3:6: can inline test
src\main.go:7:6: can inline main
src\main.go:8:6: inlining call to test
src\main.go:4:12: []int{...} does not escape // 说明没有发生逃逸
src\main.go:8:6: []int{...} does not escape // 说明没有发生逃逸
*/
-
局部变量返回,被外部引用,发生逃逸
package main
func test() []int {
// 局部变量原本应该在栈中分配,在栈中回收
// 但由于返回时被外部引用,所以被分配到堆中。
// 如何分析:分析代码是否真实的外部引用,如果没有引用,就不需要返回
a := []int{1, 2, 2}
a[1] = 4
return a
}
func main() {
test()
}
// 使用go build -gcflags=-m src/main.go
/*
# command-line-arguments
src\main.go:3:6: can inline test
src\main.go:8:6: can inline main
src\main.go:9:6: inlining call to test
src\main.go:4:12: []int{...} escapes to heap // 发生了逃逸,第4行:a := []int{1, 2, 2}
src\main.go:9:6: []int{...} does not escape
*/
-
参数为interface类型,如fmt.Println(a ...interface{}),编译期间很难决定其参数的具体类型,也能产生逃逸
package main
import "fmt"
func test() {
a := []int{1, 2, 2}
a[1] = 4
// []int{...} escapes to heap,因为fmt.Println(a ...any),any是interface{}
fmt.Println(a)
}
func main() {
test()
}
/*
go build -gcflags=-m src/main.go
# command-line-arguments
src\main.go:11:13: inlining call to fmt.Println
src\main.go:13:6: can inline main
src\main.go:9:12: []int{...} escapes to heap
src\main.go:11:13: ... argument does not escape
src\main.go:11:13: a escapes to heap
*/
-
struct场景可能发生逃逸
package main
import "fmt"
type User struct {
Id int
}
func newUser() *User {
return &User{Id: 1} // 10:9: &User{...} escapes to heap
}
func main() {
u := newUser() // 13:14: &User{...} escapes to heap
fmt.Println(u) // 14:13: ... argument does not escape
}
// 为什么会发生逃逸:因为局部变量被外部引用索引发生逃逸
// 但u只是在main()中获取并打印,并没有给到其他的函数调用,所以这种逃逸是可以避免的
// 如何避免:返回不使用指针即可
/*
go build -gcflags=-m src/main.go
# command-line-arguments
src\main.go:9:6: can inline newUser
src\main.go:13:14: inlining call to newUser
src\main.go:14:13: inlining call to fmt.Println
src\main.go:10:9: &User{...} escapes to heap
src\main.go:13:14: &User{...} escapes to heap
src\main.go:14:13: ... argument does not escape
*/
-
struct避免发生逃逸,u只是在main()中获取并打印,并没有给到其他的函数调用,所以这种逃逸是可以避免的
func newUser() User {
return User{Id: 1} // ... argument does not escape
}
func main() {
u := newUser() // ... argument does not escape
// 只有一步调用的,没必要使用指针,导致发生逃逸
fmt.Println(u) // 14:13: ... argument does not escape
}
5、设计模式
19. Go的单例模式
在类似配置文件只有一份,但是可能有多处调用时,使用单例模式。
面试回到:1. 如果自己实现,使用最基本的方法加锁;2. 使用go内置的sync.Once
-
最基本的方法:sync.Mutex 加锁,性能低 -
go提供的内置方法:sync.Once,性能高
-
最基本的方法 sync.Mutex 加锁
package main
import (
"fmt"
"sync"
)
type WebConfig struct {
Port int
}
var cc *WebConfig // 默认为cc==nil
// 多线程调用时,需要加锁
var mu sync.Mutex
func GetConfig() *WebConfig {
mu.Lock() // 调用GetConfig()时加锁
defer mu.Unlock() // 调用完成时解锁
if cc == nil {
cc = &WebConfig{Port: 8080}
}
return cc
}
func main() {
c := GetConfig()
c1 := GetConfig()
c1.Port = 80
fmt.Println(c, c == c1)
}
-
Go提供的内置方法:sync.Once
package main
import (
"fmt"
"sync"
)
type WebConfig struct {
Port int
}
var cc *WebConfig // 默认为cc==nil
var once sync.Once
func GetConfig() *WebConfig {
// 这里的func语句只执行一次,后面就不再执行了
once.Do(func() {
cc = &WebConfig{Port: 8080}
})
return cc
}
func main() {
c := GetConfig()
c1 := GetConfig()
c1.Port = 80
fmt.Println(c, c == c1)
}
20. Go的简单工厂模式
关于逼格
-
将接口与具体实现分离,根据需要(根据参数)实例化 -
一般用于不同的角色调用具有相同的方法,这时候将这个相同的方法抽象出来作为接口方法
写法:
-
User(接口): GetRole string -
Member(前台用户)实现:GetRole() string -
Admin(后台管理用户)实现:GetRole() string
-
-
Factory(工厂) -
CreateUser(type int) -
缺点:一旦新增用户类别,就要修改Factory代码
-
package main
import "fmt"
type User interface {
GetRole() string
}
type Member struct{}
func (m *Member) GetRole() string {
return "会员用户"
}
type Admin struct{}
func (a *Admin) GetRole() string {
return "后台管理用户"
}
// 常量方式实现简单枚举
const (
Mem = iota
Adm
)
// 简单工厂模式Factory
// 使用方法实现,通过判断参数值,返回不同的struct实例
// 缺点:一旦新增用户类别,就要修改const和CreateUser方法的代码
func CreateUser(t int) User {
switch t {
case Mem:
return new(Member)
case Adm:
return new(Admin)
default:
return new(Member)
}
}
func main() {
fmt.Println(CreateUser(Mem).GetRole()) // 会员用户
fmt.Println(CreateUser(Adm).GetRole()) // 后台管理用户
}
21. Go的抽象工厂模式
简单工厂模式的缺点:一旦新增用户类别,就要修改Factory代码
抽象工厂的目标就是,新增用户类别,不需要修改factory代码。如何实现?
实现:
-
将工厂类或工厂方法抽象出来成为接口,也就是新增一个抽象工厂,包含简单工厂实现用户创建接口 -
为每个类创建工厂,每个实体类成为一个工厂来实现抽象工厂 -
新增类时,只需要新增一个工厂来实现抽象工厂即可
package main
import "fmt"
type User interface {
GetRole() string
}
type Member struct{}
func (m *Member) GetRole() string {
return "会员用户"
}
type Admin struct{}
func (a *Admin) GetRole() string {
return "后台管理用户"
}
// 常量方式实现简单枚举
const (
Mem = iota
Adm
)
// 简单工厂模式
// 通过不同的参数值,返回不同的接口,希望这里是不变的
func CreateUser(t int) User {
switch t {
case Mem:
return new(Member)
case Adm:
return new(Admin)
default:
return new(Member)
}
}
// 抽象工厂
type AbstractFactory interface {
CreateUser() User
}
// MemberFactory
type MemberFactory struct{}
func (m *MemberFactory) CreateUser() User {
return &Member{}
}
// MemberFactory
type AdminFactory struct{}
func (m *AdminFactory) CreateUser() User {
return &Admin{}
}
func main() {
var fact AbstractFactory = new(MemberFactory) // 会员用户
fmt.Println(fact.CreateUser().GetRole())
var fact1 AbstractFactory = new(AdminFactory) // 台管理用户
fmt.Println(fact1.CreateUser().GetRole())
}
22. 写一个Go的装饰器模式的例子
装饰器:允许向一个现有的对象添加新的功能,同时又不改变其结构
经典的实现案例:借助http制作和使用一个装饰器
package main
import "net/http"
func index(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("index"))
}
// 装饰器: 输出 this is 前缀
func Decorator(f http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("this is "))
f(w, r) // 传入的HandlerFunc
}
}
// 装饰器:CheckLogin 判断
func CheckLogin(f http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("token") == "" {
w.Write([]byte("token err"))
} else {
w.Write([]byte("this is "))
f(w, r) // 传入的HandlerFunc
}
}
}
func main() {
http.HandleFunc("/", index) // 直接访问 输出 index
http.HandleFunc("/index", Decorator(index)) // 使用装饰器 输出 this is index
http.HandleFunc("/checklogin/", CheckLogin(index)) // 先检查token
http.ListenAndServe(":8080", nil)
}
/*
访问测试:
http://localhost:8080/ ==> index
http://localhost:8080/index ==> this is index
http://localhost:8080/checklogin/?token=123 ==> this is index
http://localhost:8080/checklogin/?aa=123 ==> index
*/
6、各种原理
23. 请简述 go channel 的底层机制(初级原理)
只能是说原理,如果让你去手撸代码,那你就微笑着夺门而出吧,从此家里没有了门。 大概源码约在:runtime/chan.go
type hchan struct {
qcount uint // buffer 中已放入的元素个数
dataqsiz uint // 用户构造 channel 时指定的 buf 大小,也就是底层循环数组的长度
buf unsafe.Pointer // 指向底层循环数组的指针 只针对有缓冲的 channel
elemsize uint16 // buffer 中每个元素的大小
closed uint32 // channel 是否关闭,== 0 代表未 closed
elemtype *_type // channel 元素的类型信息
sendx uint // 已发送元素在循环数组中的索引
recvx uint // 已接收元素在循环数组中的索引
recvq waitq // 等待接收的 goroutine list of recv waiters
sendq waitq // 等待发送的 goroutine list of send waiters
lock mutex // 保护 hchan 中所有字段
}
重点关注4个部分:
-
buf指向一个循环队列。【如 ch:=make(chan int,2)
就会创建出一个循环队列,buf指向这个队列】 -
sendx和recvx用于记录buf发送和接收的index。【 ch<-2
写入chan就是sendx;<-ch
读取chan就是recvx】 -
lock 互斥锁。【sendx和recvx都需要加锁】 -
sendq 和recvq 等待(阻塞)列表
其他:
-
qcount:队列剩余数 -
dataqsize:缓冲区大小
过程: (1)第一步;ch := make(chan int,3) ch本身是一个指针,指向堆中的hchan 循环队列,所以index = [0,1,2] (2)第二步;看buf 初始时索引,sendx = 0;recvx = 0 ; (3)第三步;插入或获取值; ch<- 100; sendx = 1; recvx = 0 ch <- 200; sendx = 2; recvx = 0 <-ch ; sendx = 2; recvx = 1 ... ch<-x 把数据copy到buf指向的循环队列(写);<-ch 把数据从buf指向的循环队列中copy出来(读)
缓冲区满了,(如执行多个 ch<- xxx):
-
PMG调度机制:P(cpu),M(worker线程),G(协程)多个。协程是一个调度概念,真正干活的是线程
-
P(cpu)以最优的方式将G(协程)取出来 ,匹配给M(worker线程)去执行。
-
如果一个chan循环队列满了,P核会将执行该操作的协程G拿开,切换新的协程做其他操作
-
被拿开的协程G会被sudog指针指向它,放入sendq队列,进入等待或阻塞状态
唤醒(比如执行<-ch )
-
数据被取出之后,chan循环队列变成可写的G队列,会再次进入可写的队列中,等待被调度。
综述:
-
chan创建在堆中,返回指针; -
使用循环队列作为缓冲区; -
每次操作都要加锁,并更新操作(sendx和recvx的index); -
缓冲满了,进入等待队列,并让出M(工作线程)。等待被唤醒; -
被唤醒后,重新加入G队列(可被调度的协程)
24. 读写关闭的channel是啥后果
总结:
-
关闭后的channel,如果还有数据,可以正常读,返回值和true;如果没有数据了,读取时返回零值和false。 -
关闭后的channel,写channel会panic,不能忘已关闭的channel send。报错: panic: send on closed channel
-
关闭channel后正常读
func main() {
ch := make(chan int, 3)
ch <- 1
ch <- 2
ch <- 3
close(ch) // 关闭channel
for item := range ch {
fmt.Println(item) // 正常读取channel的值
}
}
-
关闭channel后正常读,读完channel,返回零值和false
func main() {
ch := make(chan int, 3)
ch <- 1
ch <- 2
ch <- 3
close(ch) // 关闭channel后依然可以正常读取
item1 := <-ch
item2 := <-ch
item3, state := <-ch // 3 true
fmt.Println(item1, item2, item3, state) // 1 2 3 true
item4, state := <-ch // 0 false
fmt.Println(item4, state) // 0 false,channel 读取完成之后,返回0值,和false
}
// 也可以使用死循环读取channel
func main() {
ch := make(chan int, 3)
ch <- 1
ch <- 2
ch <- 3
close(ch) // 关闭channel后依然可以正常读取
for {
item, state := <-ch // channel没有数据时,state为false
if !state {
break
}
fmt.Println(item)
}
}
-
关闭后的channel,写会panic
func main() {
ch := make(chan int, 3)
ch <- 1
ch <- 2
ch <- 3
close(ch) // 关闭channel后依然可以正常读取
ch <- 4 // panic: send on closed channel
}
25. 简述Go协程调度机制
参考文章:
-
https://lessisbetter.site/2019/03/10/golang-scheduler-1-history/ (这个不错) -
https://zhuanlan.zhihu.com/p/27056944 (知乎的一篇文章)
首先要知道:
-
M,真正干活的是线程,OS提供的线程,M(Work Thread),代表工作线程 -
G,协程是由“用户”创建的,G(Goroutine) ,代表协程 -
协程G必须按照一定的机制绑定M,才能被执行,协程自身不会执行
-
-
P(Processor),代表处理器(cpu核),又称上下文。不超过GOMAZPROCS
让MPG三者之间发生绑定,就叫做协程调度。
绑定关系 :
-
P和M存在绑定关系 -
运行的M都必须绑定一个P,而P保存着一个协程G的队列 -
调度器还拥有一个全局的G队列 -
P从队列中提取G交给M,并执行
-
找G的顺序:
-
1/61的几率是在全局队列中找G,60/61的几率是在本地队列找G; -
如果全局队列中找不到G,从P的本地队列中找G; -
如果从P的本地队列中找不到,从其他P的本地队列中窃取G; -
如果还找不到,则从全局队列中取一部分G到本地队列。 -
这里拿取的一部分满足公式:n = min(len(GQ)/GOMAXPROCS+1,len(GQ/2))
-
-
-
阻塞过程:
-
假设G1阻塞,从而造成M1阻塞 -
P1第一件事就是和M1解绑 -
然后M1和G1依然保持着“藕断丝连”的关系 -
阻塞结束,M1寻找P来接手G1 -
如果找到了P,将G1放到P的本地队列汇总; -
如果找不到P,就将G1扔给全局队列
-
-
自旋:
-
如果M在本地队列和全局队列中都找不到G ,则会进入自旋(有条件,如2M<繁忙的P) -
自旋:可以理解为循环执行某个代码块。从而不进入休眠,当然会消耗一些CPU -
但是比起反复启动新M性能要高
-
一句话总结:
-
GO的协程调度就是:P(CPU核) 将 G(协程)合理的分配给某个M(工作线程) 的过程
26. 简述Raft协议:选举机制
图解:http://thesecretlivesofdata.com/raft/
-
如果单机 -- 一焚俱焚
-
分布式集群
-
一旦单节点宕机,其他节点依然能够提供服务 -
关键的是:数据并不会丢失。其中一个重要过程就是数据一致性 -
实现数据一致性:节点中需要有个主节点来对数据日志进行统一管理(复制)
-
-
三个身份:(以etcd为例)
-
角色: -
领导者:leader(主节点) -
跟随者:follower -
候选者:candidate
-
-
三个节点(假设记为:A,B,C): -
初始状态都是follower,都有个term值等于0,term代表任期。候选者和跟随者也有任期 -
并且随机分配一个随机时间给三个节点,谁的时间最短,谁就开始发起选举
-
-
-
选举:
-
假设A节点先“苏醒”,它要做三件事
-
(1)A设置任期 term=1,并把自己的角色改为candidate1 -
(2)给自己投一票 -
(3)给其他两个节点发送Msg(带term值)。等待回复
-
-
以B节点为例,收到A发来的投票Msg,并获取term值。发现B.term < A.term,则:
-
(1)持续保持follower角色 -
(2)把自己的term修改为1表示同意;term=1 -
(3)向A节点回执Msg
-
-
同样C也会收到A的Msg,C为follower,C.term = 1
-
-
正常情况下:
-
A会赢得选举(大多数选票) -
于是,A会立刻给所有节点发送心跳消息,避免其余节点触发新的选举
-
-
不正常情况:
-
由于网络等原因,A和B也许会同时发起选举 -
注意:在同一任期类,C只能给A或B投一票,先到先得。 -
假设A先到,此时还是A胜出 -
A胜出后,继续向其他节点发送心跳,这时候如果B收到A的心跳,A.term>B.term,B会将自己修改为follower,并更新term
-
-
为什么必须由leader节点的分布式集群要设置奇数个节点:
-
因为如果有不止一个节点发起选举时,可能会发生平票,就得等待超时后重新发起投票,从而延长了系统选举的时间; -
没有产生leader,集群就不能提供服务
27. 简述Raft协议:数据复制过程(初级)
图解:http://thesecretlivesofdata.com/raft/
比如:当etcd发生写操作时,首先会给到leader节点去做处理,通过日志记录方式复制给其他节点
基本过程如下:所有日志都必须首先提交到leader节点
-
leader加入本地日志。假设日志为 (term=1; command='set x = 1';uncommitted)
-
leader要求followers同步日志 (term=1; command='set x = 1';uncommitted)
-
leader等待多数节点的反馈(不是全部,超过半数即可)==> OK -
leader确认步骤3 操作OK,并修改本地状态和存储 (term=1; command='set x = 1';committed -> apply -> applied)
-
发出心跳要求followers也提交并存储 (term=1; command='set x = 1';committed -> apply -> applied)
状态:uncommitted -> committed -> apply -> applied
-
commit 代表日志被复制到了大多数节点的状态 -
apply 将日志命令应用到状态机(写数据,执行command)
最终一致性:
-
完全一致性是说,必须等待全部节点都复制了日志,才能给客户端响应。==> 严重影响性能 -
最终一致性:大多数节点完成数据复制,即可认为是一致,完成数据复制了,可以给客户端响应。
7、杂项
28. 框架中路由实现--手撸前缀树
用过框架的都知道,有个很重要的功能:路由
比如,/user/index -> 对应 UserController里面的index方法
如果是静态路由,简单做法,弄个hashmap就行,如
map[string]interface{
"/user/index":xxxxx, // 正好是key,则返回xxxxx
}
但是如果是动态路由:/user/:id这种形式就不方便使用
前缀树/字典树
-
一种hash树的变种,典型应用是用于统计,排序和保存大量的字符串(但不限于字符串),所以经常被搜索引擎系统用于文本词频统计。 -
优点是:利用字符串的公共前缀来减少查询时间,最大限度的减少无畏的字符串比较,查询效率比哈希树高
特征:
-
根节点不包含字符 -
每个节点的所有子节点包含的字符都不相同 -
一个节点的所有子节点都有相同的前缀
代码实现:
package main
import (
"fmt"
)
type Node struct {
isend bool // 最后一个节点(最后一个字符)标记
Children map[string]*Node // 这一步,用map就省的遍历了;如果使用[]*Node,需要遍历
}
func NewNode() *Node {
return &Node{Children: make(map[string]*Node)}
}
type Trie struct {
root *Node
}
func NewTrie() *Trie {
return &Trie{root: NewNode()}
}
func (n *Trie) Insert(str string) {
current := n.root // root没有值,是空节点
for _, item := range str {
// 节点不存在,则插入
if _, ok := current.Children[string(item)]; !ok {
// 从root的第一个子节点开始判断
current.Children[string(item)] = NewNode()
}
current = current.Children[string(item)] // 指向下一个节点
}
// 遍历完成,将str的最后一个字符标记为true,表示一个完整的str
current.isend = true
}
func (n *Trie) Search(str string) bool {
current := n.root
for _, item := range str {
if _, ok := current.Children[string(item)]; !ok {
return false // 没有找到
}
current = current.Children[string(item)]
}
return current.isend // str最后一个字符是true,才算查找成功,精准查找
}
func test() {
strs := []string{"go", "gin", "gi", "golang", "goapp", "guest"}
tree := NewTrie()
for _, str := range strs {
tree.Insert(str)
}
strs = append(strs, "gabc", "gogo", "gi", "gia", "ginp")
for _, str := range strs {
fmt.Println(str)
fmt.Println(tree.Search(str))
}
}
func main() {
test()
}
func test() {
strs := []string{"go", "gin", "gi", "golang", "goapp", "guest"}
tree := NewTrie()
for _, str := range strs {
tree.Insert(str)
}
strs = append(strs, "gabc", "gogo", "gi", "gia", "ginp")
for _, str := range strs {
fmt.Println(str)
fmt.Println(tree.Search(str))
}
}
func main() {
test()
}
29. 读取文件:如何统计文本的行数
这里用到的是bufio.scanner,好比是一个带缓冲区的迭代器,可以按行或自定义的分隔符去除数据
-
可以扫描文件内容 -
可以扫描字符串内容 -
可以新写分隔方法
-
Scanner方法 -
NewScanner 创建Scanner ==> scanner := bufio.NewScanner(file) -
Scanner.Split ,设置处理函数(分隔方法,默认按行分割) ==> scanner.Split(bufio.ScanLines) -
Scanner.Scan,获取当前token,扫描一下token -
Scanner.Bytes,将token以bytes的形式返回 -
Scanner.Text,将token以string的形式返回 -
Scanner.Err,获取处理方法返回的错误
-
-
Scanner 内置的split分隔处理方法: -
ScanBytes,将token处理为单一字节 -
ScanRunes,将token处理为utf-8编码的Unicode码 -
ScanWords,以空格分割 -
ScanLines,以换行符分割,"\n"
-
-
写一个按照自己的分隔符的函数 -
以ScanLines为例,找出 func ScanLines(data []byte, atEOF bool) (advance int, token []byte, err error) {...} -
重写func MySplit(data []byte, atEOF bool) (advance int, token []byte, err error) {...} -
改写方法体(修改换行为:):bytes.IndexByte(data, '\n'); ==> bytes.IndexByte(data, ':');
-
scanner方法的好处,使用缓冲区,不会一次将文件内容加载到内存
package main
import (
"bufio"
"bytes"
"fmt"
"log"
"os"
"strings"
)
// 分隔方法,来源于bufio.ScanLines()方法改写
func MySplit(data []byte, atEOF bool) (advance int, token []byte, err error) {
if atEOF && len(data) == 0 {
return 0, nil, nil
}
// 改写,将"\n",改为自己的分隔符。如":"
// if i := bytes.IndexByte(data, '\n'); i >= 0 {
if i := bytes.IndexByte(data, ':'); i >= 0 {
// We have a full newline-terminated line.
return i + 1, dropCR(data[0:i]), nil
}
// If we're at EOF, we have a final, non-terminated line. Return it.
if atEOF {
return len(data), dropCR(data), nil
}
// Request more data.
return 0, nil, nil
}
// 直接复制
func dropCR(data []byte) []byte {
if len(data) > 0 && data[len(data)-1] == '\r' {
return data[0 : len(data)-1]
}
return data
}
func main() {
file, err := os.Open("src/test.log")
if err != nil {
log.Fatal(err, file.Name())
}
// 扫描文件内容
// scanner := bufio.NewScanner(file)
// 扫描字符串内容
reader := strings.NewReader("aaa:bbb:ccc:ddd")
scanner := bufio.NewScanner(reader)
// scanner.Split(bufio.ScanLines) // 默认的按行分隔
// scanner.Split(bufio.ScanWords) // 按空格分隔
scanner.Split(MySplit)
count := 0
for scanner.Scan() {
fmt.Println(scanner.Text())
count++
}
fmt.Println("一共有", count, "部分")
}
30. sync.Pool(1):基本用法
sync.Pool 概念:
-
协程安全,可伸缩的,用于存放可重用对象的容器。
-
原始的目的是:存放已分配但是暂时不用的对象,需要时直接从pool中取,然后放回,以减少GC回收的压力
-
GC压力是啥:很简单,对象多了,GC要回收自然压力大(内存标记了解下)
注意:sync.Pool 不是持久的保存,而是临时的暂存。所以可以理解为sync.Cache
使用示例:
package main
import (
"fmt"
"log"
"runtime"
"sync"
)
var p *sync.Pool
type User struct {
Name string
}
func main() {
// 初始化
p = &sync.Pool{
// 取Pool时,如果是空的,会调用New创建一个新的对象,返回any;type any = interface{}
New: func() any {
log.Println("create user ...")
return &User{Name: "zhangsan"}
},
}
u1 := p.Get()
fmt.Println(u1) // pool空,执行:create user ... &{zhangsan}
u2 := p.Get().(*User)
fmt.Println(u2) // pool空,执行:create user ... &{zhangsan}
u2.Name = "lisi" // u2断言,设置值
p.Put(u2) // 将u2放入pool
u3 := p.Get() // 从poo汇中取
fmt.Println("u3:", u3) // &{lisi}
p.Put(u2) // 将u2放入pool
runtime.GC() // GC后,pool被清空,说明sync.Pool是临时存储
u4 := p.Get() // 从poo汇中取
fmt.Println("u4:", u4) // pool空,执行:create user ... &{zhangsan}
}
31. sync.Pool(2):web开发中的简单使用场景
场景:在web中,每次请求都会返回一个Result对象。因此需要在内存中反复创建Result,产出频繁GC。这种情况就可以使用sync.Pool
-
sync.Pool 在数据中开辟的临时存储,GC后会被清除
-
压测工具:hey,可以在github上找到
-
hey -n 1000 -c 500 http://localhost:8080
-
代码示例:
package main
import (
"fmt"
"sync"
"time"
"github.com/gin-gonic/gin"
)
var retPool *sync.Pool
type Result struct {
Message string `json:"message"`
Status string `json:"status"`
Logger interface{} `json:"-"`
}
func (r *Result) Success(ctx *gin.Context, msg string) {
r.Status = "success"
r.Message = msg
ctx.JSON(200, r)
}
func (r *Result) Error(ctx *gin.Context, msg string) {
r.Status = "error"
r.Message = msg
ctx.JSON(400, r)
}
func main() {
retPool = &sync.Pool{
New: func() any {
fmt.Println("create result...")
return &Result{}
},
}
r := gin.New() // 典型的使用sync.Pool的代表
// 加一个中间件
r.Use(func(ctx *gin.Context) {
defer func() {
if e := recover(); e != nil {
// ret := &Result{}
// ret.Error(ctx, e.(string))
// 改写为sync.Pool
ret := retPool.Get().(*Result)
ret.Error(ctx, e.(string))
ctx.Abort()
}
}()
ctx.Next()
})
r.Handle("GET", "/", func(ctx *gin.Context) {
// r := &Result{}
if time.Now().Unix()%2 == 0 {
ret := retPool.Get().(*Result)
defer func() {
retPool.Put(ret)
}()
ret.Success(ctx, "index")
// r.Success(ctx, "index")
} else {
panic("it's an error.")
}
})
r.Run(":8080")
}
压测:hey -n 1000 -c 500 http://localhost:8080
可以看到,1000次调用,并发500,但很少有create result...,说明内存中 New Result很少。sync.Pool 复用了Result。
作者介绍