Golang有哪些优势?
与其他语言相比,使用Go语言具有以下好处:
- 高效性:Go语言是一种编译型语言,能够生成高效的机器码。同时,Go语言的垃圾回收机制和协程支持使其在处理大规模并发任务时非常高效。
- 并发性:Go语言内置支持协程和通道,能够方便地编写并发程序。协程可以轻松实现高并发,通道可以方便地进行通信和同步,这使得Go语言在网络编程、分布式系统和大数据处理等领域具有优势。
- 简单性:Go语言语法简洁,容易学习和理解。Go语言没有继承和多态等复杂的语言特性,使得程序设计更加直观和简单。
- 可读性:Go语言具有良好的代码风格和格式,使得代码易于阅读和维护。Go语言的代码组织方式和注释规范使得代码的可读性和可维护性得到保证。
- 安全性:Go语言具有内置的安全特性,如内存安全、类型安全和并发安全等。Go语言的垃圾回收机制可以避免内存泄漏,类型安全可以防止代码中出现类型错误,而并发安全可以避免数据竞争问题。
- 跨平台性:Go语言的编译器可以将源代码编译为本地机器码,使得程序可以在各种操作系统上运行。同时,Go语言标准库中提供了许多与平台无关的包,如网络、文件操作等,可以方便地编写跨平台的程序。
总之,Go语言具有高效性、并发性、简单性、可读性、安全性和跨平台性等优势,使得它在云计算、网络编程、分布式系统、大数据处理等领域得到了广泛应用。
Golang数据类型有哪些
Go语言支持的数据类型包括以下几种:
1、基本数据类型
- bool:布尔型,值为true或false。
- int、int8、int16、int32、int64:整型,分别表示不同位数的有符号整数。
- uint、uint8、uint16、uint32、uint64:无符号整型,分别表示不同位数的无符号整数。
- float32、float64:浮点型,分别表示32位和64位的浮点数。
- complex64、complex128:复数类型,分别表示32位和64位的复数。
2、复合数据类型
- 数组:固定长度的同类型元素集合。
- 切片:可变长度的同类型元素序列。
- 映射(map):键值对集合,每个键对应一个值。
- 结构体(struct):不同类型字段的集合。
- 接口(interface):定义了一组方法的抽象类型,可以被任何类型实现。
3、其他数据类型
- 字符串(string):表示一个字符串序列,由单个字节字符组成。
- 指针(pointer):表示变量的内存地址。
- 函数(function):可以被调用的可执行代码块。
- 通道(channel):用于在协程之间进行通信和同步。
总之,Go语言支持的数据类型较为丰富,包括基本数据类型、复合数据类型、字符串、指针、函数和通道等。这些数据类型可以满足不同的编程需求,并且在处理高并发、大数据等场景中具有优势。
Go 支持什么形式的类型转换?
Go语言支持以下几种类型转换:
- 数值类型之间的转换
- Go语言支持整型和浮点型之间的转换,但需要注意转换的精度问题。通常,将高精度的数据类型转换为低精度的数据类型会丢失精度
- 字符串和数值类型之间的转换
- Go语言支持字符串和数值类型之间的相互转换。可以使用strconv包中的函数将字符串转换为数值类型,或将数值类型转换为字符串
- 指针类型之间的转换
- Go语言中的指针类型可以互相转换,但需要注意指针指向的数据类型必须一致。
- 自定义类型之间的转换
- Go语言中支持自定义类型之间的相互转换,但需要注意自定义类型的底层类型必须相同。
什么是 Goroutine?你如何停止它?
Goroutine 是 Go 语言中的一种轻量级线程,由 Go 运行时环境调度。与传统的线程相比,Goroutine 的创建和销毁代价非常低,可以创建成千上万个 Goroutine,而不会导致系统负担过重。Goroutine 可以通过 go 关键字启动,它会在一个独立的栈空间中执行相应的函数,可以在函数中执行阻塞和非阻塞操作。
要停止 Goroutine,需要使用 Go 语言提供的通道(channel)机制。可以在 Goroutine 中使用一个通道来接收停止信号,当主线程需要停止 Goroutine 时,向该通道发送一个信号即可。Goroutine 在执行任务的同时需要不断检测该通道是否有信号,如果有,则立即退出任务。
如何在运行时检查变量类型?
在 Go 语言中,可以使用反射(reflection)机制来在运行时检查变量的类型。反射是一种在程序运行时检查变量类型、值的机制,可以在不知道具体类型的情况下操作对象。
Go 语言中的反射主要依赖于 reflect 包提供的函数和类型。通过 reflect 包中的 TypeOf 和 ValueOf 函数,可以获取一个变量的类型和值,从而进行相关操作。
Go 两个接口之间可以存在什么关系?
在 Go 语言中,接口是一种类型,可以被用作变量类型、函数参数或返回值的类型。Go 语言中的接口类型是基于所包含的方法签名来定义的,两个接口之间可以存在以下几种关系:
- 接口类型的嵌套:一个接口类型可以包含其他的接口类型作为它的一部分,这种方式称为接口的嵌套。嵌套接口会继承被嵌套接口的所有方法,并可以添加新的方法。嵌套接口的实现类必须实现所有嵌套接口的方法。
- 接口类型的扩展:一个接口类型可以通过添加新的方法来扩展另一个接口类型,这种方式称为接口的扩展。被扩展接口的实现类也必须实现新添加的方法
- 接口类型的类型转换:可以将一个接口类型转换为另一个接口类型,前提是这两个接口类型都包含相同的方法集合。
- 接口类型的实现:一个接口类型可以被多个不同的类型实现,这些类型可以是基本类型、自定义类型或者其他接口类型。
需要注意的是,两个接口之间的关系并不是继承关系,而是通过包含相同的方法集合来建立联系。这意味着,一个接口类型并不会继承另一个接口类型的属性和方法。
扩展和嵌套是不同的概念。
接口类型的嵌套是将一个接口类型作为另一个接口类型的一部分,嵌套的接口类型可以继承被嵌套接口类型的所有方法,并可以添加新的方法。
接口类型的扩展是在一个接口类型的基础上新增方法,被扩展的接口类型的实现类也必须实现新添加的方法。
嵌套和扩展的区别在于,嵌套是将接口类型作为另一个接口类型的一部分,而扩展是在接口类型的基础上新增方法。嵌套是一种组合关系,扩展是一种继承关系。
Go 当中同步锁有什么特点?作用是什么
在 Go 语言中,同步锁是一种常用的并发编程技术,用于保证多个 Goroutine 之间的共享数据的安全访问。Go 语言中的同步锁是通过 sync 包提供的 Mutex 类型实现的。
同步锁的特点如下:
- 互斥性:同一时刻只能有一个 Goroutine 持有锁,其他 Goroutine 需要等待锁的释放才能获取锁。
- 阻塞性:如果一个 Goroutine 尝试获取一个已经被其他 Goroutine 持有的锁,它会被阻塞,直到锁被释放。
- 公平性:锁的获取是公平的,即锁会按照申请的先后顺序分配给等待的 Goroutine,避免某些 Goroutine 永远无法获取锁的情况。
同步锁的作用是保护共享数据,避免多个 Goroutine 同时对共享数据进行修改而导致的竞争条件和数据竞争问题。在需要保证共享数据的安全访问时,可以使用同步锁来对临界区进行加锁,以避免并发修改数据产生的问题。在使用同步锁时,需要注意避免死锁和饥饿等问题的发生,同时需要合理地设计锁的粒度和作用域,避免锁的竞争导致性能下降。
Go 语言当中 Channel(通道)有什么特点,需要注意什么?
在 Go 语言中,Channel 是一种用于 Goroutine 之间通信和同步的重要机制。Channel 具有以下几个特点:
- 线程安全:Channel 可以安全地在多个 Goroutine 之间传递数据,避免了数据竞争和死锁等问题。
- 阻塞式:当 Channel 中没有数据时,读取操作会被阻塞,直到 Channel 中有数据可读;同样地,当 Channel 已满时,写入操作会被阻塞,直到 Channel 中有空间可写入。
- 有缓冲和无缓冲:Channel 可以带有缓冲或者不带缓冲。不带缓冲的 Channel 可以保证每次写入和读取都是同步的;带缓冲的 Channel 可以在缓冲区未满时进行写入操作而不阻塞,直到缓冲区满时再阻塞写入操作。
- 可关闭:Channel 可以被显式地关闭,以通知 Channel 的接收方不再有数据可读,避免接收方被永久地阻塞。
在使用 Channel 时,需要注意以下几个问题:
- 避免死锁:当使用 Channel 进行 Goroutine 之间的通信和同步时,需要确保不会出现死锁的情况。一般来说,可以使用 select 语句和超时机制等方式来避免 Channel 的阻塞问题。
- 避免竞态条件:当多个 Goroutine 访问同一个 Channel 时,需要注意避免竞态条件的发生。可以使用 Mutex 和 sync 包中提供的其他同步机制来避免并发访问 Channel 导致的问题。
- 合理使用缓冲:当使用带缓冲的 Channel 时,需要根据实际需要设置缓冲区的大小,避免缓冲区过大或过小导致的性能问题。同时需要注意,当 Channel 中的数据过多时,会导致内存占用过高,需要及时清理不必要的数据。
- 避免 Channel 泄漏:当使用 Channel 时,需要注意避免 Channel 泄漏的问题,即在不需要使用 Channel 时及时关闭 Channel,避免 Channel 占用过多的系统资源。
Go 语言当中 Channel 缓冲有什么特点?
在 Go 语言中,Channel 缓冲是指在创建 Channel 时设置的缓冲区大小。带缓冲的 Channel 可以在缓冲区未满时进行写入操作而不阻塞,直到缓冲区满时再阻塞写入操作。
Channel 缓冲的特点如下:
- 可以提高并发性能:使用带缓冲的 Channel 可以提高并发程序的性能,因为缓冲区可以暂时存储数据,避免了每次数据传输时都需要阻塞等待的情况。这种方式特别适用于生产者-消费者模式,其中生产者的产生速度快于消费者的处理速度,缓冲区可以暂时存储一定量的数据,使得生产者和消费者的速度可以适度地解耦。
- 缓冲区大小需要合理设置:Channel 缓冲区大小的设置需要根据实际应用场景进行合理的选择,过小的缓冲区可能会导致生产者被阻塞,过大的缓冲区可能会导致内存占用过高。一般来说,需要根据实际情况进行调整,以达到最优的性能表现。
- 带缓冲的 Channel 可能会出现死锁问题:当使用带缓冲的 Channel 进行 Goroutine 之间的通信和同步时,需要注意避免死锁的问题。因为带缓冲的 Channel 可以在缓冲区未满时进行写入操作,如果生产者写入数据的速度过快,可能会导致缓冲区已满而阻塞生产者,此时如果消费者已经不再消费数据,整个程序就会进入死锁状态。
- 可以使用 close() 函数关闭 Channel:当使用带缓冲的 Channel 时,需要注意及时清理缓冲区中的数据,可以使用 close() 函数来显式地关闭 Channel。关闭 Channel 会使得 Channel 中未被读取的数据被丢弃,并且后续的写入操作会导致 panic 异常。
Go 语言中 cap 函数可以作用于哪些内容?
在 Go 语言中,cap() 函数可以用于以下类型的数据:
- 数组:cap() 函数返回数组的长度,因为数组的长度在创建时就已经确定了。
- 切片:cap() 函数返回切片的容量,即切片底层数组的长度,它可能大于或等于切片的长度。切片容量的大小是在切片创建时自动分配的,也可以通过对切片使用 append() 函数来改变容量大小。
- Channel:cap() 函数返回 Channel 的容量,即 Channel 缓冲区的大小,只有在创建带缓冲的 Channel 时,才有意义。
注意:cap() 函数只能作用于包含指针的数据类型,如数组、切片和 Channel,而不能用于值类型的数据类型,如整型、浮点型等。
Go 语言当中 new的作用是什么?
在 Go 语言中,new 是一个内置函数,用于创建一个新的零值变量并返回该变量的指针
new 的作用在于在堆上分配内存空间,而不是在栈上分配。使用 new 函数创建变量时,返回的指针指向在堆上分配的变量,即使该变量在函数调用结束后仍然存在。因此,new 通常用于创建结构体、数组和其他复杂数据类型的指针。
需要注意的是,new 只能创建变量的指针,而不能用于创建变量本身
Go 语言中 make 的作用是什么?
在 Go 语言中,make 是一个内置函数,用于创建一些特定类型的数据结构,如 slice、map 和 channel 等。
- 创建 slice:make([]T, length, capacity),其中 T 表示 slice 的元素类型,length 表示 slice 的长度,capacity 表示 slice 的容量。
- 创建 map:make(map[T]U, capacity),其中 T 表示 map 的键类型,U 表示 map 的值类型,capacity 表示 map 的容量。
- 创建 channel:make(chan T, capacity),其中 T 表示 channel 中元素的类型,capacity 表示 channel 的缓冲区大小,如果 capacity 为 0,则表示该 channel 是无缓冲的。
需要注意的是,使用 make 函数创建的数据结构是分配在堆上的,并返回一个引用,即一个指向数据结构的指针。这与使用 new 函数创建变量的方式不同,因为 new 只分配了变量所需的内存空间,而 make 分配了变量所需的内存空间,并初始化了变量的其他属性。因此,make 更适用于创建 slice、map 和 channel 等复杂的数据结构。
Printf(),Sprintf(),FprintF() 都是格式化输出,有什么不同?
在 Go 语言中,Printf()、Sprintf() 和 FprintF() 都是用于格式化输出的函数,但它们有一些不同之处,具体如下:
1、Printf()
Printf() 是最常用的格式化输出函数,它将格式化后的字符串输出到标准输出流(一般是终端窗口)。Printf() 的语法如下:
Printf(format string, a ...interface{}) (n int, err error)
其中,format 表示要输出的格式化字符串,a ...interface{} 表示要格式化的参数列表,可以是任意类型的参数。Printf() 函数会根据格式化字符串中的格式说明符将参数格式化为指定格式,并输出到标准输出流中。
2、Sprintf()
Sprintf() 与 Printf() 的作用类似,不同的是,它将格式化后的字符串输出到一个字符串中,而不是标准输出流。Sprintf() 的语法如下:
Sprintf(format string, a ...interface{}) string
其中,format 和 a ...interface{} 的含义与 Printf() 相同,但 Sprintf() 返回一个字符串,而不是将格式化后的字符串输出到标准输出流中。
3、FprintF()
FprintF() 与 Printf() 的作用也类似,不同的是,它将格式化后的字符串输出到指定的文件中,而不是标准输出流。FprintF() 的语法如下:
FprintF(w io.Writer, format string, a ...interface{}) (n int, err error)
其中,w 表示要输出的文件,可以是 os.Stdout、os.Stderr 或其他实现了 io.Writer 接口的类型。format 和 a ...interface{} 的含义与 Printf() 相同。FprintF() 会将格式化后的字符串输出到指定的文件中。
综上所述,Printf()、Sprintf() 和 FprintF() 都是格式化输出函数,它们的作用有所不同,但都可以通过格式说明符将参数格式化为指定格式。需要根据具体的需求选择不同的函数来使用。
Go 语言当中数组和切片的区别是什么?
在 Go 语言中,数组和切片都是用来存储一组相同类型的元素,但它们有一些不同之处,具体如下:
- 长度不同
- 数组的长度在定义时就确定了,不可更改,而切片的长度是可以动态增长和缩减的。
- 内存分配方式不同
- 数组是一个固定长度的数据结构,它在声明时就会分配一段连续的内存空间,而切片则是一个动态分配的数据结构,它的底层结构是一个指向数组的指针、长度和容量,容量可以随着元素的增加而自动增长。
- 传递方式不同
- 数组作为函数参数传递时,会被复制一份到函数栈中,因此在函数中修改数组的值并不会影响原数组;而切片作为函数参数传递时,会复制指向底层数组的指针、长度和容量,而不是整个底层数组,因此在函数中修改切片的值会影响原切片。
- 声明方式不同
- 数组的声明方式为:var array [n]T,其中 n 表示数组的长度,T 表示数组元素的类型。切片的声明方式为:var slice []T,其中 T 表示切片元素的类型。
综上所述,数组和切片都有自己的特点和优缺点,需要根据具体的需求来选择合适的数据结构。如果需要存储一组固定长度的元素,可以使用数组;如果需要动态增长和缩减元素,可以使用切片。
Go 语言当中值传递和地址传递(引用传递)如何运用?有什么区别?
Go 语言中,参数传递方式有两种:值传递和地址传递(引用传递),它们的主要区别如下:
- 值传递
- 值传递是指函数参数传递时,传递的是值的拷贝,而不是原始值的引用。在函数中修改参数的值并不会影响原始值。在 Go 语言中,基本数据类型、数组和结构体等类型都是以值的形式传递的。
- 地址传递(引用传递)
- 地址传递是指函数参数传递时,传递的是值的地址,函数中对参数值的修改会影响原始值。在 Go 语言中,切片、map 和指针等类型都是以地址的形式传递的。
Go 语言当中数组和切片在传递的时候的区别是什么?
在 Go 语言中,数组和切片在传递的时候有一些区别。
首先,数组在传递时是以值传递的方式进行的。也就是说,当我们把一个数组作为参数传递给函数时,实际上是将该数组的一个副本传递给函数。因此,在函数内部对该数组进行修改并不会影响原始数组的值。这也是数组的缺点之一,因为它会带来额外的内存开销。
而切片则是以引用传递的方式进行的。也就是说,当我们把一个切片作为参数传递给函数时,实际上是将该切片的一个指针传递给函数。因此,在函数内部对该切片进行修改会影响原始切片的值。这使得切片比数组更加灵活和高效,因为它可以动态地调整大小,并且不会占用额外的内存空间。
需要注意的是,虽然切片是引用传递的,但是切片底层的数组却仍然是以值传递的方式进行的。也就是说,在将一个切片作为参数传递给函数时,函数内部修改底层数组的值会影响到原始切片以及其他引用该数组的切片。
另外,数组和切片的定义方式也不同。数组的长度是固定的,而切片的长度可以动态地改变。这也是切片比数组更加灵活的原因之一。
Go 语言是如何实现切片扩容的?
在 Go 语言中,切片的扩容是通过 append() 函数实现的。当切片的容量不足时,append() 函数会自动对切片进行扩容,并返回一个新的切片。
具体来说,当一个切片的容量不足时,Go 语言会为该切片重新分配一块更大的内存空间。通常情况下,新分配的内存空间大小为原来的两倍。然后,将原来的数据复制到新的内存空间中,并在新内存空间的末尾添加新的元素。最后,返回一个新的切片,指向新的内存空间。
需要注意的是,切片的扩容可能会导致底层数组重新分配内存空间,并将原来的数据复制到新的内存空间中,因此扩容操作的时间复杂度为 O(n),其中 n 表示切片的长度。因此,如果需要对一个大型的切片进行频繁的扩容操作,可能会对程序的性能产生影响。为了避免这种情况,可以在创建切片时尽可能地指定切片的容量,或者使用数组来代替切片。
扩容条件
切片的扩容条件是:在使用 append() 函数追加元素时,如果当前的元素个数已经达到了底层数组的容量,那么就会触发切片的扩容操作。
切片底层的数组容量是在创建切片时确定的。当切片长度小于等于底层数组容量时,切片底层数组就可以满足需求,不需要扩容。当切片长度大于底层数组容量时,就需要对底层数组进行扩容,才能满足追加元素的需求。
一般情况下,切片的扩容规则是将底层数组的容量翻倍。但在实际扩容过程中,可能会存在一些优化策略,例如:在容量小于 1024 时,每次扩容增加一倍容量;在容量大于 1024 时,每次扩容增加 25% 容量等等。这些细节会由 Go 语言运行时根据实际情况进行调整,用户不需要过多关注。
需要注意的是,切片的扩容可能会导致底层数组重新分配内存空间,并将原来的数据复制到新的内存空间中,因此扩容操作的时间复杂度为 O(n),其中 n 表示切片的长度。因此,如果需要对一个大型的切片进行频繁的扩容操作,可能会对程序的性能产生影响。为了避免这种情况,可以在创建切片时尽可能地指定切片的容量,或者使用数组来代替切片。
defer 的执行顺序是什么? defer的作用和特点是什么?
在 Go 语言中,defer 是一种延迟执行机制,用于在函数退出前执行一些特定的代码,无论是函数正常返回还是发生异常。defer 语句是在函数调用结束后执行的,即使出现错误或 panic 也会执行。defer 可以用于清理资源、处理错误等场景。
defer 语句的执行顺序是“后进先出”的,也就是说最后一个被 defer 的语句会最先执行,直到第一个被 defer 的语句执行完毕为止。
需要注意的是,defer 延迟执行的代码并不是在函数退出前立即执行,而是在函数执行结束后,当函数返回时才会执行。因此,如果在 defer 语句中使用的变量在函数返回前发生了改变,那么最终执行的代码将使用最终值。
Golang Slice 的底层实现
在 Go 语言中,Slice 是一种基于数组的数据结构,它是一个拥有指向底层数组的指针、长度和容量属性的结构体。Slice 的底层实现是一个动态数组,也就是说,它可以动态地增长和缩小,同时具有数组的许多优点,例如可以进行索引和迭代操作等。
当创建一个 Slice 时,Go 语言会在内存中分配一块连续的内存空间用于存储数据,并返回一个指向该内存区域的指针。该指针称为 Slice 的底层指针。Slice 还会记录该内存区域的长度和容量。
在 Slice 容量不足以存储新的元素时,Go 语言会自动重新分配一块更大的内存区域,并将原有的元素复制到新的内存区域中,然后将新的元素添加到新的内存区域中。这种自动扩容机制使得 Slice 的大小可以根据需要自动调整,无需手动进行内存分配和释放操作,同时也保证了 Slice 的连续性,使得它在访问和遍历元素时具有更好的性能表现。
需要注意的是,由于 Slice 是对底层数组的引用,因此多个 Slice 可以共享同一个底层数组。这种特性使得 Slice 在函数之间传递时非常高效,同时也需要注意避免对 Slice 中的元素进行修改,从而影响到其他共享同一个底层数组的 Slice。
Golang Slice 的扩容机制,有什么注意点?
Go语言中的切片(Slice)具有动态扩容的能力。当切片容量不足以容纳更多元素时,就需要扩容。切片的扩容机制是在原切片容量的基础上扩容,一般是容量的2倍或者1.25倍,具体扩容的倍数由实现算法决定。以下是关于切片扩容的一些注意点:
- 切片扩容会重新分配一块连续的内存空间,因此需要将原切片中的元素复制到新的内存空间中,这个过程可能会比较耗时。
- 在使用 append() 函数向切片添加元素时,如果添加的元素个数超出了切片的容量,那么就会触发扩容操作。
- 切片扩容会导致原切片和新切片指向不同的内存空间,因此原切片的修改不会影响新切片的值。
- 切片扩容并不是每次添加元素都会触发,而是当切片容量不足以容纳更多元素时才会触发。
- 当切片容量小于1024时,扩容时新的容量会翻倍;当容量大于等于1024时,新的容量会增加原来容量的1/4,也就是乘以1.25。
- 由于切片底层是基于数组实现的,因此切片扩容时,如果原数组的容量不足以容纳新的元素,也会触发数组的重新分配和拷贝。 总之,切片的扩容机制需要注意性能和内存问题,特别是在大规模数据处理中,应该尽量减少切片扩容的次数。
扩容前后的 Slice 是否相同?
在 Golang 中,扩容前后的 Slice 是不同的。在进行 Slice 扩容时,会创建一个新的底层数组,并将原来的元素拷贝到新的数组中。因此,扩容前后的 Slice 指向的底层数组是不同的。
Golang 中的 Slice 是基于数组实现的,因此在创建 Slice 时,底层会创建一个数组来存储数据。当 Slice 中的元素个数超过底层数组的容量时,就需要进行扩容。而在 Golang 中,数组的大小是固定的,无法进行扩容,因此需要创建一个新的底层数组,并将原来的元素拷贝到新的数组中。这样就可以实现 Slice 的扩容了。由于扩容后底层数组的地址已经发生了变化,因此扩容前后的 Slice 底层数组是不同的,即扩容前后的 Slice 不再共享底层数组。
Golang 的参数传递、引用类型
在 Golang 中,函数调用时参数传递可以分为值传递和引用传递。
值传递:将参数的值复制一份,然后将复制的值传递给函数,函数对参数的修改不会影响到原始的值。常见的值类型如 int、float、bool 等都是值类型,它们的传递都是值传递。
引用传递:将参数的地址复制一份,然后将复制的地址传递给函数,函数对参数的修改会影响到原始的值。常见的引用类型如 Slice、Map、Channel、指针等都是引用类型,它们的传递都是引用传递。
需要注意的是,在 Golang 中数组虽然是引用类型,但是它的传递却是值传递。这是因为 Golang 的数组长度是固定的,数组的值复制时会将整个数组的元素都复制一遍,因此传递数组时的开销较大,而且数组的长度也不可变,因此将数组的地址复制一份也无法修改原数组的长度,所以 Golang 采用了值传递的方式。
总之,对于值类型的参数,使用值传递即可;对于引用类型的参数,使用引用传递可以避免大量数据的复制,提高程序的效率。同时,在使用引用类型的参数时,需要注意并发访问的问题。
Golang Map 底层实现
Go语言中的 map 是一种无序的键值对的集合,底层实现使用了哈希表(hash table)。
具体来说,Go语言中的 map 实际上是一个指向哈希表的指针。哈希表本身是由若干个桶(bucket)组成的,每个桶包含了若干个键值对,每个键值对由一个 key 和一个 value 组成。在对 map 进行读写操作时,Go语言会根据 key 计算出它在哈希表中的位置,然后直接访问对应的桶,从而实现高效的访问。
具体而言,当我们往 map 中添加键值对时,Go语言会首先计算出 key 的哈希值,然后根据哈希值计算出 key 在哈希表中的位置。如果该位置还没有被占用,Go语言会在该位置上创建一个新的桶,并把键值对放入该桶中;如果该位置已经被占用,Go语言会在该桶中查找是否已经有一个键值对的 key 与待添加的 key 相同。如果找到了相同的 key,就替换该键值对的 value;如果没有找到相同的 key,就将新的键值对添加到该桶的末尾。
需要注意的是,Go语言中的 map 不是线程安全的,因此在多线程并发访问时需要使用锁等机制来保证安全。
另外,由于哈希表的大小是固定的,因此当 map 中的元素数量达到一定程度时,需要对哈希表进行扩容。具体的扩容机制可以参考前面的问题“Golang Map 扩容机制,有什么注意点?”中的回答。
Golang Map 如何扩容
在 Golang 中,Map 的底层实现是使用哈希表(Hash Table)实现的。在插入新元素时,如果当前 Map 中的元素个数达到了当前 Map 所能容纳的最大元素个数,就会触发扩容操作。
Map 的扩容操作会重新创建一个更大的哈希表,并将旧哈希表中的元素重新哈希到新的哈希表中。同时,新哈希表的大小一定是旧哈希表大小的两倍,因为 Golang 采用了指数级扩容策略,每次扩容后 Map 可以容纳的元素个数是之前的两倍。
当 Map 进行扩容时,由于哈希表中的元素需要重新哈希到新的哈希表中,因此会涉及到大量的内存复制操作,导致性能下降。为了减少这种情况的发生,Golang 中的 Map 实现采用了增量式哈希算法,可以在扩容时只复制新增的元素,从而提高性能。
需要注意的是,在 Map 进行扩容时,可能会导致哈希冲突的数量增加,因此扩容后的 Map 的性能可能会有所下降。为了避免这种情况,可以考虑在创建 Map 时指定初始容量,以减少扩容的次数。
Golang Map 查找
在 Go 语言中,使用 map 查找一个键值对的过程可以通过 map[key] 来完成,返回值是对应的值和一个表示是否存在的布尔值。
具体来说,如果 map 中存在该键,则返回对应的值和布尔值 true;如果不存在该键,则返回值类型的零值和布尔值 false。
另外,也可以直接使用一个值来获取键值对中的值,但是如果键值对中不存在该键,会返回该值类型的零值。
需要注意的是,map 的键类型必须支持相等运算,例如,数字、字符串、指针、通道、接口类型、结构体类型等都是支持的,但是数组、切片、函数类型等不支持。
底层实现
在 Golang 中,Map 的查找是通过哈希表实现的。当程序执行 map 查找操作时,会先根据哈希函数将 key 转换成一个哈希值,然后在哈希表中查找该哈希值对应的桶(bucket),再在桶中查找对应的键值对。
具体来说,当 Map 中的键值对数量超过一定阈值时,会触发自动扩容操作。扩容操作会重新分配更大的桶数组,并将原有的键值对重新哈希分布到新的桶中。
在查找时,Golang 的 Map 会先通过哈希值定位到对应的桶(bucket),然后在桶中遍历链表(每个桶可能对应多个键值对)查找对应的键值对。在遍历链表的过程中,如果发现某个键值对的 key 与要查找的 key 相等,则返回该键值对的 value。
需要注意的是,如果 Map 中的键值对过多,桶中的链表会很长,查找时效率会降低,因此需要根据实际情况合理设置 Map 的容量和哈希函数,以充分利用哈希表的优势。同时,当 Map 中的键值对类型为复杂类型(如结构体)时,需要重载对应的哈希函数和比较函数,以确保哈希表的正确性。
介绍一下 Channel
Go 语言中,Channel(通道)是用于多个 Goroutine 之间进行通信的一种机制,通过它们可以安全地传递数据。
Channel 是一种类型,可以使用内置的 make() 函数来创建它们。创建 Channel 时,需要指定它们可以传输的数据类型。
使用 Channel 时,可以在 Goroutine 之间传递数据,通过它们可以进行同步和异步的操作。在使用 Channel 时,需要注意以下几点:
- Channel 是引用类型,可以像 Slice 和 Map 一样传递给函数。
- 默认情况下,Channel 是无缓冲的,只有当有 Goroutine 准备好接收数据时,发送操作才会成功。如果发送操作没有被接收,发送的 Goroutine 将会阻塞。
- 通过 make() 函数创建带缓冲的 Channel 时,可以指定缓冲区的大小。在缓冲区没有被填满之前,发送操作不会阻塞。
- Channel 支持多路复用,可以使用 select 语句在多个 Channel 上进行选择和等待。
- Channel 可以用于控制 Goroutine 的执行,例如通过关闭 Channel 来通知 Goroutine 退出。 使用 Channel 可以帮助解决并发编程中的一些常见问题,例如避免竞态条件、协调不同 Goroutine 之间的操作等。
Channel 的 ring buffer 实现
在 Go 语言中,channel 是一种用于在 goroutine 之间进行通信的机制。通常情况下,channel 会被实现为一个 FIFO 的队列。当向 channel 发送数据时,数据会被添加到队列的末尾;当从 channel 接收数据时,数据会被从队列的头部取出。
在 Go 1.3 版本中,新增了一种基于环形缓冲区(ring buffer)的 channel 实现方式,可以用于提高 channel 的性能。具体来说,当创建一个缓冲区大小为 n 的 channel 时,Go 语言会为其分配一个大小为 n 的环形缓冲区,而不是一个简单的队列。
使用环形缓冲区实现 channel 有以下几个好处:
- 避免动态内存分配:在缓冲区大小确定的情况下,环形缓冲区可以在创建时一次性分配所需的内存,避免了频繁的动态内存分配和释放操作,从而提高了性能。
- 提高缓存命中率:环形缓冲区会将元素放置在连续的内存块中,这样可以提高缓存命中率,从而减少缓存访问延迟,提高了通信的效率。
- 支持无锁访问:由于 channel 是在多个 goroutine 之间进行通信的,因此通常会涉及到并发访问的问题。环形缓冲区的实现可以采用无锁算法,从而避免了锁竞争带来的开销,提高了并发访问的效率。
需要注意的是,使用环形缓冲区实现 channel 也有一些限制和注意事项。例如,缓冲区大小必须是 2 的幂次方,否则可能会导致缓冲区溢出或者浪费内存等问题。同时,对于特殊的 channel 操作,如 close、select 和带缓冲区的 channel 等,也需要注意环形缓冲区的使用方式。
Go方法与函数的区别?
在 Go 语言中,方法(method)是一个包含接收者参数的函数,用于为接收者类型提供一些行为。而函数(function)则是一段代码,可被调用并可接收参数和返回值。
方法需要被绑定到一个类型上,它们通过使用接收者参数来实现这一点。接收者可以是值类型或指针类型。值类型的接收者在方法执行时会将调用者的值复制一份,而指针类型的接收者则直接操作调用者的值,因此可以修改调用者的状态。
与方法不同,函数没有接收者参数,因此它们无法直接修改调用者的状态。函数在 Go 语言中是一等公民,可以像任何其他类型的值一样被传递和赋值。函数还可以是匿名的,或者被作为闭包使用,以便在不同的作用域中进行操作。
Go方法值接收者和指针接收者的区别?
在Go中,方法可以定义在结构体类型上。接收者是指在方法定义中声明的函数参数。接收者可以是值接收者,也可以是指针接收者。值接收者在方法调用时会对接收者进行复制,而指针接收者则会使用指针来引用原始接收者。
使用值接收者时,方法中对接收者所做的任何修改都不会影响原始接收者。而使用指针接收者时,方法中对接收者所做的任何修改都将影响原始接收者。
另外,指针接收者的优势在于它可以避免在每次调用方法时复制接收者,从而提高程序的性能。此外,在某些情况下,只有使用指针接收者才能修改接收者的状态,因为值接收者只能修改接收者的副本。
Go函数返回局部变量的指针是否安全?
一般来说,局部变量会在函数返回后被销毁,因此被返回的引用就成为了"无所指"的引用,程序会进入未知状态。
但这在 Go 中是安全的,Go 编译器将会对每个局部变量进行逃逸分析。如果发现局部变量的作用域超出该函数,则不会将内存分配在栈上,而是分配在堆上,因为他们不在栈区,即使释放函数,其内容也不会受影响。
Go函数参数传递到底是值传递还是引用传递?
Go语言中所有的传参都是值传递(传值),都是一个副本,一个拷贝。
参数如果是非引用类型(int、string、struct等这些),这样就在函数中就无法修改原内容数据;如果是引用类型(指针、map、slice、chan等这些),这样就可以修改原内容数据。
是否可以修改原内容数据,和传值、传引用没有必然的关系。在C++中,传引用肯定是可以修改原内容数据的,在Go语言里,虽然只有传值,但是我们也可以修改原内容数据,因为参数是引用类型
引用类型和引用传递是2个概念,切记!!!
什么是值传递?
将实参的值传递给形参,形参是实参的一份拷贝,实参和形参的内存地址不同。函数内对形参值内容的修改,是否会影响实参的值内容,取决于参数是否是引用类型
什么是引用传递?
将实参的地址传递给形参,函数内对形参值内容的修改,将会影响实参的值内容。Go语言是没有引用传递的,在C++中,函数参数的传递方式有引用传递。
下面分别针对Go的值类型(int、struct等)、引用类型(指针、slice、map、channel),验证是否是值传递,以及函数内对形参的修改是否会修改原内容数据
Go defer关键字的实现原理?
defer 能够让我们推迟执行某些函数调用,推迟到当前函数返回前才实际执行。defer与panic和recover结合,形成了Go语言风格的异常与捕获机制。
- 使用场景:
- defer 语句经常被用于处理成对的操作,如文件句柄关闭、连接关闭、释放锁
- 优点:
- 方便开发者使用
- 缺点:
- 有性能损耗
- 实现原理:
- Go1.14中编译器会将defer函数直接插入到函数的尾部,无需链表和栈上参数拷贝,性能大幅提升。把defer函数在当前函数内展开并直接调用,这种方式被称为open coded defer
Go内置函数make和new的区别?
首先纠正下make和new是内置函数,不是关键字
变量初始化,一般包括2步,变量声明 + 变量内存分配,var关键字就是用来声明变量的,new和make函数主要是用来分配内存的
var声明值类型的变量时,系统会默认为他分配内存空间,并赋该类型的零值
比如布尔、数字、字符串、结构体
如果指针类型或者引用类型的变量,系统不会为它分配内存,默认就是nil。此时如果你想直接使用,那么系统会抛异常,必须进行内存分配后,才能使用。
new 和 make 两个内置函数,主要用来分配内存空间,有了内存,变量就能使用了,主要有以下2点区别:
- 使用场景区别:
- make 只能用来分配及初始化类型为slice、map、chan 的数据。
- new 可以分配任意类型的数据,并且置零。
- 返回值区别:
- make函数原型如下,返回的是slice、map、chan类型本身
- 这3种类型是引用类型,就没有必要返回他们的指针
Go slice的底层实现原理
切片是基于数组实现的,它的底层是数组,可以理解为对 底层数组的抽象。
type slice struct {
array unsafe.Pointer
len int
cap int
}
- slice占用24个字节
- array: 指向底层数组的指针,占用8个字节
- len: 切片的长度,占用8个字节
- cap: 切片的容量,cap 总是大于等于 len 的,占用8个字节
slice有四种初始化方式
// 初始化方式1:直接声明
var slice1 []int
// 初始化方式2:使用字面量
slice2 := []int{1, 2, 3, 4}
// 初始化方式3:使用make创建slice
slice3 := make([]int, 3, 5)
// 初始化方式4: 从切片或数组“截取”
slcie4 := arr[1:3]
Go array和slice的区别?
- 数组长度不同
- 数组初始化必须指定长度,并且长度就是固定的
- 切片的长度是不固定的,可以追加元素,在追加时可能使切片的容量增大
- 函数传参不同
- 数组是值类型,将一个数组赋值给另一个数组时,传递的是一份深拷贝,函数传参操作都会复制整个数组数据,会占用额外的内存,函数内对数组元素值的修改,不会修改原数组内容。
- 切片是引用类型,将一个切片赋值给另一个切片时,传递的是一份浅拷贝,函数传参操作不会拷贝整个切片,只会复制len和cap,底层共用同一个数组,不会占用额外的内存,函数内对数组元素值的修改,会修改原数组内容。
- 计算数组长度方式不同
- 数组需要遍历计算数组长度,时间复杂度为O(n)
- 切片底层包含len字段,可以通过len()计算切片长度,时间复杂度为O(1)
Go slice深拷贝和浅拷贝
深拷贝:拷贝的是数据本身,创造一个新对象,新创建的对象与原对象不共享内存,新创建的对象在内存中开辟一个新的内存地址,新对象值修改时不会影响原对象值
- 实现深拷贝的方式:
- copy(slice2, slice1)
- 遍历append赋值
浅拷贝:拷贝的是数据地址,只复制指向的对象的指针,此时新对象和老对象指向的内存地址是一样的,新对象值修改时老对象也会变化
- 实现浅拷贝的方式:
- 引用类型的变量,默认赋值操作就是浅拷贝,slice2 := slice1
Go slice扩容机制?
扩容会发生在slice append的时候,当slice的cap不足以容纳新元素,就会进行扩容,扩容规则如下
- 如果新申请容量比两倍原有容量大,那么扩容后容量大小 为 新申请容量
- 如果原有 slice 长度小于 1024, 那么每次就扩容为原来的 2 倍
- 如果原 slice 长度大于等于 1024, 那么每次扩容就扩为原来的 1.25 倍
Go slice为什么不是线程安全的?
slice底层结构并没有使用加锁等方式,不支持并发读写,所以并不是线程安全的,使用多个 goroutine 对类型为 slice 的变量进行操作,每次输出的值大概率都不会一样,与预期值不一致; slice在并发执行中不会报错,但是数据会丢失
Go语言实现线程安全常用的几种方式:
- 互斥锁
- 读写锁
- 原子操作
- sync.once
- sync.atomic
- channel
Go map的底层实现原理
Go中的map是一个指针,占用8个字节,指向hmap结构体
hmap包含若干个结构为bmap的数组,每个bmap底层都采用链表结构,bmap通常叫其bucket
hmap结构体
// A header for a Go map.
type hmap struct {
count int
// 代表哈希表中的元素个数,调用len(map)时,返回的就是该字段值。
flags uint8
// 状态标志(是否处于正在写入的状态等)
B uint8
// buckets(桶)的对数
// 如果B=5,则buckets数组的长度 = 2^B=32,意味着有32个桶
noverflow uint16
// 溢出桶的数量
hash0 uint32
// 生成hash的随机数种子
buckets unsafe.Pointer
// 指向buckets数组的指针,数组大小为2^B,如果元素个数为0,它为nil。
oldbuckets unsafe.Pointer
// 如果发生扩容,oldbuckets是指向老的buckets数组的指针,老的buckets数组大小是新的buckets的1/2;非扩容状态下,它为nil。
nevacuate uintptr
// 表示扩容进度,小于此地址的buckets代表已搬迁完成。
extra *mapextra
// 存储溢出桶,这个字段是为了优化GC扫描而设计的,下面详细介绍
}
bmap结构体
bmap 就是我们常说的“桶”,一个桶里面会最多装 8 个 key,这些 key 之所以会落入同一个桶,是因为它们经过哈希计算后,哈希结果的低B位是相同的,关于key的定位我们在map的查询中详细说明。在桶内,又会根据 key 计算出来的 hash 值的高 8 位来决定 key 到底落入桶内的哪个位置(一个桶内最多有8个位置)。
// A bucket for a Go map.
type bmap struct {
tophash [bucketCnt]uint8
// len为8的数组
// 用来快速定位key是否在这个bmap中
// 一个桶最多8个槽位,如果key所在的tophash值在tophash中,则代表该key在这个桶中
}
上面bmap结构是静态结构,在编译过程中runtime.bmap会拓展成以下结构体:
type bmap struct{
tophash [8]uint8
keys [8]keytype
// keytype 由编译器编译时候确定
values [8]elemtype
// elemtype 由编译器编译时候确定
overflow uintptr
// overflow指向下一个bmap,overflow是uintptr而不是*bmap类型,保证bmap完全不含指针,是为了减少gc,溢出桶存储到extra字段中
}
tophash就是用于实现快速定位key的位置,在实现过程中会使用key的hash值的高8位作为tophash值,存放在bmap的tophash字段中
tophash字段不仅存储key哈希值的高8位,还会存储一些状态值,用来表明当前桶单元状态,这些状态值都是小于minTopHash的
为了避免key哈希值的高8位值和这些状态值相等,产生混淆情况,所以当key哈希值高8位若小于minTopHash时候,自动将其值加上minTopHash作为该key的tophash。桶单元的状态值如下:
emptyRest = 0 // 表明此桶单元为空,且更高索引的单元也是空
emptyOne = 1 // 表明此桶单元为空
evacuatedX = 2 // 用于表示扩容迁移到新桶前半段区间
evacuatedY = 3 // 用于表示扩容迁移到新桶后半段区间
evacuatedEmpty = 4 // 用于表示此单元已迁移
minTopHash = 5 // key的tophash值与桶状态值分割线值,小于此值的一定代表着桶单元的状态,大于此值的一定是key对应的tophash值
func tophash(hash uintptr) uint8 {
top := uint8(hash >> (goarch.PtrSize*8 - 8))
if top < minTopHash {
top += minTopHash
}
return top
}
mapextra结构体
当map的key和value都不是指针类型时候,bmap将完全不包含指针,那么gc时候就不用扫描bmap。bmap指向溢出桶的字段overflow是uintptr类型,为了防止这些overflow桶被gc掉,所以需要mapextra.overflow将它保存起来。如果bmap的overflow是*bmap类型,那么gc扫描的是一个个拉链表,效率明显不如直接扫描一段内存(hmap.mapextra.overflow)
type mapextra struct {
overflow *[]*bmap
// overflow 包含的是 hmap.buckets 的 overflow 的 buckets
oldoverflow *[]*bma
// oldoverflow 包含扩容时 hmap.oldbuckets 的 overflow 的 bucket
nextOverflow *bmap
// 指向空闲的 overflow bucket 的指针
}
Go map遍历为什么是无序的?
使用 range 多次遍历 map 时输出的 key 和 value 的顺序可能不同。这是 Go 语言的设计者们有意为之,旨在提示开发者们,Go 底层实现并不保证 map 遍历顺序稳定,请大家不要依赖 range 遍历结果顺序
主要原因有2点:
- map在遍历时,并不是从固定的0号bucket开始遍历的,每次遍历,都会从一个随机值序号的bucket,再从其中随机的cell开始遍历
- map遍历时,是按序遍历bucket,同时按需遍历bucket中和其overflow bucket中的cell。但是map在扩容后,会发生key的搬迁,这造成原来落在一个bucket中的key,搬迁后,有可能会落到其他bucket中了,从这个角度看,遍历map的结果就不可能是按照原来的顺序了
map 本身是无序的,且遍历时顺序还会被随机化,如果想顺序遍历 map,需要对 map key 先排序,再按照 key 的顺序遍历 map。
Go map为什么是非线程安全的?
map默认是并发不安全的,同时对map进行并发读写时,程序会panic,原因如下:
Go 官方在经过了长时间的讨论后,认为 Go map 更应适配典型使用场景(不需要从多个 goroutine 中进行安全访问),而不是为了小部分情况(并发访问),导致大部分程序付出加锁代价(性能),决定了不支持。
Go map如何查找?
Go 语言中读取 map 有两种语法:带 comma 和 不带 comma。当要查询的 key 不在 map 里,带 comma 的用法会返回一个 bool 型变量提示 key 是否在 map 中;而不带 comma 的语句则会返回一个 value 类型的零值。如果 value 是 int 型就会返回 0,如果 value 是 string 类型,就会返回空字符串。
Go map冲突的解决方式?
Go map采用链地址法解决冲突,具体就是插入key到map中时,当key定位的桶填满8个元素后(这里的单元就是桶,不是元素),将会创建一个溢出桶,并且将溢出桶插入当前桶所在链表尾部。
当哈希冲突发生时,创建新单元,并将新单元添加到冲突单元所在链表的尾部。
Go map 的负载因子为什么是 6.5?
负载因子(load factor),用于衡量当前哈希表中空间占用率的核心指标,也就是每个 bucket 桶存储的平均元素个数。
负载因子 = 哈希表存储的元素个数/桶个数
- 在程序运行时,会不断地进行插入、删除等,会导致 bucket 不均,内存利用率低,需要迁移。
- 在程序运行时,出现负载因子过大,需要做扩容,解决 bucket 过大的问题。
Go 官方发现:装载因子越大,填入的元素越多,空间利用率就越高,但发生哈希冲突的几率就变大。反之,装载因子越小,填入的元素越少,冲突发生的几率减小,但空间浪费也会变得更多,而且还会提高扩容操作的次数
根据这份测试结果和讨论,Go 官方取了一个相对适中的值,把 Go 中的 map 的负载因子硬编码为 6.5,这就是 6.5 的选择缘由。
这意味着在 Go 语言中,当 map存储的元素个数大于或等于 6.5 * 桶个数 时,就会触发扩容行为。
Go map如何扩容?
在向 map 插入新 key 的时候,会进行条件检测,符合下面这 2 个条件,就会触发扩容
- 超过负载
- map元素个数 > 6.5 * 桶个数
- 溢出桶太多
- 当桶总数 < 2 ^ 15 时,如果溢出桶总数 >= 桶总数,则认为溢出桶过多。
- 当桶总数 >= 2 ^ 15 时,直接与 2 ^ 15 比较,当溢出桶总数 >= 2 ^ 15 时,即认为溢出桶太多了。
双倍扩容:针对条件1,新建一个buckets数组,新的buckets大小是原来的2倍,然后旧buckets数据搬迁到新的buckets。该方法我们称之为双倍扩容
等量扩容:针对条件2,并不扩大容量,buckets数量维持不变,重新做一遍类似双倍扩容的搬迁动作,把松散的键值对重新排列一次,使得同一个 bucket 中的 key 排列地更紧密,节省空间,提高 bucket 利用率,进而保证更快的存取。该方法我们称之为*等量扩容。
Go map和sync.Map谁的性能好,为什么?
Go 语言的 sync.Map 支持并发读写,采取了 “空间换时间” 的机制,冗余了两个数据结构,分别是:read 和 dirty
和原始map+RWLock的实现并发的方式相比,减少了加锁对性能的影响。它做了一些优化:可以无锁访问read map,而且会优先操作read map,倘若只操作read map就可以满足要求,那就不用去操作write map(dirty),所以在某些特定场景中它发生锁竞争的频率会远远小于map+RWLock的实现方式
Go channel有什么特点?
hannel有2种类型:无缓冲、有缓冲
channel有3种模式:写操作模式(单向通道)、读操作模式(单向通道)、读写操作模式(双向通道)
- 一个 channel不能多次关闭,会导致painc
- 如果多个 goroutine 都监听同一个 channel,那么 channel 上的数据都可能随机被某一个 goroutine 取走进行消费
- 如果多个 goroutine 监听同一个 channel,如果这个 channel 被关闭,则所有 goroutine 都能收到退出信号
Mutex 几种状态
- mutexLocked — 表示互斥锁的锁定状态
- mutexWoken — 表示从正常模式被从唤醒
- mutexStarving — 当前的互斥锁进入饥饿状态
- waitersCount — 当前互斥锁上等待的 Goroutine 个数
Mutex 正常模式和饥饿模式
- 正常模式(非公平锁)
- 正常模式下,所有等待锁的 goroutine 按照 FIFO(先进先出)顺序等待。唤醒 的 goroutine 不会直接拥有锁,而是会和新请求 goroutine 竞争锁。新请求的 goroutine 更容易抢占:因为它正在 CPU 上执行,所以刚刚唤醒的 goroutine有很大可能在锁竞争中失败。在这种情况下,这个被唤醒的 goroutine 会加入 到等待队列的前面。
- 饥饿模式(公平锁)
- 为了解决了等待 goroutine 队列的长尾问题
- 饥饿模式下,直接由 unlock 把锁交给等待队列中排在第一位的 goroutine (队头),同时,饥饿模式下,新进来的 goroutine 不会参与抢锁也不会进入自旋状态,会直接进入等待队列的尾部。这样很好的解决了老的 goroutine 一直抢不 到锁的场景。 饥饿模式的触发条件:当一个 goroutine 等待锁时间超过 1 毫秒时,或者当前 队列只剩下一个 goroutine 的时候,Mutex 切换到饥饿模式。
- 总结
- 对于两种模式,正常模式下的性能是最好的,goroutine 可以连续多次获取锁,饥饿模式解决了取锁公平的问题,但是性能会下降,这其实是性能和公平 的一个平衡模式。
Mutex 允许自旋的条件
- 锁已被占用,并且锁不处于饥饿模式。
- 积累的自旋次数小于最大自旋次数(active_spin=4)。
- CPU 核数大于 1。
- 有空闲的 P。
- 当前 Goroutine 所挂载的 P 下,本地待运行队列为空。
RWMutex 实现
sync.RWMutex是Go语言标准库提供的一种读写锁,用于在多个goroutine同时访问共享资源时进行保护。与sync.Mutex类似,sync.RWMutex也是通过互斥锁实现的,但是它允许多个goroutine同时获取读锁,而只允许一个goroutine获取写锁。
- 优先唤醒写者:在释放读锁或写锁时,如果有正在等待的写锁goroutine,应该优先唤醒它们,因为写锁的优先级更高。
- 读锁的等待问题:在等待读锁的goroutine中,如果有其他goroutine正在持有写锁或等待写锁,那么这些读锁goroutine应该等待写锁goroutine释放锁,避免因等待读锁而导致写锁饥饿。
RWMutex 注意事项
- RWMutex 是单写多读锁,该锁可以加多个读锁或者一个写锁
- 读锁占用的情况下会阻止写,不会阻止读,多个 Goroutine 可以同时获取 读锁
- 写锁会阻止其他 Goroutine(无论读和写)进来,整个锁由该 Goroutine 独占
- 适用于读多写少的场景
- RWMutex 类型变量的零值是一个未锁定状态的互斥锁
- RWMutex 在首次被使用之后就不能再被拷贝
- RWMutex 的读锁或写锁在未锁定状态,解锁操作都会引发 panic
- RWMutex 的一个写锁去锁定临界区的共享资源,如果临界区的共享资源已 被(读锁或写锁)锁定,这个写锁操作的 goroutine 将被阻塞直到解锁
- RWMutex 的读锁不要用于递归调用,比较容易产生死锁
- RWMutex 的锁定状态与特定的 goroutine 没有关联。一个 goroutine 可 以 RLock(Lock),另一个 goroutine 可以 RUnlock(Unlock)
- 写锁被解锁后,所有因操作锁定读锁而被阻塞的 goroutine 会被唤醒,并 都可以成功锁定读锁
- 读锁被解锁后,在没有被其他读锁锁定的前提下,所有因操作锁定写锁而 被阻塞的 Goroutine,其中等待时间最长的一个 Goroutine 会被唤醒
Cond 是什么
在 Go 语言中,sync.Cond 是一个条件变量的实现,它可以在多个 Goroutine 之间传递信号和数据。条件变量是一种同步机制,用于解决某些 Goroutine 需要等待某个事件或条件发生的问题。
sync.Cond 是基于 sync.Mutex 或 sync.RWMutex 的,它提供了 Wait()、Signal() 和 Broadcast() 三个方法。
- Wait():释放锁并阻塞当前 Goroutine,直到调用 Signal() 或 Broadcast() 并重新获得锁。在阻塞期间,Goroutine 处于等待状态并且不会消耗 CPU 资源。
- Signal():唤醒一个等待中的 Goroutine。
- Broadcast():唤醒所有等待中的 Goroutine。
Broadcast 和 Signal 区别
在 Go 语言中,sync.Cond 类型提供了 Broadcast() 和 Signal() 两个方法来唤醒等待条件变量的 Goroutine。这两个方法的区别在于:
- Signal() 方法只会唤醒等待条件变量的一个 Goroutine,具体哪个 Goroutine 会被唤醒是不确定的。如果多个 Goroutine 等待同一个条件变量,那么只会有一个 Goroutine 被唤醒,其他 Goroutine 仍然会继续等待条件变量。
- Broadcast() 方法会唤醒所有等待条件变量的 Goroutine,使它们都开始运行。如果多个 Goroutine 等待同一个条件变量,那么所有 Goroutine 都会被唤醒。
一般来说,使用 Signal() 方法可以提高程序的效率,因为只需要唤醒一个 Goroutine,其他 Goroutine 仍然会等待条件变量,不会消耗 CPU 资源。但是,如果有多个 Goroutine 都需要同时等待条件变量,那么使用 Broadcast() 方法才能保证它们都能被唤醒,否则可能会出现死锁等问题。
总之,Broadcast() 方法是一种安全可靠的方法,但是可能会导致一些性能问题。而 Signal() 方法则可以提高程序的效率,但是需要确保程序的正确性。在实际应用中,应该根据具体情况选择合适的方法。
Cond 中 Wait 使用
在 Go 语言中,sync.Cond 类型提供了 Wait() 方法来让 Goroutine 等待条件变量。当 Goroutine 调用 Wait() 方法时,它会释放已经持有的锁,并阻塞在条件变量上,直到另一个 Goroutine 调用 Signal() 或 Broadcast() 方法,并释放锁,唤醒了它。被唤醒的 Goroutine 会重新尝试获得锁,然后继续执行。
需要注意的是,调用 Wait() 方法之前必须先获取锁,否则会出现死锁等问题。另外,在使用 Wait() 方法时,要确保有其他 Goroutine 会发送 Signal() 或 Broadcast() 信号,否则可能会导致 Goroutine 永久阻塞。
WaitGroup 用法
在 Go 语言中,sync.WaitGroup 类型提供了一种方便的方式来等待多个 Goroutine 完成它们的任务。WaitGroup 可以被用来跟踪一组 Goroutine,等待它们完成任务并汇总结果。
WaitGroup 还提供了一个 WaitGroup.Add() 方法,可以将计数器增加指定的值,以便一次性添加多个需要等待的 Goroutine。另外,WaitGroup 还支持嵌套使用,即在一个 Goroutine 中使用 WaitGroup 等待一组 Goroutine 完成任务,并在另一个 WaitGroup 中使用这个 Goroutine 作为一项任务等待其他 Goroutine 完成任务。
WaitGroup 实现原理
在 Go 语言中,sync.WaitGroup 的实现原理非常简单,它基本上是通过一个计数器来实现的。当我们调用 WaitGroup.Add(n) 方法时,它会将计数器的值增加 n。当我们调用 WaitGroup.Done() 方法时,它会将计数器的值减 1。而当我们调用 WaitGroup.Wait() 方法时,它会阻塞等待,直到计数器的值为 0。
什么是 sync.Once
sync.Once 是 Go 语言中的一个同步原语,用于实现只执行一次的操作。它可以保证在多个 Goroutine 中只执行一次指定的操作,即使这个操作被多次调用。
sync.Once 的使用非常简单,只需要创建一个 sync.Once 类型的变量,然后使用 Do() 方法来指定要执行的操作。Do() 方法会保证指定的操作只会被执行一次,无论它被调用多少次。
需要注意的是,Do() 方法是阻塞的,也就是说,在第一次调用还没有完成之前,后续的调用会被阻塞。这个特性可以用来保证只有一个 Goroutine 执行指定的操作,而其他 Goroutine 等待它完成之后再继续执行。此外,Do() 方法只会执行一次指定的操作,即使在多个 Goroutine 中调用它。这个特性可以用来避免重复初始化等问题。
什么操作叫做原子操作
在并发编程中,原子操作是一种不可中断的操作,要么全部完成,要么全部不完成。这意味着在多线程环境下,原子操作可以保证数据的一致性和可靠性,防止多个线程同时对同一数据进行操作而导致的竞争条件和数据不一致。
在 Go 语言中,sync/atomic 包提供了一些原子操作函数,用于在多线程环境中执行原子操作。这些原子操作函数可以确保对共享变量的访问是原子的,即不会被其他线程打断。
需要注意的是,原子操作函数仅保证对共享变量的访问是原子的,但并不能保证对多个变量之间的操作是原子的。如果需要对多个变量进行原子操作,可以使用互斥锁或其他同步机制来保证线程安全。
原子操作和锁的区别
Go 中的原子操作和锁都是用于保证并发安全的机制,但它们之间有一些区别。
原子操作是一种特殊的操作,它可以在单个 CPU 指令周期内完成对共享变量的读取和修改,从而保证了操作的原子性。在 Go 中,使用 sync/atomic 包提供的原子操作函数可以对共享变量进行原子操作,从而避免了多个 Goroutine 对同一变量进行并发修改时出现的竞争条件问题。原子操作不需要获取锁,因此效率比锁更高,但是只适用于一些简单的操作,比如读取和修改整数类型的变量。
锁是另一种保证并发安全的机制,它可以确保同一时间只有一个 Goroutine 可以访问共享资源。在 Go 中,使用 sync 包提供的锁可以实现互斥访问共享资源的目的。锁的机制需要获取锁才能对共享变量进行操作,因此效率比原子操作略低。但是锁可以用于任何类型的变量,而不仅仅是整数类型的变量,因此可以用于更复杂的操作。
综上所述,如果需要对简单的整数类型变量进行原子操作,可以使用原子操作;如果需要对任意类型的变量进行并发安全的操作,应该使用锁。需要根据具体的应用场景选择使用哪种机制,以获得最佳的性能和可靠性。
什么是 CAS
CAS,即 Compare-And-Swap,是一种常见的并发控制机制,也是原子操作的一种。它用于实现在多个线程并发修改同一数据时的同步和互斥访问,是实现锁、并发队列等数据结构的基础。
CAS 操作需要三个参数:内存地址 V,期望值 A 和新值 B。CAS 操作的执行过程如下:
比较内存地址 V 中存储的值与期望值 A 是否相等; 如果相等,则将内存地址 V 中存储的值更新为新值 B; 如果不相等,则说明其他线程已经修改了内存地址 V 中存储的值,此时 CAS 操作失败,需要重新尝试。
在 Go 中,使用 sync/atomic 包提供的 CompareAndSwapXXX() 函数可以执行 CAS 操作,其中 XXX 表示不同的数据类型。例如,CompareAndSwapInt32() 函数用于对一个 int32 类型的变量执行 CAS 操作。
CompareAndSwapInt32() 函数的第一个参数是一个指向 int32 类型变量的指针,它告诉函数要对哪个变量进行 CAS 操作。第二个参数是期望值 A,第三个参数是新值 B。如果 value 的值与期望值 A 相等,则函数会将 value 的值更新为新值 B,并返回 true,否则不会更新 value 的值,并返回 false。
sync.Pool 有什么用
sync.Pool 是 Go 标准库中的一个对象池实现,它的作用是缓存对象,减少对象的创建和垃圾回收,从而提高程序的性能。
在程序中,创建和销毁对象是很耗费时间和资源的操作,特别是在高并发情况下。如果能够复用已经创建好的对象,就可以减少对象的创建和垃圾回收,提高程序的性能。这就是对象池的作用。
sync.Pool 的实现比较简单,它维护了两个对象池:一个是空闲对象池,用于存储可重复使用的对象;另一个是新对象池,用于存储不能重复使用的对象。在使用对象时,首先从空闲对象池中获取对象,如果空闲对象池为空,则从新对象池中获取对象,如果新对象池也为空,则创建一个新对象。使用完对象后,将对象放回空闲对象池中。
需要注意的是,sync.Pool 并不保证对象一定会被重用。如果空闲对象池中没有可用的对象,或者对象已经达到了一定的数量限制,那么 sync.Pool 会选择创建新对象。因此,在使用 sync.Pool 时,需要谨慎设计对象的数量和生命周期,以确保对象的重复使用。
Goroutine 定义
Goroutine 是 Go 语言中的一种轻量级线程实现,它可以在单个进程中同时执行多个任务,实现了并发编程。与传统的线程相比,Goroutine 的创建和切换开销非常小,因此可以轻松创建数以千计的 Goroutine,而不会导致系统资源的耗尽。
Goroutine 的定义非常简单,只需要在函数调用前添加关键字 go 即可创建一个 Goroutine。
需要注意的是,Goroutine 是由 Go 运行时环境调度的,它们并不是线程或进程。每个 Goroutine 都是由 Go 运行时环境自动分配的,它们共享相同的地址空间和堆栈。因此,在 Goroutine 中共享内存需要采用同步机制来保证线程安全。
Goroutine 是 Go 语言的核心特性之一,它使得并发编程变得简单而高效。通过合理使用 Goroutine,可以充分发挥多核 CPU 的性能,提高程序的并发处理能力。
GMP 指的是什么
GMP 指的是 Go 语言运行时的三个关键组件:Goroutine、M(Machine)和 P(Processor)。
Goroutine 已经在前面的问题中讲到了,是 Go 语言中轻量级线程的实现,它可以在单个进程中同时执行多个任务,实现了并发编程。
M(Machine)是 Go 语言运行时的机器模型,它是操作系统线程(OS thread)和 Goroutine 之间的中间件。在 Go 语言中,每个 Goroutine 都会被分配到一个 M 上执行,而每个 M 只能同时执行一个 Goroutine,这是 Go 语言实现并发的关键之一。当一个 Goroutine 阻塞或者需要等待 I/O 操作时,对应的 M 会被回收,等待其它 Goroutine 上的任务。
P(Processor)是 Go 语言运行时的处理器,它负责调度 Goroutine 在 M 上运行,同时也负责管理 Goroutine 的队列、调度等工作。在 Go 语言中,P 的数量是可以配置的,默认情况下为机器的核心数,但是可以通过环境变量 GOMAXPROCS 来进行修改。
GMP 模型在 Go 语言中实现了一种高效的并发编程机制,它可以轻松地创建数以千计的 Goroutine,实现并发编程,而不会导致系统资源的耗尽。同时,GMP 模型也提供了一个高度灵活的调度器,可以自动地调整 Goroutine 的数量和 P 的数量,以适应不同的负载。
1.0 之前 GM 调度模型
在 Go 1.0 之前,Go 语言的运行时使用的是 GM 调度模型,与现在的 GMP 调度模型有所不同。在 GM 模型中,M(Machine)和 P(Processor)被合并为一个单一的调度器,称为 G(Goroutine)调度器。
在 GM 模型中,所有的 Goroutine 都被分配到一个全局的 Goroutine 队列中,每个 M 都会从队列中取出一个 Goroutine 来执行。当一个 Goroutine 阻塞或者需要等待 I/O 操作时,对应的 M 会回收它,并从全局队列中取出另外一个 Goroutine 继续执行。这样,一个 M 可以执行多个 Goroutine,而不像现在的 GMP 模型一样只能执行一个。
GM 模型相对于 GMP 模型的优势是它的调度器更加简单,同时在低负载的情况下可以更加高效地利用系统资源。然而,GM 模型也存在一些问题,最大的问题是在高负载的情况下,由于所有的 Goroutine 都被放在全局队列中,导致竞争变得非常激烈,从而降低了并发性能。另外,GM 模型也无法支持多核 CPU 的并行执行,因为它只有一个单一的调度器。
因此,从 Go 1.0 开始,Go 语言的运行时采用了 GMP 调度模型,通过引入 M 和 P 的概念,实现了更加高效的并发编程机制,同时支持多核 CPU 的并行执行。
GMP 调度流程
在 GMP 调度模型下,Go 程序的执行流程可以简单地描述为以下几个步骤:
- 初始化:在程序启动时,运行时会初始化一个 G(Goroutine)调度器和一组 M(Machine)线程。调度器用于调度 Goroutine 的执行,M 线程用于执行 Goroutine。
- Goroutine 创建和调度:当程序启动时,会创建一个主 Goroutine,然后程序可以创建更多的 Goroutine。每个 Goroutine 都有一个 G 对象,其中包含了 Goroutine 的状态和执行栈。当一个 Goroutine 被创建时,它会被加入到一个本地队列中等待调度。
- M 的绑定:在 GMP 调度模型中,每个 M 线程都会绑定一个 P(Processor),P 负责调度 Goroutine 的执行。当一个 M 线程启动时,它会尝试获取一个 P 来绑定,如果当前没有可用的 P,它就会等待直到有一个可用的 P。
- P 的调度:P 会不断从全局队列和本地队列中获取 Goroutine 并将其调度到绑定的 M 线程上执行。如果一个 Goroutine 阻塞或者需要等待 I/O 操作时,P 会将其从 M 上回收,并将 M 设置为闲置状态。在此期间,P 会从全局队列和其他 M 的本地队列中获取更多的 Goroutine 并将其调度到闲置的 M 上执行。
- 阻塞和唤醒:当一个 Goroutine 阻塞或者需要等待 I/O 操作时,它会被回收并从本地队列中移除。当阻塞或等待结束时,它会重新加入到本地队列中等待调度。
- 关闭和退出:当程序结束时,所有的 Goroutine 都会被终止并回收。此外,程序也可以使用 channel 等机制来通知 Goroutine 退出并回收资源。 总的来说,GMP 调度模型是 Go 语言运行时的核心机制,它通过多线程和协作调度等技术实现了高效的并发编程,支持多核 CPU 的并行执行。
GMP 中 work stealing 机制
GMP 中的 work stealing 机制是指在某个 M 线程的本地队列中没有 Goroutine 可供执行时,它会从其他 M 线程的本地队列中偷取 Goroutine 来执行。
具体地,work stealing 机制的实现过程如下:
每个 M 线程都有一个本地队列,用于存储待执行的 Goroutine。当一个 Goroutine 被创建时,它会被加入到一个本地队列中等待调度。 当一个 M 线程的本地队列中没有 Goroutine 可供执行时,它会从其他 M 线程的本地队列中随机选择一个队列,并尝试从该队列中偷取一些 Goroutine 来执行。在此期间,当前 M 线程会不断尝试从全局队列中获取 Goroutine 并将其调度到本地队列中执行。 当一个 M 线程偷取了其他 M 线程的 Goroutine 后,它会将这些 Goroutine 添加到自己的本地队列中,并将它们调度到绑定的 P 上执行。 work stealing 机制的好处是可以避免线程饥饿,提高 Goroutine 的调度效率。当某个 M 线程的本地队列中没有 Goroutine 可供执行时,它可以从其他 M 线程的队列中偷取 Goroutine 来执行,从而提高整个系统的并发能力和负载均衡性。同时,由于 work stealing 机制的实现比较复杂,因此在高并发场景下可能会增加一些额外的开销,需要谨慎使用。
package main
import (
"fmt"
"runtime"
"sync"
"time"
)
func main() {
runtime.GOMAXPROCS(2) // 启用两个 P
var wg sync.WaitGroup
wg.Add(10)
for i := 0; i < 10; i++ {
go func() {
time.Sleep(time.Second) // 模拟 Goroutine 的耗时操作
fmt.Println("Goroutine executed.")
wg.Done()
}()
}
wg.Wait()
}
在上述代码中,我们启用了两个 P,并创建了 10 个 Goroutine。每个 Goroutine 都会执行一个耗时操作(这里用 time.Sleep() 来模拟),然后输出一条信息。
当这些 Goroutine 执行完毕后,我们可以观察到它们是如何在不同的 P 上执行的。由于 work stealing 机制的存在,每个 P 可能会从其他 P 的队列中偷取 Goroutine 来执行,从而达到负载均衡的目的。
GMP 中 hand off 机制
GMP 中的 hand off 机制是指在某个 M 线程需要将当前正在执行的 Goroutine 交给另一个 M 线程时,使用的一种机制。
具体地,hand off 机制的实现过程如下:
当一个 M 线程需要将当前正在执行的 Goroutine 交给另一个 M 线程时,它会将该 Goroutine 和一个指向目标 M 线程的指针打包成一个结构体,称为 hand off 对象。
当目标 M 线程的本地队列中没有 Goroutine 可供执行时,它会从全局队列中获取一个 hand off 对象,并尝试将其中的 Goroutine 从原来的 M 线程中获取出来,添加到自己的本地队列中执行。在此期间,当前 M 线程会不断尝试从全局队列中获取 Goroutine 并将其调度到本地队列中执行。
当目标 M 线程成功获取到 hand off 对象后,它会将其中的 Goroutine 添加到自己的本地队列中,并将它们调度到绑定的 P 上执行。
hand off 机制的好处是可以避免线程饥饿,提高 Goroutine 的调度效率。当一个 M 线程需要将当前正在执行的 Goroutine 交给另一个 M 线程时,可以使用 hand off 机制来尽快地将 Goroutine 交给目标 M 线程,从而避免线程饥饿的问题。同时,由于 hand off 机制只在需要将当前正在执行的 Goroutine 交给另一个 M 线程时才会被使用,因此相对于 work stealing 机制来说,它的实现比较简单,不会增加太多额外的开销。
协作式的抢占式调度
在 Go 语言中,Goroutine 调度器采用的是协作式调度,也就是说,在一个 Goroutine 执行过程中,如果没有主动交出控制权(比如调用 time.Sleep()、channel 操作等),其他 Goroutine 是无法抢占执行的。这样可以避免出现线程安全的问题,但也会导致某个 Goroutine 长时间占用 CPU 时间,从而降低程序整体的并发性能。
为了解决这个问题,Go 语言在 1.14 版本引入了抢占式调度。抢占式调度的主要思想是,在 Goroutine 执行过程中,如果某个 Goroutine 执行时间过长,会被强制抢占,让其他 Goroutine 有机会执行。这样可以保证所有 Goroutine 公平地获得 CPU 时间,从而提高程序的并发性能。
在抢占式调度中,Go 语言采用了基于信号的抢占方式。具体来说,当一个 Goroutine 执行时间过长时,会在指定时间内收到一个抢占信号,然后在信号处理程序中暂停当前 Goroutine 的执行,并将控制权交给调度器,让调度器决定下一个要执行的 Goroutine。当下一个 Goroutine 开始执行时,之前被暂停的 Goroutine 就被称为“被抢占”的 Goroutine。
需要注意的是,抢占式调度只在 Go 语言的系统线程中生效,而在非系统线程中,仍然采用协作式调度。这是因为非系统线程是由 Go 语言运行时管理的,无法被操作系统直接抢占,因此只能采用协作式调度。另外,抢占式调度对于需要实现低延迟的应用程序可能不太适合,因为抢占操作需要额外的 CPU 时间,从而增加了系统的响应时间。
基于信号的抢占式调度
在 golang 中,除了协作式调度和抢占式调度,还有一种基于信号的抢占式调度。基于信号的抢占式调度可以让 Goroutine 在执行过程中被立即中断,并强制切换到其他 Goroutine,从而实现抢占式调度。
在 golang 中,我们可以使用 runtime 包中的两个函数实现基于信号的抢占式调度:
- runtime.Gosched():让出 CPU 时间片,让其他 Goroutine 运行。
- runtime.LockOSThread():将当前 Goroutine 绑定到当前线程上,让该 Goroutine 独占一个线程,从而实现更精细的调度控制。
GMP 调度过程中存在哪些阻塞
在 GMP(GNU 多精度算术库)调度过程中,可能会存在以下几种阻塞情况:
- IO 阻塞:当 GMP 库进行 IO 操作时,如果 IO 操作需要等待数据读取或写入,此时 GMP 库的调度可能会被阻塞。
- 系统调用阻塞:当 GMP 库使用系统调用时,如申请内存、获取时间等,如果系统调用需要等待结果返回,此时 GMP 库的调度可能会被阻塞。
- 锁竞争阻塞:当多个线程同时访问 GMP 库的同一个数据结构时,可能会出现锁竞争的情况,如果某个线程获得锁并持有锁的时间过长,其他线程的调度可能会被阻塞。
- 垃圾回收阻塞:在 GMP 库中,存在一种称为“垃圾回收”的机制,用于释放不再使用的内存。当垃圾回收机制启动时,所有线程的调度都会被暂停,直到垃圾回收完成。
总之,GMP 调度过程中的阻塞情况可能会导致程序执行时间延长,因此在编写 GMP 应用程序时需要考虑如何避免或减少阻塞情况的发生。
Sysmon 有什么作用
ysmon 也叫监控线程,变动的周期性检查,好处
- 释放闲置超过 5 分钟的 span 物理内存
- 如果超过 2 分钟没有垃圾回收,强制执行
- 将长时间未处理的 netpoll 添加到全局队列
- 30 向长时间运行的 G 任务发出抢占调度(超过 10ms 的 g,会进行 retake)
- 收回因 syscall 长时间阻塞的 P
三色标记原理
三色标记算法(Tri-color Mark and Sweep Algorithm)是垃圾回收算法中常用的一种,也是 Go 语言中垃圾回收器采用的算法之一。
三色标记算法的基本思路是将内存中的对象分为三种状态:白色(未访问)、灰色(已访问,但还未处理)、黑色(已访问,且已处理),并按照一定的顺序遍历所有对象,标记出所有可达的对象,最终清除不可达的对象。
具体来说,三色标记算法分为以下几个步骤:
初始时,所有对象都是白色,加入一个“根集合”(root set),根集合是一组已知可达对象的集合,如全局变量、函数调用栈等。
将根集合中的所有对象标记为灰色,并加入一个“灰色集合”(gray set)中。
从灰色集合中取出一个灰色对象,遍历其引用的所有对象。如果某个对象是白色,将其标记为灰色,并加入灰色集合中;如果某个对象是灰色,不做处理;如果某个对象是黑色,也不做处理。
将该灰色对象标记为黑色,并从灰色集合中移除。
重复步骤 3 和 4,直到灰色集合为空。
此时所有可达对象都被标记为黑色,所有不可达对象都是白色。将所有白色对象标记为垃圾,并回收其占用的内存空间。
在 Go 语言中,三色标记算法是垃圾回收器采用的一种算法,其中还包括了其他的优化算法,如并发标记(Concurrent Mark)和并发清除(Concurrent Sweep)。Go 的垃圾回收器会在程序运行时自动启动,不需要手动管理内存,大大减少了程序员的工作量。
写屏障
在 Go 语言的垃圾回收机制中,写屏障(Write Barrier)是一种机制,用于保证内存对象在进行垃圾回收过程中的正确性。写屏障是通过在程序运行过程中对写操作进行监测来实现的。如果一个指针变量被修改为指向一个新的内存对象,写屏障会将被修改的指针变量所指向的对象标记为“灰色”状态,并将新指向的对象标记为“黑色”状态,以确保该对象不被误判为垃圾对象。在垃圾回收过程中,只有标记为“黑色”状态的对象才能被视为可达对象,而未被标记或者被标记为“灰色”状态的对象则被认为是不可达对象,可以被回收。
在 Go 语言中,写屏障是由垃圾回收器来实现的,Go 编译器会在需要使用写屏障的地方插入相应的代码。具体来说,当一个指针变量被修改为指向一个新的对象时,Go 编译器会在生成的汇编代码中插入一个钩子(Hook)函数,这个钩子函数会在对象头中设置一个标志位,表示该对象需要被扫描,以便在垃圾回收的过程中正确地标记该对象。
需要注意的是,写屏障虽然可以提高垃圾回收的准确性,但也会带来一定的性能开销,因为需要在写操作时对对象进行监测和处理。因此,在 Go 语言中,写屏障只在必要的情况下才会被触发。同时,也可以通过一些手段来减少写屏障的触发次数,如尽可能减少对象的复制和移动,或者将一些对象的内存布局重新调整,以减少垃圾回收时的复杂度。
插入写屏障
在 Go 语言中,插入写屏障的具体实现方式是通过调用内置函数 writebarrier 来实现的。writebarrier 函数接收两个参数,第一个参数是指向指针变量的指针(也就是指针变量的地址),第二个参数是新的指针值。当程序执行到 writebarrier 函数时,垃圾回收器会监测指针变量的修改,并根据新的指针值来更新对象的标记状态,以保证垃圾回收的正确性。
需要注意的是,在实际使用中,我们通常不需要手动插入写屏障,因为 Go 语言的垃圾回收机制会自动为我们插入写屏障。只有在编写某些底层库或者需要手动管理内存的场景下,才需要手动插入写屏障。
删除写屏障
Go 语言的垃圾回收器会自动为我们插入写屏障,因此通常不需要手动插入写屏障。在某些特殊情况下,我们可能需要删除写屏障,例如在编写一些性能敏感的代码时。在 Go 1.15 及之前的版本中,我们可以通过 //go:nowritebarrier 注释来实现删除写屏障。在 Go 1.16 版本中,删除写屏障的方式发生了变化,现在需要使用内置函数 nowritebarrier 来实现。
需要注意的是,删除写屏障可能会导致垃圾回收的不准确性,因此在使用时应谨慎。通常情况下,我们不建议删除写屏障。
混合写屏障
Go 语言的垃圾回收器使用了混合写屏障(Mixed-Mode Write Barrier)来提高垃圾回收的效率和准确性。混合写屏障结合了写屏障和并发标记,可以在不暂停程序运行的情况下进行垃圾回收,并且可以最大程度地减少对程序性能的影响。
混合写屏障是在 Go 1.5 版本中引入的。与 Go 1.4 版本及之前的版本不同,Go 1.5 版本开始使用混合写屏障进行垃圾回收。在混合写屏障中,写屏障会在并发标记过程中被触发。写屏障的作用是在对象被修改后,标记被修改的对象,并将对象的指针添加到待处理队列中。在并发标记过程中,垃圾回收器会扫描这些队列,并将其中的对象标记为活动对象。
需要注意的是,混合写屏障的实现方式可能会因为不同的垃圾回收器版本而有所不同。因此,在使用混合写屏障时,应该仔细查阅相关文档,确保代码的正确性和兼容性。
GC 触发时机
Go 语言的垃圾回收器采用了自适应的垃圾回收策略,会根据当前程序的运行情况和垃圾回收的历史记录动态地调整垃圾回收的触发时机和策略。在一般情况下,垃圾回收器会在下列情况下触发垃圾回收:
- 内存占用达到阈值:当程序使用的内存超过一定的阈值时,垃圾回收器会被触发,回收无用的内存,以避免进一步的内存占用。
- 分配速率超过阈值:当程序的内存分配速率超过一定的阈值时,垃圾回收器会被触发,回收无用的内存,以避免进一步的内存占用。
- 空闲时间超过阈值:当程序空闲时间超过一定的阈值时,垃圾回收器会被触发,回收无用的内存,以避免内存泄漏。
- 调用 runtime.GC() 函数:程序可以调用 runtime.GC() 函数主动触发垃圾回收。
需要注意的是,Go 语言的垃圾回收器是并发的,因此在程序运行过程中,垃圾回收器可能会随时被触发。此外,Go 语言还提供了一些垃圾回收相关的环境变量和参数,可以用于调整垃圾回收的触发时机和策略。例如,可以通过设置 GOGC 环境变量来调整垃圾回收的阈值,也可以通过设置 GODEBUG 环境变量来查看垃圾回收的相关信息。
Go 语言中 GC 的流程是什么?
Go 语言中的垃圾回收是自动的,采用了并发、分代的垃圾回收策略。下面是 Go 语言中垃圾回收的大致流程:
- 标记阶段(Marking Phase):垃圾回收器首先从根对象(如全局变量、栈变量等)出发,标记所有可达的对象,标记完成后,所有未被标记的对象就可以被回收了。
- 清除阶段(Sweeping Phase):在清除阶段,垃圾回收器会遍历整个堆,将未被标记的对象进行回收,并将已回收的内存加入空闲链表,以供下次分配使用。
- 整理阶段(Compacting Phase):在清除阶段结束后,堆中会留下大量不连续的内存碎片,这会影响程序的性能。因此,垃圾回收器会在需要的时候进行整理,将存活的对象移动到一段连续的内存空间中,从而消除内存碎片。
需要注意的是,Go 语言的垃圾回收器是并发的,它可以在程序继续运行的同时进行垃圾回收,不会影响程序的正常运行。此外,Go 语言的垃圾回收器还采用了分代策略,将堆分为多个代,每个代使用不同的回收策略。在每次回收中,垃圾回收器会优先回收较老的对象,从而减少垃圾回收的开销。
GC 如何调优
Go 语言的垃圾回收器是自动运行的,它会自动检测堆的大小、活动对象的数量等信息,并根据这些信息来调整垃圾回收的策略和频率。但是,如果应用程序的内存使用模式比较特殊,或者对响应时间、吞吐量等方面有特殊的需求,我们可以通过调整一些参数来优化垃圾回收器的性能。
以下是一些常见的调优方法:
- GOGC 环境变量:可以通过设置 GOGC 环境变量来调整垃圾回收的频率和策略。GOGC 的默认值是 100,表示每个新的内存分配都会触发垃圾回收,这可能会影响程序的响应时间和吞吐量。如果程序需要更高的吞吐量和更低的延迟,可以将 GOGC 的值增大,例如 200 或 300,来减少垃圾回收的频率。
- 内存池:可以通过使用 sync.Pool 等内存池来减少内存分配和回收的开销,从而减轻垃圾回收的压力。
- 对象大小和生命周期:可以尽量使用较小的对象,并尽可能减少对象的生命周期,从而减少内存的占用和垃圾回收的开销。
- 避免过度分配:可以使用性能分析工具来检测程序中是否存在过度分配的情况,并尽可能减少内存分配的次数和大小,从而减少垃圾回收的开销。
- 并发垃圾回收:Go 语言的垃圾回收器是并发的,可以在程序运行时自动调整垃圾回收的频率和策略,从而最大限度地减少对程序性能的影响。可以通过修改 GOMAXPROCS 环境变量来控制垃圾回收器使用的 CPU 核数,从而提高并发度,进一步提升程序的性能。
Go channel为什么是线程安全的?
Go channel 是线程安全的,原因在于 channel 内部实现了同步机制,它可以保证在多个 goroutine 之间的同步和互斥访问。
具体来说,Go channel 内部实现了两个重要的操作:发送和接收。当一个 goroutine 向一个 channel 发送数据时,如果 channel 已满,那么发送操作会被阻塞,直到 channel 中有足够的空间。同样地,当一个 goroutine 从一个 channel 接收数据时,如果 channel 已空,那么接收操作也会被阻塞,直到 channel 中有新的数据可供接收。
这种阻塞式的操作可以保证 channel 在多个 goroutine 之间的同步和互斥访问,从而避免了多个 goroutine 同时对同一个变量进行修改的竞争条件(race condition)问题。而在 Go 语言中,对于同一个变量的竞争条件问题是需要通过同步机制来解决的。
因此,通过使用 channel,我们可以很方便地实现多个 goroutine 之间的数据交换和同步,而不必担心竞争条件问题。同时,Go channel 还具有一些其他的优点,例如可以实现单向通信、支持多路复用、可用于控制流等。
Go channel如何控制goroutine并发执行顺序?
Go channel 可以用于控制 goroutine 的并发执行顺序。具体来说,我们可以利用 channel 的阻塞特性来控制 goroutine 的执行顺序。
比如,我们可以创建一个带缓冲的 channel,并在 goroutine 中向该 channel 中发送数据。当缓冲区已满时,该 goroutine 会被阻塞,直到有其他 goroutine 从 channel 中接收数据,释放出缓冲区空间为止。这样,我们就可以利用 channel 的缓冲区大小来控制 goroutine 的并发执行数量。
另外,我们还可以使用无缓冲的 channel 来控制 goroutine 的执行顺序。具体来说,我们可以利用 channel 的阻塞特性和同步机制来保证 goroutine 的有序执行。
比如,我们可以创建两个 goroutine,其中一个 goroutine 向一个无缓冲的 channel 发送数据,另一个 goroutine 从该 channel 中接收数据,当该 channel 中有数据时,才会执行该 goroutine。这样,我们就可以保证第一个 goroutine 先执行,并将数据发送到 channel 中,然后第二个 goroutine 才能执行,并从该 channel 中接收数据。
Go channel共享内存有什么优劣势?
Go channel 通过共享内存的方式来实现 goroutine 之间的通信,这种方式相对于传统的锁和信号量等同步原语来说,具有一些优点和缺点。
优点:
- 高效:相对于传统的锁和信号量等同步原语,使用 channel 实现并发控制更加高效,因为 channel 本身就是一种高效的并发原语。
- 可以减少死锁和竞态条件:由于 channel 的特性,即只能有一个 goroutine 在读取或写入数据,所以可以避免出现死锁和竞态条件等并发问题。
- 安全性高:channel 作为一种线程安全的并发原语,可以确保在 goroutine 之间共享数据时,数据不会被意外篡改或损坏。
缺点:
- 内存占用高:使用 channel 共享内存时,需要占用一定的内存空间来存储数据,当数据量较大时,会占用较多的内存。
- 传输数据类型受限:channel 只能传输固定类型的数据,不支持传输复杂的数据结构,如函数、接口等。
- 难以调试:由于 channel 的异步特性,当出现并发问题时,难以排查和调试。
因此,在实际应用中,我们需要权衡使用 channel 和传统同步原语的优缺点,根据实际情况来选择合适的并发控制方式。一般来说,当需要传输简单类型的数据时,使用 channel 的优势更加明显,而当需要传输复杂的数据类型或者控制复杂的并发逻辑时,使用传统同步原语可能更为适合。
Go channel发送和接收什么情况下会死锁?
在 Go 中,channel 发送和接收操作可能会导致死锁的情况,一般来说,有以下几种情况容易引起死锁:
- 发送者和接收者数量不一致:如果发送者的数量不等于接收者的数量,那么可能会导致发送者或接收者被阻塞,进而导致死锁。
- 单向 channel 的使用不当:如果使用了单向 channel 并且发送者和接收者之间出现了交叉,那么可能会导致死锁。比如,一个只允许发送数据的 channel 被多个 goroutine 接收,或者一个只允许接收数据的 channel 被多个 goroutine 发送。
- 循环等待:当多个 goroutine 彼此等待对方释放资源时,就会发生循环等待的情况,从而导致死锁。这种情况在 channel 中也可能出现,例如,一个 goroutine 在发送数据时,另一个 goroutine 在接收数据之前需要释放某些资源,而这个资源的释放需要等待第一个 goroutine 发送完数据。
- channel 缓冲区已满或为空:当一个非缓冲的 channel 中没有数据可以被读取或者缓冲区已满时,发送和接收操作都会被阻塞,从而导致死锁。
为了避免以上情况的发生,我们可以在编写代码时注意使用 channel,确保发送者和接收者数量一致,使用单向 channel 时注意正确的使用方式,避免循环等待的情况,以及正确地使用 channel 的缓冲区。此外,我们还可以使用 Go 语言提供的一些工具来检测死锁的情况,例如 go vet、go test -race 等。
Go channel有无缓冲的区别?
无缓冲:一个送信人去你家送信,你不在家他不走,你一定要接下信,他才会走。
有缓冲:一个送信人去你家送信,扔到你家的信箱转身就走,除非你的信箱满了,他必须等信箱有多余空间才会走。
Go 互斥锁的实现原理?
Go sync包提供了两种锁类型:互斥锁sync.Mutex 和 读写互斥锁sync.RWMutex,都属于悲观锁。
概念:
Mutex是互斥锁,当一个 goroutine 获得了锁后,其他 goroutine 不能获取锁(只能存在一个写者或读者,不能同时读和写)
使用场景:
多个线程同时访问临界区,为保证数据的安全,锁住一些共享资源, 以防止并发访问这些共享数据时可能导致的数据不一致问题。
获取锁的线程可以正常访问临界区,未获取到锁的线程等待锁释放后可以尝试获取锁
互斥锁对应的是底层结构是sync.Mutex结构体
type Mutex struct {
state int32
sema uint32
}
state表示锁的状态,有锁定、被唤醒、饥饿模式等,并且是用state的二进制位来标识的,不同模式下会有不同的处理方式
sema表示信号量,mutex阻塞队列的定位是通过这个变量来实现的,从而实现goroutine的阻塞和唤醒
- 在 Lock() 之前使用 Unlock() 会导致 panic 异常
- 使用 Lock() 加锁后,再次 Lock() 会导致死锁(不支持重入),需Unlock()解锁后才能再加锁
- 锁定状态与 goroutine 没有关联,一个 goroutine 可以 Lock,另一个 goroutine 可以 Unlock
Go 互斥锁正常模式和饥饿模式的区别?
在 Go 语言中,互斥锁(Mutex)有两种模式:正常模式和饥饿模式。
- 正常模式
- 在正常模式下,当多个 goroutine 请求锁时,锁会随机地分配给其中的一个 goroutine,其他 goroutine 则会被阻塞。当锁被释放后,等待锁的 goroutine 中的一个会被唤醒,并重新尝试获取锁。
- 饥饿模式
- 在饥饿模式下,当多个 goroutine 请求锁时,锁会优先分配给等待时间最长的 goroutine,而其他 goroutine 则会被继续阻塞。这种模式下可以避免某些 goroutine 长时间无法获得锁的问题,但是会导致其他 goroutine 无法获得锁的饥饿现象。
在正常情况下,使用正常模式的互斥锁即可满足需求,因为它可以保证所有 goroutine 都有机会获得锁。但是在某些特殊情况下,饥饿模式可能更适合,例如,对于某些实时系统,为了保证某些关键任务及时完成,可能需要使用饥饿模式来保证这些任务获得足够的 CPU 资源。
在 Go 中,默认使用正常模式,但是可以通过在创建互斥锁时设置 Mutex 结构体的 MutexProfile 字段为 MutexProfile{Starvation: true} 来使用饥饿模式。
Go 互斥锁允许自旋的条件?
线程没有获取到锁时常见有2种处理方式:
一种是没有获取到锁的线程就一直循环等待判断该资源是否已经释放锁,这种锁也叫做自旋锁,它不用将线程阻塞起来, 适用于并发低且程序执行时间短的场景,缺点是cpu占用较高 另外一种处理方式就是把自己阻塞起来,会释放CPU给其他线程,内核会将线程置为「睡眠」状态,等到锁被释放后,内核会在合适的时机唤醒该线程,适用于高并发场景,缺点是有线程上下文切换的开销 Go语言中的Mutex实现了自旋与阻塞两种场景,当满足不了自旋条件时,就会进入阻塞
允许自旋的条件:
- 锁已被占用,并且锁不处于饥饿模式。
- 积累的自旋次数小于最大自旋次数(active_spin=4)。
- cpu 核数大于 1。
- 有空闲的 P。
- 当前 goroutine 所挂载的 P 下,本地待运行队列为空。
Go 读写锁的实现原理?
读写互斥锁RWMutex,是对Mutex的一个扩展,当一个 goroutine 获得了读锁后,其他 goroutine可以获取读锁,但不能获取写锁;当一个 goroutine 获得了写锁后,其他 goroutine既不能获取读锁也不能获取写锁(只能存在一个写者或多个读者,可以同时读)
使用场景:
读多于写的情况(既保证线程安全,又保证性能不太差)
底层实现结构:
互斥锁对应的是底层结构是sync.RWMutex结构体
type RWMutex struct {
w Mutex // 复用互斥锁
writerSem uint32 // 信号量,用于写等待读
readerSem uint32 // 信号量,用于读等待写
readerCount int32 // 当前执行读的 goroutine 数量
readerWait int32 // 被阻塞的准备读的 goroutine 的数量
}
注意点:
- 读锁或写锁在 Lock() 之前使用 Unlock() 会导致 panic 异常
- 使用 Lock() 加锁后,再次 Lock() 会导致死锁(不支持重入),需Unlock()解锁后才能再加锁
- 锁定状态与 goroutine 没有关联,一个 goroutine 可以 RLock(Lock),另一个 goroutine 可以 RUnlock(Unlock)
互斥锁和读写锁的区别:
- 读写锁区分读者和写者,而互斥锁不区分
- 互斥锁同一时间只允许一个线程访问该对象,无论读写;读写锁同一时间内只允许一个写者,但是允许多个读者同时读对象。
Go 可重入锁如何实现?
可重入锁又称为递归锁,是指在同一个线程在外层方法获取锁的时候,在进入该线程的内层方法时会自动获取锁,不会因为之前已经获取过还没释放再次加锁导致死锁
实现一个可重入锁需要这两点:
- 记住持有锁的线程
- 统计重入的次数
Go 原子操作有哪些?
Go atomic包是最轻量级的锁(也称无锁结构),可以在不形成临界区和创建互斥量的情况下完成并发安全的值替换操作,不过这个包只支持int32/int64/uint32/uint64/uintptr这几种数据类型的一些基础操作(增减、交换、载入、存储等)
概念:
原子操作仅会由一个独立的CPU指令代表和完成。原子操作是无锁的,常常直接通过CPU指令直接实现。 事实上,其它同步技术的实现常常依赖于原子操作。
使用场景:
当我们想要对某个变量并发安全的修改,除了使用官方提供的 mutex,还可以使用 sync/atomic 包的原子操作,它能够保证对变量的读取或修改期间不被其他的协程所影响。
atomic 包提供的原子操作能够确保任一时刻只有一个goroutine对变量进行操作,善用 atomic 能够避免程序中出现大量的锁操作。
常见操作:
- 增减Add
- 载入Load
- 比较并交换CompareAndSwap
- 交换Swap
- 存储Store
atomic 操作的对象是一个地址,你需要把可寻址的变量的地址作为参数传递给方法,而不是把变量的值传递给方法
Go原子操作和锁的区别?
Go 中的原子操作和锁(Mutex)都是用于实现并发控制的机制,它们的主要区别如下:
- 作用范围
- 原子操作用于对单个共享变量的读写进行原子性的保障,而锁则用于对一段代码(即临界区)进行互斥访问,保障多个 goroutine 之间对共享资源的访问顺序。
- 并发性
- 原子操作的实现是通过硬件级别上的指令保证原子性,因此在高并发情况下执行效率较高。而锁则需要在多个 goroutine 之间进行状态切换和内核态和用户态之间的切换,因此在高并发情况下执行效率相对较低。
- 使用场景
- 原子操作适合于对单个共享变量进行频繁的读写操作,例如计数器等场景。锁则适用于需要对一段临界区进行互斥访问的场景,例如多个 goroutine 对同一数据结构进行操作时。
- 错误处理
- 在使用原子操作时,出现错误可能会导致程序崩溃,因此需要仔细地处理错误。而在使用锁时,错误处理相对简单,可以使用 defer 关键字保证锁的正确释放。
综上所述,原子操作和锁各有各的使用场景,应该根据具体情况进行选择。
Go goroutine的底层实现原理?
Goroutine可以理解为一种Go语言的协程(轻量级线程),是Go支持高并发的基础,属于用户态的线程,由Go runtime管理而不是操作系统。
- 创建
- 通过go关键字调用底层函数runtime.newproc()创建一个goroutine
- 当调用该函数之后,goroutine会被设置成runnable状态
- 创建好的这个goroutine会新建一个自己的栈空间,同时在G的sched中维护栈地址与程序计数器这些信息。
- 每个 G 在被创建之后,都会被优先放入到本地队列中,如果本地队列已经满了,就会被放入到全局队列中。
- 运行
- goroutine 本身只是一个数据结构,真正让 goroutine 运行起来的是调度器。Go 实现了一个用户态的调度器(GMP模型),这个调度器充分利用现代计算机的多核特性,同时让多个 goroutine 运行,同时 goroutine 设计的很轻量级,调度和上下文切换的代价都比较小。
- 调度时机:
- 新起一个协程和协程执行完毕
- 会阻塞的系统调用,比如文件io、网络io
- channel、mutex等阻塞操作
- time.sleep
- 垃圾回收之后
- 主动调用runtime.Gosched()
- 运行过久或系统调用过久等等
- 每个 M 开始执行 P 的本地队列中的 G时,goroutine会被设置成running状态
- 如果某个 M 把本地队列中的G都执行完成之后,然后就会去全局队列中拿 G,这里需要注意,每次去全局队列拿 G 的时候,都需要上锁,避免同样的任务被多次拿。
- 如果全局队列都被拿完了,而当前 M 也没有更多的 G 可以执行的时候,它就会去其他 P 的本地队列中拿任务,这个机制被称之为 work stealing 机制,每次会拿走一半的任务,向下取整,比如另一个 P 中有 3 个任务,那一半就是一个任务。
- 当全局队列为空,M 也没办法从其他的 P 中拿任务的时候,就会让自身进入自选状态,等待有新的 G 进来。最多只会有 GOMAXPROCS 个 M 在自旋状态,过多 M 的自旋会浪费 CPU 资源。
- 阻塞
- channel的读写操作、等待锁、等待网络数据、系统调用等都有可能发生阻塞,会调用底层函数runtime.gopark(),会让出CPU时间片,让调度器安排其它等待的任务运行,并在下次某个时候从该位置恢复执行。
- 当调用该函数之后,goroutine会被设置成waiting状态
- 唤醒
- 处于waiting状态的goroutine,在调用runtime.goready()函数之后会被唤醒,唤醒的goroutine会被重新放到M对应的上下文P对应的runqueue中,等待被调度。
- 当调用该函数之后,goroutine会被设置成runnable状态
- 退出
- 当goroutine执行完成后,会调用底层函数runtime.Goexit()
- 当调用该函数之后,goroutine会被设置成dead状态
Go goroutine和线程的区别?
Go 的 goroutine 是一种轻量级的协程,与传统的操作系统线程相比,具有以下区别:
- 调度模型
- Go 语言中的 goroutine 采用的是 M:N 调度模型,即 M 个 goroutine(协程)映射到 N 个系统线程上,这些线程由 Go 运行时进行调度。而传统的线程则是采用的 1:1 调度模型,即一个线程对应一个系统线程。
- 轻量级
- Go 的 goroutine 比传统的线程更加轻量级,创建和销毁 goroutine 的代价比较小,且 goroutine 的初始栈大小只有几 KB,而线程的栈大小通常要大得多。
- 内存占用
- 由于 goroutine 的内存占用比线程更小,因此在同样的硬件资源下,Go 程序可以运行更多的 goroutine。
- 通信机制
- 在 Go 中,goroutine 之间的通信使用的是 Channel,Channel 可以避免传统线程中使用锁带来的复杂性和性能损失。
- 错误处理
- 在传统线程中,如果一个线程抛出异常或崩溃,那么整个进程都会被杀死。而在 Go 中,当一个 goroutine 抛出 panic 时,只有该 goroutine 会被终止,不会影响其他 goroutine 和整个进程。
综上所述,与传统的线程相比,Go 的 goroutine 更加轻量级,占用资源更少,拥有更好的并发性能和更容易处理错误。这也是 Go 语言在并发编程中的优势所在。
Go goroutine泄露的场景?
泄露原因
- Goroutine 内进行channel/mutex 等读写操作被一直阻塞。
- Goroutine 内的业务逻辑进入死循环,资源一直无法释放。
- Goroutine 内的业务逻辑进入长时间等待,有不断新增的 Goroutine 进入等待
泄露场景
如果输出的 goroutines 数量是在不断增加的,就说明存在泄漏
- nil channel
- channel 如果忘记初始化,那么无论你是读,还是写操作,都会造成阻塞。
- 发送不接收
- channel 发送数量 超过 channel接收数量,就会造成阻塞
- 发送不接收
- channel 发送数量 超过 channel接收数量,就会造成阻塞
- http request body未关闭
- resp.Body.Close() 未被调用时,goroutine不会退出
- 互斥锁忘记解锁
- 第一个协程获取 sync.Mutex 加锁了,但是他可能在处理业务逻辑,又或是忘记 Unlock 了。
- 因此导致后面的协程想加锁,却因锁未释放被阻塞了
- sync.WaitGroup使用不当
- 由于 wg.Add 的数量与 wg.Done 数量并不匹配,因此在调用 wg.Wait 方法后一直阻塞等待
如何排查
单个函数:调用 runtime.NumGoroutine 方法来打印 执行代码前后Goroutine 的运行数量,进行前后比较,就能知道有没有泄露了。
生产/测试环境:使用PProf实时监测Goroutine的数量
Go 如何查看正在执行的goroutine数量?
- 在程序中引入pprof package
- 程序中开启HTTP监听服务
package main
import (
"net/http"
_ "net/http/pprof"
)
func main() {
for i := 0; i < 100; i++ {
go func() {
select {}
}()
}
go func() {
http.ListenAndServe("localhost:6060", nil)
}()
select {}
}
分析goroutine文件,在命令行下执行go tool pprof -http=:1248 http://127.0.0.1:6060/debug/pprof/goroutine
会自动打开浏览器页面如下图所示
在图中可以清晰的看到goroutine的数量以及调用关系,可以看到有103个goroutine
Go 如何控制并发的goroutine数量?
为什么要控制goroutine并发的数量?
在开发过程中,如果不对goroutine加以控制而进行滥用的话,可能会导致服务整体崩溃。比如耗尽系统资源导致程序崩溃,或者CPU使用率过高导致系统忙不过来。
用什么方法控制goroutine并发的数量?
- 有缓冲channel
- 利用缓冲满时发送阻塞的特性
package main
import (
"fmt"
"runtime"
"time"
)
var wg = sync.WaitGroup{}
func main() {
// 模拟用户请求数量
requestCount := 10
fmt.Println("goroutine_num", runtime.NumGoroutine())
// 管道长度即最大并发数
ch := make(chan bool, 3)
for i := 0; i < requestCount; i++ {
wg.Add(1)
ch <- true
go Read(ch, i)
}
wg.Wait()
}
func Read(ch chan bool, i int) {
fmt.Printf("goroutine_num: %d, go func: %d
", runtime.NumGoroutine(), i)
<-ch
wg.Done()
}
- 无缓冲channel
- 任务发送和执行分离,指定消费者并发协程数
package main
import (
"fmt"
"runtime"
"sync"
)
var wg = sync.WaitGroup{}
func main() {
// 模拟用户请求数量
requestCount := 10
fmt.Println("goroutine_num", runtime.NumGoroutine())
ch := make(chan bool)
for i := 0; i < 3; i++ {
go Read(ch, i)
}
for i := 0; i < requestCount; i++ {
wg.Add(1)
ch <- true
}
wg.Wait()
}
func Read(ch chan bool, i int) {
for _ = range ch {
fmt.Printf("goroutine_num: %d, go func: %d
", runtime.NumGoroutine(), i)
wg.Done()
}
}
Go GMP和GM模型?
GMP模型
GMP是Go运行时调度层面的实现,包含4个重要结构,分别是G、M、P、Sched
- G(Goroutine):代表Go 协程Goroutine,存储了 Goroutine 的执行栈信息、Goroutine 状态以及 Goroutine 的任务函数等。G的数量无限制,理论上只受内存的影响,创建一个 G 的初始栈大小为2-4K,配置一般的机器也能简简单单开启数十万个 Goroutine ,而且Go语言在 G 退出的时候还会把 G 清理之后放到 P 本地或者全局的闲置列表 gFree 中以便复用。
- M(Machine): Go 对操作系统线程(OS thread)的封装,可以看作操作系统内核线程,想要在 CPU 上执行代码必须有线程,通过系统调用 clone 创建。M在绑定有效的 P 后,进入一个调度循环,而调度循环的机制大致是从 P 的本地运行队列以及全局队列中获取 G,切换到 G 的执行栈上并执行 G 的函数,调用 goexit 做清理工作并回到 M,如此反复。M 并不保留 G 状态,这是 G 可以跨 M 调度的基础。M的数量有限制,默认数量限制是 10000,可以通过 debug.SetMaxThreads() 方法进行设置,如果有M空闲,那么就会回收或者睡眠。
- P(Processor):虚拟处理器,M执行G所需要的资源和上下文,只有将 P 和 M 绑定,才能让 P 的 runq 中的 G 真正运行起来。P 的数量决定了系统内最大可并行的 G 的数量,P的数量受本机的CPU核数影响,可通过环境变量$GOMAXPROCS或在runtime.GOMAXPROCS()来设置,默认为CPU核心数。
- Sched:调度器结构,它维护有存储M和G的全局队列,以及调度器的一些状态信息
GM模型
Go早期是GM模型,没有P组件
- 全局队列的锁竞争,当 M 从全局队列中添加或者获取 G 的时候,都需要获取队列锁,导致激烈的锁竞争
- M 转移 G 增加额外开销,当 M1 在执行 G1 的时候, M1 创建了 G2,为了继续执行 G1,需要把 G2 保存到全局队列中,无法保证G2是被M1处理。因为 M1 原本就保存了 G2 的信息,所以 G2 最好是在 M1 上执行,这样的话也不需要转移G到全局队列和线程上下文切换
- 线程使用效率不能最大化,没有work-stealing 和hand-off 机制
计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决,为了解决这一的问题 go 从 1.1 版本引入P,在运行时系统的时候加入 P 对象,让 P 去管理这个 G 对象,M 想要运行 G,必须绑定 P,才能运行 P 所管理 的 G
Go 抢占式调度?
在1.2版本之前,Go的调度器仍然不支持抢占式调度,程序只能依靠Goroutine主动让出CPU资源才能触发调度,这会引发一些问题,比如:
- 某些 Goroutine 可以长时间占用线程,造成其它 Goroutine 的饥饿
- 垃圾回收器是需要stop the world的,如果垃圾回收器想要运行了,那么它必须先通知其它的goroutine停下来,这会造成较长时间的等待时间
为解决这个问题:
- Go 1.2 中实现了基于协作的“抢占式”调度
- Go 1.14 中实现了基于信号的“抢占式”调度
基于协作的抢占式调度
协作式:大家都按事先定义好的规则来,比如:一个goroutine执行完后,退出,让出p,然后下一个goroutine被调度到p上运行。这样做的缺点就在于 是否让出p的决定权在groutine自身。一旦某个g不主动让出p或执行时间较长,那么后面的goroutine只能等着,没有方法让前者让出p,导致延迟甚至饿死。
非协作式: 就是由runtime来决定一个goroutine运行多长时间,如果你不主动让出,对不起,我有手段可以抢占你,把你踢出去,让后面的goroutine进来运行。
基于协作的抢占式调度流程:
- 编译器会在调用函数前插入 runtime.morestack,让运行时有机会在这段代码中检查是否需要执行抢占调度
- Go语言运行时会在垃圾回收暂停程序、系统监控发现 Goroutine 运行超过 10ms,那么会在这个协程设置一个抢占标记
- 当发生函数调用时,可能会执行编译器插入的 runtime.morestack,它调用的 runtime.newstack会检查抢占标记,如果有抢占标记就会触发抢占让出cpu,切到调度主协程里
这种解决方案只能说局部解决了“饿死”问题,只在有函数调用的地方才能插入“抢占”代码(埋点),对于没有函数调用而是纯算法循环计算的 G,Go 调度器依然无法抢占。为了解决这些问题,Go 在 1.14 版本中增加了对非协作的抢占式调度的支持,这种抢占式调度是基于系统信号的,也就是通过向线程发送信号的方式来抢占正在运行的 Goroutine
基于信号的抢占式调度
真正的抢占式调度是基于信号完成的,所以也称为“异步抢占”。不管协程有没有意愿主动让出 cpu 运行权,只要某个协程执行时间过长,就会发送信号强行夺取 cpu 运行权。
- M 注册一个 SIGURG 信号的处理函数:sighandler
- sysmon启动后会间隔性的进行监控,最长间隔10ms,最短间隔20us。如果发现某协程独占P超过10ms,会给M发送抢占信号
- M 收到信号后,内核执行 sighandler 函数把当前协程的状态从_Grunning正在执行改成 _Grunnable可执行,把抢占的协程放到全局队列里,M继续寻找其他 goroutine 来运行
- 被抢占的 G 再次调度过来执行时,会继续原来的执行流
抢占分为_Prunning和_Psyscall,_Psyscall抢占通常是由于阻塞性系统调用引起的,比如磁盘io、cgo。_Prunning抢占通常是由于一些类似死循环的计算逻辑引起的。
Go 如何查看运行时调度信息?
有 2 种方式可以查看一个程序的调度GMP信息,分别是go tool trace和GODEBUG