CrabNote螃蟹笔记

Golang面试题

本文《Golang面试题》由小宇宙从原站整理搬运,详细讲解相关技术要点。

大家好,我是小宇宙。 Go 语言这几年在后端岗位的面试里出现得越来越频繁,尤其是字节、腾讯、美团这类大厂,Go 岗位的面试题难度其实不低。很多人第一次接触 Go 面试会有点懵,语言本身学起来挺快的,但面试要考的那些底层原理,比如 GMP 调度模型、GC 三色标记、Channel 的阻塞机制,根本不是会用就能回答上来的。 这篇文章把 Go 面试中最常考的知识点都整理进来了,从基础语法到并发编程,再到内存管理和垃圾回收,基本覆盖了你在 Go 面试中会遇到的绝大多数问题。每道题尽量把背后的原理讲清楚,不只是给一个结论。 几个特别值得花时间搞透的模块: Slice 和 Map :不只是会用,要知道扩容机制、底层结构、并发安全问题,这两个基本是每场面试必提。 Channel 和并发 :Go 最有特色的地方,Channel 的收发过程、select 的执行机制、各种锁的底层实现,面试官特别喜欢在这里深挖。 GMP 调度模型 :Go 高并发的核心,P 的作用、work stealing、goroutine 调度时机,这块是 Go 进阶面试的分水岭。 GC 垃圾回收 :三色标记法、写屏障、STW,理解原理比死记结论重要得多。 如果你刚开始准备 Go 面试,建议先把基础和 Slice/Map/Channel 这些核心数据结构吃透,并发和 GMP 相对抽象,可以结合代码多跑几个例子来理解。 1. Go基础面试题 1.1 与其他语言相比,使用 Go 有什么好处? 与其他语言不同,Go 代码的设计是务实的,Go的语法更简洁。每个功能和语法决策都旨在让程序员的开发效率更高 Golang 针对并发进行了优化,支持协程,并且实现了高效的GMP调度模型。 由于单一的标准代码格式,Golang 通常被认为比其他语言更具可读性。 有高效的垃圾回收机制,支持并行垃圾回收,垃圾回收效率比比 Java 或 Python 更高 1.2 什么是协程? 协程是用户态轻量级线程,它是线程调度的基本单位。通常在函数前加上 go 关键字就能实现并发。一个 Goroutine 会以一个很小的栈启动(自 Go 1.4 起初始 2KB,早期版本曾为 8KB),当遇到栈空间不足时,栈会自动伸缩,因此可以轻易实现成千上万个 goroutine 同时启动。 1.3 协程和线程和进程的区别? 进程:进程是具有一定独立功能的程序,进程是系统资源分配和调度的最小单位。 每个进程都有自己的独立内存空间,不同进程通过进程间通信来通信。由于进程比较重量,占据独立的内存,所以上下文进程间的切换开销(栈、寄存器、虚拟内存、文件句柄等)比较大,但相对比较稳定安全。 线程:线程是进程的一个实体,线程是内核态,而且是 CPU 调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。线程间通信主要通过共享内存,上下文切换很快,资源开销较少,但相比进程不够稳定容易丢失数据。 协程:协程是一种用户态的轻量级线程,协程的调度完全是由用户来控制的。协程拥有自己的寄存器上下文和栈。 协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文的切换非常快。 1.4 Golang 中 make 和 new 的区别? 回答: 和 都是用于内存分配的内建函数,但它们的使用场景和功能有所不同: 1. : 用于初始化并分配内存,只能用于创建 、 和 三种类型。 返回的是初始化后的数据结构,而不是指针。 2. : 用于分配内存,但不初始化,返回的是指向该内存的指针。 可以用于任何类型的内存分配。 分析: 函数创建的是数据结构( 、 、 )本身,且返回初始化后的值。而 函数创建的是可以指向任意类型的指针,返回指向未初始化零值的内存地址。 1.5 Golang 中数组和切片的区别? 数组: 数组固定长度。数组长度是数组类型的一部分,所以[3]int 和[4]int 是两种不 同的数组类型数组需要指定大小,不指定也会根据初始化,自动推算出大小, 大小不可改变。数组是通过值传递的 切片: 切片可以改变长度。切片是轻量级的数据结构,三个属性:指针、长度、容量;不需要指定大小。切片在传参时按值传递的是 slice header(包含底层数组指针、长度、容量)的副本,但因为副本中的指针指向同一底层数组,所以函数内对底层数组元素的修改对调用方可见。切片可以通过数组来初始化,也可以通过内置函数 make() 来初始化,初始化的时候 len=cap,然后进行扩容。 分析: slice 的底层数据其实也是数组,slice 是对数组的封装,它描述一个数组的片段。slice 实际上是一个结构体,包含三个字段:长度、容量、底层数组。 1.6 使用for range 的时候,它的地址会发生变化吗? 在 Go 1.22 之前,对于 循环中的迭代变量,其内存地址是不会发生变化的(在每次迭代中复用同一个变量)。Go 1.22 起,循环变量改为 per iteration(每次迭代都是新变量),地址也会发生变化,不再共享内存。注意:该行为依赖于 中声明的 Go 版本(go directive ≥ 1.22)才会生效。 分析: Go1.22之前: 这里 是一个 副本 。在每次迭代中, 中的当前元素值会被 复制 到 这个变量中。Go 编译器通常会为 分配一块固定的内存地址,然后在每次迭代时,将当前元素的值覆盖到这块内存中。所以,当你打印 时,你会发现它的内存地址在整个循环过程中都是保持不变的。 但是在 Go 1.22 及以后,使用 遍历一个集合时, 迭代变量的地址会发生变化 。这是因为 每次迭代时都会重新生成迭代变量(如 ),这些变量在内存中是不同的地址。 1.7 如何高效地拼接字符串? 拼接字符串的方式有: , , (opens new window), (opens new window), 1. "+" 使用 操作符进行拼接时,会对字符串进行遍历,计算并开辟一个新的空间来存储原来的两个字符串。 2. fmt.Sprintf 由于采用了接口参数,必须要用反射获取值,因此有性能损耗。 3. strings.Builder: 用WriteString()进行拼接,内部实现是指针+切片,同时String()返回拼接后的字符串,它是直接把[]byte转换为string,从而避免变量拷贝。 4. bytes.Buffer 是一个一个缓冲 类型的缓冲器,这个缓冲器里存放着都是 , 底层也是一个 切片。 5. strings.join 也是基于 来实现的,并且可以自定义分隔符,在join方法内调用了b.Grow(n)方法,这个是进行初步的容量分配,而前面计算的n的长度就是我们要拼接的slice的长度,因为我们传入切片长度固定,所以提前进行容量分配可以减少内存分配,很高效。 性能比较: strings.Join ≈ strings.Builder bytes.Buffer "+" fmt.Sprintf 5种拼接方法的实例代码 1.8 defer 的执行顺序是怎样的?defer 的作用或者使用场景是什么? defer执行顺序和调用顺序相反,类似于栈后进先出(LIFO) defer 的作用是:当 defer 语句被执行时,跟在 defer 后面的函数会被延迟执行。直到 包含该 defer 语句的函数执行完毕时,defer 后的函数才会被执行,不论包含 defer 语句的函数是通过 return 正常结束,还是由于 panic 导致的异常结束。 你可以在一个函数中执行多条 defer 语句,它们的执行顺序与声明顺序相反。 defer 的常用场景: defer语句经常被用于处理成对的操作,如打开、关闭、连接、断开连接、 加锁、释放锁。 通过defer机制,不论函数逻辑多复杂,都能保证在任何执行路径下,资 源被释放。 释放资源的defer应该直接跟在请求资源的语句后。 分析: 上面这个例子中,test返回值并没有修改,这是由于Go的返回机制决定的,执行Return语句后,Go会创建一个临时变量保存返回值。如果是有名返回(也就是指明返回值 ) 这个例子中,返回值被修改了。对于有名返回值的函数,执行 return 语句时,并不会再创建临时变量保存,因此,defer 语句修改了 i,即对返回值产生了影响。 1.9 什么是 rune 类型? Go 语言的字符有以下两种: uint8 类型,或者叫 byte 型,代表了 ASCII 码的一个字符。 rune 类型,代表一个 Unicode 码点(code point),当需要处理中文、日文或者其他非 ASCII 字符时,则需要用到 rune 类型。rune 是 int32 的别名(UTF 8 是 Go 字符串的底层编码方式,rune 表示的是 Unicode 标量值,两者概念不同)。 1.10 Go 语言 tag 有什么用? tag可以为结构体成员提供属性。常见的: 1. json序列化或反序列化时字段的名称 2. db: sqlx模块中对应的数据库字段名 3. form: gin框架中对应的前端的数据字段名 4. binding: 搭配 form 使用, 默认如果没查找到结构体中的某个字段则不报错值为空, binding为 required 代表没找到返回错误给前端 1.11 go 打印时 %v %+v % v 的区别? %v 只输出所有的值; %+v 先输出字段名字,再输出该字段的值; % v 先输出结构体名字值,再输出结构体(字段名字+字段的值); 1.12 Go语言中空 struct{} 占用空间么? 可以使用 unsafe.Sizeof 计算出一个数据类型实例需要占用的字节数,空struct{}不占用任何空间 1.13 Go语言中,空 struct{} 有什么用? 用map模拟一个set,那么就要把值置为struct{},struct{}本身不占任何空间,可以避免任何多余的内存分配。 有时候给通道发送一个空结构体,channel< struct{}{},可以节省空间 表示仅有方法的结构体 1.14 init() 函数是什么时候执行的? 简答: 在main函数之前执行。 详细: init()函数是go初始化的一部分,由runtime初始化每个导入的包,初始化不是按照从上到下的导入顺序,而是按照解析的依赖关系,没有依赖的包最先初始化。 每个包首先初始化包作用域的常量和变量(常量优先于变量),然后执行包的 函数。同一个包,甚至是同一个源文件可以有多个 函数。 函数没有入参和返回值,不能被其他函数调用,同一个包内多个 函数的执行顺序不作保证。 执行顺序:import – const – var – – 一个文件可以有多个 函数! 1.15 2 个 interface 可以比较吗 ? Go 语言中,interface 的内部实现包含了 2 个字段,类型 和 值 ,interface 可以使用 或 比较。2 个 interface 相等有以下 2 种情况 1. 两个 interface 均等于 nil(此时 V 和 T 都处于 unset 状态) 2. 类型 T 相同,且对应的值 V 相等。 和 对应的类型是 ,值是 Stu 结构体的地址,两个地址不同,因此结果为 false。 和 对应的类型是 ,值是 Stu 结构体,且各字段相等,因此结果为 true。 1.16 2 个 nil 可能不相等吗? 可能不等。interface 内部由 (动态类型 T, 动态值 V) 两部分组成, 只有当 T 和 V 都为 nil 时,接口才等于 nil 。把一个类型化的 nil 指针(typed nil,如 )装入接口后,接口的动态类型不是 nil,所以接口不等于 nil——这是 Go 中最常见的"nil 陷阱"。举个例子: 总结:判断接口是否为 nil 时,必须动态类型和动态值都为 nil 才相等;不要把"typed nil 装入接口"和"nil 接口"混为一谈。 1.17 Go 语言函数传参是值类型还是引用类型? 在 Go 语言中只存在值传递,要么是值的副本,要么是指针的副本。无论是值类型的变量还是引用类型的变量亦或是指针类型的变量作为参数传递都会发生值拷贝,开辟新的内存空间。 另外值传递、引用传递和值类型、引用类型是两个不同的概念,不要混淆了。引用类型作为变量传递可以影响到函数外部是因为发生值拷贝后新旧变量指向了相同的内存地址。 1.18 如何知道一个对象是分配在栈上还是堆上? Go和C++不同,Go局部变量会进行逃逸分析。如果变量离开作用域后没有被引用,则优先分配到栈上,否则分配到堆上。那么如何判断是否发生了逃逸呢? . 关于逃逸的可能情况:变量大小不确定,变量类型不确定,变量分配的内存超过用户栈最大值,暴露给了外部指针。 1.19 Go语言的多返回值是如何实现的? Go 语言的多返回值是通过在函数调用栈帧上预留空间并进行 值复制 来实现的。在函数调用发生时,Go 编译器会计算出函数所有返回值的总大小。在为该函数创建 栈帧 时,就会在调用方(caller)的栈帧上,为这些返回值预留出连续的内存空间。 当函数执行到 语句时,它会将其要返回的各个值 复制 到这些预留好的栈空间中。函数执行完毕后,控制权返回给调用方。此时,调用方可以直接从它自己的栈帧上(即之前为返回值预留的空间)获取这些返回的值。 1.20 Go语言中"\ "的作用 1. 忽略多返回值:在 Go 语言中,函数可以返回多个值。如果你只关心其中的一部分返回值,而不需要使用其余的,就可以用 来忽略它们,从而避免编译器报错 2. 当你导入一个包时,通常会使用它的某个功能。但有时你可能只想执行包的 函数(例如,注册驱动、初始化全局变量等),而不需要直接使用包中的任何导出成员。这时,你就可以使用 来进行 匿名导入 示例: 1.21 Go语言普通指针和unsafe.Pointer有什么区别? 普通指针比如 、 ,它们有明确的类型信息,编译器会进行类型检查和垃圾回收跟踪。不同类型的指针之间不能直接转换,这是Go类型安全的体现。 而 unsafe.Pointer 是Go的通用指针类型,可以理解为C语言中的 ,它绕过了Go的类型系统。unsafe.Pointer可以与任意类型的指针相互转换,也可以与uintptr进行转换来做指针运算。 另外,普通指针受 GC 管理和类型约束,unsafe.Pointer 不受类型约束但仍受 GC 跟踪。 1.22 unsafe.Pointer与uintptr有什么区别和联系 unsafe.Pointer和uintptr可以相互转换,这是Go提供的唯一合法的指针运算方式。典型用法是先将unsafe.Pointer转为uintptr做算术运算,然后再转回unsafe.Pointer使用。 最关键的区别在于 GC跟踪 。unsafe.Pointer会被垃圾回收器跟踪,它指向的内存不会被错误回收;而uintptr只是一个普通整数,GC完全不知道它指向什么,如果没有其他引用,对应内存可能随时被回收。 所以记住:unsafe.Pointer有GC保护,uintptr没有,这是它们最本质的区别。 2. Slice面试题 2.1 slice的底层结构是怎样的? slice 的底层数据其实也是数组,slice 是对数组的封装,它描述一个数组的片段。slice 实际上是一个结构体,包含三个字段:长度、容量、底层数组。 2.2 Go语言里slice是怎么扩容的? 1.17及以前 1. 如果期望容量大于当前容量的两倍就会使用期望容量; 2. 如果当前切片的容量小于 1024 就会将容量翻倍; 3. 如果当前切片的容量大于等于 1024 就会每次增加 25% 的容量,直到新容量大于期望容量; Go1.18及以后,引入了新的扩容规则: 当原slice容量(oldcap)小于256的时候,新slice(newcap)容量为原来的2倍;原slice容量超过256,新slice容量newcap = oldcap+(oldcap+3\ 256)/4 2.3 从一个切片截取出另一个切片,修改新切片的值会影响原来的切片内容吗 在截取完之后,如果新切片没有触发扩容,则修改切片元素会影响原切片,如果触发了扩容则不会。 示例: 运行结果: 从 索引2(闭区间)到索引5(开区间,元素真正取到索引4),长度为3,容量默认到数组结尾,为8。 从 的索引2(闭区间)到索引6(开区间,元素真正取到索引5),容量到索引7(开区间,真正到索引6),为5。 接着,向 尾部追加一个元素 100: 容量刚好够,直接追加。不过,这会修改原始数组对应位置的元素。这一改动,数组和 都可以看得到。 再次向 追加元素200 这时, 的容量不够用,该扩容了。于是, 另起炉灶,将原来的元素复制新的位置,扩大自己的容量。并且为了应对未来可能的 带来的再一次扩容, 会在此次扩容的时候多留一些 ,将新的容量将扩大为原始容量的2倍,也就是10了。 最后,修改 索引为2位置的元素: 这次只会影响原始数组相应位置的元素。它影响不到 了,人家已经远走高飞了。 再提一点,打印 的时候,只会打印出 长度以内的元素。所以,只会打印出3个元素,虽然它的底层数组不止3个元素。 2.4 slice作为函数参数传递,会改变原slice吗? 当 slice 作为函数参数时,因为会拷贝一份新的slice作为实参,所以原来的 slice 结构并不会被函数中的操作改变,也就是说,slice 其实是一个结构体,包含了三个成员:len, cap, array并不会变化。但是需要注意的是,尽管slice结构不会变,但是其底层数组的数据如果有修改的话,则会发生变化。若传的是 slice 的指针,则原 slice 结构会变,底层数组的数据也会变。 示例: 程序输出: 果真改变了原始 slice 的底层数据。这里传递的是一个 slice 的副本,在 函数中, 只是 函数中 的一个拷贝。在 函数内部,对 的作用并不会改变外层 函数的 的结构。 要想真的改变外层 ,只有将返回的新的 slice 赋值到原始 slice,或者向函数传递一个指向 slice 的指针。我们再来看一个例子: 程序输出 函数里,虽然改变了 ,但它只是一个值传递,并不会影响外层的 ,因此第一行打印出来的结果仍然是 。 而 是一个新的 ,它是基于 得到的。因此它打印的是追加了一个 之后的结果: 。 最后,将 赋值给了 , 这时才真正变成了一个新的slice。之后,再给 函数传入一个 ,这回它真的被改变了: 3. Map面试题 3.1 Go语言Map的底层实现原理是怎样的? map的就是一个hmap的结构。Go Map的底层实现是一个 哈希表 。它在运行时表现为一个指向 结构体的指针, 中记录了 桶数组指针 、 溢出桶指针 以及 元素个数 等字段。每个桶是一个 结构体,能存储 8个键值对 和 8个 ,并有指向下一个 溢出桶的指针 。为了 内存紧凑 , 中采用的是先存8个键再存8个值的存储方式。 分析: hmap结构定义: bmap结构如下: 3.2 Go语言Map的遍历是有序的还是无序的? Go语言里Map的遍历是 完全随机 的,并没有固定的顺序。map每次遍历,都会从一个随机值序号的桶,在每个桶中,再从按照之前选定随机槽位开始遍历,所以是无序的。 3.3 Go语言Map的遍历为什么要设计成无序的? map 在扩容后,会发生 key 的搬迁,原来落在同一个 bucket 中的 key,搬迁后,有些 key 就要远走高飞了(bucket 序号加上了 2^B)。而遍历的过程,就是按顺序遍历 bucket,同时按顺序遍历 bucket 中的 key。搬迁后,key 的位置发生了重大的变化,有些 key 飞上高枝,有些 key 则原地不动。这样,遍历 map 的结果就不可能按原来的顺序了。 Go团队为了避免开发者写出依赖底层实现细节的脆弱代码,而 有意为之 的一个设计。通过在遍历时引入随机数,Go从根本上杜绝了程序员依赖特定遍历顺序的可能性,强制我们写出更健壮的代码。 3.4 Map如何实现顺序读取? 如果业务上确实需要有序遍历,最规范的做法就是将Map的键(Key)取出来放入一个切片(Slice)中,用 包对切片进行排序,然后根据这个有序的切片去遍历Map。 3.5 Go语言的Map是否是并发安全的? map 不是线程安全的。 在查找、赋值、遍历、删除的过程中都会检测写标志,一旦发现写标志已经被置位(说明已经有 goroutine 在写),则直接 throw(runtime 触发 fatal error,不可被 recover 捕获)。赋值和删除函数在检测到写标志为复位状态后,会先将写标志位置位,再进行后续操作。 检测写标志: 设置写标志: 3.6 Map的Key一定要是可比较的吗?为什么? Map的Key必须要可比较。 首先,Map会对我们提供的Key进行哈希运算,得到一个哈希值。这个哈希值决定了这个键值对大概存储在哪个位置(也就是哪个"桶"里)。然而,不同的Key可能会产生相同的哈希值,这就是"哈希冲突"。当多个Key被定位到同一个"桶"里时,Map就没法只靠哈希值来区分它们了。此时,它必须在桶内进行逐个遍历,用我们传入的Key和桶里已有的每一个Key进行\ \ 相等(==)\ \ 比较。这样才能确保我们操作的是正确的键值对。 3.7 Go语言Map的扩容时机是怎样的? 向 map 插入新 key 的时候,会进行条件检测,符合下面这 2 个条件,就会触发扩容 1. 装载因子超过阈值(源码里定义的阈值是 6.5), 触发双倍扩容 (B+1,buckets 数量翻倍)。 2. overflow 的 bucket 数量过多( 触发等量扩容 ,B 不变,仅重新整理键值对让分布更紧凑): 1. 当 B 小于 15,也就是 bucket 总数 2^B 小于 2^15 时,如果 overflow 的 bucket 数量超过 2^B; 2. 当 B = 15,也就是 bucket 总数 2^B 大于等于 2^15,如果 overflow 的 bucket 数量超过 2^15。 3.8 Go语言Map的扩容过程是怎样的? Go的扩容是 渐进式(gradual )的。它不会在触发扩容时"stop the world"来一次性把所有数据搬迁到新空间,而是只分配新空间,然后在后续的每一次插入、修改或删除操作时,才会顺便搬迁一两个旧桶的数据。这种设计将庞大的扩容成本分摊到了多次操作中,极大地减少了服务的瞬间延迟(STW),保证了性能的平滑性。 如果是触发双倍扩容,会新建一个buckets数组,新的buckets数量大小是原来的2倍,然后旧buckets数据搬迁到新的buckets。如果是等量扩容,buckets数量维持不变,重新做一遍类似双倍扩容的搬迁动作,把松散的键值对重新排列一次,使得同一个 bucket 中的 key 排列地更紧密,这样节省空间,存取效率更高 3.9 可以对Map的元素取地址吗? 无法对 map 的 key 或 value 进行取址。会发生编译报错,这样设计主要是因为map一旦发生扩容,key 和 value 的位置就会改变,之前保存的地址也就失效了。 示例: 会出现编译报错: 3.10 Map 中删除一个 key,它的内存会释放么? 不会, 一个key,并不会立刻释放或收缩Map占用的内存。具体来说, 这个操作,只是把key和value对应的内存块标记为"空闲",让它们的内容可以被后续的垃圾回收(GC)处理掉。但是,Map底层为了存储这些键值对而分配的"桶"(buckets)数组,它的规模是不会缩小的。只有当 map 失去所有引用(被置为 nil 或离开作用域)后,整个 map 的空间才会被垃圾回收后释放。 3.11 Map可以边遍历边删除吗 map 并不是一个线程安全的数据结构。如果多个线程边遍历,边删除,同时读写一个 map 是未定义的行为,如果被检测到,会直接 panic。 如果是发生在多个协程同时读写同一个 map 的情况下。 如果在同一个协程内边遍历边删除,并不会检测到同时读写,理论上是可以这样做的。但是,遍历的结果就可能不会是相同的了,有可能结果遍历结果集中包含了删除的 key,也有可能不包含,这取决于删除 key 的时间:是在遍历到 key 所在的 bucket 时刻前或者后。这种情况 下,可以通过加读写锁sync.RWMutex来保证 4. Channel面试题 4.1 什么是CSP? CSP(Communicating Sequential Processes,通信顺序进程)并发编程模型,它的核心思想是:通过通信共享内存,而不是通过共享内存来通信。Go 语言的Goroutine 和 Channel机制,就是 CSP 的经典实现,具有以下特点: 1. 避免共享内存:协程(Goroutine)不直接修改变量,而是通过 Channel 通信 2. 天然同步:Channel 的发送/接收自带同步机制,无需手动加锁 3. 易于组合:Channel 可以嵌套使用,构建复杂并发模式(如管道、超时控制) 4.2 Channel的底层实现原理是怎样的? Channel的底层是一个名为 的结构体,核心包含几个关键组件: 环形缓冲区: 有缓冲channel内部维护一个固定大小的环形队列,用 指针指向缓冲区, 和 分别记录发送和接收的位置索引。这样设计能高效利用内存,避免数据搬移。 两个等待队列 : 用来管理阻塞的goroutine。 存储因channel满而阻塞的发送者, 存储因channel空而阻塞的接收者。这些队列用双向链表实现,当条件满足时会唤醒对应的goroutine。 互斥锁: 内部有个mutex,所有的发送、接收操作都需要先获取锁,用来保证并发安全。虽然看起来可能影响性能,但Go的调度器做了优化,大多数情况下锁竞争并不激烈。 分析: hchan定义如下: 4.3 向channel发送数据的过程是怎样的? 向channel发送数据的整个过程都会在mutex保护下进行,保证并发安全。会经历几个关键步骤: 1. 首先是检查是否有等待的接收者 。如果 队列不为空,说明有goroutine在等待接收数据,这时会直接把数据传递给等待的接收者,跳过缓冲区,这是最高效的路径。同时会唤醒对应的goroutine继续执行。 2. 如果没有等待接收者,就尝试写入缓冲区 。检查缓冲区是否还有空间,如果 ,就把数据复制到 位置,然后更新 索引和 计数。这是无缓冲或缓冲区未满时的正常流径。 3. 当缓冲区满了就需要阻塞等待 。创建一个 结构体包装当前goroutine和要发送的数据,加入到 等待队列中,然后调用 让当前goroutine进入阻塞状态,让出CPU给其他goroutine。 被唤醒后继续执行 。当有接收者从channel读取数据后,会从 中唤醒一个等待的发送者,被唤醒的goroutine会完成数据发送并继续执行。 还有个特殊情况是向已关闭的channel发送数据会直接panic 。这是Go语言的设计原则,防止向已关闭的通道写入数据。 分析: 在第 17 行,主协程向 ch 发送了一个元素 3,来看下接下来会发生什么。 sender 发现 ch 的 recvq 里有 receiver 在等待着接收,就会出队一个 sudog,把 recvq 里 first 指针的 sudo "推举"出来了,并将其加入到 P 的可运行 goroutine 队列中。然后,sender 把发送元素拷贝到 sudog 的 elem 地址处,最后会调用 goready 将 G1 唤醒,状态变为 runnable。 当调度器光顾 G1 时,将 G1 变成 running 状态,执行 goroutineA 接下来的代码。G 表示其他可能有的 goroutine。 这里其实涉及到一个协程写另一个协程栈的操作。有两个 receiver 在 channel 的一边虎视眈眈地等着,这时 channel 另一边来了一个 sender 准备向 channel 发送数据,为了高效,用不着通过 channel 的 buf "中转"一次,直接从源地址把数据 copy 到目的地址就可以了,效率高啊! 上图是一个示意图, 会被拷贝到 G1 栈上的某个位置,也就是 val 的地址处,保存在 elem 字段。 4.4 从Channel读取数据的过程是怎样的? 从channel读取数据也有几个关键步骤: 1. 首先检查是否有等待的发送者 。如果 队列不为空,说明有goroutine在等待发送数据。对于无缓冲channel,会直接从发送者那里接收数据;对于有缓冲channel,会先从缓冲区取数据,然后把等待发送者的数据放入缓冲区,这样保持FIFO顺序。 2. 如果没有等待发送者,尝试从缓冲区读取 。检查 ,如果缓冲区有数据,就从 位置取出数据,然后更新 索引和 计数。这是缓冲区有数据时的正常路径。 缓冲区为空时需要阻塞等待 。创建 结构体包装当前goroutine,加入到 等待队列,调用 进入阻塞状态。当有发送者写入数据时会被唤醒继续执行。 从已关闭channel读取有特殊处理 。如果channel已关闭且缓冲区为空,会返回零值和false标志;如果缓冲区还有数据,可以正常读取直到清空。这就是为什么 中的ok能判断channel状态的原因。 4.5 从一个已关闭Channel仍能读出数据吗? 从一个有缓冲的 channel 里读数据,当 channel 被关闭,依然能读出有效值。只有当返回的 ok 为 false 时,读出的数据才是无效的。 示例: 程序输出: 先创建了一个有缓冲的 channel,向其发送一个元素,然后关闭此 channel。之后两次尝试从 channel 中读取数据,第一次仍然能正常读出值。第二次返回的 ok 为 false,说明 channel 已关闭,且通道里没有数据。 4.6 Channel在什么情况下会引起内存泄漏? Channel引起内存泄漏最常见的是引起goroutine泄漏从而导致的间接内存泄漏,当goroutine阻塞在channel操作上永远无法退出时,goroutine本身和它引用的所有变量都无法被GC回收。比如一个goroutine在等待接收数据,但发送者已经退出了,这个接收者就会永远阻塞下去。或者 select语句使用不当, 在没有default分支的select中,如果所有case都无法执行,goroutine会永远阻塞。出现内存泄漏 4.7 关闭Channel会产生异常吗? 试图重复关闭一个channel、,关闭一个nil值的channel、关闭一个只有接收方向的channel都将导致panic异常。 4.8 往一个关闭的Channel写入数据会发生什么? 往已关闭的channel写入数据会直接panic。 向已关闭的channel发送数据时,runtime会检测到channel的 标志位已经设置,立即抛出"send on closed channel"的panic。这个检查发生在发送操作的最开始阶段,甚至在获取mutex锁之前就会进行判断,所以不会有任何数据写入的尝试,直接就panic了。 4.9 什么是select? select是Go语言专门为channel操作设计的多路复用控制结构,类似于网络编程中的select系统调用。 核心作用是同时监听多个channel操作。当有多个channel都可能有数据收发时,select能够选择其中一个可执行的case进行操作,而不是按顺序逐个尝试。比如同时监听数据输入、超时信号、取消信号等。 4.10 select的执行机制是怎样的? select的执行机制是随机选择。如果多个case同时满足条件,Go会随机选择一个执行,这避免了饥饿问题。如果没有case能执行就会执行default,如果没有default,当前goroutine会阻塞等待。 4.11 select的实现原理是怎样的? Go语言实现 时,定义了一个数据结构scase表示每个 语句(包含 )。scase结构包含channel指针、操作类型等信息。select操作的整个过程通过selectgo函数在runtime层面实现。 Go运行时会将所有case进行 随机排序 ,这是为了避免饥饿问题。然后执行 两轮扫描策略 : 第一轮 直接检查每个channel是否可读写,如果找到就绪的立即执行;如果都没就绪,第二轮就把当前goroutine加入到所有channel的发送或接收队列中,然后调用gopark进入睡眠状态,使 当前goroutine 让出CPU。 当某个channel变为可操作时,调度器会唤醒对应的goroutine,此时需要从其他channel的等待队列中清理掉这个goroutine,然后执行对应的case分支。 其核心原理是:case随机化 + 双重循环检测 分析: scase结构定义: 在默认的情况下,select 语句会在编译阶段经过如下过程的处理: 1. 将所有的 转换成包含 以及类型等信息的 scase 结构体; 2. 调用运行时函数 获取被选择的 结构体索引,如果当前的 是一个接收数据的操作,还会返回一个指示当前 是否是接收的布尔值; 3. 通过 循环生成一组 语句,在语句中判断自己是不是被选中的 。 5. Sync面试题 5.1 除了 mutex 以外还有那些方式安全读写共享变量? 除了Mutex,主要还有 信号量 、 通道(Channel),原子操作(atomic) 这几种方式。 信号量的实现其实跟mutex差不多,实现起来也很方便,主要通过信号量计数来保证。chanenl是Go最推崇的方式,它通过通信来传递数据所有权,从根源上避免竞争,更适合复杂的业务逻辑;而原子操作则针对最简单的整型或指针等进行无锁操作,性能最高,常用于实现计数器或状态位。选择哪种,完全取决于数据结构的复杂度和业务的读写模型。 5.2 Go 语言是如何实现原子操作的? Go语言实现原子操作,其根本是 依赖底层CPU硬件提供的原子指令 ,而不是通过操作系统或更上层的锁机制。 具体来说,Go的 包中的函数,在编译时会被编译器识别,并直接转换成对应目标硬件平台(如x86、ARM)的单条原子机器指令。例如,在x86架构上, 这类操作会对应到像 这样的指令。前面的 前缀是关键,它会锁住总线或缓存行,确保后续的 指令在执行期间,其他CPU核心不能访问这块内存,从而保证了整个操作的原子性。 5.3 聊聊原子操作和锁的区别? 原子操作和锁最核心的区别在于它们的 实现层级 和 保护范围 。 原子操作 是CPU硬件层面的"微观"机制,它保证对单个数据(通常是整型或指针)的单次读改写操作是绝对不可分割的,性能极高,因为它不涉及操作系统内核的介入和goroutine的挂起。 锁 则是操作系统或语言运行时提供的"宏观"机制,它保护的是一个 代码块 (临界区),而不仅仅是单个变量。当获取锁失败时,它会让goroutine休眠,而不是空耗CPU。虽然锁的开销远大于原子操作,但它能保护一段复杂的、涉及多个变量的业务逻辑。 所以,对于简单的计数器或标志位更新,用原子操作追求极致性能;而只要需要保护一段逻辑或多个变量的一致性,就必须用锁。 5.4 Go语言互斥锁mutex底层是怎么实现的? mutex底层是通过原子操作加信号量来实现的,通过atomic 包中的一些原子操作来实现锁的锁定,通过信号量来实现协程的阻塞与唤醒 分析 互斥锁对应的是底层结构是sync.Mutex结构体 state表示锁的状态,有锁定、被唤醒、饥饿模式等,并且是用state的二进制位来标识的,不同模式下会有不同的处理方式 sema表示信号量,mutex阻塞队列的定位是通过这个变量来实现的,从而实现goroutine的阻塞和唤醒 5.5 Mutex 有几种模式? Go的 主要有两种模式: 正常模式(Normal Mode)和饥饿模式(