Go 面试题持续整理

Go 面试题持续整理

Q: =:= 的区别?

=是赋值变量,:=是定义变量

Q: 指针的作用

一个指针可以指向任意变量的地址,它所指向的地址在32位或64位机器上分别固定占4或8个字节。指针的作用有:

  • 获取变量的值
import fmt
 
 func main(){
  a := 1
  p := &a//取址&
  fmt.Printf("%d\n", *p);//取值*
 }
  • 改变变量的值
// 交换函数
 func swap(a, b *int) {
     *a, *b = *b, *a
 }
  • 用指针替代值传入函数,比如类的接收器就是这样的。
type A struct{}
 
 func (a *A) fun(){}

Q: Go 允许多个返回值吗?

可以。通常函数除了一般返回值还会返回一个error。

Q: Go 有异常类型吗?

有。Go用error类型代替try...catch语句,这样可以节省资源。同时增加代码可读性:

_,err := errorDemo()
  if err!=nil{
   fmt.Println(err)
   return
  }

也可以用errors.New()来定义自己的异常。errors.Error()会返回异常的字符串表示。只要实现error接口就可以定义自己的异常,

type errorString struct {
  s string
 }
 
 func (e *errorString) Error() string {
  return e.s
 }
 
 // 多一个函数当作构造函数
 func New(text string) error {
  return &errorString{text}
 }

Q: 什么是协程(Goroutine)

协程是用户态轻量级线程,它是线程调度的基本单位。通常在函数前加上go关键字就能实现并发。一个Goroutine会以一个很小的栈启动2KB或4KB,当遇到栈空间不足时,栈会自动伸缩, 因此可以轻易实现成千上万个goroutine同时启动。

Q: 如何高效地拼接字符串

拼接字符串的方式有:"+", fmt.Sprintf, strings.Builder, bytes.Buffer, strings.Join

1 "+"

使用 +操作符进行拼接时,会对字符串进行遍历,计算并开辟一个新的空间来存储原来的两个字符串。

2 fmt.Sprintf

由于采用了接口参数,必须要用反射获取值,因此有性能损耗。

3 strings.Builder:

用WriteString()进行拼接,内部实现是指针+切片,同时String()返回拼接后的字符串,它是直接把[]byte转换为string,从而避免变量拷贝。

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

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

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

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

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

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

4 bytes.Buffer

bytes.Buffer是一个一个缓冲 byte类型的缓冲器,这个缓冲器里存放着都是 byte。使用方式如下:

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

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方法是如何拼接字符串的:

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类型依旧使用了标准类型,所以会发生内存分配:

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

5 strings.join

strings.join也是基于 strings.builder来实现的,并且可以自定义分隔符,代码如下:

func Join(elems []string, sep string) string {
    switch len(elems) {
    case 0:
        return ""
    case 1:
        return elems[0]
    }
    n := len(sep) * (len(elems) - 1)
    for i := 0; i < len(elems); i++ {
        n += len(elems[i])
    }

    var b Builder
    b.Grow(n)
    b.WriteString(elems[0])
    for _, s := range elems[1:] {
        b.WriteString(sep)
        b.WriteString(s)
    }
    return b.String()
}

唯一不同在于在 join方法内调用了 b.Grow(n)方法,这个是进行初步的容量分配,而前面计算的n的长度就是我们要拼接的slice的长度,因为我们传入切片长度固定,所以提前进行容量分配可以减少内存分配,很高效。

func main(){
	a := []string{"a", "b", "c"}
	//方式1:
	ret := a[0] + a[1] + a[2]
	//方式2:
	ret := fmt.Sprintf(a[0],a[1],a[2])
	//方式3:
	var sb strings.Builder
	sb.WriteString(a[0])
	sb.WriteString(a[1])
	sb.WriteString(a[2])
	ret := sb.String()
	//方式4:
	buf := new(bytes.Buffer)
	buf.Write(a[0])
	buf.Write(a[1])
	buf.Write(a[2])
	ret := buf.String()
	//方式5:
	ret := strings.Join(a,"")
}

总结:

strings.Join ≈ strings.Builder > bytes.Buffer > "+" > fmt.Sprintf

参考资料:字符串拼接性能及原理 | Go 语言高性能编程 | 极客兔兔

Q: 什么是 rune 类型

rune是int32的别名,用来区分字符值和整数值。比如utf-8汉字占3个字节,按照一般方法遍历汉字字符串得到的是乱码,这个时候要将字符串转换为rune:

sample := "我爱GO"
	runeSamp := []rune(sample)
	runeSamp[0] = '你'
	fmt.Println(string(runeSamp))

Q: 如何判断 map 中是否包含某个 key ?

var sample map[int]int
if _, ok := sample[10];ok{

}else{

}

Q: Go 支持默认参数或可选参数吗?

不支持。但是可以利用结构体参数,或者...传入参数切片。

Q: defer 的执行顺序

defer执行顺序和调用顺序相反,类似于栈先进后出。

Q: 如何交换 2 个变量的值?

对于变量而言 a,b = b,a; 对于指针而言 *a,*b = *b, *a

Q: Go 语言 tag 的用处?

tag可以为结构体成员提供属性。常见的:

  1. json序列化或反序列化时字段的名称
  2. db: sqlx模块中对应的数据库字段名
  3. form: gin框架中对应的前端的数据字段名
  4. binding: 搭配 form 使用, 默认如果没查找到结构体中的某个字段则不报错值为空, binding为 required 代表没找到返回错误给前端

Q: 如何获取一个结构体的所有tag?

利用反射:

type Author struct {
	Name         int      `json:Name`
	Publications []string `json:Publication,omitempty`
}

func main() {
	t := reflect.TypeOf(Author{})
	for i := 0; i < t.NumField(); i++ {
		name := t.Field(i).Name
		s, _ := t.FieldByName(name)
		fmt.Println(s.Tag)
	}
}

Q: 如何判断 2 个字符串切片(slice) 是相等的?

reflect.DeepEqual()

Q: 结构体打印时,%v%+v 的区别

%v输出结构体各成员的值;

%+v输出结构体各成员的名称和值;

%#v输出结构体名称和结构体各成员的名称和值

Q: Go 语言中如何表示枚举值(enums)?

在常量中用iota可以表示枚举。iota从0开始。

const (
	B = 1 << (10 * iota)
	KiB 
	MiB
	GiB
	TiB
	PiB
	EiB
)

Q: 空 struct{} 的用途

    1. 用map模拟一个set,那么就要把值置为struct{},struct{}本身不占任何空间,可以避免任何多余的内存分配。
    2. 有时候给通道发送一个空结构体,channel<-struct{}{},也是节省了空间。
    3. 仅有方法的结构体

Q: go里面的int和int32是同一个概念吗?

不是一个概念!千万不能混淆。go语言中的int的大小是和操作系统位数相关的,如果是32位操作系统,int类型的大小就是4字节。如果是64位操作系统,int类型的大小就是8个字节。除此之外uint也与操作系统有关。

int8占1个字节,int16占2个字节,int32占4个字节,int64占8个字节。

Q: init() 函数是什么时候执行的?

在main函数之前执行。

Q: 如何知道一个对象是分配在栈上还是堆上?

Go和C++不同,Go局部变量会进行逃逸分析。如果变量离开作用域后没有被引用,则优先分配到栈上,否则分配到堆上,实用语句

go build -gcflags '-m -m -l' xxx.go.

关于逃逸的可能情况:变量大小不确定,变量类型不确定,变量分配的内存超过用户栈最大值,暴露给了外部指针。

Q: 2 个 interface 可以比较吗 ?

不能直接比较,只有同时为nil且类型相同才可以比较。

Q: 2 个 nil 可能不相等吗?

可能不等。interface在运行时绑定值,只有值为nil接口值才为nil,但是与指针的nil不相等。举个例子:

var p *int = nil
var i interface{} = nil
if(p == i){
	fmt.Println("Equal")
}

两者并不相同。总结:两个nil只有在类型相同时才相等

Q: 简述 Go 语言GC(垃圾回收)的工作原理

垃圾回收机制是Go一大特(nan)色(dian)。Go1.3采用标记清除法, Go1.5采用三色标记法,Go1.8采用三色标记法+混合写屏障

*标记清除法*

分为两个阶段:标记和清除

标记阶段:从根对象出发寻找并标记所有存活的对象。

清除阶段:遍历堆中的对象,回收未标记的对象,并加入空闲链表。

缺点是需要暂停程序STW。

*三色标记法*

将对象标记为白色,灰色或黑色。

白色:不确定对象(默认色);黑色:存活对象。灰色:存活对象,子对象待处理。

标记开始时,先将所有对象加入白色集合(需要STW)。首先将根对象标记为灰色,然后将一个对象从灰色集合取出,遍历其子对象,放入灰色集合。同时将取出的对象放入黑色集合,直到灰色集合为空。最后的白色集合对象就是需要清理的对象。

