golang面试

193 阅读14分钟

// todo 见参考1,2

golang基础问题

1 切片和数组区别:

  • 数组值类型,切片引用类型 数组赋值和传参时,整个数组数据都会赋值一遍,消耗掉大量内存
  • 数组定长,切片长度可变 切片的内部实现是通过指针引用底层数组

2 new和make区别

  1. new和make都是在堆上分配内存的內建函数。
  2. make返回值为类型本身,而不是指针;而new返回的是指向类型的指针。
  3. make只能用来分配及初始化类型为slice,map,channel的数据;new可以分配任意类型的数据。
  4. make分配并初始化,new分配的空间会被清零。

3 值拷贝与引用拷贝

其实,不管是值传递还是引用传递,传递给函数的都是变量的副本,不同的是,值传递的是值的拷贝,引用传递的是地址的拷贝,一般来说,地址拷贝效率高,因为数据量小,而值拷贝决定拷贝的数据大小,数据越大,效率越低。

1)值类型:基本数据类型int系列,float系列,bool, string、数组和结构体struct,默认值传递。拷贝的地址在值上。
2)引用类型:指针、slice切片、map、管道chan、interface等都是引用类型,默认引用传递,此时拷贝的地址在堆上。

4 为什么协程开销小

  1. 协程内存消耗低 协程通常只需要保存少量的上下文信息,例如程序计数器、寄存器的值等,因此协程的内存消耗相对较低。相比之下,线程和进程需要独立的内存空间来存储完整的堆栈信息、寄存器状态等,开销较大。

  2. 切换开销小:协程的切换(或称为挂起和恢复)开销相对较小。在协程间切换时,只需要保存和恢复少量的上下文信息

  3. 完全用户态:无需涉及操作系统内核。无需引入额外的系统调用和内核态切换开销。

  4. 有自己的轻量级调度器算法

  5. 无锁通信,避免各种竞争条件(例如资源争用、锁竞争等)

5 map和slice的底层实现

go的map是分bucket和链式结构,低八位找在哪个bucket,高八位找具体在bucket的哪个位置。 具体来说,Go语言的 map 使用了哈希表的实现,通过哈希函数将键(key)映射到哈希表的某个槽(bucket)上。如果多个键映射到同一个槽上(发生了哈希冲突),则使用链表或者其他的数据结构来解决冲突。
Go语言的 map 使用了开链法(chaining)来解决哈希冲突。每个槽上都连接了一个链表,当发生哈希冲突时,新的键值对会被添加到对应槽上的链表上。在查询时,根据哈希值找到对应的槽,然后在链表上进行线性搜索。

slice 包含三个字段:指向底层数组的指针、slice 的长度(len)和slice 的容量(cap)。当向 slice 中追加元素时,如果超出了当前容量,Go语言会创建一个新的底层数组,并将原来的数据拷贝到新数组中,然后将 slice 的指针指向新的数组。
slice 的容量不够时,底层数组会被重新分配,新的数组大小通常是原数组的两倍。这样做的目的是为了避免频繁的内存分配和释放操作,提高性能。

6 defer

go中一种延迟调用机制,通常用于释放资源。多个defer出现的时候,它会把defer之后的函数压入一个栈中延迟执行,也就是先进后出(LIFO),写在前面的defer会比写在后面的defer调用的晚。

defer和return的顺序:  return 执行的时候,并不是原子性操作,一般是分为两步:将结果x赋值给了返回值,然后执行了RET指令;而defer语句执行的时候,是在赋值变量之后,在RET指令之前。

defer和panic: 当函数遇到panic,defer仍然会被执行。Go会先执行所有的defer链表(该函数的所有defer),当所有defer被执行完毕且没有recover时,才会进行panic。
defer 最大的功能是 panic 后依然有效,所以defer可以保证你的一些资源一定会被关闭,从而避免一些异常出现的问题。

7 Mutex和RWMutex使用情况

Mutex 基本的互斥锁

  • 方法:
    Lock(): 用于获取锁。
    Unlock(): 用于释放锁。
  • 场景:当你希望在同一时刻只有一个Goroutine能够访问共享资源时

