Golang八股文汇总

Olivia的小跟班 Lv3

题记

本文记录Golang语言面试的八股文。

Go基础

init和main函数相关特点

init函数(没有输入参数、返回值)的主要作用:

  • 初始化不能采用初始化表达式初始化的变量。
  • 程序运行前的注册。
  • 实现sync.Once功能。
  • 其他

init 顺序

1、在同一个 package 中,可以多个文件中定义 init 方法

2、在同一个 go 文件中,可以重复定义 init 方法

3、在同一个 package 中,不同文件中的 init 方法的执行按照文件名先后执行各个文件中的 init 方法

4、在同一个文件中的多个 init 方法,按照在代码中编写的顺序依次执行不同的 init 方法

5、对于不同的 package,如果不相互依赖的话,按照 main 包中 import 的顺序调用其包中的 init() 函数

6、如果 package 存在依赖,调用顺序为最后被依赖的最先被初始化,例如:导入顺序 main –> A –> B –> C,则初始化顺序为 C –> B –> A –> main,一次执行对应的 init 方法。

所有 init 函数都在同⼀个 goroutine 内执行。

所有 init 函数结束后才会执行 main.main 函数

img

Go的数据结构的零值是什么?

所有整型类型:0

浮点类型:0.0

布尔类型:false

字符串类型:””

指针、interface、切片(slice)、channel、map、function :nil

Go的零值初始是递归的,即数组、结构体等类型的零值初始化就是对其组成元素逐一进行零值初始化。

byte和rune有什么区别

rune和byte在go语言中都是字符类型,且都是别名类型

byte型本质上是uint8类型的别名,代表了ASCII 码的一个字符

rune型本质上是int32型的别名,代表一个 UTF-8 字符

Go struct 能不能比较

需要具体情况具体分析,如果struct中含有不能被比较的字段类型,就不能被比较。

如果struct中所有的字段类型都支持比较,那么就可以被比较。

不可被比较的类型:

  • slice,因为slice是引用类型,除非是和nil比较
  • map,和slice同理,如果要比较两个map只能通过循环遍历实现
  • 函数类型

其他的类型都可以比较。

还有两点值得注意:

  • 结构体之间只能比较它们是否相等,而不能比较它们的大小
  • 只有所有属性都相等而且属性顺序都一致的结构体才能进行比较

Go语言如何初始化变量

1
2
3
var a int=10
var a=10
a:=10

Go import的三种方式

一、加下划线:

import 下划线(如:_ “github.com/go-sql-driver/mysql”)的作用:当导入一个包时,该包下的文件里所有init()函数 都会被执行。然而,有些时候我们并不需要把整个包都导入进来,仅仅是是希望它执行init()函数而已。这个时候就可以使用 import _ 引用该包。即:使用[import _ 包路径]只是引用该包,仅仅是为了调用init()函数,所以无法通过包名来调用包中的其他函数

二、加点(.):

import和引用的包名之间加点(.)操作的含义就是这个包导入之后在调用这个包的函数时,可以省略前缀的包名。

三、别名:

别名操作顾名思义可以把包命名成另一个用起来容易记忆的名字。

与其他语言相比,使用 Go 有什么好处?

  • 与其他作为学术实验开始的语⾔不同,Go 代码的设计是务实的。每个功能和语法决策都旨在让程序员的⽣活更轻松。
  • Golang 针对并发进行了优化,并且在规模上运行良好。
  • 由于单⼀的标准代码格式,Golang 通常被认为比其他语⾔更具可读性。
  • ⾃动垃圾收集明显比Java 或 Python 更有效,因为它与程序同时执行。

听说go有什么什么的缺陷,你怎么看

1、缺少框架;

2、go语言通过函数和预期的调用代码简单地返回错误,容易丢失错误发生的范围;

3、go语言的软件包管理没有办法制定特定版本的依赖库。

Golang的常量取地址

1
2
3
4
5
6
7
8
const i = 100

var j = 123

func main() {
fmt.Println(&j, j)
fmt.Println(&i, i) //panic
} //Go语⾔中,常量⽆法寻址, 是不能进⾏取指针操作的

Golang的字符串拼接

1
2
3
4
A. str := 'abc' + '123'
B. str := "abc" + "123"
C. str := '123' + "abc"
D. fmt.Sprintf("abc%d", 123)

答案B、D

string和[]byte如何取舍

string 擅长的场景:

  • 需要字符串比较的场景;
  • 不需要nil字符串的场景;

[]byte擅长的场景:

  • 修改字符串的场景,尤其是修改粒度为1个字节;
  • 函数返回值,需要用nil表示含义的场景;
  • 需要切片操作的场景;

字符串转成byte数组,会发生内存拷贝吗

https://mp.weixin.qq.com/s/qmlPuGVISx8NYp2b9LrqnA

翻转含有中文、数字、英文字母的字符串

https://mp.weixin.qq.com/s/ssinnUM22PHPWRug8EzAkg

json包变量不加tag会怎么样?

https://mp.weixin.qq.com/s/bZlKV_BWSqc-qCa4DrsCbg

reflect(反射包)如何获取字段tag?为什么json包不能导出私有变量的tag?

https://mp.weixin.qq.com/s/P7TEx2mInwEktXTEE6JDWQ

昨天那个在for循环里append元素的同事,今天还在么?

https://mp.weixin.qq.com/s/SHxcspmiKyPwPBbhfVxsGA

Golang语言的自增,自减操作

Golang语言没++i、–i,只有i++、i–。

Printf()、Sprintf()、Fprintf()函数的区别用法是什么

都是把格式好的字符串输出,只是输出的目标不一样。

Printf(),是把格式字符串输出到标准输出(一般是屏幕,可以重定向)。Printf() 是和标准输出文件 (stdout) 关联的,Fprintf 则没有这个限制。

Sprintf(),是把格式字符串输出到指定字符串中,所以参数比printf多一个char*。那就是目标字符串地址。

Fprintf(),是把格式字符串输出到指定文件设备中,所以参数比 printf 多一个文件指针 FILE*。主要用于文件操作。Fprintf() 是格式化输出到一个stream,通常是到文件。

Go语言中cap 函数可以作用于哪些内容?

array 返回数组的元素个数;

slice 返回 slice 的最⼤容量;

channel 返回 channel 的容量;

Golang语言的引用类型有什么?

Go语言中的引用类型有func(函数类型),interface(接口类型),slice(切片类型),map(字典类型),channel(管道类型),*(指针类型)

通过指针变量 p 访问其成员变量name,有哪几种方式?

A. p.name

B. (&p).name

C. (*p).name

D. p->name

答案:A、C

for select时,如果通道已经关闭会怎么样?如果只有⼀个case呢?

https://mp.weixin.qq.com/s/Oa3eExufo2Req_9IrDys-g

Golang的bool类型的赋值

1
2
3
4
5
6
7
8
A. b = true
B. b = 1
C. b = bool(1)
D. b = (1 == 2)

赋值正确的是A,D。
首先B选项,int类型不能由bool类型来表示。
其次C选项,bool()不能转化int类型。int和float可以相互转化。

Go关键字fallthrough有什么作用

fallthrough关键字只能用在switch中。且只能在每个case分支中最后一行出现,作用是如果这个case分支被执行,将会继续执行下一个case分支,而且不会去判断下一个分支的case条件是否成立。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package main

import "fmt"

func main() {
switch "a" {
case "a":
fmt.Println("匹配a")
fallthrough
case "b":
fmt.Println("a成功了,也执行b分支")
case "c":
fmt.Println("a成功了,c分支会执行吗?")
default:
fmt.Println("默认执行")
}
}
/*
匹配a
a成功了,也执行b分支
*/

空结构体占不占内存空间? 为什么使用空结构体?

空结构体是没有内存大小的结构体。
通过 unsafe.Sizeof() 可以查看空结构体的宽度,代码如下:

1
2
var s struct{}
fmt.Println(unsafe.Sizeof(s)) // prints 0

准确的来说,空结构体有一个特殊起点: zerobase 变量。zerobase是一个占用 8 个字节的uintptr全局变量。每次定义 struct {} 类型的变量,编译器只是把zerobase变量的地址给出去。也就是说空结构体的变量的内存地址都是一样的。
空结构体的使用场景主要有三种:

  • 实现方法接收者:在业务场景下,我们需要将方法组合起来,代表其是一个 ”分组“ 的,便于后续拓展和维护。
  • 实现集合类型:在 Go 语言的标准库中并没有提供集合(Set)的相关实现,因此一般在代码中我们图方便,会直接用 map 来替代:type Set map[string]struct{}
  • 实现空通道:在 Go channel 的使用场景中,常常会遇到通知型 channel,其不需要发送任何数据,只是用于协调 Goroutine 的运行,用于流转各类状态或是控制并发情况。

Go 的面向对象特性

接口

接口使用 interface 关键字声明,任何实现接口定义方法的类都可以实例化该接口,接口和实现类之间没有任何依赖,你可以实现一个新的类当做 Sayer 来使用,而不需要依赖 Sayer 接口,也可以为已有的类创建一个新的接口,而不需要修改任何已有的代码,和其他静态语言相比,这可以算是 golang 的特色了吧

1
2
3
4
type Sayer interface {
Say(message string)
SayHi()
}

继承

继承使用组合的方式实现

1
2
3
4
5
6
7
8
9
10
11
12
type Animal struct {
Name string
}