这种方法有一个缺陷,如果对象的引用被用户修改了,那么之前的标记就无效了。因此Go采用了写屏障技术,当对象新增或者更新会将其着色为灰色。

一次完整的GC分为四个阶段:

  1. 准备标记(需要STW),开启写屏障。
  2. 开始标记
  3. 标记结束(STW),关闭写屏障
  4. 清理(并发)

基于插入写屏障和删除写屏障在结束时需要STW来重新扫描栈,带来性能瓶颈。混合写屏障分为以下四步:

  1. GC开始时,将栈上的全部对象标记为黑色(不需要二次扫描,无需STW);
  2. GC期间,任何栈上创建的新对象均为黑色
  3. 被删除引用的对象标记为灰色
  4. 被添加引用的对象标记为灰色

总而言之就是确保黑色对象不能引用白色对象,这个改进直接使得GC时间从 2s降低到2us。

Q: 函数返回局部变量的指针是否安全?

这一点和C++不同,在Go里面返回局部变量的指针是安全的。因为Go会进行逃逸分析,如果发现局部变量的作用域超过该函数则会把指针分配到堆区,避免内存泄漏。

Q: 非接口的任意类型 T() 都能够调用 *T 的方法吗?反过来呢?

一个T类型的值可以调用*T类型声明的方法,当且仅当T是可寻址的

反之:*T 可以调用T()的方法,因为指针可以解引用。

Q: go slice是怎么扩容的?

如果当前容量小于1024,则判断所需容量是否大于原来容量2倍,如果大于,当前容量加上所需容量;否则当前容量乘2。

如果当前容量大于1024,则每次按照1.25倍速度递增容量,也就是每次加上cap/4。

Q: 无缓冲的 channel 和有缓冲的 channel 的区别?

对于无缓冲区channel,一直是阻塞的;有缓冲区channel只有缓冲区满之后才会阻塞

有缓冲的channel是异步的,而无缓冲channel是同步的。

Q: 为什么有协程泄露(Goroutine Leak)?

协程泄漏是指协程创建之后没有得到释放。主要原因有:

  1. 缺少接收器,导致发送阻塞
  2. 缺少发送器,导致接收阻塞
  3. 死锁。多个协程由于竞争资源导致死锁。
  4. WaitGroup Add()和Done()不相等,前者更大。

Q: Go 可以限制运行时操作系统线程的数量吗? 常见的goroutine操作函数有哪些?

可以,使用runtime.GOMAXPROCS(num int)可以设置线程数目。该值默认为CPU逻辑核数,如果设的太大,会引起频繁的线程切换,降低性能。

runtime.Gosched(),用于让出CPU时间片,让出当前goroutine的执行权限,调度器安排其它等待的任务运行,并在下次某个时候从该位置恢复执行。
runtime.Goexit(),调用此函数会立即使当前的goroutine的运行终止(终止协程),而其它的goroutine并不会受此影响。runtime.Goexit在终止当前goroutine前会先执行此goroutine的还未执行的defer语句。请注意千万别在主函数调用runtime.Goexit,因为会引发panic。

Q: 如何控制协程数目。

对于协程,可以用带缓冲区的channel来控制,下面的例子是协程数为1024的例子

var wg sync.WaitGroup
ch := make(chan struct{}, 1024)
for i:=0; i<20000; i++{
	wg.Add(1)
	ch<-struct{}{}
	go func(){
		defer wg.Done()
		<-ch
	}
}
wg.Wait()

此外还可以用协程池:其原理无外乎是将上述代码中通道和协程函数解耦,并封装成单独的结构体。常见第三方协程池库,比如tunny等。

Q: go struct能不能比较

  • 相同struct类型的可以比较
  • 不同struct类型的不可以比较,编译都不过,类型不匹配

Q: new 和 make 的区别

首先我们得知道,Go分为数据类型分为值类型和引用类型,其中

值类型是 int、float、string、bool、struct和array,它们直接存储值,分配栈的内存空间,它们被函数调用完之后会释放

引用类型是 slice、map、chan和值类型对应的指针 它们存储是一个地址(或者理解为指针),指针指向内存中真正存储数据的首地址,内存通常在堆分配,通过GC回收

区别

new 的参数要求传入一个类型,而不是一个值,它会申请该类型的内存大小空间,并初始化为对应的零值,返回该指向类型空间的一个指针

make 也用于内存分配,但它只用于引用对象 slice、map、channel的内存创建,返回的类型是类型本身

Q: 值传递和指针传递有什么区别

值传递:会创建一个新的副本并将其传递给所调用函数或方法 指针传递:将创建相同内存地址的新副本

需要改变传入参数本身的时候用指针传递,否则值传递

另外,如果函数内部返回指针,会发生内存逃逸

Q: 聊聊内存逃逸分析

Go的逃逸分析是一种确定指针动态范围的方法,可以分析程序在哪些可以访问到指针,它涉及到指针分析和状态分析。

当一个变量(或对象)在子程序中被分配时,一个指向变量的指针可能逃逸到其它程序,或者去调用子程序。 如果使用尾递归优化(通常函数式编程是需要的),对象也可能逃逸到被调用程序中。如果一个子程序分配一个对象并返回一个该对象的指针,该对象可能在程序中的任何一个地方都可以访问。

如果指针存储在全局变量或者其它数据结构中,它们也可能发生逃逸,这种情况就是当前程序的指针逃逸。逃逸分析需要确定指针所有可以存储的地方,保证指针的生命周期只在当前进程或线程中。

导致内存逃逸的情况比较多(有些可能官方未能够实现精确的逃逸分析情况的bug),通常来讲就是如果变量的作用域不会扩大并且行为或者大小能够在其编译时确定,一般情况下都分配栈上,否则就可能发生内存逃逸到堆上。

引用内存逃逸的典型情况: * 在函数内部返回把局部变量指针返回 局部变量原本应该在栈中分配,在栈中回收。但是由于返回时被外部引用,因此生命周期大于栈,则溢出

  • 发送指针或带有指针的值到channel中 在编译时,是没办法知道哪个 goroutine 会在 channel上接受数据,所以编译器没办法知道变量什么时候释放。
  • 在一个切片上存储指针或带指针的值 一个典型的例子就是 []*string,这会导致切片的内容逃逸,尽管其后面的数组在栈上分配,但其引用值一定是在堆上
  • slice 的背后数组被重新分配了 因为 append 时可能会超出其容量( cap )。 slice 初始化的地方在编译时是可以知道的,它最开始会在栈上分配。如果切片背后的存储要基于运行时的数据进行扩充,就会在堆上分配。
  • 在 interface 类型上调用方法 在 interface 类型上调用方法都是动态调度的 —— 方法的真正实现只能在运行时知道。想像一个 io.Reader 类型的变量 r , 调用 r.Read(b) 会使得 r 的值和切片b 的背后存储都逃逸掉,所以会在堆上分配。

Q: 了解过golang的内存管理吗

内存池概述

Go语言的内存分配器采用了跟 tcmalloc 库相同的实现,是一个带内存池的分配器,底层直接调用操作系统的 mmpa 等函数。

作为一个内存池,它的基本部分包括以下几部分:

  • 首先,它会想操作系统申请大块内存,自己管理这部分内存
  • 然后,它是一个池子,当上层释放内存时它不实际归还给操作系统,而是放回池子重复利用
  • 接着,内存管理中必然会考虑的就是内存碎片问题,如果尽量避免内存碎片,提高内存利用率,像操作系统中的首次适应,最佳适应,最差适应,伙伴算法都是一些相关的知识背景。
  • 另外,Go语言是一个支持 goroutine 这种多线程的语言,所以它的内存管理系统必须要考虑在多线程下的稳定性和效率问题。

在多线程方面

很自然的做法就是每条线程都有自己的本地的内存,然后有一个全局的分配链,当某个线程中的内存不足后就向全局分配链中申请内存。这样就避免了多线程同时访问共享变量的加锁。

在避免内存碎片方面,大块内存直接按页为单位分配,小块内存会切成各种不同的固定大小的块,申请做任意字节内存时会向上取整到最接近的块,将整块分配给申请者以避免随意切割。

在避免内存碎片方面

大块内存直接按页为单位分配,小块内存会切成各种不同的固定大小的块,申请做任意字节内存时会向上取整到最接近的块,将整块分配给申请者以避免随意切割。

Go语言中为每个系统线程分配一个本地的 MCahe,少量的地址分配就直接从 MCache 中分配,并且定期做垃圾回收,将线程的 MCache 中的空闲内存返回给全局控制堆。小于 32K为小对象,大对象直接从全局控制堆上以页(4k)为单位进行分配,也就是说大对象总是以页对齐的。一个页可以存入一些相同大小的小对象,小对象从本地内存链表中分配,大对象从中心内存对分配。