RWMutex 读写互斥锁

  • 方法:
    • RLock(): 用于获取读锁。可以被多个Goroutine同时获取,直到有Goroutine持有写锁为止。
    • RUnlock(): 释放读锁。
    • Lock(): 用于获取写锁。如果有任何Goroutine持有读锁或写锁,Lock() 方法会阻塞当前Goroutine。
    • Unlock(): 释放写锁。
  • 场景:当你希望允许多个Goroutine同时读取共享资源,但只允许一个Goroutine写入共享资源时,可以使用 RWMutex

8 recover函数怎么捕获,

协程报panic时,会使整个服务挂掉,golang中引用recover函数来捕获异常,即使报panic也能继续运行下去。

作用域:recover() 只是针对当前函数和以及直接调用的函数可能产生的panic,它无法处理其调用产生的其它协程的panic

// 必须和defer延迟函数结合使用,当程序遇到`panic`时,程序会中断执行,并在调用栈中逐层寻找已注册的`defer`函数(延迟函数),并执行它们。如果其中的某个`defer`函数包含了`recover()`调用,那么`recover()`会捕获到当前的`panic`异常,并继续执行程序,而不是让程序崩溃。  
// 在想要捕获协程异常的函数首部加上下面这段,就可以成功捕获该函数中的异常
defer func() {
		if err := recover(); err !=nil {
			fmt.Println(err)
		}
	}()

panic的原理: 1698765788004.png

9. channel的写入数据流程

这种同步的特性确保了在无缓冲的channel上的发送和接收操作是一对一的,即每次发送操作都有一个对应的接收操作。这种同步机制确保了goroutines之间的同步通信,使得数据的传递是确定性的。如果没有接收者,发送操作就会一直阻塞,直到有接收者出现。这种特性保证了数据不会被丢失,并且发送和接收操作是同步的。

向无缓冲 || 缓冲区满的channel写入时:

  • 如果channel中的接收队列为空,写入的goroutine堵塞并放入到channel的队列中,直到接收队列不为空,将堵塞的goroutine唤醒完成数据交换
  • 如果channel中的接收队列不为空,直接写入数据传递给等待的接收者,完成数据传输,

向缓冲区有空余位置的channel写入时:

  • 加锁,数据拷贝到channel缓冲区,更新写入指针。

从无缓冲的channel读取时:

  • 如果发送队列不为空,则从发送队列中取出goroutine,直接读取数据,并唤醒该goroutine
  • 如果发送队列为空,堵塞加入接收队列等待唤醒。

从有缓冲区但是缓冲区为空的channel读取时:直接堵塞,缓冲区都为空了说明发送队列上肯定没有goroutine

从有缓冲区的channel读取时:直接读缓冲区结束操作。

总结:

  1. 有缓冲区可读写就读写,没有就看有无对应的发送接收队列等待,都没有就堵塞等待其他读写goroutine唤醒。
  2. 无缓冲则同步接收,有缓冲则异步

10. 怎么查看goroutine的数量?怎么限制Goroutine的数量

要查看当前程序中正在运行的goroutine数量,你可以使用runtime包的NumGoroutine()函数。示例如下:num := runtime.NumGoroutine();

限制goroutine数目:

  1. 协程池

golang阻塞