func (a *Animal) Say(message string) {
fmt.Printf("Animal[%v] say: %v
", a.Name, message)
}

type Dog struct {
Animal
}

Dog 将继承 Animal 的 Say 方法,以及其成员 Name

覆盖

子类可以重新实现父类的方法

1
2
3
4
5
// override Animal.Say
func (d *Dog) Say(message string) {
fmt.Printf("Dog[%v] say: %v
", d.Name, message)
}

Dog.Say 将覆盖 Animal.Say

多态

接口可以用任何实现该接口的指针来实例化

1
2
3
4
var sayer Sayer

sayer = &Dog{Animal{Name: "Yoda"}}
sayer.Say("hello world")

但是不支持父类指针指向子类,下面这种写法是不允许的

1
2
var animal *Animal
animal = &Dog{Animal{Name: "Yoda"}}

同样子类继承的父类的方法引用的父类的其他方法也没有多态特性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func (a *Animal) Say(message string) {
fmt.Printf("Animal[%v] say: %v
", a.Name, message)
}

func (a *Animal) SayHi() {
a.Say("Hi")
}

func (d *Dog) Say(message string) {
fmt.Printf("Dog[%v] say: %v
", d.Name, message)
}

func main() {
var sayer Sayer

sayer = &Dog{Animal{Name: "Yoda"}}
sayer.Say("hello world") // Dog[Yoda] say: hello world
sayer.SayHi() // Animal[Yoda] say: Hi
}

上面这段代码中,子类 Dog 没有实现 SayHi 方法,调用的是从父类 Animal.SayHi,而 Animal.SayHi 调用的是 Animal.Say 而不是Dog.Say,这一点和其他面向对象语言有所区别,需要特别注意,但是可以用下面的方式来实现类似的功能,以提高代码的复用性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func SayHi(s Sayer) {
s.Say("Hi")
}

type Cat struct {
Animal
}

func (c *Cat) Say(message string) {
fmt.Printf("Cat[%v] say: %v
", c.Name, message)
}

func (c *Cat) SayHi() {
SayHi(c)
}

func main() {
var sayer Sayer

sayer = &Cat{Animal{Name: "Jerry"}}
sayer.Say("hello world") // Cat[Jerry] say: hello world
sayer.SayHi() // Cat[Jerry] say: Hi
}

Go语言中,下面哪个关于指针的说法是错误的?

  • 指针不能进行算术运算
  • 指针可以比较
  • 指针可以是nil
  • 指针可以指向任何类型

针在Go语言中只能指向相同类型的结构体或者基本类型。例如,一个int类型的变量,只能指向int类型的指针。如果尝试将一个不同类型的指针赋给一个变量,将会导致编译错误。

Go语言的接口类型是如何实现的?

在Go语言中,接口类型是通过类型嵌入(embedding)的方式实现的。每个实现了接口的类型的结构体中都有一个隐含的成员,该成员是指向接口类型的指针。通过这种方式,接口实现了对类型的约束和定义。

具体来说,当一个类型实现了某个接口的所有方法后,该类型就被认为是实现了该接口。在结构体中,可以通过嵌入接口类型的方式来实现接口方法。在实现接口方法时,方法的签名需要与接口定义中的方法签名保持一致。

关于switch语句,下⾯说法正确的有?

A. 条件表达式必须为常量或者整数;

B. 单个case中,可以出现多个结果选项;

C. 需要⽤break来明确退出⼀个case;

D. 只有在case中明确添加fallthrough关键字,才会继续执⾏紧跟的下⼀个case;

答案B、D

1
2
3
4
switch{
case 1,2,3,4:
default:
} //case可以有多个数据

Go 编程语言中 switch 语句的语法如下:

1
2
3
4
5
6
7
8
switch var1 {
case val1:
...
case val2:
...
default:
...
}

变量 var1 可以是任何类型,而 val1 和 val2 则可以是同类型的任意值。类型不被局限于常量或整数,但必须是相同的类型;或者最终结果为相同类型的表达式。

下列Add函数定义正确的是?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
func Test54(t *testing.T) {
var a Integer = 1
var b Integer = 2
var i interface{} = &a
sum := i.(*Integer).Add(b)
fmt.Println(sum)
}

A.
type Integer int
func (a Integer) Add(b Integer) Integer {
return a + b
}

B.
type Integer int
func (a Integer) Add(b *Integer) Integer {
return a + *b
}

C.
type Integer int
func (a *Integer) Add(b Integer) Integer {
return *a + b
}

D.
type Integer int
func (a *Integer) Add(b *Integer) Integer {
return *a + *b
}

答案A、C。其他两个选项的add参数b的类型错误。

copy是操作符还是内置函数

Golang中copy是内置函数。

Go 两个接口之间可以存在什么关系?

如果两个接口有相同的方法列表,那么他们就是等价的,可以相互赋值。如果接口 A的方法列表是接口B的方法列表的自己,那么接口B可以赋值给接口A。接口查询是否成功,要在运行期才能够确定。

如何在运行时检查变量类型?

类型开关是在运行时检查变量类型的最佳方式。类型开关按类型而不是值来评估变量。每个 Switch ⾄少包含⼀个case,⽤作条件语句,和⼀个 default,如果没有⼀个 case 为真,则执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func classifier(items ...interface{}) {
for i, x := range items {
switch x.(type) {
case bool:
fmt.Printf("Param #%d is a bool\n", i)
case float64:
fmt.Printf("Param #%d is a float64\n", i)
case int, int64:
fmt.Printf("Param #%d is a int\n", i)
case nil:
fmt.Printf("Param #%d is a nil\n", i)
case string:
fmt.Printf("Param #%d is a string\n", i)
default:
fmt.Printf("Param #%d is unknown\n", i)
}
}
}

Golang的返回值命名

image-20230724165455977

Golang的iota如何使用?

image-20230724165708801

  1. iota在const关键字出现时被重置为0
  2. const声明块中每新增一行iota值自增1
  3. 第一个常量必须指定一个表达式,后续的常量如果没有表达式,则继承上面的表达式

数组之间如何进行比较?

image-20230724170030544

for range的注意点和坑

第一个说法

1.迭代变量。Python中for in 可以直接的到value,但Go的for range 迭代变量有两个,第一个是元素在迭代集合中的序号值key(从0开始),第二个值才是元素值value。

2.针对字符串。在Go中对字符串运用for range操作,每次返回的是一个码点,而不是一个字节。Go编译器不会为[]byte进行额外的内存分配,而是直接使用string的底层数据。

3.对map类型内元素的迭代顺序是随机的。要想有序迭代map内的元素,我们需要额外的数据结构支持,比如使用一个切片来有序保存map内元素的key值。

4.针对切片类型复制之后,如果原切片扩容增加新元素。迭代复制后的切片并不会输出扩容新增元素。这是因为range表达式中的切片实际上是原切片的副本。

5.迭代变量是重用的。类似PHP语言中的i=0;如果其他循环中使用相同的迭代变量,需要重新初始化i。

6.for range使用时,k,v值已经赋值好了,不会因为for循环的改变而改变

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

import (
"fmt"
)

func main() {
x := []string{"a", "b", "c"}
for v := range x {
fmt.Println(v)
}
}
//输出 0 1 2

第二个说法

应该是一个for循环中作用域的问题

1
2
3
4
5
6
7
8
9
10
11
12
src := []int{1, 2, 3, 4, 5}
var dst2 []*inv
for _, v := range src {
dst2 = append(dst2, &v)
// fmt.println(&v)
}

for _, p := range dst2 {
fmt.Print(*p)
}
// 输出
// 5555

为什么呢, 因为 for-range 中 循环变量的作用域的规则限制
假如取消append()后一行的注释,可以发现循环中v的变量内存地址是一样的,也可以解释为for range相当于

1
2
3
4
5
var i int
for j := 0; j < len(src); j++ {
i = src[j]
dst2 = append(dst2, &i)
}

而不是我们想象中的

1
2
3
for j := 0; j < len(src); j++ {
dst2 = append(dst2, &src[j])
}

如果要在for range中实现,我们可以改写为

1
2
3
4
5
6
7
8
9
10
11
src := []int{1, 2, 3, 4, 5}
var dst2 []*int
for _, v := range src {
new_v := v
dst2 = append(dst2, &new_v)
// fmt.println(&new_v)
}

for _, p := range dst2 {
fmt.Print(*p)
}

Golang的断言

Go中的所有程序都实现了interface{}的接口,这意味着,所有的类型如string,int,int64甚至是自定义的struct类型都就此拥有了interface{}的接口.那么在一个数据通过func funcName(interface{})的方式传进来的时候,也就意味着这个参数被自动的转为interface{}的类型。

如以下的代码:

1
func funcName(a interface{}) string {     return string(a)}

编译器将会返回:cannot convert a (type interface{}) to type string: need type assertion

此时,意味着整个转化的过程需要类型断言。类型断言有以下几种形式:

  1. 直接断言使用
1
var a interface{}fmt.Println("Where are you,Jonny?", a.(string))

但是如果断言失败一般会导致panic的发生。所以为了防止panic的发生,我们需要在断言前进行一定的判断

1
value, ok := a.(string)

如果断言失败,那么ok的值将会是false,但是如果断言成功ok的值将会是true,同时value将会得到所期待的正确的值。示例:

1
2
3
4
5
6
value, ok := a.(string)
if !ok {
fmt.Println("It's not ok for type string")
return
}
fmt.Println("The value is ", value)

另外也可以配合switch语句进行判断:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var t interface{}
t = functionOfSomeType()
switch t := t.(type) {
default:
fmt.Printf("unexpected type %T", t) // %T prints whatever type t has break
case bool:
fmt.Printf("boolean %t\n", t) // t has type bool break
case int:
fmt.Printf("integer %d\n", t) // t has type int break
case *bool:
fmt.Printf("pointer to boolean %t\n", *t) // t has type *bool break
case *int:
fmt.Printf("pointer to integer %d\n", *t) // t has type *int break
}

精通Golang项目依赖Go modules

https://www.topgoer.cn/docs/golangxiuyang/golangxiuyang-1cmee13oek1e8

Go string的底层实现

源码包src/runTime/string.go.stringStruct定义了string的数据结构

1
2
3
4
Type stringStruct struct{
str unsafe.Pointer // 字符串的首地址
len int // 字符串的长度
}

声明:

如下代码所示,可以声明一个string变量赋予初值

1
2
var str string
str = "Hello world"

字符串构建过程是根据字符串构建stringStruct,再转化成string。转换的源码如下:

1
2
3
4
5
func gostringnocopy(str *byte) string{       //根据字符串地址构建string
ss := stringStruct{str:unsafe.Pointer(str),len:findnull(str)} // 先构造 stringStruct
s := *(*string)(unsafe.Pointer(&ss)) //再将stringStruct 转换成string
return s
}

Go 语言的 panic 如何恢复

recover 可以中止 panic 造成的程序崩溃,或者说平息运行时恐慌,recover 函数不需要任何参数,并且会返回一个空接口类型的值。需要注意的是 recover 只能在 defer 中发挥作用,在其他作用域中调用不会发挥作用。 编译器会将 recover 转换成 runtime.gorecover,该函数的实现逻辑是如果当前 goroutine 没有调用 panic,那么该函数会直接返回 nil,当前 goroutine 调用 panic 后,会先调用 runtime.gopaic 函数runtime.gopaic 会从 runtime._defer 结构体中取出程序计数器 pc 和栈指针 sp,再调用 runtime.recovery 函数来恢复程序,runtime.recovery 会根据传入的 pc 和 sp 跳转回 runtime.deferproc,编译器自动生成的代码会发现 runtime.deferproc 的返回值不为 0,这时会调回 runtime.deferreturn 并恢复到正常的执行流程。总的来说恢复流程就是通过程序计数器来回跳转。

Go如何避免panic

首先明确panic定义:go把真正的异常叫做 panic,是指出现重大错误,比如数组越界之类的编程BUG或者是那些需要人工介入才能修复的问题,比如程序启动时加载资源出错等等。
几个容易出现panic的点:

  • 函数返回值或参数为指针类型,nil, 未初始化结构体,此时调用容易出现panic,可加 != nil 进行判断
  • 数组切片越界
  • 如果我们关闭未初始化的通道,重复关闭通道,向已经关闭的通道中发送数据,这三种情况也会引发 panic,导致程序崩溃
  • 如果我们直接操作未初始化的映射(map),也会引发 panic,导致程序崩溃
  • 另外,操作映射可能会遇到的更为严重的一个问题是,同时对同一个映射并发读写,它会触发 runtime.throw,不像 panic 可以使用 recover 捕获。所以,我们在对同一个映射并发读写时,一定要使用锁。
  • 如果类型断言使用不当,比如我们不接收布尔值的话,类型断言失败也会引发 panic,导致程序崩溃。
  • 如果很多时候不可避免地出现了panic, 记得使用 defer/recover

空结构体的使用场景

空结构体(empty struct)是在 Go 语言中一个特殊的概念,它没有任何字段。在 Go 中,它通常被称为匿名结构体或零宽度结构体。尽管它没有字段,但它在某些情况下仍然有其用途,以下是一些常见的空结构体的使用场景:

  1. 占位符:空结构体可以用作占位符,用于表示某个数据结构或数据集合的存在而不实际存储任何数据。这在某些数据结构的实现中非常有用,特别是在要实现某种数据结构的集合或映射时,但并不需要存储实际的值。

    1
    2
    3
    goCopy code// 表示集合中是否包含某个元素的映射
    set := make(map[string]struct{})
    set["apple"] = struct{}{}
  2. 信号量:空结构体可以用作信号量,用于控制并发操作。通过向通道发送或接收空结构体,可以实现信号的传递和同步。

    1
    2
    3
    4
    5
    6
    7
    goCopy code// 用通道作为信号量
    semaphore := make(chan struct{}, 5) // 控制并发数为5
    go func() {
    semaphore <- struct{}{} // 获取信号量
    defer func() { <-semaphore }() // 释放信号量
    // 执行并发操作
    }()
  3. 强调结构:有时,空结构体可用于强调某个结构的重要性或存在。它可以用作结构体的标签,表示关注该结构的存在而不是其内容。

    1
    2
    3
    4
    5
    6
    7
    goCopy code// 表示一篇文章的元信息,不包含实际内容
    type Article struct {
    Title string
    Author string
    PublishedAt time.Time
    Metadata struct{} // 空结构体强调元信息的存在
    }
  4. JSON序列化:在处理JSON数据时,有时需要表示一个空对象。可以使用空结构体来表示JSON中的空对象({})。

    1
    2
    3
    4
    goCopy code// 表示一个空的JSON对象
    emptyJSON := struct{}{}
    jsonBytes, _ := json.Marshal(emptyJSON)
    fmt.Println(string(jsonBytes)) // 输出: {}

尽管空结构体没有字段,但它在上述情况下提供了一种轻量级的方式来实现特定的需求,而无需分配额外的内存或定义具体的数据结构。这使得它成为 Go 中的一种有用工具,可以在编写清晰、高效和易于理解的代码时派上用场。

struct的特点

  • 用来自定义复杂数据结构
  • struct里面可以包含多个字段(属性)
  • struct类型可以定义方法,注意和函数的区分
  • struct类型是值类型
  • struct类型可以嵌套
  • GO语言没有class类型,只有struct类型

特殊之处

  • 结构体是用户单独定义的类型,不能和其他类型进行强制转换
  • golang中的struct没有构造函数,一般可以使用工厂模式来解决这个问题
  • 我们可以为struct中的每个字段,写上一个tag。这个tag可以通过反射的机制获取到,最常用的场景就是json序列化和反序列化。
  • 结构体中字段可以没有名字,即匿名字段

defer的几个坑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func main() {
fmt.Println(test())
}

func test() error {
var err error
defer func() {
if r := recover(); r != nil {
err = errors.New(fmt.Sprintf("%s", r))
}
}()
raisePanic()
return err
}

func raisePanic() {
panic("发生了错误")
}

为什么输出****?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main

import (
"fmt"
)

func main() {

defer func() {
if err := recover(); err != nil{
fmt.Println(err)
}else {
fmt.Println("fatal")
}
}()

defer func() {
panic("defer panic")
}()

panic("panic")
}

结果

1
defer panic

分析

panic仅有最后一个可以被revover捕获

触发panic("panic")后defer顺序出栈执行,第一个被执行的defer中 会有panic("defer panic")异常语句,这个异常将会覆盖掉main中的异常panic("panic"),最后这个异常被第二个执行的defer捕获到。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package main

import "fmt"

func function(index int, value int) int {

fmt.Println(index)

return index
}

func main() {
defer function(1, function(3, 0))
defer function(2, function(4, 0))
}

这里,有4个函数,他们的index序号分别为1,2,3,4。

那么这4个函数的先后执行顺序是什么呢?这里面有两个defer, 所以defer一共会压栈两次,先进栈1,后进栈2。 那么在压栈function1的时候,需要连同函数地址、函数形参一同进栈,那么为了得到function1的第二个参数的结果,所以就需要先执行function3将第二个参数算出,那么function3就被第一个执行。同理压栈function2,就需要执行function4算出function2第二个参数的值。然后函数结束,先出栈fuction2、再出栈function1.

所以顺序如下:

  • defer压栈function1,压栈函数地址、形参1、形参2(调用function3) –> 打印3
  • defer压栈function2,压栈函数地址、形参1、形参2(调用function4) –> 打印4
  • defer出栈function2, 调用function2 –> 打印2
  • defer出栈function1, 调用function1–> 打印1
1
2
3
3
4
2

使用过哪些 Golang 的 String 类库

string.builder

Go 语言提供了一个专门操作字符串的库 strings,可以用于字符串查找、替换、比较等。

使用 strings.Builder 可以进行字符串拼接,提供了 writeString 方法拼接字符串,使用方式如下:

1
2
3
var builder strings.Builder
builder.WriteString("asong")
builder.String()

strings.builder 的实现原理很简单,结构如下:

1
2
3
4
type Builder struct {
addr *Builder // of receiver, to detect copies by value
buf []byte // 1
}

addr 字段主要是做 copycheckbuf 字段是一个 byte 类型的切片,这个就是用来存放字符串内容的,提供的 writeString() 方法就是向切片 buf 中追加数据:

1
2
3
4
5
func (b *Builder) WriteString(s string) (int, error) {
b.copyCheck()
b.buf = append(b.buf, s...)
return len(s), nil
}

提供的 String 方法就是将 []byte 转换为 string 类型,这里为了避免内存拷贝的问题,使用了强制转换来避免内存拷贝:

1
2
3
func (b *Builder) String() string {
return *(*string)(unsafe.Pointer(&b.buf))
}

bytes.Buffer

因为 string 类型底层就是一个 byte 数组,所以我们就可以 Go 语言的 bytes.Buffer 进行字符串拼接。bytes.Buffer 是一个一个缓冲 byte 类型的缓冲器,这个缓冲器里存放着都是 byte。使用方式如下:

1
2
3
buf := new(bytes.Buffer)
buf.WriteString("asong")
buf.String()

bytes.buffer 底层也是一个 []byte 切片,结构体如下:

1
2
3
4
5
type Buffer struct {
buf []byte // contents are the bytes buf[off : len(buf)]
off int // read at &buf[off], write at &buf[len(buf)]
lastRead readOp // last read operation, so that Unread* can work correctly.
}

因为 bytes.Buffer 可以持续向 Buffer 尾部写入数据,从 Buffer 头部读取数据,所以 off 字段用来记录读取位置,再利用切片的 cap 特性来知道写入位置,这个不是本次的重点,重点看一下 WriteString 方法是如何拼接字符串的:

1
2
3
4
5
6
7
8
func (b *Buffer) WriteString(s string) (n int, err error) {
b.lastRead = opInvalid
m, ok := b.tryGrowByReslice(len(s))
if !ok {
m = b.grow(len(s))
}
return copy(b.buf[m:], s), nil
}

切片在创建时并不会申请内存块,只有在往里写数据时才会申请,首次申请的大小即为写入数据的大小。如果写入的数据小于64字节,则按64字节申请。采用 动态扩展slice 的机制,字符串追加采用 copy 的方式将追加的部分拷贝到尾部,copy是内置的拷贝函数,可以减少内存分配。

但是在将 []byte 转换为 string 类型依旧使用了标准类型,所以会发生内存分配:

1
2
3
4
5
6
7
func (b *Buffer) String() string {
if b == nil {
// Special case, useful in debugging.
return "<nil>"
}
return string(b.buf[b.off:])
}

Go结构体内嵌后的命名冲突

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package main

import (
"fmt"
)

type A struct {
a int
}

type B struct {
a int
}

type C struct {
A
B
}

func main() {
c := &C{}
c.A.a = 1
fmt.Println(c)
}
// 输出 &{{1} {0}}
  • 第7行和第11行分别定义了两个拥有a int字段的结构体。
  • 第15行的结构体嵌入了A和B的结构体。
  • 第21行实例化C结构体。
  • 第22行按常规的方法,访问嵌入结构体A中的a字段,并赋值1。
  • 第23行可以正常输出实例化C结构体。

接着,将第22行修改为如下代码:

1
2
3
4
5
func main(){
c:=&C{}
c.a=1
fmt.Println(c)
}

img

此时再编译运行,编译器报错:

.main.go:22:3:ambiguousselectorc.a

编译器告知C的选择器a引起歧义,也就是说,编译器无法决定将1赋给C中的A还是B里的字段a。使用c.a引发二义性的问题一般应该由程序员逐级完整写出避免错误。

在使用内嵌结构体时,Go语言的编译器会非常智能地提醒我们可能发生的歧义和错误。

解决:可以通过:c.A.a或者c.B.a 都可以正确得到对应的值

Go程序中的包是什么?

包(pkg)是 Go 工作区中包含 Go 源⽂件或其他包的目录。源文件中的每个函数、变量和类型都存储在链接包中。每个 Go 源文件都属于⼀个包,该包在文件顶部使⽤以下命令声明:

1
package <packagename>

您可以使⽤以下⽅法导⼊和导出包以重⽤导出的函数或类型:

1
import <packagename>

Golang 的标准包是 fmt,其中包含格式化和打印功能,如 Println().

Go 实现不重启热部署

根据系统的 SIGHUP 信号量,以此信号量触发进程重启,达到热更新的效果。

热部署我们需要考虑几个能力:

  • 新进程启动成功,老进程不会有资源残留
  • 新进程初始化的过程中,服务不会中断
  • 新进程初始化失败,老进程仍然继续工作
  • 同一时间,只能有一个更新动作执行

监听信号量的方法的环境是在 类 UNIX 系统中,在现在的 UNIX 内核中,允许多个进程同时监听一个端口。在收到 SIGHUP 信号量时,先 fork 出一个新的进程监听端口,同时等待旧进程处理完已经进来的连接,最后杀掉旧进程。

我基于这个思路,实现了一段示例代码,仓库地址:https://github.com/guowei-gong/tablefilp-example, 如果你希望动手来加深印象可以打开看看。

Go中的指针强转

在 Golang 中无法使用指针类型对指针进行强制转换

image-20220501142757244

但可以借助 unsafe 包中的 unsafe.Pointer 转换

image-20220501142724715

src/unsafe.go 中可以看到指针类型说明

1
2
3
4
5
6
7
8
// ArbitraryType 与 IntegerType 在此只用于文档描述,实际并不 unsafe 包中的一部分
// 表示任意 go 的表达式
type ArbitraryType int

// 表示任意 integer 类型
type IntegerType int

type Pointer *ArbitraryType

对于指针类型 Pointer 强调以下四种操作

  • 指向任意类型的指针都可以被转化成 Pointer
  • Pointer 可以转化成指向任意类型的指针
  • uintptr 可以转化成 Pointer
  • Pointer 可以转化成 uintptr

uintptr 在 src/builtin/builtin.go 中定义

其后描述了六种指针转换的情形

其一:*Conversion of a T1 to Pointer to *T2

转换条件:

  • T2 的数据类型不大于 T1
  • T1、T2 的内存模型相同

因此对于 *int 不能强制转换 *float64 可以变化为 *int -> unsafe.Pointer -> *float64 的过程

Go支持什么形式的类型转换?将整数转换为浮点数。

Go 支持显式类型转换以满足其严格的类型要求。

1
2
3
i := 55 //int
j := 67.8 //float64
sum := i + int(j)//j is converted to int

Golang语言中==的使用

1
2
3
4
5
6
7
8
9
package main

func main() {
var x interface{}
var y interface{} = []int{3, 5}
_ = x == x //输出true
_ = x == y //interface{}比较的是动态类型和动态值,输出false
_ = y == y //panic,切片不可比较
}

make函数底层实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func makeslice(et *_type, len, cap int) unsafe.Pointer {
mem, overflow := math.MulUintptr(et.size, uintptr(cap))
if overflow || mem > maxAlloc || len < 0 || len > cap {
// NOTE: Produce a 'len out of range' error instead of a
// 'cap out of range' error when someone does make([]T, bignumber).
// 'cap out of range' is true too, but since the cap is only being
// supplied implicitly, saying len is clearer.
// See golang.org/issue/4085.
mem, overflow := math.MulUintptr(et.size, uintptr(len))
if overflow || mem > maxAlloc || len < 0 {
panicmakeslicelen()
}
panicmakeslicecap()
}

return mallocgc(mem, et, true)
}

函数功能:

  • 检查切片占用的内存空间是否溢出。
  • 调用mallocgc在堆上申请一片连续的内存。

检查内存空间这里是根据切片容量进行计算的,根据当前切片元素的大小与切片容量的乘积得出当前内存空间的大小,检查溢出的条件:

  • 内存空间大小溢出了
  • 申请的内存空间大于最大可分配的内存
  • 传入的len小于0cap的大小只小于`len

Go语言实现小根堆

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
package main

import (
"container/heap"
"fmt"
)

type MinHeap []int

func (h MinHeap) Len() int { return len(h) }
func (h MinHeap) Less(i, j int) bool { return h[i] < h[j] }
func (h MinHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }

func (h *MinHeap) Push(x interface{}) {
*h = append(*h, x.(int))
}

func (h *MinHeap) Pop() interface{} {
old := *h
n := len(old)
x := old[n-1]
*h = old[:n-1]
return x
}

func main() {
h := &MinHeap{2, 1, 5, 3, 4}
heap.Init(h)
fmt.Println("堆中最小的元素是:", (*h)[0])
heap.Push(h, 0)
fmt.Println("插入后最小的元素是:", (*h)[0])
min := heap.Pop(h).(int)
fmt.Println("弹出最小的元素是:", min)
}

Go 怎么实现func的自定义参数

在 golang中,type 可以定义任何自定义的类型

func 也是可以作为类型自定义的,type myFunc func(int) int,意思是自定义了一个叫 myFunc 的函数类型,这个函数的签名必须符合输入为 int,输出为 int。

golang通过type定义函数类型
通过 type 可以定义函数类型,格式如下

1
type typeName func(arguments) retType

函数类型也是一种类型,故可以将其定义为函数入参,在 go 语言中函数名可以看做是函数类型的常量,所以我们可以直接将函数名作为参数传入的函数中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package main

import "fmt"

func add(a, b int) int {
return a + b
}

//sub作为函数名可以看成是 op 类型的常量
func sub(a, b int) int {
return a - b
}

//定义函数类型 op
type op func(a, b int) int

//形参指定传入参数为函数类型op
func Oper(fu op, a, b int) int {
return fu(a, b)
}

func main() {
//在go语言中函数名可以看做是函数类型的常量,所以我们可以直接将函数名作为参数传入的函数中。
aa := Oper(add, 1, 2)
fmt.Println(aa)
bb := Oper(sub, 1, 2)
fmt.Println(bb)
}

为什么go的变量申请类型是为了什么?

在 Go 编程语言中,数据类型用于声明函数和变量。
数据类型的出现是为了把数据分成所需内存大小不同的数据,编程的时候需要用大数据的时候才需要申请大内存,就可以充分利用内存。

Go的闭包语法

go语言的闭包可以理解为一个引用外部变量的匿名函数,Go语言中闭包是引用了自由变量的函数,被引用的自由变量和函数一同存在,即使已经离开了自由变量的环境也不会被释放或者删除,在闭包中可以继续使用这个自由变量,因此,简单的说:
函数 + 引用环境 = 闭包
同一个函数与不同引用环境组合,可以形成不同的实例,如下图:
img
一个函数类型就像结构体一样,可以被实例化,函数本身不存储任何信息,只有与引用环境结合后形成的闭包才具有“记忆性”,函数是编译期静态的概念,而闭包是运行期动态的概念。

Go语言中int占几个字节

Go语言中的int的大小是和操作系统位数相关的,如果是32位操作系统,int类型的大小就是4字节; 如果是64位操作系统,int类型的大小就是8个字节

Golang程序启动过程

Go程序启动过程

Golang 程序启动过程

Golang开发新手常犯的50个错误

https://blog.csdn.net/gezhonglei2007/article/details/52237582

Slice

nil切片和空切片指向的地址一样?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func main() {
var s1 []int
s2 := make([]int, 0)
s3 := make([]int, 0)
data1 := (*reflect.SliceHeader)(unsafe.Pointer(&s1)).Data
data2 := (*reflect.SliceHeader)(unsafe.Pointer(&s2)).Data
data3 := (*reflect.SliceHeader)(unsafe.Pointer(&s3)).Data

fmt.Printf("s1 data:%+v\n", data1)
fmt.Printf("s2 data:%+v\n", data2)
fmt.Printf("s3 data:%+v\n", data3)

fmt.Printf("s1:s2=>%t\n", data1 == data2)
fmt.Printf("s2:s3=>%t\n", data2 == data3)
}

//输出
s1 data:0
s2 data:824634859200
s3 data:824634859200
s1:s2=>false
s2:s3=>true

nil切片和空切片指向的地址不一样。

nil切片引用数组指针地址为0(无指向任何实际地址)

空切片的引用数组指针地址是有的,且固定为一个值。

1
2
3
4
5
6
//切片的数据结构
type SliceHeader struct {
Data uintptr //引用数组指针地址
Len int
Cap int
}

nil切片和空切片最大的区别在于指向的数组引用地址是不一样的

拷贝大切片一定比小切片代价大吗?

并不是,所有切片的大小相同;三个字段(一个uintptr,两个int)。切片中的第一个字段是指向切片底层数组的指针,这是切片的存储空间,第二个字段是切片的长度,第三个字段是容量。将一个slice变量分配给另一个变量只会复制三个机器字。所以拷贝大切片跟小切片的代价应该是一样的。

SliceHeader 是切片在go的底层结构。

1
2
3
4
5
type SliceHeader struct {
Data uintptr
Len int
Cap int
}

大切片跟小切片的区别无非就是 Len 和 Cap的值比小切片的这两个值大一些,如果发生拷贝,本质上就是拷贝上面的三个字段。

json库对nil slice和空slice的处理是一致的吗?

json库对nil slice和空slice的处理是不一致的,

因为nil slice只是声明了slice,却没有给实例化的对象。

1
2
3
4
5
6
7
8
9
10
	var f1 []string
f2 := make([]string, 0)
json1, _ := json.Marshal(Person{f1})
json2, _ := json.Marshal(Person{f2})
fmt.Printf("%s\n", string(json1))
fmt.Printf("%s\n", string(json2))

//输出
{"Friends":null}
{"Friends":[]}

json库对nil slice编码为null, json库对空slice编码为[]。

Golang切片如何删除数据

go语言删除切片元素的方法:
1、指定删除位置,如【index := 1】;
2、查看删除位置之前的元素和之后的元素;
3、将删除点前后的元素连接起来即可。
Go 语言并没有对删除切片元素提供专用的语法或者接口,需要使用切片本身的特性来删除元素。
示例代码如下:

1
2
3
4
5
6
7
8
9
str := []string{"a","b","c"}
// step 1
index := 1
// step 2
fmt.Println(str[:index], str[index+1])
// step 3
str = append(str[:index], str[index+1]...)
// res
fmt.Println(str)

扩容前后的Slice是否相同?

情况一:原数组还有容量可以扩容(实际容量没有填充完),这种情况下,扩容以后的数组还是指向原来的数组, 对一个切片的操作可能影响多个指针指向相同地址的slice。

情况二:原来数组的容量已经达到了最大值,再想扩容,go默认会先开一片内存区域,把原来的值拷贝过来,然 后再执行append()操作。这种情况丝毫不影响原数组。 要复制一个slice,最好使用copy函数。

image-20230906170544338

输出:

1
2
函数内s=[1,2]
主函数内s=[1]

使用值为 nil 的 slice、map会发生啥

允许对值为 nil 的 slice 添加元素,但对值为 nil 的 map 添加元素,则会造成运行时 panic。

1
2
3
4
5
6
7
8
9
10
11
12
// map 错误示例
func main() {
var m map[string]int
m["one"] = 1 // error: panic: assignment to entry in nil map
// m := make(map[string]int)// map 的正确声明,分配了实际的内存
}

// slice 正确示例
func main() {
var s []int
s = append(s, 1)
}

如果先使用make(),那么可以使用m["one"]=1,因为分配了内存。

slice分配在堆上还是栈上

有可能分配到栈上,也有可能分配到栈上。当开辟切片空间较大时,会逃逸到堆上。

通过命令go build -gcflags "-m -l" xxx.go观察golang是如何进行逃逸分析的

1
2
3
4
5
6
7
8
9
10
package main

func main() {
_ = make([]string, 200) //1
//_ = make([]string, 20000) //2
}

//output
//1. make([]string, 200) does not escape
//2. make([]string, 20000) escapes to heap

slice切片的截取

1
2
3
4
5
6
7
 x := make([]int, 2, 10)
_ = x[6:10]
_ = x[6:]
_ = x[2:]

//_ = x[6:] 这⼀⾏会发⽣panic, 截取符号 [i:j],
//如果 j 省略,默认是原切⽚或者数组的⻓度,x 的⻓度是 2,⼩于起始下标 6 ,所以 panic

Go中如果new一个切片会怎么样

image-20230724165822059

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package main

import "fmt"

func main() {
list := new([]int)
// 编译错误
// new([]int) 之后的 list 是⼀个未设置⻓度的 *[]int 类型的指针
// 不能对未设置⻓度的指针执⾏ append 操作。
*list = append(*list, 1)
fmt.Println(*list)
s1 := []int{1, 2, 3}
s2 := []int{4, 5}
// 编译错误,s2需要展开
s1 = append(s1, s2...)
fmt.Println(s1)
}//正确写法

整型切片如何初始化?

1
2
3
4
//数组初始化
arr1 := [3]int{1, 2, 3}
arr2 := [...]int{1, 2, 3}
arr3 := [3]int{0:3,1:4}

数组怎么转集合?

可以使用数组的索引作为map的key,数组的值作为map的值。

1
2
3
4
5
6
7
8
func main(){
arr := [5]int{1,2,3,4,5}
m := make(map[int]int, 5)
for i, v := range arr {
m[i] = v
}
fmt.Println(m)
}

数组是如何实现根据下标随机访问数组元素的吗?

例如: a := [10]int{0}

  • 计算机给数组a,分配了一组连续的内存空间。
  • 比如内存块的首地址为 base_address=1000。
  • 当计算给每个内存单元分配一个地址,计算机通过地址来访问数据。当计算机需要访问数组的某个元素的时候,会通过一个寻址公式来计算存储的内存地址。

一个函数传参一个slice,先 append 再赋值和另一个函数先赋值再append,哪个会发生变化?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package main

import "fmt"

func BeforeAppend(s []int) []int {
s = append(s, 1)
s = []int{1, 2, 3}
return s
}

func AfterAppend(s []int) []int {
s = []int{1, 2, 3}
s = append(s, 1)
return s
}

func main() {
s := make([]int, 0)
fmt.Println(BeforeAppend(s))
fmt.Println(AfterAppend(s))
}

一个对象数组,不用delete[] 使用delete有什么影响

1、针对简单类型 使用 new 分配后的不管是数组还是非数组形式内存空间用两种方式均可 如:

1
2
3
int *a = new int[10];   
delete a;
delete [] a;

此种情况中的释放效果相同,原因在于:分配简单类型内存时,内存大小已经确定,系统可以记忆并且进行管理,在析构时,系统并不会调用析构函数, 它直接通过指针可以获取实际分配的内存空间,哪怕是一个数组内存空间(在分配过程中 系统会记录分配内存的大小等信息,此信息保存在结构体_CrtMemBlockHeader中, 具体情况可参看VC安装目录下CRTSRCDBGDEL.cpp)

2、针对类Class,两种方式体现出具体差异

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class A
{
private:
char *m_cBuffer;
int m_nLen;
public:
A(){ m_cBuffer = new char[m_nLen]; }
~A() { delete [] m_cBuffer; }
};
A *a = new A[10];

// 仅释放了a指针指向的全部内存空间 但是只调用了a[0]对象的析构函数 剩下的从a[1]到a[9]这9个用户自行分配的m_cBuffer对应内存空间将不能释放 从而造成内存泄漏
delete a;

// 调用使用类对象的析构函数释放用户自己分配内存空间并且 释放了a指针指向的全部内存空间
delete [] a;

所以总结下就是,如果ptr代表一个用new申请的内存返回的内存空间地址,即所谓的指针,那么:

delete ptr – 代表用来释放内存,且只用来释放ptr指向的内存。
delete[] rg – 用来释放rg指向的内存,!!还逐一调用数组中每个对象的 destructor!!
对于像 int/char/long/int*/struct 等等简单数据类型,由于对象没有 destructor,所以用 delete 和 delete [] 是一样的!但是如果是C++ 对象数组就不同了!

Map

Go Map的查询复杂度

空间复杂度:

1
首先我们不考虑因删除大量元素导致的空间浪费情况(这种情况现在go是留给程序员自己解决),只考虑一个持续增长状态的map的一个空间使用率:

由于溢出桶数量超过hash桶数量时会触发缩容,所以最坏的情况是数据被集中在一条链上,hash表基本是空的,这时空间浪费O(n)。
最好的情况下,数据均匀散列在hash表上,没有元素溢出,这时最好的空间复杂度就是扩散因子决定了,当前go的扩散因子由全局变量决定,即loadFactorNum/loadFactorDen = 6.5。即平均每个hash桶被分配到6.5个元素以上时,开始扩容。所以最小的空间浪费是(8-6.5)/8 = 0.1875,即O(0.1875n)

结论:go map的空间复杂度(指除去正常存储元素所需空间之外的空间浪费)是O(0.1875n) ~ O(n)之间。
​ 具体细节:https://blog.csdn.net/dongjijiaoxiangqu/article/details/109643025

时间复杂度

go采用的hash算法应是很成熟的算法,极端情况暂不考虑。所以综合情况下go map的时间复杂度应为O(1)

map的key可以是哪些类型?可以嵌套map吗?

map key 必须是可比较的类型,语言规范中定义了可比较的类型:boolean, numeric, string, pointer, channel, interface, 以及仅包含这些类型的struct和array 。不能作为map key的类型有:slice,map, function。可以嵌套map。

Map怎么知道自己处于竞争状态?是Go编码实现的还是底层硬件实现的?

代码实现的,在查找、赋值、遍历、删除的过程中都会检测写标志flags,一旦发现写标志置位(等于1),则直接panic。赋值和删除函数载检测完标志是复位状态(等于0)之后,先将写标志位置位,才会进行之后的操作。

Map的panic能被recover掉吗?了解panic和recover的机制?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func main() {
defer errorHandler()
m := map[string]int{}

go func() {
for {
m["x"] = 1
}
}()
for {
_ = m["x"]
}
}

func errorHandler() {
if r := recover(); r != nil {
fmt.Println(r)
}
}//不能

map 由于不是线程安全的,所以在遇到并发读写的时候会抛出 concurrent map read and map write异常,从而使程序直接退出。

1
2
3
4
5
6
7
func mapaccess1_faststr(t *maptype, h *hmap, ky string) unsafe.Pointer {
...
if h.flags&hashWriting != 0 {
throw("concurrent map read and map write")
}
...

这里的 throw 和上面一样,最终会调用到 exit 执行退出。

Go中两个map对象如何比较

使用reflect.DeepEqual 这个函数进行比较。使用 reflect.DeepEqual 有一点注意:由于使用了反射,所以有性能的损失。如果你多做一些测试,那么你会发现 reflect.DeepEqual 会比 == 慢 100 倍以上。

map的优缺点以及改进?

优点

1.map类似其他语言中的哈希表或字典,以key-value形式存储数据

2.key必须是支持==或!=比较运算的类型,不可以是函数、map或slice

3.map通过key查找value比线性搜索快很多。

4.map使用make()创建,支持:=这种简写方式

5.超出容量时会自动扩容,

6.当键值对不存在时自动添加,使用delete()删除某键值对

缺点:

并发中的map不是安全的

sync.Map怎么使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package main

import (
"fmt"
"sync"
)

func main() {
var scene sync.Map
// 将键值对保存到sync.Map
scene.Store("1", 1)
scene.Store("2", 2)
scene.Store("3", 3)
// 从sync.Map中根据键取值
fmt.Println(scene.Load("1"))
// 根据键删除对应的键值对
scene.Delete("1")
// 遍历所有sync.Map中的键值对
scene.Range(func(k, v interface{}) bool {
fmt.Println("iterate:", k, v)
return true
})
}

如果一个map没申请空间,去向里面取值,会发生什么情况

在map查询操作中,最多可以给两个变量赋值,第一个为值,第二个为bool类型的变量,用于指示是否存在指定的键,如果键不存在,那么第一个值为相应类型的零值。如果只指定一个变量,那么该变量仅表示改键对应的值,如果键不存在,那么该值同样为相应类型的零值

1
2
3
4
5
6
7
8
9
10
11
package main

import "fmt"

func main() {
var myMap map[string]int // 未初始化的 map
value := myMap["some_key"] // 尝试获取一个键的值

fmt.Println(value)
}
//panic: assignment to entry in nil map

sync.Map底层数据结构

image-20230922140118984

image-20230922140130100

image-20230922140139039

image-20230922140145882

image-20230922140150436

image-20230922140201135

Channel

Channel的大小是否对性能有影响

Channel的大小对性能会产生一定的影响。Channel的大小是指Channel可以容纳的元素数量,可以通过在创建Channel时指定容量大小来控制。当Channel的容量较小时,可能会导致发送和接收操作的阻塞,从而影响程序的性能。而当Channel的容量较大时,可能会增加系统的内存开销,也可能会导致Channel中的元素被占用的时间较长,从而影响程序的响应性。

Channel的内存模型是什么

在Go语言中,Channel的内存模型是基于通信顺序进程(Communicating Sequential Processes,CSP)模型的。CSP模型是一种并发计算模型,它将并发程序看作是一组顺序进程,这些进程通过Channel进行通信和同步。

在CSP模型中,每个进程都是独立的,它们之间通过Channel进行通信。Channel是一个具有FIFO特性的数据结构,用于在多个进程之间传递数据。当一个进程向Channel发送数据时,它会阻塞等待,直到另一个进程从Channel中接收到数据。同样地,当一个进程从Channel中接收数据时,它也会阻塞等待,直到另一个进程向Channel发送数据。

在Go语言中,Channel的内存模型采用了CSP模型的概念,即每个Channel都是一个独立的顺序进程。当一个进程向Channel发送数据时,数据会被复制到Channel的缓冲区或者直接发送到接收方。当一个进程从Channel中接收数据时,数据会被从Channel的缓冲区中取出或者等待发送方发送数据。

Channel的读写操作是否是原子性的,如何实现

Channel的读写操作是原子性的,并且是由Go语言内部的同步机制来保证的。

当一个goroutine进行Channel的读写操作时,Go语言内部会自动进行同步,保证该操作的原子性和顺序性。这种同步机制主要涉及到两个部分:

  1. 基于锁的同步:在Channel的底层实现中,使用了一种基于锁的同步机制,它可以保证每个读写操作都是原子性的,避免了多个goroutine同时读写导致的数据竞争问题。
  2. 基于等待的同步:当一个goroutine进行Channel的读写操作时,如果Channel当前为空或已满,它就会被添加到等待队列中,直到满足条件后才会被唤醒,这种等待的同步机制可以避免因Channel状态不满足条件而导致的死锁问题。

通过这种基于锁和等待的同步机制,Go语言保证了Channel的读写操作是原子性的,可以在多个goroutine之间安全地进行通信和同步。

如何避免在Channel中出现死锁的情况

  1. 避免在单个goroutine中对Channel进行读写操作:如果一个goroutine同时进行Channel的读写操作,很容易出现死锁的情况,因为该goroutine无法切换到其他任务,导致无法释放Channel的读写锁。因此,在进行Channel的读写操作时,应该尽量将它们分配到不同的goroutine中,以便能够及时切换任务。
  2. 使用缓冲Channel:缓冲Channel可以在一定程度上缓解读写操作的同步问题,避免因为Channel状态不满足条件而导致的死锁问题。如果Channel是非缓冲的,那么写操作必须等到读操作执行之后才能完成,反之亦然,这种同步会导致程序无法继续执行。而如果使用缓冲Channel,就可以避免这种同步问题,即使读写操作之间存在时间差,也不会导致死锁。
  3. 使用select语句:select语句可以在多个Channel之间进行选择操作,避免因为某个Channel状态不满足条件而导致的死锁问题。在使用select语句时,应该注意判断每个Channel的状态,避免出现同时等待多个Channel的情况,这可能导致死锁。
  4. 使用超时机制:在进行Channel的读写操作时,可以设置一个超时时间,避免因为Channel状态不满足条件而一直等待的情况。如果超过一定时间仍然无法读写Channel,就可以选择放弃或者进行其他操作,以避免死锁。

Channel在go中起什么作用

在 Go 中,channel 是一种用于在 goroutine 之间传递数据的并发原语。channel 可以让 goroutine 在发送和接收操作之间同步,从而避免了竞态条件,从而更加安全地共享内存。

channel 类似于一个队列,数据可以从一个 goroutine 中发送到 channel,然后从另一个 goroutine 中接收。channel 可以是有缓冲的,这意味着可以在 channel 中存储一定数量的值,而不仅仅是一个。如果 channel 是无缓冲的,则发送和接收操作将会同步阻塞,直到有 goroutine 准备好接收或发送数据。

Channel为什么需要两个队列实现

一个Channel可以被看作是一个通信通道,用于在不同的进程之间传递数据。在具体的实现中,一个Channel通常需要使用两个队列来实现。这两个队列是发送队列和接收队列。

发送队列是用来存储将要发送的数据的队列。当一个进程想要通过Channel发送数据时,它会将数据添加到发送队列中。发送队列中的数据会按照先进先出的顺序被逐个发送到接收进程。如果发送队列已经满了,那么发送进程就需要等待,直到有足够的空间可以存储数据。

接收队列是用来存储接收进程已经准备好接收的数据的队列。当一个进程从Channel中接收数据时,它会从接收队列中取出数据。如果接收队列是空的,那么接收进程就需要等待,直到有新的数据可以接收。

使用两个队列实现Channel的主要原因是为了实现异步通信。发送进程可以在发送数据之后立即继续执行其他任务,而不需要等待接收进程确认收到数据。同样,接收进程也可以在等待数据到达的同时执行其他任务。这种异步通信的实现方式可以提高系统的吞吐量和响应速度。

Go为什么要开发Channel,而别的语言为什么没有

在Go语言中,Channel是一种非常重要的并发原语。Go语言将Channel作为语言内置的原语,可能是出于以下几个方面的考虑:

  1. 并发安全:在多线程并发环境下,使用Channel可以保证数据的安全性,避免多个线程同时访问共享数据导致的数据竞争和锁的开销。
  2. 简单易用:Go语言中的Channel是一种高度抽象的概念,可以非常方便地实现不同线程之间的数据传输和同步。通过Channel,程序员不需要手动地管理锁、条件变量等底层的同步原语,使得程序的编写更加简单和高效。
  3. 天然支持并发:Go语言中的Channel与goroutine密切相关,这使得Channel天然地支持并发。程序员可以通过使用Channel和goroutine来实现非常高效的并发编程。

虽然其他编程语言中没有像Go语言中的Channel这样的内置并发原语,但是许多编程语言提供了类似于Channel的实现,比如Java的ConcurrentLinkedQueue、Python的Queue、C++的std::queue等。这些实现虽然没有Go语言中的Channel那么简单易用和高效,但也能够满足多线程编程中的数据传输和同步需求。

Channel底层是使用锁控制并发的,为什么不直接使用锁

虽然在Go语言中,Channel底层实现是使用锁控制并发的,但是Channel和锁的使用场景是不同的,具有不同的优势和适用性。

首先,Channel比锁更加高级和抽象。Channel可以实现多个goroutine之间的同步和数据传递,不需要程序员显式地使用锁来进行线程间的协调。Channel可以避免常见的同步问题,比如死锁、饥饿等问题。

其次,Channel在语言层面提供了一种更高效的并发模型。在使用锁进行并发控制时,需要程序员自己手动管理锁的获取和释放,这增加了代码复杂度和错误的风险。而使用Channel时,可以通过goroutine的调度和Channel的阻塞机制来实现更加高效和简单的并发控制。

此外,Channel还可以避免一些由锁导致的性能问题,如锁竞争、锁粒度过大或过小等问题。Channel提供了一种更加精细的控制机制,能够更好地平衡不同goroutine之间的并发性能。

总的来说,虽然Channel底层是使用锁控制并发的,但是Channel在语言层面提供了更加高级、抽象和高效的并发模型,可以使程序员更加方便和安全地进行并发编程。

Channel可以在多个goroutine之间传递什么类型的数据

在Go语言中,Channel可以在多个goroutine之间传递任何类型的数据,包括基本数据类型、复合数据类型、结构体、自定义类型等。这些数据类型在传递过程中都会被封装成对应的指针类型,并由Channel进行传递。

如何在Channel中传递复杂的数据类型

在Go语言中,Channel可以传递任何类型的数据,包括复杂的数据类型。如果要在Channel中传递复杂的数据类型,可以将其定义为一个结构体,然后通过Channel进行传递。

例如,假设我们有一个结构体类型Person,它包含姓名和年龄两个字段:

1
2
3
4
type Person struct {
Name string
Age int
}

我们可以定义一个Channel,用于传递Person类型的数据:

1
ch := make(chan Person)

现在我们可以在不同的Goroutine中向Channel发送和接收Person类型的数据:

1
2
3
4
5
6
7
8
9
// 发送Person类型数据到Channel
go func() {
p := Person{Name: "Alice", Age: 18}
ch <- p
}()

// 从Channel接收Person类型数据
p := <-ch
fmt.Println(p.Name, p.Age)

注意,如果要在Channel中传递复杂的数据类型,需要确保该类型是可导出的。

在Go语言中,Channel和锁的使用场景有哪些区别

在Go语言中,Channel和锁(sync.Mutex等)都可以用于并发编程中的同步和共享数据,但它们的使用场景有一些区别。

Channel通常用于Goroutine之间传递数据,并发的Goroutine之间可以通过Channel进行同步。使用Channel可以避免锁的问题,例如死锁、饥饿等问题。Channel可以将数据在多个Goroutine之间进行传递和共享,而且在数据传递的过程中,不需要使用锁来保证数据的安全性,这也是Channel比锁更加安全和高效的原因之一。因此,当需要在不同的Goroutine之间传递数据时,使用Channel是比较合适的选择。

锁通常用于对共享资源进行保护,防止多个Goroutine同时访问和修改同一个共享资源,从而导致数据的竞争和不一致。使用锁可以保证同一时刻只有一个Goroutine能够访问和修改共享资源,从而保证数据的安全性和一致性。当需要对共享资源进行保护时,使用锁是比较合适的选择。

Channel和锁都是Go语言中常用的并发编程工具,它们各自有不同的使用场景。在实际开发中,应根据具体的需求选择合适的并发编程工具来实现同步和共享数据。

在使用Channel时,如何保证数据的同步性和一致性

在使用Channel时,为了保证数据的同步性和一致性,可以采用以下几种方式:

  1. 合理设计Channel的容量:当Channel容量过小时,容易出现发送者和接收者之间的阻塞,而当容量过大时,可能会出现数据不一致的问题。因此,在设计Channel时,需要根据实际情况合理设定容量大小,以避免数据同步性和一致性的问题。
  2. 使用互斥锁保证数据访问的互斥性:如果多个goroutine同时对某个共享的数据进行访问,可能会导致数据不一致的问题。此时,可以使用互斥锁来保证数据访问的互斥性,以避免多个goroutine同时对同一份数据进行访问。
  3. 使用同步机制实现数据同步:在某些情况下,我们可能需要在多个goroutine之间进行数据同步,以确保数据的一致性。此时,可以使用一些同步机制,例如WaitGroup、Barrier、Cond等,来实现数据同步。

如何保证Channel的安全性

  1. 确保Channel的正确使用:在使用Channel时,需要确保发送和接收操作的正确性。特别是在并发环境下,必须正确处理并发操作,避免出现竞争条件或死锁等问题。因此,在使用Channel时,需要根据实际情况选择合适的同步机制,例如互斥锁、条件变量、原子操作等,以确保Channel的正确使用。
  2. 避免Channel的泄漏:如果Channel没有被及时关闭,可能会导致资源泄漏和性能问题。因此,在使用Channel时,需要确保及时关闭Channel,避免出现资源泄漏的情况。
  3. 避免Channel的阻塞:如果Channel的容量较小,可能会导致发送和接收操作的阻塞。此时,可以使用缓冲Channel或者带超时的发送和接收操作,避免Channel的阻塞。
  4. 避免Channel的死锁:如果多个goroutine之间出现死锁,可能会导致程序的停滞和性能问题。因此,在使用Channel时,需要避免死锁的情况,例如避免循环依赖、避免同时使用多个Channel等

channel的应用场景

channel适用于数据在多个协程中流动的场景,有很多实际应用:

① 任务定时

比如超时处理:

1
2
select {
case <-time.After(time.Second):

定时任务

1
2
select {
case <- time.Tick(time.Second)

② 解耦生产者和消费者

可以将生产者和消费者解耦出来,生产者只需要往channel发送数据,而消费者只管从channel中获取数据。

③ 控制并发数

以爬虫为例,比如需要爬取1w条数据,需要并发爬取以提高效率,但并发量又不过过大,可以通过channel来控制并发规模,比如同时支持5个并发任务:ch := make(chan int, 5)

1
2
3
4
5
6
7
for _, url := range urls {
go func() {
ch <- 1
worker(url)
<- ch
}
}

④协程间通信

协程之间的通信. 通过channel传递数据,实现通信

给channel用空结构体的好处是什么?

在 Go 语言中,使用空结构体作为通道(channel)元素的类型有一些优点和好处:

  1. 低内存开销:空结构体不包含任何字段,因此它的内存占用非常小,通常为零字节。这意味着使用空结构体的通道在存储元素时几乎没有内存开销。这对于需要大量通信的高并发应用程序非常有用,因为它可以减少内存占用,提高性能。
  2. 高效的信号传递:空结构体通道通常用于实现信号传递或同步操作,而不是传递实际的数据。由于空结构体的内存占用极小,它的传输速度非常快,适用于高频率的通信,如控制并发数量的信号量。
  3. 语义清晰:使用空结构体作为通道元素的类型可以传达出一种清晰的语义,即通道的目的是进行信号传递或同步,而不是传输实际的数据。这可以使代码更容易理解和维护。
  4. 防止数据竞态:当通道用于多个协程之间的同步时,使用空结构体可以帮助防止数据竞态。因为空结构体通常不存储实际的数据,所以不会发生多个协程同时访问或修改相同数据的情况。
  5. 编译时类型检查:使用空结构体作为通道元素可以在编译时进行类型检查,确保只有空结构体可以被发送到和接收到该通道,从而减少了在运行时出现类型错误的可能性。

总的来说,使用空结构体作为通道元素的类型是一种有效的方式来实现轻量级的信号传递和同步,同时保持低内存开销和高性能。这在 Go 中的并发编程中非常有用,特别是在需要进行大量协程间通信的情况下

如何判断channel是否关闭?

首先官方没有提供判断channel是否关闭的接口,也不需要去判断,因此在使用的时候,需要保证好时序,避免往已关闭的channel中写入数据引发panic。
但是可以通过以下几个办法判断:

  1. 通过读来判断,_, ok := <- c,如果c是个阻塞的,并且没有关闭的话,会阻塞在这,没办法正常调用。最好是结合select使用,而且要有default语句,不然又阻塞了!

    1
    2
    3
    4
    5
    6
    7
    8
    func isChanClose(ch chan int) bool {
    select {
    case _, received := <- ch:
    return !received
    default:
    }
    return false
    }
  2. 通过写来判断,立马panic,可以捕捉一下进行判断,够野…

怎么理解“不要用共享内存来通信,而是用通信来共享内存”

共享内存会涉及到多个线程同时访问修改数据的情况,为了保证数据的安全性,那就会加锁,加锁会让并行变为串行,cpu此时也会忙于线程抢锁。另外使用过多的锁,容易使得程序的代码逻辑坚涩难懂,并且容易使程序死锁,死锁了以后排查问题相当困难,特别是很多锁同时存在的时候。

在这种情况下,不如换一种方式,把数据复制一份,每个线程有自己的,只要一个线程干完一件事其他线程不用去抢锁了,这就是一种通信方式,把共享的以通知方式交给线程,实现并发。go语言的channel就保证同一个时间只有一个goroutine能够访问里面的数据,为开发者提供了一种优雅简单的工具,所以go原生的做法就是使用channel来通信,而不是使用共享内存来通信。

channel和共享内存有什么优劣势?

“不要通过共享内存来通信,我们应该使用通信来共享内存” 这句话想必大家已经非常熟悉了,在官方的博客,初学时的教程,甚至是在 Go 的源码中都能看到

无论是通过共享内存来通信还是通过通信来共享内存,最终我们应用程序都是读取的内存当中的数据,只是前者是直接读取内存的数据,而后者是通过发送消息的方式来进行同步。而通过发送消息来同步的这种方式常见的就是 Go 采用的 CSP(Communication Sequential Process) 模型以及 Erlang 采用的 Actor 模型,这两种方式都是通过通信来共享内存。

02_Go进阶03_blog_channel.png

大部分的语言采用的都是第一种方式直接去操作内存,然后通过互斥锁,CAS 等操作来保证并发安全。Go 引入了 Channel 和 Goroutine 实现 CSP 模型将生产者和消费者进行了解耦,Channel 其实和消息队列很相似。而 Actor 模型和 CSP 模型都是通过发送消息来共享内存,但是它们之间最大的区别就是 Actor 模型当中并没有一个独立的 Channel 组件,而是 Actor 与 Actor 之间直接进行消息的发送与接收,每个 Actor 都有一个本地的“信箱”消息都会先发送到这个“信箱当中”。

优点

  • 使用 channel 可以帮助我们解耦生产者和消费者,可以降低并发当中的耦合

缺点

  • 容易出现死锁的情况

Go里的Mutex和channel的性能有区别吗?

channel的底层也是用了syns.Mutex,算是对锁的封装,性能应该是有损耗的,用测试的数据更有说服力

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package channel

import "sync"
var mutex = sync.Mutex{}
var ch = make(chan struct{}, 1)
func UseMutex() {
mutex.Lock()
mutex.Unlock()
}
func UseChan() {
ch <- struct{}{}
<-ch
}
package channel

import "testing"

func BenchmarkUseMutex(b *testing.B) {
for i := 0; i < b.N; i++ {
UseMutex()
}
}

func BenchmarkUseChan(b *testing.B) {
for i := 0; i < b.N; i++ {
UseChan()
}
}

执行bench命令

1
go test -bench=.

测试结果如下

1
2
3
4
BenchmarkUseMutex-8     87120927                13.61 ns/op
BenchmarkUseChan-8 42295345 26.47 ns/op
PASS
ok mytest/channel 2.906s

根据压测结果来说Mutex 比 channel的性能快了两倍左右

Channel的ring buffer实现

image-20230922140315653

用Go撸一个生产者消费型,用channel通信,怎么友好的关闭chan?

如何优雅的关闭channel 记住两点

  1. 向一个已关闭的channel发送数据会panic
  2. 关闭一个已经关闭的channel会panic

针对单个生产者 在发送侧关闭channel即可

单个生产者单个消费者模型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func main() {
var ch = make(chan int)

// 单生产者
go func() {
for i := 1; i < 100; i++ {
ch <- i
}
close(ch)
}()

// 消费者
go func() {
for elem := range ch {
fmt.Println(elem)
}
}()

select {}
}

单个生产者多个消费者模型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func main() {
var ch = make(chan int)

// 单生产者
go func() {
for i := 1; i < 100; i++ {
ch <- i
}
close(ch)
}()

// 多消费者
for i := 0; i < 100; i++ {
go func() {
for elem := range ch {
fmt.Println(elem)
}
}()
}

select {}
}

针对多个生产者 不应该关闭生产者,消费者通知生产者不发送数据即可

多生产者单消费者模型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
func main() {
var ch = make(chan int)
var stopCh = make(chan struct{})
// 多生产者
for i := 1; i <= 100; i++ {
go func(n int) {
for {
select {
case ch <- n:
case <-stopCh:
return
}
}
}(i)
}

// 单消费者
go func() {
for elem := range ch {
fmt.Println(elem)
if elem == 100{
return
}
}
}()

select {}
}

多生产者多消费者模型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
func main() {
var ch = make(chan int)
// 停止信号
var stopCh = make(chan struct{})
// 协调者
var toStopCh = make(chan struct{}, 1)
// 多生产者
for i := 1; i <= 100; i++ {
go func(n int) {
for {
select {
case ch <- n:
case <-stopCh:
return
}
}
}(i)
}

// 接收通知给协调者
go func() {
<-toStopCh
close(stopCh)
}()

// 多消费者
for i := 0; i < 100; i++ {
go func() {
for {
select {
case elem := <-ch:
if elem == 100 {
toStopCh <- struct{}{}
return
}
fmt.Println(elem)
}
}
}()
}

select {}
}

Mutex

Mutex几种状态

image-20230921213238958

RWMutex 实现,RWMutex注意事项

通过记录 readerCount 读锁的数量来进⾏控制,当有⼀个写锁的时候,会将读锁数量设置为负数1<<30。⽬的是让新进⼊的读锁等待写锁之后释放通知读锁。同样的写锁也会等等待之前的读锁都释放完毕,才会开始进⾏后续的操作。而等写锁释放完之后,会将值重新加上1<<30,并通知刚才新进⼊的读锁(rw.readerSem),两者互相限制。

image-20230624162219175

Go 当中同步锁有什么特点?作用是什么

当⼀个 Goroutine(协程)获得了 Mutex 后,其他 Goroutine(协程)就只能乖乖的等待,除非该 goroutine 释放了该 Mutex RWMutex在读锁占⽤的情况下,会阻止写,但不阻止读 RWMutex 在写锁占用情况下,会阻止任何其他goroutine(⽆论读和写)进来,整个锁相当于由该 goroutine 独占同步锁的作用是保证资源在使用时的独有性,不会因为并发而导致数据错乱,保证系统的稳定性。

获取不到锁会一直等待吗?

会。
在 2016 年 Go 1.9 中 Mutex 增加了饥饿模式,让锁变得更公平,不公平的等待时间限制在 1 毫秒,并且修复了一个大 Bug:总是把唤醒的 goroutine 放在等待队列的尾部,会导致出现不公平的等待时间。那什么时候会进入饥饿模式?1 毫秒,一旦等待者等待时间超过这个时间阈值,就可能会进入饥饿模式,优先让等待着先获取到锁。有饥饿模式自然就有正常模式了,这里就不展开了。你只需要记住,Mutex 锁不会容忍一个 goroutine 被落下,永远没有机会获取锁。Mutex 尽可能地让等待较长的 goroutine 更有机会获取到锁。

如何实现一个 timeout 的锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
package main

import (
"fmt"
"sync"
"sync/atomic"
"time"
)

type ChanMutex chan struct{}

func NewTryLock() ChanMutex {
ch := make(chan struct{}, 1)
return ch
}
func (m *ChanMutex) Lock() {
ch := (chan struct{})(*m)
ch <- struct{}{}
}
func (m *ChanMutex) Unlock() {
ch := (chan struct{})(*m)
select {
case <-ch:
default:
panic("unlock of unlocked mutex")
}
}
func (m *ChanMutex) TryLockWithTimeOut(d time.Duration) bool {
ch := (chan struct{})(*m)
t := time.NewTimer(d)
select {
case <-t.C:
return false
case ch <- struct{}{}:
t.Stop()
return true
}
}
func (m *ChanMutex) TryLock() bool {
ch := (chan struct{})(*m)
select {
case ch <- struct{}{}:
return true
default:
return false
}
}

func main() {
n1 := int64(0)
n2 := int64(0)
c := NewTryLock()

wg := sync.WaitGroup{}
for i := 0; i < 10000; i++ {
wg.Add(1)
go func() {
if c.TryLock() {
n1++
c.Unlock()
} else {
atomic.AddInt64(&n2, 1)
}
wg.Done()
}()
}
wg.Wait()

fmt.Printf("total: %v, success: %v, fail: %v\n", n1+n2, n1, n2)
}

Goroutine

开五个协程,全部执行一个函数,怎么保证协程执行完全部打印

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package main

import (
"fmt"
"sync"
)

func main() {
var wg sync.WaitGroup
wg.Add(5) // 设置等待的协程数量

for i := 0; i < 5; i++ {
go func(i int) {
defer wg.Done() // 每个协程执行完成后调用 wg.Done() 减少计数

// 执行你的函数
// 这里可以替换为你的具体函数逻辑
fmt.Println("执行协程", i)
}(i)
}
wg.Wait() // 等待所有协程执行完成
fmt.Println("所有协程执行完毕")
}

用Channel和两个协程实现数组相加

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package main
import "fmt"

//用channel和两个goroutine实现数组相加
func add(a, b []int) []int {
ch := make(chan int)
c := make([]int,len(a))
go func() {
for _,v := range a{
ch <- v
}
}()
go func() {
for i,t := range b{
temp := <- ch
c[i] = temp+t
}
}()
return c
}

func main() {
a := []int{2,4,6,8}
b := []int{1,3,5,7}
ans := add(a,b)
fmt.Println(ans)
}

2个协程交替打印字母和数字

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package main

import (
"fmt"
)

func main() {
limit := 26

numChan := make(chan int, 1)
charChan := make(chan int, 1)
mainChan := make(chan int, 1)
charChan <- 1

go func() {
for i := 0; i < limit; i++ {
<-charChan
fmt.Printf("%c\n", 'a'+i)
numChan <- 1

}
}()
go func() {
for i := 0; i < limit; i++ {
<-numChan
fmt.Println(i)
charChan <- 1

}
mainChan <- 1
}()
<-mainChan
close(charChan)
close(numChan)
close(mainChan)
}

为什么不要大量使用goroutine

1、过多会占有太多的cpu资源和内存,可能使系统资源耗尽

2、因为GMP,M和P都是有数量限制的,如果调度队列过长,也会影响性能;

3、频繁GC也会影响性能;

4、内存占用,不好管理,容易资源泄漏或者死锁

Golang中除了加Mutex锁以外还有哪些方式安全读写共享变量?

Golang中Goroutine可以通过 Channel 进行安全读写共享变量。

Golang Panic 子Goroutine发生panic,主进程会panic吗?

虽然Goroutine能够实现高并发,但是如果某个Goroutine panic了,而且这个Goroutine里面没有捕获recover,那么整个进程就会挂掉。

并行goroutine如何实现

通过设置最大的可同时使用的 CPU 核数,例如同时执行两个goroutine,设置runtime.GOMAXPROCS(2),用来实现goroutine并行。

协程中参数直接使用,和传参的区别是什么,为什么会造成这种结果

以一个例子说明

直接使用

1
2
3
4
5
6
7
8
9
10
11
12
func main() {
wg := sync.WaitGroup{}
for i := 0; i < 10; i++ {
wg.Add(1)
go func(ctx,func(ctx context.Context) {
fmt.Println(i)
wg.Done()
})

}
wg.Wait()
}

执行输出结果

10 10 10 10 10 10 10 10 10 10

传参使用

1
2
3
4
5
6
7
8
9
10
11
func main() {
wg := sync.WaitGroup{}
for i := 0; i < 10; i++ {
wg.Add(1)
go func(ctx,func(ctx context.Context) {
fmt.Println(i)
wg.Done()
})(i)
}
wg.Wait()
}

执行输出结果

0 9 3 4 5 6 7 8 1 2

产生这种结果的原因是,对于一部协程来说,函数在执行异步执行注册时,该函数并未真正开始执行注册时只在goroutine的栈中保存了变量i的内存地址,而一旦开始执行函数时才会去读取变量i的值,而这时变量i的值已经自增到了10,改进的方案就是在注册异步执行函数的时候,把变量的值也一并传递获取,或者吧当前变量i的值赋值给一个不会改变的临时变量中,在函数中使用该临时变量而不是直接使用i

Go 中用 for 遍历多次执行 goroutine会存在什么问题

程序会panic: too many open files
解决的方法:通过带缓冲的channel和sync.waitgroup控制协程并发量。

Go里面一个协程能保证绑定在一个内核线程上面的。

协程是用户级的线程,对内核是透明的,系统并不知道协程的存在,并且协程是非抢占式调度,无法实现公平的任务调用,通常只进行协作式调度,需要协程自己主动把控制权转让出去之后,其他协程才能被执行到。
Go Scheduler会把goroutine调度到逻辑处理器上运行,逻辑处理器会一对一的绑定到操作系统的线程。当goroutine可以运行时,会被放入一个逻辑处理器的待执行队列中;当goroutine遇到长时间执行或执行了一个阻塞的系统调用时(如打开文件),Go Scheduler会将这个逻辑处理器与线程分离,并将另一个线程绑定到这个逻辑处理器,之后从待执行队列中选择下一个goroutine来运行,原来的goroutine保存到待执行队列等待调用(逻辑处理器是不动的)。

协程池的使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var (
ctx = gctx.New()
)

func main() {
wg := sync.WaitGroup{}
for i := 0; i < 10; i++ {
wg.Add(1)
v := i
grpool.Add(ctx, func(ctx context.Context) {
fmt.Println(v)
wg.Done()
})
}
wg.Wait()
}

自主实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
package main

import (
"fmt"
"runtime"
"sync"
"time"
)

// Task 任务接口
type Task interface {
Execute()
}

// Pool 协程池
type Pool struct {
TaskChannel chan Task // 任务队列
}

// NewPool 创建一个协程池
func NewPool(cap ...int) *Pool {
// 获取 worker 数量
var n int
if len(cap) > 0 {
n = cap[0]
}
if n == 0 {
n = runtime.NumCPU()
}

p := &Pool{
TaskChannel: make(chan Task),
}

// 创建指定数量 worker 从任务队列取出任务执行
for i := 0; i < n; i++ {
go func() {
for task := range p.TaskChannel {
task.Execute()
}
}()
}
return p
}

// Submit 提交任务
func (p *Pool) Submit(t Task) {
p.TaskChannel <- t
}

// EatFood 吃饭任务
type EatFood struct {
wg *sync.WaitGroup
}

func (e *EatFood) Execute() {
defer e.wg.Done()
fmt.Println("eat cost 3 seconds")
time.Sleep(3 * time.Second)
}

// WashFeet 洗脚任务
type WashFeet struct {
wg *sync.WaitGroup
}

func (w *WashFeet) Execute() {
defer w.wg.Done()
fmt.Println("wash feet cost 3 seconds")
time.Sleep(3 * time.Second)
}

// WatchTV 看电视任务
type WatchTV struct {
wg *sync.WaitGroup
}

func (w *WatchTV) Execute() {
defer w.wg.Done()
fmt.Println("watch tv cost 3 seconds")
time.Sleep(3 * time.Second)
}

func main() {
p := NewPool()
var wg sync.WaitGroup
wg.Add(3)
task1 := &EatFood{
wg: &wg,
}
task2 := &WashFeet{
wg: &wg,
}
task3 := &WatchTV{
wg: &wg,
}
p.Submit(task1)
p.Submit(task2)
p.Submit(task3)
// 等待所有任务执行完成
wg.Wait()
}

Go源码:协程栈

https://segmentfault.com/a/1190000019570427

Go如何关闭goroutine

  1. 关闭 channel

第一种方法,就是借助 channel 的 close 机制来完成对 goroutine 的精确控制。
在 Go 语言的 channel 中,channel 接受数据有两种方法:
msg := <-ch
msg, ok := <-ch
这两种方式对应着不同的 runtime 方法,我们可以利用其第二个参数进行判别,当关闭 channel 时,就根据其返回结果跳出。

  1. 定期轮询 channel

第二种方法,是更为精细的方法,其结合了第一种方法和类似信号量的处理方式。
而 goroutine 的关闭是不知道什么时候发生的,因此在 Go 语言中会利用 for-loop 结合 select 关键字进行监听,再进行完毕相关的业务处理后,再调用 close 方法正式关闭 channel。
若程序逻辑比较简单结构化,也可以不调用 close 方法,因为 goroutine 会自然结束,也就不需要手动关闭了。

  1. 使用 context

可以借助 Go 语言的上下文(context)来做 goroutine 的控制和关闭。
在 context 中,我们可以借助 ctx.Done 获取一个只读的 channel,类型为结构体。可用于识别当前 channel 是否已经被关闭,其原因可能是到期,也可能是被取消了。
因此 context 对于跨 goroutine 控制有自己的灵活之处,可以调用 context.WithTimeout 来根据时间控制,也可以自己主动地调用 cancel 方法来手动关闭。

父 goroutine 退出,如何使得子 goroutine 也退出?

父子协程的退出分为两种情况:

  • 当父协程是 main 协程时,父协程退出,父协程下的所有子协程也会跟着退出;
  • 当父协程不是main协程时,父协程退出,父协程下的所有子协程并不会跟着退出(子协程直到自己的所有逻辑执行完或者是main协程结束才结束)

这时候就需要使子协程退出,context 就登场了:
context主要用于父子任务之间的同步取消信号,本质上是一种协程调度的方式。

1
2
3
4
5
6
7
8
9
10
type Context interface {

Deadline() (deadline time.Time, ok bool)

Done() <-chan struct{}

Err() error

Value(key interface{}) interface{}
}

context.Context 是 Go 语言在 1.7 版本中引入标准库的接口,该接口定义了四个需要实现的方法,其中包括:

  1. Deadline — 返回 context.Context 被取消的时间,也就是完成工作的截止日期;
  2. Done — 返回一个 Channel,这个 Channel 会在当前工作完成或者上下文被取消后关闭,多次调用 Done 方法会返回同一个 Channel;
    1. Err — 返回 context.Context 结束的原因,它只会在 Done 方法对应的 Channel 关闭时返回非空的值;
    2. 如果 context.Context 被取消,会返回 Canceled 错误;
  3. 如果 context.Context 超时,会返回 DeadlineExceeded 错误;
  4. Value — 从 context.Context 中获取键对应的值,对于同一个上下文来说,多次调用 Value 并传入相同的 Key 会返回相同的结果,该方法可以用来传递请求特定的数据;

一个线程打印奇数一个线程打印偶数 交替打印

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
package main

import (
"fmt"
"time"
)

var num = 100

func goroutine1(p chan int) {
for i := 1; i <= num; i++ {
p <- i
if i%2 == 1 {
fmt.Println("goroutine-1:", i)
}
}
}

func goroutine2(p chan int) {
for i := 1; i <= num; i++ {
<-p
if i%2 == 0 {
fmt.Println("goroutine-2:", i)
}
}
}

func main() {
msg := make(chan int)

go goroutine1(msg)
go goroutine2(msg)

time.Sleep(time.Second * 1)

Goroutine 和 kernel thread 之间是什么关系?

在进程被划分为更小的线程后,线程成为了最小的调度单元,也是在 CPU 上执行的最小单元

操作系统将内存空间划分为内核空间用户空间

由于用户级线程一般使用线程库来模拟线程且对操作系统保持透明,因此对操作系统而言只能调度内核空间中的内核级线程

内核级线程 kernel thread 简称 KSE

由此可以将内核级线程视作用户级线程上 CPU 运行的机会

none

对于传统的内核级线程和用户线程,有一对一一对多多对多三种模型

对于同一进程内的用户级线程切换,不需要切换上下文也无需额外开销

对于不同进程内的用户级线程切换,要切换进程的上下文,开销较大

image-20220417135413988

在 go 中使用 goroutine,而 goroutine 是 go 中实现的用户级线程

因此一般来看 go 中的线程调度应该如左图所示

在 go 中使用 GMP 模型,其中 P(processor) 专门管理一个 goroutine 的队列,实际情况如右图所示

none

总结:goroutine 依靠 kernel thread 执行

协程实现顺序打印123

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
package main

import "fmt"

var one = make(chan struct{}, 1)
var two = make(chan struct{}, 1)
var three = make(chan struct{}, 1)
var done = make(chan struct{})

func PrintOne() {
defer close(one)
for i := 0; i < 10; i++ {
<-three
fmt.Println("1")
one <- struct{}{}
}
}
func PrintTwo() {
defer close(two)
for i := 0; i < 10; i++ {
<-one
fmt.Println("2")
two <- struct{}{}
}
}

func PrintThere() {
defer close(three)
for i := 0; i < 10; i++ {
<-two
fmt.Println("3")
three <- struct{}{}
}
done <- struct{}{}
}

func main() {
defer close(done)
three <- struct{}{}
go PrintOne()
go PrintTwo()
go PrintThere()
<-done
}

两个协程交替打印1到20

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package main

import (
"fmt"
"sync"
"time"
)


func main() {
wg := &sync.WaitGroup{}
ch1 := make(chan int)
ch2 := make(chan int)

wg.Add(2)
go say(wg, ch2, ch1)
go say1(wg, ch1, ch2)
wg.Wait()
time.Sleep(1 * time.Second)
}

func say(wg *sync.WaitGroup, ch2 chan int, ch1 chan int) {
defer wg.Done()
for i := 1; i <= 10; i++ {
ch2 <- 2*i - 1
fmt.Println(<-ch1)
}
}

func say1(wg *sync.WaitGroup, ch1 chan int, ch2 chan int) {
defer wg.Done()
for i := 1; i <= 10; i++ {
fmt.Println(<-ch2)
ch1 <- 2 * i
}
}

两个协程交替打印一个数组,使数组中的数据按顺序输出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
package main

import (
"fmt"
"sync"
)

var wg sync.WaitGroup

func worker1(ch chan struct{}, data chan interface{}) {
defer wg.Done()

for {
select {
case <-ch:
tmp := <-data
//更清楚看见输出!
time.Sleep(time.Second * 1)
fmt.Printf("%v ", tmp)
ch <- struct{}{} //发送信号
default:
}
}
}

func worker2(ch chan struct{}, data chan interface{}) {
defer wg.Done()
ch <- struct{}{} // 先发送信号

for {
select {
case <-ch:
tmp := <-data
time.Sleep(time.Second * 1)
fmt.Printf("%v ", tmp)
ch <- struct{}{} //发送信号
default:
}
}
}

func main() {
done := make(chan struct{})
data := make(chan interface{})
wg.Add(1)
go func() {
defer wg.Done()
/*s1 := []int{1, 2, 3, 4, 5, 6}*/
s1 := []string{"a", "b", "c", "d", "e", "f"}
for _, v := range s1 {
data <- v
}
}()
wg.Add(2)
go worker1(done, data)
go worker2(done, data)

wg.Wait()
}

调度模型

P和M的数量一定是1:1吗?如果一个G阻塞了会怎么样?

不一定,M必须持有P才可以执行代码,跟系统中的其他线程一样,M也会被系统调用阻塞。P的个数在启动程序时决定,默认情况下等于CPU的核数,可以使用环境变量GOMAXPROCS或在程序中使用runtime.GOMAXPROCS()方法指定P的个数。
M的个数通常稍大于P的个数,因为除了运行Go代码,runtime包还有其他内置任务需要处理。

Golang GMP模型,全局队列中的G会不会饥饿,为什么?P的数量是多少?能修改吗?M的数量是多少?

  1. 全局队列中的G不会饥饿。 因为线程想运行任务就得获取P,从P的本地队列获取G,P队列为空时,M也会尝试从全局队列拿一批G放到P的本地队列,或从其他P的本地队列偷一半放到自己P的本地队列。
    M运行G,G执行之后,M会从P获取下一个G,不断重复下去。所以全局队列中的G总是能被消费掉.
  2. P的数量可以理解为最大为本机可执行的cpu的最大数量。
    通过runtime.GOMAXPROCS(runtime.NumCPU())设置。
    runtime.NumCPU()方法返回当前进程可用的逻辑cpu数量。

服务器能开多少个P由什么决定

  • P的个数在程序启动时决定,默认情况下等同于CPU的核数
  • 程序中可以使用 runtime.GOMAXPROCS() 设置P的个数,在某些IO密集型的场景下可以在一定程度上提高性能。
  • 一般来讲,程序运行时就将GOMAXPROCS大小设置为CPU核数,可让Go程序充分利用CPU。在某些IO密集型的应用里,这个值可能并不意味着性能最好。理论上当某个Goroutine进入系统调用时,会有一个新的M被启用或创建,继续占满CPU。但由于Go调度器检测到M被阻塞是有一定延迟的,也即旧的M被阻塞和新的M得到运行之间是有一定间隔的,所以在IO密集型应用中不妨把GOMAXPROCS设置的大一些,或许会有好的效果。

服务器能开多少个M由什么决定

  • 由于M必须持有一个P才可以运行Go代码,所以同时运行的M个数,也即线程数一般等同于CPU的个数,以达到尽可能的使用CPU而又不至于产生过多的线程切换开销。
  • P的个数默认等于CPU核数,每个M必须持有一个P才可以执行G,一般情况下M的个数会略大于P的个数,这多出来的M将会在G产生系统调用时发挥作用。
  • Go语⾔本身是限定M的最⼤量是10000,可以在runtime/debug包中的SetMaxThreads函数来修改设置

M和P是怎么样的关系

  • M必须拥有P才可以执行G中的代码,理想情况下一个M对应一个P,P含有包含多个G的队列,P会周期性地将G调度到M种执行。

GMP调度过程中存在哪些阻塞

  • I/O,select
  • block on syscall
  • channel
  • 等待锁
  • runtime.Gosched()

GMP当一个G堵塞时,G、M、P会发生什么

当g阻塞时,p会和m解绑,去寻找下一个可用的m。
g&m在阻塞结束之后会优先寻找之前的p,如果此时p已绑定其他m,当前m会进入休眠,g以可运行的状态进入全局队列

sysmon 有什么作用

sysmon 也叫监控线程,变动的周期性检查,好处

  • 释放闲置超过5 分钟的 span 物理内存;
  • 如果超过2 分钟没有垃圾回收,强制执行;
  • 将长时间未处理的 netpoll 添加到全局队列;
  • 向长时间运行的 G 任务发出抢占调度(超过10ms的g,会进行retake);
  • 收回因 syscall 长时间阻塞的P;

GMP模型里为什么要有P?

https://mp.weixin.qq.com/s/SEE2TUeZQZ7W1BKkmnelAA

为什么GMP模型会更快

谈到 Go 语言调度器,绕不开操作系统,进程与线程这些概念。线程是操作系统调度的最小单元,而 Linux 调度器并不区分进程和线程的调度,它们在不同操作系统上的实现也不同,但是在大多数实现中线程属于进程。多个线程可以属于同一个进程并共享内存空间。因为多线程不需要创建新的虚拟内存空间,所以它们也不需要内存管理单元处理上下文的切换,线程之间的通信也正是基于共享内存进行的,与重量级进程相比,线程显得比较轻量。虽然线程比较轻量,但是在调度时也有比较大的额外开销。每个线程会都占用 1MB 以上的内存空间,在切换线程时不止会消耗较多内存,恢复寄存器中的内存还需要向操作系统申请或者销毁资源。每一个线程上下文的切换都需要消耗 1 us 的时间,而 Go 调度器对 Goroutine 的上下文切换越为 0.2us,减少了 80% 的额外开销。Go 语言的调度器使用与 CPU 数量相等的线程来减少线程频繁切换带来的内存开销,同时在每一个线程上执行额外开销更低的 Goroutine 来降低操作系统和硬件的负载。

同时启动了一万个G,如何调度?

首先一万个G会按照P的设定个数,尽量平均地分配到每个P的本地队列中。如果所有本地队列都满了,那么剩余的G则会分配到GMP的全局队列上。接下来便开始执行GMP模型的调度策略:

  • 本地队列轮转:每个P维护着一个包含G的队列,不考虑G进入系统调用或IO操作的情况下,P周期性的将G调度到M中执行,执行一小段时间,将上下文保存下来,然后将G放到队列尾部,然后从队首中重新取出一个G进行调度。
  • 系统调用:上面说到P的个数默认等于CPU核数,每个M必须持有一个P才可以执行G,一般情况下M的个数会略大于P的个数,这多出来的M将会在G产生系统调用时发挥作用。当该G即将进入系统调用时,对应的M由于陷入系统调用而进被阻塞,将释放P,进而某个空闲的M1获取P,继续执行P队列中剩下的G。
  • 工作量窃取:多个P中维护的G队列有可能是不均衡的,当某个P已经将G全部执行完,然后去查询全局队列,全局队列中也没有新的G,而另一个M中队列中还有3很多G待运行。此时,空闲的P会将其他P中的G偷取一部分过来,一般每次偷取一半。

Go如何调度,假设4核的cpu应该有几个M,那能有几个groutinue,groutinue数量的上限是多少?

协程的数量,理论上没有上限
M的最大数量一万
4核的cpu默认最大并发的M=4

GMP并发模型,Goroutine切换的时候上下文环境放在哪里

协程切换时候的上下文存储在处理器中

Golang调度能不能不要p

1.介绍golang调度器中P是什么?

Processor的简称,处理器,上下文。

2.简述p的功能与为什么必须要P

它的主要用途就是用来执行goroutine的,它维护了一个goroutine队列,即runqueue。Processor是让咱们从N:1调度到M:N调度的重要部分。

Goroutine的调度是出现在什么情况下,调度时做了什么

Go调度器会在以下三种情况对goroutine进行调度:

  1. goroutine执行某个操作因条件不满足需要等待而发生的调度。
  2. goroutine主动调用Gosched()让出CPU而发生的调度。
  3. goroutine运行时间太长或长时间处于系统调用中,被调度器剥夺运行权而发生的调度。

调度器一般做以下事:

  • 协程调度。因为系统内核不能再决定协程的切换,那么协程的切换时间点则是由程序内部的调度器决定的。
  • 垃圾回收。垃圾回收的必要条件是内存位于一致状态,这就需要暂停所有的线程,如果交给系统去做,那么会暂停所有的线程使其一致。程序自身的调度器知道什么时候内存位于一致状态,那么就没有必要暂停所有运行的协程。

为什么P的local queue可无锁访问,任务窃取的时候要加锁吗?

绑定在P上的local queue是顺序执行的,不存在执行状态的G协程抢占,所以可以无锁访问。

任务窃取也是窃取其他P上等待状态的G协程,所以也可以不用加锁。

一个goroutine sleep了,操作系统是怎么唤醒的

  1. goroutine唤醒
    goroutine的唤醒涉及到一个很重要的函数(goready),它的作用就是唤醒waiting状态的goroutine.
    通过systemstack切到g0栈,在g0栈上发起调度.
    获取goroutine的状态.
    将waiting状态的goroutine切换到runable状态
    尝试唤起一个p来执行当前goroutine
  2. 注释: go程序中,每个M都会绑定一个叫g0的初代goroutine,它在M的创建的时候创建,g0的主要工作就是goroutine的调度、垃圾回收等.

Go的协程可以只挂在一个线程上面吗

不能。可以保证一个P,用runtime.GOMAXPROCS(1)设置处理器P只启动一个,但程序初始化的线程M一般不会只有一个。

内存管理

Golang的内存模型,为什么小对象多了会造成gc压力。

通常小对象过多会导致GC三色法消耗过多的GPU。优化思路是,减少对象分配。

Go语言什么时候垃圾回收,写代码的时候如何减少对象分配

当 goroutine 申请新的内存管理单元时触发垃圾回收。写代码的时候如何减少对象分配,这是一个关于性能的问题,例如如果需要把数字转换成字符串,使用 strconv.Itoa() 比 fmt.Sprintf() 要快一倍左右。如果需要把数字转换成字符串,使用 strconv.Itoa() 比 fmt.Sprintf() 要快一倍左右。这里就不一一展开了。

怎么避免内存逃逸?

https://mp.weixin.qq.com/s/VzRTHz1JaDUvNRVB_yJa1A

简单聊聊内存逃逸?

https://mp.weixin.qq.com/s/wJmztRMB1ZAAIItyMcS0tw

给大家丢脸了,用了三年Golang,我还是没答对这道内存泄漏题

https://mp.weixin.qq.com/s/-agtdhlW7Yj7S88a0z7KHg

Go内存泄漏?不是那么简单

https://colobu.com/2019/08/28/go-memory-leak-i-dont-think-so/

Go内存分配,和 tcmalloc 的区别?

go 内存分配核心思想就是把内存分为多级管理,从而降低锁的粒度。它将可用的堆内存采用二级分配的方式进行管理:每个线程都会自行维护一个独立的内存池,进行内存分配时优先从该内存池中分配,当内存池不足时才会向全局内存池申请,以避免不同线程对全局内存池的频繁竞争。

  • Go在程序启动时,会向操作系统申请一大块内存,之后自行管理。
  • Go内存管理的基本单元是mspan,它由若干个页组成,每种mspan可以分配特定大小的object。
  • mcache, mcentral, mheap是Go内存管理的三大组件,层层递进。mcache管理线程在本地缓存的mspan;mcentral管理全局的mspan供所有线程使用;mheap管理Go的所有动态分配内存。
  • 极小的对象(<=16B)会分配在一个object中,以节省资源,使用tiny分配器分配内存;一般对象(16B-32KB)通过mspan分配内存;大对象(>32KB)则直接由mheap分配内存。

tcmalloc
tcmalloc 是google开发的内存分配算法库,最开始它是作为google的一个性能工具库 perftools 的一部分。TCMalloc是用来替代传统的malloc内存分配函数。它有减少内存碎片,适用于多核,更好的并行性支持等特性。
TC就是Thread Cache两英文的简写。它提供了很多优化,如:
1.TCMalloc用固定大小的page(页)来执行内存获取、分配等操作。这个特性跟Linux物理内存页的划分是不是有同样的道理。
2.TCMalloc用固定大小的对象,比如8KB,16KB 等用于特定大小对象的内存分配,这对于内存获取或释放等操作都带来了简化的作用。
3.TCMalloc还利用缓存常用对象来提高获取内存的速度。
4.TCMalloc还可以基于每个线程或者每个CPU来设置缓存大小,这是默认设置。
5.TCMalloc基于每个线程独立设置缓存分配策略,减少了多线程之间锁的竞争。

Go中的内存分类并不像TCMalloc那样分成小、中、大对象,但是它的小对象里又细分了一个Tiny对象,Tiny对象指大小在1Byte到16Byte之间并且不包含指针的对象。小对象和大对象只用大小划定,无其他区分。
Go内存管理与tcmalloc最大的不同在于,它提供了逃逸分析和垃圾回收机制。

Golang 里怎么避免内存逃逸?

  1. 不要盲目使用变量指针作为参数,虽然减少了复制,但变量逃逸的开销更大。
  2. 预先设定好slice长度,避免频繁超出容量,重新分配。
  3. 一个经验是,指针指向的数据大部分在堆上分配的,请注意。

出现内存逃逸的情况有:

1.发送指针或带有指针的值到channel,因为编译时候无法知道那个goroutine会在channel接受数据,编译器无法知道什么时候释放。

2.在一个切片上存储指针或带指针的值。比如[]*string,导致切片内容逃逸,其引用值一直在堆上。

3.切片的append导致超出容量,切片重新分配地址,切片背后的存储基于运行时的数据进行扩充,就会在堆上分配。

4.调用接口类型时,接口类型的方法调用是动态调度,实际使用的具体实现只能在运行时确定,如一个接口类型为io.Reader的变量r,对r.Read(b)的调用将导致r的值和字节片b的后续转义并因此分配到堆上。

5.在方法内把局部变量指针返回,被外部引用,其生命周期大于栈,导致内存溢出。

Go语言中的堆和栈

栈主要用来存储值类型的数据,如整数、浮点数、布尔值等。因为值类型的数据大小是固定的,所以可以直接分配在栈上,访问速度非常快。

堆主要用来存储引用类型的数据,如字符串、切片、字典等。因为引用类型的数据大小是不固定的,所以需要动态分配内存,通常在堆上进行。同时,由于引用类型的数据通常需要共享和修改,因此使用指针来进行引用和操作,从而避免了复制大量的数据。

可以看出,栈的性能会更好——不需要额外的垃圾回收机制(离开该作用域,它们的内存就会被自动回收),CPU可以连续缓存(内存空间是连续的)。堆是通过GC回收内存的。

并发编程

下面哪个不是Go语言中的并发原语?

  • Channel
  • Mutex
  • WaitGroup
  • Semaphore

什么是 sync.Once

Once 可以用来执行且仅仅执行一次动作,常常用于单例对象的初始化场景。

Once 常常用来初始化单例资源,或者并发访问只需初始化⼀次的共享资源,或者在测试的时候初始化⼀次测试资源。

sync.Once 只暴露了⼀个⽅法 Do,你可以多次调⽤ Do ⽅法,但是只有第⼀次调⽤ Do 方法时 f 参数才会执行,这⾥的 f 是⼀个无参数无返回值的函数。

源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
type Once struct {
m Mutex
done uint32
}

func (o *Once) Do(f func()) {
if atomic.LoadUint32(&o.done) == 1 {
return
}
o.m.Lock()
defer o.m.Unlock()
if o.done == 0 {
defer atomic.StoreUint32(&o.done, 1)
f()
}
}

Golang除了goroutine还有什么处理并发的方法

处理并发的方法,主要使用goroutine,还可以使用channel + goroutine 以及使用 sync 包提供的并发锁以及经常用的信号量机制.

select可以用于什么

Go 的通道有两种操作方式,一种是带 range 子句的 for 语句,另一种则是 select 语句,它是专门为了操作通道而存在的。这里主要介绍 select 的用法。

select的语法如下:

1
2
3
4
5
6
7
8
9
select {
case <-ch1 :
statement(s)
case ch2 <- 1 :
statement(s)

default : /* 可选 */
statement(s)
}

这里要注意:

  • 每个 case 都必须是一个通信。由于 select 语句是专为通道设计的,所以每个 case 表达式中都只能包含操作通道的表达式,比如接收表达式。
  • 如果有多个 case 都可以运行,select 会随机公平地选出一个执行,其他不会执行。
  • 如果多个 case 都不能运行,若有 default 子句,则执行该语句,反之,select 将阻塞,直到某个 case 可以运行。
  • 所有 channel 表达式都会被求值。
  • select机制⽤来处理异步IO问题。
  • select机制最⼤的⼀条限制就是每个case语句⾥必须是⼀个IO操作。

实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package main

import (
"fmt"
"math/rand"
)

func main() {
// 准备好几个通道。
intChannels := [5]chan int{
make(chan int, 1),
make(chan int, 1),
make(chan int, 1),
make(chan int, 1),
make(chan int, 1)
}
// 随机选择一个通道,并向它发送元素值。
index := rand.Intn(5)
fmt.Printf("The index: %d", index)
intChannels[index] <- index
// 哪一个通道中有可取的元素值,哪个对应的分支就会被执行。
select {
case <-intChannels[0]:
fmt.Println("The first candidate case is selected.")
case <-intChannels[1]:
fmt.Println("The second candidate case is selected.")
case elem := <-intChannels[2]:
fmt.Printf("The third candidate case is selected. The element is %d.", elem)
default:
fmt.Println("No candidate case is selected!")
}
}

select死锁
select使用不当会发生死锁。如果通道没有数据发送,但 select 中有存在接收通道数据的语句,将发生死锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func main() {  
ch := make(chan string)
select {
case <-ch:
}
}
/*
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan receive]:
main.main()
/workspace/src/test.go:5 +0x52
exit status 2
*/
//可以添加 default 语句来避免产生死锁。

空 select{}

1
2
3
4
5
6
7
8
9
10
11
func main() {  
select {}
}

/*
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [select (no cases)]:
main.main()
/workspace/src/test.go:3 +0x20
exit status 2
*/

select和for结合使用

select 语句只能对其中的每一个case表达式各求值一次。所以,如果想连续或定时地操作其中的通道的话,就需要通过在for语句中嵌入select语句的方式实现。

1
2
3
4
5
6
7
8
9
10
11
func main() {
tick := time.Tick(time.Second)
for {
select {
case t := <-tick:
fmt.Println(t)
break
}
}
fmt.Println("end")
}

你会发现 break 只跳出了 select,无法跳出for。 解决办法有两种:
使用 goto 跳出循环

1
2
3
4
5
6
7
8
9
10
11
12
13
func main() {
tick := time.Tick(time.Second)
for {
select {
case t := <-tick:
fmt.Println(t)
//跳到指定位置
goto END
}
}
END:
fmt.Println("end")
}

使用标签

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func main() {
tick := time.Tick(time.Second)
//这是标签
FOREND:
for {
select {
case t := <-tick:
fmt.Println(t)
//跳出FOREND标签
break ForEnd
}
}
END:
fmt.Println("end")
}

select实现超时机制
主要使用的 time.After 实现超时控制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func main() {
ch := make(chan int)
quit := make(chan bool)

go func() {
for {
select {
case num := <-ch: //如果有数据,下面打印。但是有可能ch一直没数据
fmt.Println("num = ", num)
case <-time.After(3 * time.Second): //上面的ch如果一直没数据会阻塞,那么select也会检测其他case条件,检测到后3秒超时
fmt.Println("超时")
quit <- true //写入
}
}

}()

for i := 0; i < 5; i++ {
ch <- i
time.Sleep(time.Second)
}
<-quit //这里暂时阻塞,直到可读
fmt.Println("程序结束")
}

执行后,可以观察到:依次打印出0-4,几秒过后打印出“超时”和“程序结束”,打印结果如下:

1
2
3
4
5
6
7
num =  0
num = 1
num = 2
num = 3
num = 4
超时
程序结束

select的底层原理:Go select使用与底层原理

WaitGroup的坑

① Add一个负数

如果计数器的值小于0会直接panic

② Add在Wait之后调用

比如一些子协程开头调用Add结束调用Wait,这些 Wait无法阻塞子协程。正确做法是在开启子协程之前先Add特定的值。

③ 未置为0就重用

WaitGroup可以完成一次编排任务,计数值降为0后可以继续被其他任务所用,但是不要在还没使用完的时候就用于其他任务,这样由于带着计数值,很可能出问题。

④ 复制waitgroup

WaitGroup有nocopy字段,不能被复制。也意味着WaitGroup不能作为函数的参数。

深入理解sync.Waitgroup

https://juejin.cn/post/7181812988461252667

Data Race问题怎么解决?能不能不加锁解决这个问题?

image-20230724164303002

runtime提供常见的方法

  1. **Gosched()**:让当前线程让出 cpu 以让其它线程运行,它不会挂起当前线程,因此当前线程未来会继续执行。
  2. **NumCPU()**:返回当前系统的 CPU 核数量。
  3. **GOMAXPROCS()**:设置最大的可同时使用的 CPU 核数。
    通过runtime.GOMAXPROCS函数,应用程序可以设置运行时系统中的 P 最大数量。注意,如果在运行期间设置该值的话,会引起“Stop the World”。所以,应在应用程序最早期调用,并且最好是在运行Go程序之前设置好操作程序的环境变量GOMAXPROCS,而不是在程序中调用runtime.GOMAXPROCS函数。

无论我们传递给函数的整数值是什么值,运行时系统的P最大值总会在1~256之间。

go1.8 后,默认让程序运行在多个核上,可以不用设置了。

go1.8 前,还是要设置一下,可以更高效的利用 cpu。

  1. Goexit():退出当前 goroutine(但是defer语句会照常执行)。
  2. NumGoroutine:返回正在执行和排队的任务总数。
    runtime.NumGoroutine函数在被调用后,会返回系统中的处于特定状态的 Goroutine 的数量。这里的特定状态是指GrunnableGruningGsyscallGwaition。处于这些状态的Goroutine即被看做是活跃的或者说正在被调度。

注意:垃圾回收所在Goroutine的状态也处于这个范围内的话,也会被纳入该计数器。

  1. GOOS:查看目标操作系统。很多时候,我们会根据平台的不同实现不同的操作,就可以用GOOS来查看自己所在的操作系统。
  2. runtime.GC:会让运行时系统进行一次强制性的垃圾收集。
    强制的垃圾回收:不管怎样,都要进行的垃圾回收。非强制的垃圾回收:只会在一定条件下进行的垃圾回收(即运行时,系统自上次垃圾回收之后新申请的堆内存的单元(也成为单元增量)达到指定的数值)。
  3. **GOROOT()**:获取 goroot 目录。
  4. runtime.LockOSThread 和 runtime.UnlockOSThread 函数:前者调用会使调用他的 Goroutine 与当前运行它的M锁定到一起,后者调用会解除这样的锁定。

Go语言怎么做的连接复用,怎么支持的并发请求,Go的netpoll是怎么实现的像阻塞read一样去使用底层的非阻塞read

Golang的IO多路复用的netpoll模型

  1. go语言怎么做的连接复用

    go语言中IO多路复用使用netpoll模型

    netpoll本质上是对 I/O 多路复用技术的封装,所以自然也是和epoll一样脱离不了下面几步:

    1. netpoll创建及其初始化;
    2. 向netpoll中加入待监控的任务;
    3. 从netpoll获取触发的事件;

    在go中对epoll提供的三个函数进行了封装

    1
    2
    3
    func netpollinit()
    func netpollopen(fd uintptr, pd *pollDesc) int32
    func netpoll(delay int64) gList

    netpollinit函数负责初始化netpoll;

    netpollopen负责监听文件描述符上的事件;

    netpoll会阻塞等待返回一组已经准备就绪的 Goroutine;

  2. go语言怎么支持的并发请求

    Go中有goroutine,所以可以采用多协程来解决并发问题。accept连接后,将连接丢给goroutine处理后续的读写操作。在开发者看到的这个goroutine中业务逻辑是同步的,也不用考虑IO是否阻塞。

Golang的协程通信有哪些方式

1)共享内存

  • 共享内存是指多个协程直接访问共享变量的方式,这种方式不需要显式地进行通信,但需要考虑并发访问时的竞态问题,需要使用互斥锁等机制来确保同步和一致性。

2)通道

  • 通道是Go语言中一个重要的并发原语,它是一种线程安全的、带缓冲的FIFO队列。通道支持阻塞式读写,可以用来在不同的协程之间传递数据,也可以用来进行同步操作。通道在多个协程之间传递数据时,会自动进行同步,不需要程序员显式地进行加锁和解锁操作。

3)选择器

  • 选择器是Go语言中的一种控制结构,可以同时监听多个通道的操作,并选择其中一个可以进行操作的通道。选择器可以用来实现非阻塞的通信操作,避免了因等待某个通道操作而导致的阻塞。选择器通常与通道配合使用,用于多个协程之间的协作和同步。

4)条件变量(Cond)

  • 条件变量用于在协程之间进行复杂的通信和协调。在 Go 中,可以使用sync包中的Cond类型来实现条件变量。它通常与互斥锁一起使用,以便协程可以在特定条件下等待或被唤醒。

5)原子操作(Atomic Operations)

  • Go 语言提供了sync/atomic包,用于执行原子操作,这些操作通常用于共享资源的更新,以避免竞态条件。原子操作可以用于对变量的读取、写入、加法等操作,而不需要额外的锁定。

总之,Go协程之间的通信是非常重要的,不同的应用场景需要选择不同的通信方式,以确保程序的正确性和性能。共享内存通常用于需要高性能的并发场景,但需要注意线程安全和同步问题;通道是一种简单、安全、高效的通信方式,适用于大多数并发场景;选择器则适用于多通道协作和同步的场景。

Go为啥使用CSP模型来实现并发?

Go 语言使用CSP(Communicating Sequential Processes,通信顺序进程)模型来实现并发,这是由Go语言设计者选择的一种并发模型,有以下几个重要的原因:

  1. 简单性和清晰性:CSP模型提供了一种清晰且直观的方式来表达并发程序。它基于协程之间的通信来进行协作,通过通道(channel)进行消息传递,使得并发程序的结构和逻辑更加简单和可读。
  2. 避免共享状态:CSP模型强调避免共享状态,而是通过通信共享数据。共享状态是许多并发程序中的错误和难点来源之一,而CSP模型可以减少竞态条件(race condition)等问题的出现。
  3. 安全性:Go的CSP模型通过通道提供了一种安全的并发机制。通道的发送和接收操作都是原子的,不需要额外的锁定,因此减少了程序中出现的锁定问题,如死锁和竞态条件。
  4. 可扩展性:CSP模型可以轻松扩展到大量的协程,因为通道和协程的创建成本相对较低。这使得Go非常适合构建高并发的系统,如Web服务器、分布式系统和网络服务。
  5. 编译器和运行时支持:Go编译器和运行时系统针对CSP模型进行了优化。Go的并发原语在语言级别得到支持,而不是通过库的方式实现,这使得并发编程更加容易。

总之,Go选择CSP模型是为了提供一种简单、安全、高效和可扩展的并发编程模型,以便开发者能够更轻松地构建并发程序,同时避免共享状态和典型的并发问题。这使得Go成为了一个流行的选择,特别适用于需要高度并发性能的应用程序和系统。

sync.Pool 有什么用

对于很多需要重复分配、回收内存的地方,sync.Pool是一个很好的选择。频繁地分配、回收内存会给GC带来一定的负担,严重的时候会引起CPU的毛刺,而sync.Pool可以将暂时不用的对象缓存起来,待下次需要的时候直接使用,不用再次经过内存分配,复用对象的内存,减轻GC的压力,提升系统的性能。

sync.Pool底层原理

有没有什么线程安全的办法?

在Go 语言中,线程安全一般指协程安全,因为 Go 一般使用协程进行调度;而 Go 中为了保证其协程安全,有以下几种机制:

1、互斥锁:在 Go 的标准库中有 sync 包,sync.Mutex 就是解决并发冲突导致的安全性问题的一种方式。

2、读写锁:是在互斥锁上的进一步升级版本,主要为了解决并发多写少读、少写多读两种高并发的情况

3、如果不是需要强制使用同一个对象,那么也可以采用创建对象副本的方式,每个协程独占一个对象,相互之间不关联,但是这显然不符合我们的要求。

综上,使用互斥锁或者读写锁就能很好的解决问题。

  • 标题: Golang八股文汇总
  • 作者: Olivia的小跟班
  • 创建于 : 2023-09-21 15:49:10
  • 更新于 : 2023-10-17 11:54:14
  • 链接: https://www.youandgentleness.cn/2023/09/21/Golang八股文汇总/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论
此页目录
Golang八股文汇总