大约有 100 种内存块类别,每一个类别都有自己对象的空闲链表。小于 32KB 的内存分配被向上取整到对应的尺寸类别,从相应的空闲链表中分配。一页内存只可以被分裂成同一种尺寸类别的对象,然后由空间链表分配管理器。

大约有 100 种内存块类别,每一个类别都有自己对象的空闲链表。小于 32kB 的内存分配被向上取整到对应的尺寸类别,从相应的空闲链表中分配。一页内存只可以被分裂成同一种尺寸类别的对象,然后由空闲链表分配器管理。

分配器的数据结构包括: FixAlloc:固定大小(128kB)的对象的空闲链分配器,被分配器用于管理存储; MHeap:分配堆,按页的粒度进行管理(4kB); MSpan:一些由 MHeap 管理的页; MCentral:对于给定尺寸类别的共享的 free list; * MCache:用于小对象的每 M 一个的 cache。

我们可以将Go语言的内存管理看成一个两级的内存管理结构 MHeap 和 MCache。上面一级管理的基本单位是页,用于分配大对象,每次分配都是若干连续的页,也就是若干个 4KB 的大小。使用的数据结构是 MHeap 和 MSpan,用 BestFit 算法做分配,用位示图做回收。下面一级管理的基本单位是不同类型的固定大小的对象,更像一个对象池而不是内存池,用引用计数做回收。下面这一级使用的数据结构是 MCache。

Q: 线程有几种模型?

线程模型

  • 内核线程模型
  • 用户级线程模型
  • 混合型线程模型

Linux历史上线程的3种实现模型: 线程的实现曾有3种模型:

  • 多对一(M:1)的用户级线程模型
  • 一对一(1:1)的内核级线程模型
  • 多对多(M:N)的两级线程模型

Q: Goroutine 的原理你了解过吗,将一下实现和原理

goroutine的原理

基于CSP并发模型开发了GMP调度器,其中 * G(Goroutine) : 每个 Goroutine 对应一个 G 结构体,G 存储 Goroutine 的运行堆栈、状态以及任务函数

  • M(Machine): 对OS内核级线程的封装,数量对应真实的CPU数(真正干活的对象).
  • P (Processor): 逻辑处理器,即为G和M的调度对象,用来调度G和M之间的关联关系,其数量可通过 GOMAXPROCS()来设置,默认为核心数。

在单核情况下,所有Goroutine运行在同一个线程(M0)中,每一个线程维护一个上下文(P),任何时刻,一个上下文中只有一个Goroutine,其他Goroutine在runqueue中等待。

一个Goroutine运行完自己的时间片后,让出上下文,自己回到runqueue中(如下图所示)。

img

当正在运行的G0阻塞的时候(可以需要IO),会再创建一个线程(M1),P转到新的线程中去运行。

当M0返回时,它会尝试从其他线程中“偷”一个上下文过来,如果没有偷到,会把Goroutine放到Global runqueue中去,然后把自己放入线程缓存中。 上下文会定时检查Global run queue。

Q: go语言的并发机制以及它所使用的CSP并发模型.

CSP模型是上个世纪七十年代提出的,不同于传统的多线程通过共享内存来通信,CSP讲究的是“以通信的方式来共享内存”。用于描述两个独立的并发实体通过共享的通讯 channel(管道)进行通信的并发模型。 CSP中channel是第一类对象,它不关注发送消息的实体,而关注与发送消息时使用的channel。

Golang中channel 是被单独创建并且可以在进程之间传递,它的通信模式类似于 boss-worker 模式的,一个实体通过将消息发送到channel 中,然后又监听这个 channel 的实体处理,两个实体之间是匿名的,这个就实现实体中间的解耦,其中 channel 是同步的一个消息被发送到 channel 中,最终是一定要被另外的实体消费掉的,在实现原理上其实类似一个阻塞的消息队列。

Goroutine 是Golang实际并发执行的实体,它底层是使用协程(coroutine)实现并发,coroutine是一种运行在用户态的用户线程,类似于 greenthread,go底层选择使用coroutine的出发点是因为,它具有以下特点:

  • 用户空间 避免了内核态和用户态的切换导致的成本。
  • 可以由语言和框架层进行调度。
  • 更小的栈空间允许创建大量的实例。

Golang中的Goroutine的特性:

Golang内部有三个对象: P对象(processor) 代表上下文(或者可以认为是cpu),M(work thread)代表工作线程,G对象(goroutine).

正常情况下一个cpu对象启一个工作线程对象,线程去检查并执行goroutine对象。碰到goroutine对象阻塞的时候,会启动一个新的工作线程,以充分利用cpu资源。 所有有时候线程对象会比处理器对象多很多.

G(Goroutine) :我们所说的协程,为用户级的轻量级线程,每个Goroutine对象中的sched保存着其上下文信息.

M(Machine) :对内核级线程的封装,数量对应真实的CPU数(真正干活的对象).

P(Processor) :即为G和M的调度对象,用来调度G和M之间的关联关系,其数量可通过GOMAXPROCS()来设置,默认为核心数.

在单核情况下,所有Goroutine运行在同一个线程(M0)中,每一个线程维护一个上下文(P),任何时刻,一个上下文中只有一个Goroutine,其他Goroutine在runqueue中等待。

一个Goroutine运行完自己的时间片后,让出上下文,自己回到runqueue中(如下图所示)。

当正在运行的G0阻塞的时候(可以需要IO),会再创建一个线程(M1),P转到新的线程中去运行。

当M0返回时,它会尝试从其他线程中“偷”一个上下文过来,如果没有偷到,会把Goroutine放到Global runqueue中去,然后把自己放入线程缓存中。 上下文会定时检查Global runqueue。

Golang是为并发而生的语言,Go语言是为数不多的在语言层面实现并发的语言;也正是Go语言的并发特性,吸引了全球无数的开发者。

Golang的CSP并发模型,是通过Goroutine和Channel来实现的。

Goroutine 是Go语言中并发的执行单位。有点抽象,其实就是和传统概念上的”线程“类似,可以理解为”线程“。 Channel是Go语言中各个并发结构体(Goroutine)之前的通信机制。通常Channel,是各个Goroutine之间通信的”管道“,有点类似于Linux中的管道。

通信机制channel也很方便,传数据用channel <- data,取数据用<-channel。

在通信过程中,传数据channel <- data和取数据<-channel必然会成对出现,因为这边传,那边取,两个goroutine之间才会实现通信。

而且不管传还是取,必阻塞,直到另外的goroutine传或者取为止。

Q: 无缓冲 Chan 的发送和接收是否同步?

ch := make(chan int)    无缓冲的channel由于没有缓冲发送和接收需要同步.
ch := make(chan int, 2) 有缓冲channel不要求发送和接收操作同步.
  • channel无缓冲时,发送阻塞直到数据被接收,接收阻塞直到读到数据。
  • channel有缓冲时,当缓冲满时发送阻塞,当缓冲空时接收阻塞。

Q: Golang 中常用的并发控制?

Golang 中常用的并发模型有三种:

  • 通过channel通知实现并发控制

无缓冲的通道指的是通道的大小为0,也就是说,这种类型的通道在接收前没有能力保存任何值,它要求发送 goroutine 和接收 goroutine 同时准备好,才可以完成发送和接收操作。

从上面无缓冲的通道定义来看,发送 goroutine 和接收 gouroutine 必须是同步的,同时准备后,如果没有同时准备好的话,先执行的操作就会阻塞等待,直到另一个相对应的操作准备好为止。这种无缓冲的通道我们也称之为同步通道。

func main() {
    ch := make(chan struct{})
    go func() {
        fmt.Println("start working")
        time.Sleep(time.Second * 1)
        ch <- struct{}{}
    }()

    <-ch

    fmt.Println("finished")
}

当主 goroutine 运行到 <-ch 接受 channel 的值的时候,如果该 channel 中没有数据,就会一直阻塞等待,直到有值。 这样就可以简单实现并发控制

  • 通过sync包中的WaitGroup实现并发控制

Goroutine是异步执行的,有的时候为了防止在结束mian函数的时候结束掉Goroutine,所以需要同步等待,这个时候就需要用 WaitGroup了,在 sync 包中,提供了 WaitGroup ,它会等待它收集的所有 goroutine 任务全部完成。在WaitGroup里主要有三个方法:

  • Add, 可以添加或减少 goroutine的数量.
  • Done, 相当于Add(-1).
  • Wait, 执行后会堵塞主线程,直到WaitGroup 里的值减至0.

在主 goroutine 中 Add(delta int) 索要等待goroutine 的数量。 在每一个 goroutine 完成后 Done() 表示这一个goroutine 已经完成,当所有的 goroutine 都完成后,在主 goroutine 中 WaitGroup 返回返回。