todo : channel什么时候阻塞 golang主协程阻塞实现:

  1. waitgroup

    var wg sync.waitgroup wg.add() defer wg.Done() wg.wait()

  2. 通过独立的Listenchannel控制
    func main() {  
            ch := make(chan int, 5)
            listenCh := make(chan int)  
            go readNum(ch, listenCh)
            for i := 0; i <= 4; i++ {
                    ch <- i
                    if i == 4 {
                            close(ch)
                    }
            }
            <-listenCh // 无缓存区且无数据写入 堵塞直至有数据写入
        }
    func readNum(ch chan int, listenCh chan int) {
           for {
                if num, ok := <-ch; !ok {
                fmt.Println("Channel is closed")
                listenCh <- 0
            } else {
                fmt.Println("num:", num)
            }
    }
    

通过golang实现协程交替打印 eg:go1 => 1 3 5 7;go2 => 2 4 6 8 原理:两个协程for循环打印,用两个协程阻塞保证协程交替运行

func main() {
	wg := sync.WaitGroup{}
	wg.Add(2)
	arr1 := []int{1,3,5,7}
	arr2 := []int{2,4,6,8}
	c := make(chan int, 1) // cd用来堵塞
	d := make(chan int, 1)
	go func() {
          for i := 0;i < 4; i++ {
                fmt.Print(arr1[i])
                c <- 1  // 开启宁一个协程
                <-d     // 阻塞本协程
		}
		wg.Done()
	}()

	go func() {
		for i := 0;i < 4 {
			<-c   // 阻塞本协程
			fmt.Print(arr2[i])
			d <- 1 // 开启宁一个协程
		}
		wg.Done()
	}()
	wg.Wait()  // 阻塞主协程
	fmt.Println()
	fmt.Println("hello world!")
}

channel阻塞

image.png 环形缓存区概念,对缓存区满时会存在一个sudog类型的链表里,该链表记录哪个协程在等待哪个channel以及等待发送数据等。 环形缓存区保证了:当channel缓冲区满时不会立刻堵塞,数据会临时存入到sudog里面,当该channel中数据在被接收时,sudog的数据会立刻填回去,充当了一个临时存储作用。 阻塞时机:

  • channel为nil
  • channel没有缓存区或缓存区用尽,也没有协程等着接收数据。

image.png

340c7753b68987fbc4984e98252a4241.png

向一个已经关闭了的channel写入数据会怎样?

关闭channel时,接收队列上的goroutine全被唤醒,只不过本该写入goroutine的数据位置为nil, 接收队列中的gotoutine全部唤醒,但是这些会panic

对一个关闭的通道再发送值就会导致panic。
对一个关闭的通道进行接收会一直获取值直到通道为空。
对一个关闭的并且没有值的通道执行接收操作会得到对应类型的零值。
关闭一个已经关闭的通道会导致panic。

怎么判断一个channel已经关闭了

num, ok := <-ch; 管道关闭时num为零值,ok为false

golang如何从协程中拿到返回值

  1. 给返回值放到宁外一个独立的goroutine里面
  2. 加锁or原子操作存储到外层变量

介绍一下Golang吗?

Golang由于简单和易读的语法,使得入门简单,代码编写和维护容易,它的主要特性有

  1. 高并发支持:内置了轻量级的协程(goroutine)和通道(channel)。
  2. 高效性能:Go被设计成一种高性能语言,编译速度快,执行速度也很快。它通过垃圾回收器来管理内存,使开发者不必担心手动内存管理,从而减少了出错的机会。
  3. 内置网络库:Go的标准库中包含了一组强大的网络库,这些库可以轻松处理网络通信、HTTP请求、WebSocket等,非常适合构建网络相关的云原生组件。
  4. 静态类型检查:Go是一种静态类型语言,它在编译时进行类型检查,这有助于提前捕获潜在的错误,并提高了代码的稳定性和可维护性。
  5. 内置工具:Go附带了许多内置工具,如代码格式化工具(gofmt)、依赖管理工具(Go Modules)、性能分析工具(pprof)等,使得开发和维护应用程序更加方便。

适合用来处理高并发和大规模负载情况。 使用于网络编程

golang协程池

Golang学习篇——协程池_golang的协程池-CSDN博客

  1. 限制并发执行任务数量的机制,通常用于处理大量的并发任务,以避免系统资源被过度消耗。使用协程池可以有效控制并发度,提高系统性能。
  2. 协程池是一种利用池化技术,复用对象,减少内存分配的频率以及协程创建开销,从而提高协程执行效率的技术。在高并发场景下,我们可能会启动大量的协程来处理业务逻辑。

简单的协程池的实现:

  • task 是一个待执行的任务节点,同时还包含了指向下一个任务的指针,链表结构;
  • worker 是一个实际执行任务的执行器,它会异步启动一个 goroutine 执行协程池里面未执行的task;一个 worker 就是逻辑上的一个执行器,它唯一对应到一个协程池 pool。当一个worker被唤起,将会开启一个goroutine ,不断地从 pool 中的 task链表获取任务并执行。
  • pool 是一个逻辑上的协程池,对应了一个task链表,同时负责维护task状态的更新,以及在需要的时候创建新的 worker

协程的复用实现点: 预先开启固定数目的协程,将待执行的任务用链表或者channel存储,这固定数目的goroutine会去for range所有的待执行任务并处理

// 任务定义: 任务包含执行方法,任务创建,任务调用

// 定义任务Task类型,每一个任务Task都可以抽象成一个函数
type Task struct {
	f func() error //一个无参的函数类型
}

//通过NewTask来创建一个Task
func NewTask(f func() error) *Task {
	t := Task{
		f: f,
	}
	return &t
}
 
//执行Task任务的方法
func (t *Task) Execute() {
	t.f() //调用任务所绑定的函数
}

//定义池类型

type Pool struct {
	EntryChannel chan *Task //对外接收Task的入口
	worker_num   int        //协程池最大worker数量,限定Goroutine的个数
	JobsChannel  chan *Task //协程池内部的任务就绪队列
        // task 链表 
        //taskHead *task 
        //taskTail *task 
        //taskLock sync.Mutex 
        //taskCount int32
}

// 让协程池Pool开始工作
func (p *Pool) Run() {
	//1,首先根据协程池的worker数量限定,开启固定数量的Worker,
	//  每一个Worker用一个Goroutine承载
	for i := 0; i < p.worker_num; i++ {
		fmt.Println("开启固定数量的Worker:", i)
		go p.worker(i)
	}
 
	//2, 从EntryChannel协程池入口取外界传递过来的任务
	//   并且将任务送进JobsChannel中
	for task := range p.EntryChannel {
		p.JobsChannel <- task
	}
 
	//3, 执行完毕需要关闭JobsChannel
	close(p.JobsChannel)
	fmt.Println("执行完毕需要关闭JobsChannel")
 
	//4, 执行完毕需要关闭EntryChannel
	close(p.EntryChannel)
	fmt.Println("执行完毕需要关闭EntryChannel")
}

//创建一个协程池
func NewPool(cap int) *Pool {
	p := Pool{
		EntryChannel: make(chan *Task), // 对外接收Task的入口
		worker_num:   cap,              // 协程池的最大worker数量,限定goroutine的数量
		JobsChannel:  make(chan *Task,maxTaskNum),  // 协程池内部的就绪队列
	}
	return &p
}

//协程池创建一个worker并且开始工作
func (p *Pool) worker(work_ID int) {
	//worker不断的从JobsChannel内部任务队列中拿任务
	for task := range p.JobsChannel {
		//如果拿到任务,则执行task任务
		task.Execute()
		fmt.Println("worker ID ", work_ID, " 执行完毕任务")
	}
}
 
//让协程池Pool开始工作
func (p *Pool) Run() {
	//1,首先根据协程池的worker数量限定,开启固定数量的Worker,
	//  每一个Worker用一个Goroutine承载
	for i := 0; i < p.worker_num; i++ {
		fmt.Println("开启固定数量的Worker:", i)
		go p.worker(i)
	}
 
	//2, 从EntryChannel协程池入口取外界传递过来的任务
	//   并且将任务送进JobsChannel中
	for task := range p.EntryChannel {
		p.JobsChannel <- task
	}
 
	//3, 执行完毕需要关闭JobsChannel
	close(p.JobsChannel)
	fmt.Println("执行完毕需要关闭JobsChannel")
 
	//4, 执行完毕需要关闭EntryChannel
	close(p.EntryChannel)
	fmt.Println("执行完毕需要关闭EntryChannel")
}

//主函数

func main() {
	//创建一个Task
	t := NewTask(func() error {
		fmt.Println("创建一个Task:", time.Now().Format("2006-01-02 15:04:05"))
		return nil
	})
 
	//创建一个协程池,最大开启3个协程worker
	p := NewPool(3)
 
	//开一个协程 不断的向 Pool 输送打印一条时间的task任务
	go func() {
		for {
			p.EntryChannel <- t
		}
	}()
 
	//启动协程池p
	p.Run()

实现缺点

  1. 对pool, worker的状态有了没有很好的管理。
  2. 在第一个实现的简单groutine池和go-playground/pool中,都是先启动预定好的groutine来完成任务执行,在并发量远小于任务量的情况下确实能够做到groutine的复用,如果任务量不多则会导致任务分配到每个groutine不均匀,甚至可能出现启动的groutine根本不会执行任务从而导致浪费,而且对于协程池也没有动态的扩容和缩小
  3. 没有稳定性,一个panic所有协程都会挂

参考

[1] (28条消息) Golang常见面试题及解答_golang 面试_西木Qi的博客-CSDN博客 [2] (28条消息) Golang 面试总结_golang gmp调度面试_CHAO9172的博客-CSDN博客 [3] (28条消息) GoLang之channel数据结构及阻塞、非阻塞操作、多路select_channel非阻塞_~庞贝的博客-CSDN博客