func main(){
    var wg sync.WaitGroup
    var urls = []string{
        "http://www.golang.org/",
        "http://www.google.com/",
    }
    for _, url := range urls {
        wg.Add(1)
        go func(url string) {
            defer wg.Done()
            http.Get(url)
        }(url)
    }
    wg.Wait()
}

在Golang官网中对于WaitGroup介绍是 A WaitGroup must not be copied after first use,在 WaitGroup 第一次使用后,不能被拷贝

应用示例:

func main(){
 wg := sync.WaitGroup{}
    for i := 0; i < 5; i++ {
        wg.Add(1)
        go func(wg sync.WaitGroup, i int) {
            fmt.Printf("i:%d", i)
            wg.Done()
        }(wg, i)
    }
    wg.Wait()
    fmt.Println("exit")
}

运行:

i:1i:3i:2i:0i:4fatal error: all goroutines are asleep - deadlock!

goroutine 1 [semacquire]:
sync.runtime_Semacquire(0xc000094018)
        /home/keke/soft/go/src/runtime/sema.go:56 +0x39
sync.(*WaitGroup).Wait(0xc000094010)
        /home/keke/soft/go/src/sync/waitgroup.go:130 +0x64
main.main()
        /home/keke/go/Test/wait.go:17 +0xab
exit status 2

它提示所有的 goroutine 都已经睡眠了,出现了死锁。这是因为 wg 给拷贝传递到了 goroutine 中,导致只有 Add 操作,其实 Done操作是在 wg 的副本执行的。

因此 Wait 就死锁了。

这个第一个修改方式:将匿名函数中 wg 的传入类型改为 *sync.WaitGrou,这样就能引用到正确的WaitGroup了。 这个第二个修改方式:将匿名函数中的 wg 的传入参数去掉,因为Go支持闭包类型,在匿名函数中可以直接使用外面的 wg 变量

  • 在Go 1.7 以后引进的强大的Context上下文,实现并发控制

通常,在一些简单场景下使用 channel 和 WaitGroup 已经足够了,但是当面临一些复杂多变的网络并发场景下 channel 和 WaitGroup 显得有些力不从心了。 比如一个网络请求 Request,每个 Request 都需要开启一个 goroutine 做一些事情,这些 goroutine 又可能会开启其他的 goroutine,比如数据库和RPC服务。 所以我们需要一种可以跟踪 goroutine 的方案,才可以达到控制他们的目的,这就是Go语言为我们提供的 Context,称之为上下文非常贴切,它就是goroutine 的上下文。 它是包括一个程序的运行环境、现场和快照等。每个程序要运行时,都需要知道当前程序的运行状态,通常Go 将这些封装在一个 Context 里,再将它传给要执行的 goroutine 。

context 包主要是用来处理多个 goroutine 之间共享数据,及多个 goroutine 的管理。

context 包的核心是 struct Context,接口声明如下:

// A Context carries a deadline, cancelation signal, and request-scoped values
// across API boundaries. Its methods are safe for simultaneous use by multiple
// goroutines.
type Context interface {
    // Done returns a channel that is closed when this `Context` is canceled
    // or times out.
    Done() <-chan struct{}

    // Err indicates why this Context was canceled, after the Done channel
    // is closed.
    Err() error

    // Deadline returns the time when this Context will be canceled, if any.
    Deadline() (deadline time.Time, ok bool)

    // Value returns the value associated with key or nil if none.
    Value(key interface{}) interface{}
}

Done() 返回一个只能接受数据的channel类型,当该context关闭或者超时时间到了的时候,该channel就会有一个取消信号

Err() 在Done() 之后,返回context 取消的原因。

Deadline() 设置该context cancel的时间点

Value() 方法允许 Context 对象携带request作用域的数据,该数据必须是线程安全的。

Context 对象是线程安全的,你可以把一个 Context 对象传递给任意个数的 gorotuine,对它执行 取消 操作时,所有 goroutine 都会接收到取消信号。

一个 Context 不能拥有 Cancel 方法,同时我们也只能 Done channel 接收数据。 其中的原因是一致的:接收取消信号的函数和发送信号的函数通常不是一个。 典型的场景是:父操作为子操作操作启动 goroutine,子操作也就不能取消父操作。

Q: Goroutine 的优势

  • 上下文切换代价小:从GMP调度器可以看出,避免了用户态和内核态线程切换,所以上下文切换代价小
  • 内存占用少:线程栈空间通常是 2M,Goroutine 栈空间最小 2K;

Q: goroutine 什么时候发生阻塞

  • channel 在等待网络请求或者数据操作的IO返回的时候会发生阻塞
  • 发生一次系统调用等待返回结果的时候
  • goroutine进行sleep操作的时候

Q: 在GPM调度模型,goroutine 有哪几种状态?线程呢?

有9种状态

  • _Gidle:刚刚被分配并且还没有被初始化
  • _Grunnable:没有执行代码,没有栈的所有权,存储在运行队列中
  • _Grunning:可以执行代码,拥有栈的所有权,被赋予了内核线程 M 和处理器 P
  • _Gsyscall:正在执行系统调用,拥有栈的所有权,没有执行用户代码,被赋予了内核线程 M 但是不在运行队列上
  • _Gwaiting:由于运行时而被阻塞,没有执行用户代码并且不在运行队列上,但是可能存在于 Channel 的等待队列上
  • _Gdead:没有被使用,没有执行代码,可能有分配的栈
  • _Gcopystack:栈正在被拷贝,没有执行代码,不在运行队列上
  • _Gpreempted:由于抢占而被阻塞,没有执行用户代码并且不在运行队列上,等待唤醒
  • _Gscan:GC 正在扫描栈空间,没有执行代码,可以与其他状态同时存在

去抢占 G 的时候,会有一个自旋和非自旋的状态

Q: 线程和协程内存多少

线程一般是2M,协程一般是2K

Q: 如果 goroutine 一直占用资源怎么办,GMP模型怎么解决这个问题

如果有一个goroutine一直占用资源的话,GMP模型会从正常模式转为饥饿模式,通过信号协作强制处理在最前的 goroutine 去分配使用

Q: goroutine的锁机制了解过吗?Mutex有哪几种模式?Mutex 锁底层如何实现

互斥锁的加锁是靠 sync.Mutex.Lock 方法完成的, 当锁的状态是 0 时,将 mutexLocked 位置成 1:

// Lock locks m.
// If the lock is already in use, the calling goroutine
// blocks until the mutex is available.
func (m *Mutex) Lock() {
    // Fast path: grab unlocked mutex.
    if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
        if race.Enabled {
            race.Acquire(unsafe.Pointer(m))
        }
        return
    }
    // Slow path (outlined so that the fast path can be inlined)
    m.lockSlow()
}

Mutex:正常模式和饥饿模式

在正常模式下,锁的等待者会按照先进先出的顺序获取锁。

但是刚被唤起的 Goroutine 与新创建的 Goroutine 竞争时,大概率会获取不到锁,为了减少这种情况的出现,一旦 Goroutine 超过 1ms 没有获取到锁,它就会将当前互斥锁切换饥饿模式,防止部分 Goroutine 被饿死。

饥饿模式是在 Go 语言 1.9 版本引入的优化的,引入的目的是保证互斥锁的公平性(Fairness)。

在饥饿模式中,互斥锁会直接交给等待队列最前面的 Goroutine。新的 Goroutine 在该状态下不能获取锁、也不会进入自旋状态,它们只会在队列的末尾等待。

如果一个 Goroutine 获得了互斥锁并且它在队列的末尾或者它等待的时间少于 1ms,那么当前的互斥锁就会被切换回正常模式。

相比于饥饿模式,正常模式下的互斥锁能够提供更好地性能,饥饿模式的能避免 Goroutine 由于陷入等待无法获取锁而造成的高尾延时。

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

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

Q: 协程,线程,进程的区别。

  • 进程

进程是具有一定独立功能的程序关于某个数据集合上的一次运行活动,进程是系统进行资源分配和调度的一个独立单位。每个进程都有自己的独立内存空间,不同进程通过进程间通信来通信。由于进程比较重量,占据独立的内存,所以上下文进程间的切换开销(栈、寄存器、虚拟内存、文件句柄等)比较大,但相对比较稳定安全。

  • 线程

线程是进程的一个实体,是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位.线程自己基本上不拥有系统资源,只拥有一点在运行中必不可少的资源(如程序计数器,一组寄存器和栈),但是它可与同属一个进程的其他的线程共享进程所拥有的全部资源。线程间通信主要通过共享内存,上下文切换很快,资源开销较少,但相比进程不够稳定容易丢失数据。

  • 协程

协程是一种用户态的轻量级线程,协程的调度完全由用户控制。协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文的切换非常快。

Q: 如果若干个线程发生OOM,会发生什么?Goroutine中内存泄漏的发现与排查?项目出现过OOM吗,怎么解决

线程

如果线程发生OOM,也就是内存溢出,发生OOM的线程会被kill掉,其它线程不受影响。

Goroutine中内存泄漏的发现与排查

go中的内存泄漏一般都是goroutine泄露,就是goroutine没有被关闭,或者没有添加超时控制,让goroutine一只处于阻塞状态,不能被GC。

场景

在Go中内存泄露分为暂时性内存泄露和永久性内存泄露

暂时性内存泄露

  • 获取长字符串中的一段导致长字符串未释放
  • 获取长slice中的一段导致长slice未释放
  • 在长slice新建slice导致泄漏

string相比切片少了一个容量的cap字段,可以把string当成一个只读的切片类型。获取长string或者切片中的一段内容,由于新生成的对象和老的string或者切片共用一个内存空间,会导致老的string和切片资源暂时得不到释放,造成短暂的内存泄漏

永久性内存泄露

  • goroutine永久阻塞而导致泄漏
  • time.Ticker未关闭导致泄漏
  • 不正确使用Finalizer导致泄漏

使用pprof排查

Q: 互斥锁,读写锁,死锁问题是怎么解决。

  • 互斥锁

互斥锁就是互斥变量mutex,用来锁住临界区的.

条件锁就是条件变量,当进程的某些资源要求不满足时就进入休眠,也就是锁住了。当资源被分配到了,条件锁打开,进程继续运行;读写锁,也类似,用于缓冲区等临界资源能互斥访问的。

  • 读写锁

通常有些公共数据修改的机会很少,但其读的机会很多。并且在读的过程中会伴随着查找,给这种代码加锁会降低我们的程序效率。读写锁可以解决这个问题。

注意:写独占,读共享,写锁优先级高

  • 死锁

一般情况下,如果同一个线程先后两次调用lock,在第二次调用时,由于锁已经被占用,该线程会挂起等待别的线程释放锁,然而锁正是被自己占用着的,该线程又被挂起而没有机会释放锁,因此就永远处于挂起等待状态了,这叫做死锁(Deadlock)。 另外一种情况是:若线程A获得了锁1,线程B获得了锁2,这时线程A调用lock试图获得锁2,结果是需要挂起等待线程B释放锁2,而这时线程B也调用lock试图获得锁1,结果是需要挂起等待线程A释放锁1,于是线程A和B都永远处于挂起状态了。

死锁产生的四个必要条件:

  1. 互斥条件:一个资源每次只能被一个进程使用
  2. 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
  3. 不剥夺条件:进程已获得的资源,在末使用完之前,不能强行剥夺。
  4. 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。 这四个条件是死锁的必要条件,只要系统发生死锁,这些条件必然成立,而只要上述条件之一不满足,就不会发生死锁。

a. 预防死锁

可以把资源一次性分配:(破坏请求和保持条件)

然后剥夺资源:即当某进程新的资源未满足时,释放已占有的资源(破坏不可剥夺条件)

资源有序分配法:系统给每类资源赋予一个编号,每一个进程按编号递增的顺序请求资源,释放则相反(破坏环路等待条件)

b. 避免死锁

预防死锁的几种策略,会严重地损害系统性能。因此在避免死锁时,要施加较弱的限制,从而获得 较满意的系统性能。由于在避免死锁的策略中,允许进程动态地申请资源。因而,系统在进行资源分配之前预先计算资源分配的安全性。若此次分配不会导致系统进入不安全状态,则将资源分配给进程;否则,进程等待。其中最具有代表性的避免死锁算法是银行家算法。

c. 检测死锁

首先为每个进程和每个资源指定一个唯一的号码,然后建立资源分配表和进程等待表.

d. 解除死锁

当发现有进程死锁后,便应立即把它从死锁状态中解脱出来,常采用的方法有.

e. 剥夺资源

从其它进程剥夺足够数量的资源给死锁进程,以解除死锁状态.

f. 撤消进程

可以直接撤消死锁进程或撤消代价最小的进程,直至有足够的资源可用,死锁状态.消除为止.所谓代价是指优先级、运行代价、进程的重要性和价值等。

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

同步访问共享数据是处理数据竞争的一种有效的方法.golang在1.1之后引入了竞争检测机制,可以使用 go run -race 或者 go build -race来进行静态检测。 其在内部的实现是,开启多个协程执行同一个命令, 并且记录下每个变量的状态.

竞争检测器基于C/C++的ThreadSanitizer 运行时库,该库在Google内部代码基地和Chromium找到许多错误。这个技术在2012年九月集成到Go中,从那时开始,它已经在标准库中检测到42个竞争条件。现在,它已经是我们持续构建过程的一部分,当竞争条件出现时,它会继续捕捉到这些错误。

竞争检测器已经完全集成到Go工具链中,仅仅添加-race标志到命令行就使用了检测器。

$ go test -race mypkg    // 测试包
$ go run -race mysrc.go  // 编译和运行程序
$ go build -race mycmd   // 构建程序
$ go install -race mypkg // 安装程序

要想解决数据竞争的问题可以使用互斥锁sync.Mutex,解决数据竞争(Data race),也可以使用管道解决,使用管道的效率要比互斥锁高.

Q: Go的垃圾回收算法

Go 1.5 后,采取的是并发标记和并发清除,三色标记的算法

Go 中的 gc 基本上是标记清除的过程:

image-20220907180855058

Go 的垃圾回收是基于标记清除算法,这种算法需要进行 STW (stop the world),这个过程就是会导致程序是卡顿的,频繁的 GC 会严重影响程序性能

Go 在此基础上进行了改进,通过三色标记清除扫法与写屏障来减少 STW 的时间

GC 的过程一共分为四个阶段:

  • 栈扫描(STW),所有对象开始都是白色
  • 从 root 开始找到所有可达对象(所有可以找到的对象),标记灰色,放入待处理队列
  • 遍历灰色对象队列,将其引用对象标记为灰色放入待处理队列,自身标记为黑色
  • 清除(并发)循环步骤3 直到灰色队列为空为止,此时所有引用对象都被标记为黑色,所有不可达的对象依然为白色,白色的就是需要进行回收的对象。三色标记法相对于普通标记清除,减少了 STW 时间。这主要得益于标记过程是 “on-the-fly”的,在标记过程中是不需要 STW的,它与程序是并发执行的,这就大大缩短了 STW 的时间。

Go GC 优化的核心就是尽量使得 STW(Stop The World) 的时间越来越短。

写屏障:

当标记和程序是并发执行的,这就会造成一个问题. 在标记过程中,有新的引用产生,可能会导致误清扫.

清扫开始前,标记为黑色的对象引用了一个新申请的对象,它肯定是白色的,而黑色对象不会被再次扫描,那么这个白色对象无法被扫描变成灰色、黑色,它就会最终被清扫,而实际它不应该被清扫.

这就需要用到屏障技术,golang采用了写屏障,其作用就是为了避免这类误清扫问题. 写屏障即在内存写操作前,维护一个约束,从而确保清扫开始前,黑色的对象不能引用白色对象.

Q: Go语言的栈空间管理是怎么样的?

Go语言的运行环境(runtime)会在goroutine需要的时候动态地分配栈空间,而不是给每个goroutine分配固定大小的内存空间。这样就避免了需要程序员来决定栈的大小。

分块式的栈是最初Go语言组织栈的方式。当创建一个goroutine的时候,它会分配一个8KB的内存空间来给goroutine的栈使用。我们可能会考虑当这8KB的栈空间被用完的时候该怎么办?

为了处理这种情况,每个Go函数的开头都有一小段检测代码。这段代码会检查我们是否已经用完了分配的栈空间。如果是的话,它会调用 morestack函数。morestack函数分配一块新的内存作为栈空间,并且在这块栈空间的底部填入各种信息(包括之前的那块栈地址)。在分配了这块新的栈空间之后,它会重试刚才造成栈空间不足的函数。这个过程叫做栈分裂(stack split)。

在新分配的栈底部,还插入了一个叫做 lessstack的函数指针。这个函数还没有被调用。这样设置是为了从刚才造成栈空间不足的那个函数返回时做准备的。当我们从那个函数返回时,它会跳转到 lessstacklessstack函数会查看在栈底部存放的数据结构里的信息,然后调整栈指针(stack pointer)。这样就完成了从新的栈块到老的栈块的跳转。接下来,新分配的这个块栈空间就可以被释放掉了。

分块式的栈让我们能够按照需求来扩展和收缩栈的大小。 Go开发者不需要花精力去估计goroutine会用到多大的栈。创建一个新的goroutine的开销也不大。当 Go开发者不知道栈会扩展到多少大时,它也能很好的处理这种情况。

这一直是之前Go语言管理栈的的方法。但这个方法有一个问题。缩减栈空间是一个开销相对较大的操作。如果在一个循环里有栈分裂,那么它的开销就变得不可忽略了。一个函数会扩展,然后分裂栈。当它返回的时候又会释放之前分配的内存块。如果这些都发生在一个循环里的话,代价是相当大的。 这就是所谓的热分裂问题(hot split problem)。它是Go语言开发者选择新的栈管理方法的主要原因。新的方法叫做 栈复制法(stack copying)

栈复制法一开始和分块式的栈很像。当goroutine运行并用完栈空间的时候,与之前的方法一样,栈溢出检查会被触发。但是,不像之前的方法那样分配一个新的内存块并链接到老的栈内存块,新的方法会分配一个两倍大的内存块并把老的内存块内容复制到新的内存块里。这样做意味着当栈缩减回之前大小时,我们不需要做任何事情。栈的缩减没有任何代价。而且,当栈再次扩展时,运行环境也不需要再做任何事。它可以重用之前分配的空间。

栈的复制听起来很容易,但实际操作并非那么简单。存储在栈上的变量的地址可能已经被使用到。也就是说程序使用到了一些指向栈的指针。当移动栈的时候,所有指向栈里内容的指针都会变得无效。然而,指向栈内容的指针自身也必定是保存在栈上的。这是为了保证内存安全的必要条件。否则一个程序就有可能访问一段已经无效的栈空间了。

因为垃圾回收的需要,我们必须知道栈的哪些部分是被用作指针了。当我们移动栈的时候,我们可以更新栈里的指针让它们指向新的地址。所有相关的指针都会被更新。我们使用了垃圾回收的信息来复制栈,但并不是任何使用栈的函数都有这些信息。因为很大一部分运行环境是用C语言写的,很多被调用的运行环境里的函数并没有指针的信息,所以也就不能够被复制了。当遇到这种情况时,我们只能退回到分块式的栈并支付相应的开销。

这也是为什么现在运行环境的开发者正在用Go语言重写运行环境的大部分代码。无法用Go语言重写的部分(比如调度器的核心代码和垃圾回收器)会在特殊的栈上运行。这个特殊栈的大小由运行环境的开发者设置。

这些改变除了使栈复制成为可能,它也允许我们在将来实现并行垃圾回收。

另外一种不同的栈处理方式就是在虚拟内存中分配大内存段。由于物理内存只是在真正使用时才会被分配,因此看起来好似你可以分配一个大内存段并让操 作系统处理它。下面是这种方法的一些问题

首先,32位系统只能支持4G字节虚拟内存,并且应用只能用到其中的3G空间。由于同时运行百万goroutines的情况并不少见,因此你很可 能用光虚拟内存,即便我们假设每个goroutine的stack只有8K。

第二,然而我们可以在64位系统中分配大内存,它依赖于过量内存使用。所谓过量使用是指当你分配的内存大小超出物理内存大小时,依赖操作系统保证 在需要时能够分配出物理内存。然而,允许过量使用可能会导致一些风险。由于一些进程分配了超出机器物理内存大小的内存,如果这些进程使用更多内存 时,操作系统将不得不为它们补充分配内存。这会导致操作系统将一些内存段放入磁盘缓存,这常常会增加不可预测的处理延迟。正是考虑到这个原因,一 些新系统关闭了对过量使用的支持。

Q: Golang GC 时会发生什么?

首先我们先来了解下垃圾回收.什么是垃圾回收?

内存管理是程序员开发应用的一大难题。传统的系统级编程语言(主要指C/C++)中,程序开发者必须对内存小心的进行管理操作,控制内存的申请及释放。因为稍有不慎,就可能产生内存泄露问题,这种问题不易发现并且难以定位,一直成为困扰程序开发者的噩梦。如何解决这个头疼的问题呢?

过去一般采用两种办法:

  • 内存泄露检测工具。这种工具的原理一般是静态代码扫描,通过扫描程序检测可能出现内存泄露的代码段。然而检测工具难免有疏漏和不足,只能起到辅助作用。
  • 智能指针。这是 c++ 中引入的自动内存管理方法,通过拥有自动内存管理功能的指针对象来引用对象,是程序员不用太关注内存的释放,而达到内存自动释放的目的。这种方法是采用最广泛的做法,但是对程序开发者有一定的学习成本(并非语言层面的原生支持),而且一旦有忘记使用的场景依然无法避免内存泄露。

为了解决这个问题,后来开发出来的几乎所有新语言(java,python,php等等)都引入了语言层面的自动内存管理 – 也就是语言的使用者只用关注内存的申请而不必关心内存的释放,内存释放由虚拟机(virtual machine)或运行时(runtime)来自动进行管理。而这种对不再使用的内存资源进行自动回收的行为就被称为垃圾回收。

常用的垃圾回收的方法:

  • 引用计数(reference counting)

这是最简单的一种垃圾回收算法,和之前提到的智能指针异曲同工。对每个对象维护一个引用计数,当引用该对象的对象被销毁或更新时被引用对象的引用计数自动减一,当被引用对象被创建或被赋值给其他对象时引用计数自动加一。当引用计数为0时则立即回收对象。

这种方法的优点是实现简单,并且内存的回收很及时。这种算法在内存比较紧张和实时性比较高的系统中使用的比较广泛,如ios cocoa框架,php,python等。

但是简单引用计数算法也有明显的缺点:

  1. 频繁更新引用计数降低了性能。

一种简单的解决方法就是编译器将相邻的引用计数更新操作合并到一次更新;还有一种方法是针对频繁发生的临时变量引用不进行计数,而是在引用达到0时通过扫描堆栈确认是否还有临时对象引用而决定是否释放。等等还有很多其他方法,具体可以参考这里。

  1. 循环引用。

当对象间发生循环引用时引用链中的对象都无法得到释放。最明显的解决办法是避免产生循环引用,如cocoa引入了strong指针和weak指针两种指针类型。或者系统检测循环引用并主动打破循环链。当然这也增加了垃圾回收的复杂度。

  • 标记-清除(mark and sweep)

标记-清除(mark and sweep)分为两步,标记从根变量开始迭代得遍历所有被引用的对象,对能够通过应用遍历访问到的对象都进行标记为“被引用”;标记完成后进行清除操作,对没有标记过的内存进行回收(回收同时可能伴有碎片整理操作)。这种方法解决了引用计数的不足,但是也有比较明显的问题:每次启动垃圾回收都会暂停当前所有的正常代码执行,回收是系统响应能力大大降低!当然后续也出现了很多mark&sweep算法的变种(如三色标记法)优化了这个问题。

  • 分代搜集(generation)

java的jvm 就使用的分代回收的思路。在面向对象编程语言中,绝大多数对象的生命周期都非常短。分代收集的基本思想是,将堆划分为两个或多个称为代(generation)的空间。新创建的对象存放在称为新生代(young generation)中(一般来说,新生代的大小会比 老年代小很多),随着垃圾回收的重复执行,生命周期较长的对象会被提升(promotion)到老年代中(这里用到了一个分类的思路,这个是也是科学思考的一个基本思路)。

因此,新生代垃圾回收和老年代垃圾回收两种不同的垃圾回收方式应运而生,分别用于对各自空间中的对象执行垃圾回收。新生代垃圾回收的速度非常快,比老年代快几个数量级,即使新生代垃圾回收的频率更高,执行效率也仍然比老年代垃圾回收强,这是因为大多数对象的生命周期都很短,根本无需提升到老年代。

Golang GC 时会发生什么?

Golang 1.5后,采取的是“非分代的、非移动的、并发的、三色的”标记清除垃圾回收算法。

golang 中的 gc 基本上是标记清除的过程:

gc的过程一共分为四个阶段:

  1. 栈扫描(开始时STW)
  2. 第一次标记(并发)
  3. 第二次标记(STW)
  4. 清除(并发)

整个进程空间里申请每个对象占据的内存可以视为一个图,初始状态下每个内存对象都是白色标记。

  1. 先STW,做一些准备工作,比如 enable write barrier。然后取消STW,将扫描任务作为多个并发的goroutine立即入队给调度器,进而被CPU处理
  2. 第一轮先扫描root对象,包括全局指针和 goroutine 栈上的指针,标记为灰色放入队列
  3. 第二轮将第一步队列中的对象引用的对象置为灰色加入队列,一个对象引用的所有对象都置灰并加入队列后,这个对象才能置为黑色并从队列之中取出。循环往复,最后队列为空时,整个图剩下的白色内存空间即不可到达的对象,即没有被引用的对象;
  4. 第三轮再次STW,将第二轮过程中新增对象申请的内存进行标记(灰色),这里使用了write barrier(写屏障)去记录

Golang gc 优化的核心就是尽量使得 STW(Stop The World) 的时间越来越短。

Q: Go 数据竞争怎么解决

Data Race 问题可以使用互斥锁解决,或者也可以通过CAS无锁并发解决

中使用同步访问共享数据或者CAS无锁并发是处理数据竞争的一种有效的方法.

golang在1.1之后引入了竞争检测机制,可以使用 go run -race 或者 go build -race来进行静态检测。

其在内部的实现是,开启多个协程执行同一个命令, 并且记录下每个变量的状态.

竞争检测器基于C/C++的ThreadSanitizer运行时库,该库在Google内部代码基地和Chromium找到许多错误。这个技术在2012年九月集成到Go中,从那时开始,它已经在标准库中检测到42个竞争条件。现在,它已经是我们持续构建过程的一部分,当竞争条件出现时,它会继续捕捉到这些错误。

竞争检测器已经完全集成到Go工具链中,仅仅添加-race标志到命令行就使用了检测器。

$ go test -race mypkg    // 测试包
$ go run -race mysrc.go  // 编译和运行程序
$ go build -race mycmd  // 构建程序
$ go install -race mypkg // 安装程序

要想解决数据竞争的问题可以使用互斥锁sync.Mutex,解决数据竞争(Data race),也可以使用管道解决,使用管道的效率要比互斥锁高.

Q: Go:反射之用字符串函数名调用函数

package main

import (
    "fmt"
    "reflect"
)

type Animal struct {
}

func (m *Animal) Eat() {
    fmt.Println("Eat")
}
func main() {
    animal := Animal{}
    value := reflect.ValueOf(&animal)
    f := value.MethodByName("Eat") //通过反射获取它对应的函数,然后通过call来调用
    f.Call([]reflect.Value{})
}

Q: 开发用过gin框架吗?参数检验怎么做的?中间使怎么使用的

gin框架使用http://github.com/go-playground/validator进行参数校验 在 struct 结构体添加 binding tag,然后调用 ShouldBing 方法,下面是一个示例

type SignUpParam struct {
    Age        uint8  `json:"age" binding:"gte=1,lte=130"`
    Name       string `json:"name" binding:"required"`
    Email      string `json:"email" binding:"required,email"`
    Password   string `json:"password" binding:"required"`
    RePassword string `json:"re_password" binding:"required,eqfield=Password"`
}

func main() {
    r := gin.Default()

    r.POST("/signup", func(c *gin.Context) {
        var u SignUpParam
        if err := c.ShouldBind(&u); err != nil {
            c.JSON(http.StatusOK, gin.H{
                "msg": err.Error(),
            })
            return
        }
        // 保存入库等业务逻辑代码...

        c.JSON(http.StatusOK, "success")
    })

    _ = r.Run(":8999")
}

中间件使用use方法,Gin的中间件其实就是一个HandlerFunc,那么只要我们自己实现一个HandlerFunc,下面是一个示例

func costTime() gin.HandlerFunc {
    return func(c *gin.Context) {
        //请求前获取当前时间
        nowTime := time.Now()

        //请求处理
        c.Next()

        //处理后获取消耗时间
        costTime := time.Since(nowTime)
        url := c.Request.URL.String()
        fmt.Printf("the request URL %s cost %v\n", url, costTime)
    }
}

以上我们就实现了一个Gin中间件,比较简单,而且有注释加以说明,这里要注意的是c.Next方法,这个是执行后续中间件请求处理的意思(含没有执行的中间件和我们定义的GET方法处理),这样我们才能获取执行的耗时。也就是在c.Next方法前后分别记录时间,就可以得出耗时。

Q: JSON 标准库对 nil slice 和 空 slice 的处理是一致的吗? 

首先JSON 标准库对 nil slice 和 空 slice 的处理是不一致.

通常错误的用法,会报数组越界的错误,因为只是声明了slice,却没有给实例化的对象。

var slice []int
slice[1] = 0

此时slice的值是nil,这种情况可以用于需要返回slice的函数,当函数出现异常的时候,保证函数依然会有nil的返回值。

empty slice 是指slice不为nil,但是slice没有值,slice的底层的空间是空的,此时的定义如下:

slice := make([]int,0)
slice := []int{}

当我们查询或者处理一个空的列表的时候,这非常有用,它会告诉我们返回的是一个列表,但是列表内没有任何值。

总之,nil slice 和 empty slice是不同的东西,需要我们加以区分的.

Q: Epoll原理是什么

开发高性能网络程序时,windows开发者们言必称Iocp,linux开发者们则言必称Epoll。大家都明白Epoll是一种IO多路复用技术,可以非常高效的处理数以百万计的Socket句柄,比起以前的Select和Poll效率提高了很多。

先简单了解下如何使用C库封装的3个epoll系统调用。

int epoll_create(int size);  
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);  
int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);

使用起来很清晰,首先要调用 epoll_create建立一个epoll对象。参数size是内核保证能够正确处理的最大句柄数,多于这个最大数时内核可不保证效果。 epoll_ctl可以操作上面建立的epoll,例如,将刚建立的 socket加入到epoll中让其监控,或者把 epoll正在监控的某个socket句柄移出epoll,不再监控它等等。

epoll_wait在调用时,在给定的timeout时间内,当在监控的所有句柄中有事件发生时,就返回用户态的进程。

从调用方式就可以看到epoll相比select/poll的优越之处是,因为后者每次调用时都要传递你所要监控的所有socket给select/poll系统调用,这意味着需要将用户态的socket列表copy到内核态,如果以万计的句柄会导致每次都要copy几十几百KB的内存到内核态,非常低效。而我们调用 epoll_wait时就相当于以往调用select/poll,但是这时却不用传递socket句柄给内核,因为内核已经在epoll_ctl中拿到了要监控的句柄列表。

所以,实际上在你调用 epoll_create后,内核就已经在内核态开始准备帮你存储要监控的句柄了,每次调用 epoll_ctl只是在往内核的数据结构里塞入新的socket句柄。

在内核里,一切皆文件。所以,epoll向内核注册了一个文件系统,用于存储上述的被监控socket。当你调用epoll_create时,就会在这个虚拟的epoll文件系统里创建一个file结点。当然这个file不是普通文件,它只服务于epoll。

epoll在被内核初始化时(操作系统启动),同时会开辟出epoll自己的内核高速cache区,用于安置每一个我们想监控的socket,这些socket会以红黑树的形式保存在内核cache里,以支持快速的查找、插入、删除。这个内核高速cache区,就是建立连续的物理内存页,然后在之上建立slab层,通常来讲,就是物理上分配好你想要的size的内存对象,每次使用时都是使用空闲的已分配好的对象。

static int __init eventpoll_init(void)  {  
    ... ...  
  
    /* Allocates slab cache used to allocate "struct epitem" items */  
    epi_cache = kmem_cache_create("eventpoll_epi", sizeof(struct epitem),  
            0, SLAB_HWCACHE_ALIGN|EPI_SLAB_DEBUG|SLAB_PANIC,  
            NULL, NULL);  
  
    /* Allocates slab cache used to allocate "struct eppoll_entry" */  
    pwq_cache = kmem_cache_create("eventpoll_pwq",  
            sizeof(struct eppoll_entry), 0,  
            EPI_SLAB_DEBUG|SLAB_PANIC, NULL, NULL);  
 ... ...  
 }

epoll的高效就在于,当我们调用 epoll_ctl往里塞入百万个句柄时,epoll_wait仍然可以飞快的返回,并有效的将发生事件的句柄给我们用户。这是由于我们在调用 epoll_create时,内核除了帮我们在epoll文件系统里建了个file结点,在内核cache里建了个红黑树用于存储以后epoll_ctl传来的socket外,还会再建立一个list链表,用于存储准备就绪的事件,当epoll_wait调用时,仅仅观察这个list链表里有没有数据即可。有数据就返回,没有数据就sleep,等到timeout时间到后即使链表没数据也返回。所以,epoll_wait非常高效。

而且,通常情况下即使我们要监控百万计的句柄,大多一次也只返回很少量的准备就绪句柄而已,所以,epoll_wait仅需要从内核态copy少量的句柄到用户态而已,因此就会非常的高效!

然而,这个准备就绪list链表是怎么维护的呢?当我们执行epoll_ctl时,除了把socket放到epoll文件系统里file对象对应的红黑树上之外,还会给内核中断处理程序注册一个回调函数,告诉内核,如果这个句柄的中断到了,就把它放到准备就绪list链表里。所以,当一个socket上有数据到了,内核在把网卡上的数据copy到内核中后就来把socket插入到准备就绪链表里了。

如此,一个红黑树,一张准备就绪句柄链表,少量的内核cache,就帮我们解决了大并发下的socket处理问题。执行 epoll_create时,创建了红黑树和就绪链表,执行epoll_ctl时,如果增加socket句柄,则检查在红黑树中是否存在,存在立即返回,不存在则添加到树干上,然后向内核注册回调函数,用于当中断事件来临时向准备就绪链表中插入数据。执行epoll_wait时立刻返回准备就绪链表里的数据即可。

最后看看epoll独有的两种模式LT和ET。无论是LT和ET模式,都适用于以上所说的流程。区别是,LT模式下,只要一个句柄上的事件一次没有处理完,会在以后调用epoll_wait时每次返回这个句柄,而ET模式仅在第一次返回。

当一个socket句柄上有事件时,内核会把该句柄插入上面所说的准备就绪list链表,这时我们调用 epoll_wait,会把准备就绪的socket拷贝到用户态内存,然后清空准备就绪list链表,最后,epoll_wait需要做的事情,就是检查这些socket,如果不是ET模式(就是LT模式的句柄了),并且这些socket上确实有未处理的事件时,又把该句柄放回到刚刚清空的准备就绪链表了。所以,非ET的句柄,只要它上面还有事件,epoll_wait每次都会返回。而ET模式的句柄,除非有新中断到,即使socket上的事件没有处理完,也是不会每次从epoll_wait返回的。

因此epoll比select的提高实际上是一个用空间换时间思想的具体应用.对比阻塞IO的处理模型, 可以看到采用了多路复用IO之后, 程序可以自由的进行自己除了IO操作之外的工作, 只有到IO状态发生变化的时候由多路复用IO进行通知, 然后再采取相应的操作, 而不用一直阻塞等待IO状态发生变化,提高效率.

Q: 微服务架构是什么样子的?

通常传统的项目体积庞大,需求、设计、开发、测试、部署流程固定。新功能需要在原项目上做修改。

但是微服务可以看做是对大项目的拆分,是在快速迭代更新上线的需求下产生的。新的功能模块会发布成新的服务组件,与其他已发布的服务组件一同协作。 服务内部有多个生产者和消费者,通常以http rest的方式调用,服务总体以一个(或几个)服务的形式呈现给客户使用。

微服务架构是一种思想对微服务架构我们没有一个明确的定义,但简单来说微服务架构是:

采用一组服务的方式来构建一个应用,服务独立部署在不同的进程中,不同服务通过一些轻量级交互机制来通信,例如 RPC、HTTP 等,服务可独立扩展伸缩,每个服务定义了明确的边界,不同的服务甚至可以采用不同的编程语言来实现,由独立的团队来维护。

Golang的微服务框架kit中有详细的微服务的例子,可以参考学习.

微服务架构设计包括:

  1. 服务熔断降级限流机制 熔断降级的概念(Rate Limiter 限流器,Circuit breaker 断路器).
  2. 框架调用方式解耦方式 Kit 或 Istio 或 Micro 服务发现(consul zookeeper kubeneters etcd ) RPC调用框架.
  3. 链路监控,zipkin和prometheus.
  4. 多级缓存.
  5. 网关 (kong gateway).
  6. Docker部署管理 Kubenetters.
  7. 自动集成部署 CI/CD 实践.
  8. 自动扩容机制规则.
  9. 压测 优化.
  10. Trasport 数据传输(序列化和反序列化).
  11. Logging 日志.
  12. Metrics 指针对每个请求信息的仪表盘化.

Q: 请你讲一下Go面向对象是如何实现的?

Go实现面向对象的两个关键是struct和interface。

封装:对于同一个包,对象对包内的文件可见;对不同的包,需要将对象以大写开头才是可见的。

继承:继承是编译时特征,在struct内加入所需要继承的类即可:

type A struct{}
type B struct{
A
}

多态:多态是运行时特征,Go多态通过interface来实现。类型和接口是松耦合的,某个类型的实例可以赋给它所实现的任意接口类型的变量。

Go支持多重继承,就是在类型中嵌入所有必要的父类型。

Q: 二维切片如何初始化

一种方式是对每一个维度都初始化。

另一种方式是用一个单独的一维切片初始化。

// Allocate the top-level slice.
picture := make([][]uint8, YSize) // One row per unit of y.
// Loop over the rows, allocating the slice for each row.
for i := range picture {
	picture[i] = make([]uint8, XSize)
}
// Allocate the top-level slice, the same as before.
picture := make([][]uint8, YSize) // One row per unit of y.
// Allocate one large slice to hold all the pixels.
pixels := make([]uint8, XSize*YSize) // Has type []uint8 even though picture is [][]uint8.
// Loop over the rows, slicingog each row from the front of the remaining pixels slice.
for i := range picture {
	picture[i], pixels = pixels[:XSize], pixels[XSize:]

Q: uint型变量值分别为 1,2,它们相减的结果是多少?

var a uint = 1
	var b uint = 2
	fmt.Println(a - b)

答案,结果会溢出,如果是32位系统,结果是232-1,如果是64位系统,结果264-1.

Q: 讲一下go有没有函数在main之前执行?怎么用?

go的init函数在main函数之前执行,它有如下特点:

  • 初始化不能采用初始化表达式初始化的变量;
  • 程序运行前执行注册
  • 实现sync.Once功能
  • 不能被其它函数调用
  • init函数没有入口参数和返回值:
func init(){
	register...
}
  • 每个包可以有多个init函数,每个源文件也可以有多个init函数
  • 同一个包的init执行顺序,golang没有明确定义,编程时要注意程序不要依赖这个执行顺序。
  • 不同包的init函数按照包导入的依赖关系决定执行顺序。

Q: 下面的空值什么意思?

var _ Codec = (*GobCodec)(nil)
type GobCodec struct{
	conn io.ReadWriteCloser
	buf *bufio.Writer
	dec *gob.Decoder
	enc *gob.Encoder
}

type Codec interface {
	io.Closer
	ReadHeader(*Header) error
	ReadBody(interface{})  error
	Write(*Header, interface{}) error
}

答:将nil转换为GobCodec类型,然后再转换为Codec接口,如果转换失败,说明GobCodec没有实现Codec接口的所有方法。

Q: golang的内存管理的原理清楚吗?简述go内存管理机制。

golang内存管理基本是参考tcmalloc来进行的。go内存管理本质上是一个内存池,只不过内部做了很多优化:自动伸缩内存池大小,合理的切割内存块。

一些基本概念:
页Page:一块8K大小的内存空间。Go向操作系统申请和释放内存都是以页为单位的。
span : 内存块,一个或多个连续的 page 组成一个 span 。如果把 page 比喻成工人, span 可看成是小队,工人被分成若干个队伍,不同的队伍干不同的活。
sizeclass : 空间规格,每个 span 都带有一个 sizeclass ,标记着该 span 中的 page 应该如何使用。使用上面的比喻,就是 sizeclass 标志着 span 是一个什么样的队伍。
object : 对象,用来存储一个变量数据内存空间,一个 span 在初始化时,会被切割成一堆等大的 object 。假设 object 的大小是 16B , span 大小是 8K ,那么就会把 span 中的 page 就会被初始化 8K / 16B = 512 个 object 。所谓内存分配,就是分配一个 object 出去。

  1. mheap

一开始go从操作系统索取一大块内存作为内存池,并放在一个叫mheap的内存池进行管理,mheap将一整块内存切割为不同的区域,并将一部分内存切割为合适的大小。

img

mheap.spans :用来存储 page 和 span 信息,比如一个 span 的起始地址是多少,有几个 page,已使用了多大等等。

mheap.bitmap 存储着各个 span 中对象的标记信息,比如对象是否可回收等等。

mheap.arena_start : 将要分配给应用程序使用的空间。

  1. mcentral

用途相同的span会以链表的形式组织在一起存放在mcentral中。这里用途用sizeclass来表示,就是该span存储哪种大小的对象。

找到合适的 span 后,会从中取一个 object 返回给上层使用。

  1. mcache

为了提高内存并发申请效率,加入缓存层mcache。每一个mcache和处理器P对应。Go申请内存首先从P的mcache中分配,如果没有可用的span再从mcentral中获取。

Q: mutex有几种模式?

mutex有两种模式:normalstarvation

正常模式

所有goroutine按照FIFO的顺序进行锁获取,被唤醒的goroutine和新请求锁的goroutine同时进行锁获取,通常新请求锁的goroutine更容易获取锁(持续占有cpu),被唤醒的goroutine则不容易获取到锁。公平性:否。

饥饿模式

所有尝试获取锁的goroutine进行等待排队,新请求锁的goroutine不会进行锁获取(禁用自旋),而是加入队列尾部等待获取锁。公平性:是。

本文由 在码圈 创作,如果您觉得本文不错,请随意赞赏
采用 知识共享署名4.0 国际许可协议进行许可
您可以自由的转载和修改,但请务必注明文章来源并且不可用于商业目的。
本站部分内容收集于互联网,如果有侵权内容、不妥之处,请联系我们删除。敬请谅解!
原文链接:https://www.bedebug.com/archives/gointerviewquestion
最后更新于:2024-03-28 14:20:07

请博主喝咖啡 ☕.