golang—面试题大全

目录标题

基础

=和:=的区别

=是赋值,:=是声明并初始化一个新的变量

go异常类型

在 Go 语言中,并没有像一些其他编程语言那样使用传统的异常处理机制,Go 语言使用了一种不同的错误处理模式,通过返回错误值来进行错误处理。Go 的错误处理模式更加简洁和明确,使用了多返回值来传递错误信息。在 Go 中,通常会将函数的最后一个返回值用于传递错误信息。这个错误值通常是一个实现了 error 接口的类型。error 接口只有一个方法 Error() string,该方法返回错误的描述信息。

type error interface {
	Error() string
}

协程的介绍

Go 语言(也称为 Golang)在并发编程方面有一个独特的特性叫做 “协程”(Goroutine)。协程是一种轻量级的线程,由 Go 运行时管理。协程可以在相同的地址空间中同时运行,可以在多个协程之间高效地共享内存,但也因此需要开发者自己来确保数据同步和访问的安全性。

以下是关于 Go 协程的一些重要信息:

  1. 创建协程(Goroutine): 使用关键字 go 可以在 Go 语言中创建一个协程。协程是非常轻量级的,可以在程序中创建成百上千个而不会引起太大的开销。
func main() {
       go someFunction() // 启动一个新的协程来执行 someFunction
   }
  1. 协程调度: Go 运行时会自动管理协程的调度,将其分配到可用的系统线程上执行。这样的调度方式称为 M:N 调度,其中 M 是逻辑协程,N 是操作系统线程。

  2. 通信: Go 协程之间可以通过通道(Channel)来进行通信。通道是一种特殊的数据类型,用于在协程之间传递数据。通道提供了同步和数据传输的机制,可以用于协程之间的协作。

  3. 并发与并行: Go 协程使得并发编程变得简单,因为它们可以在单个线程内并发运行。这与传统的并行编程方式不同,后者通常需要多个线程在多个处理器上同时执行。

  4. 协程之间的数据共享: 虽然协程之间可以共享内存,但是在并发编程中需要小心处理数据同步和竞态条件,以避免数据不一致或其他问题。

  5. 内置并发支持: Go 语言提供了丰富的标准库用于并发编程,包括协程、通道、锁等。这些工具使得编写并发代码更加容易。

协程是 Go 语言并发编程的核心概念之一,它使得在高并发场景下编写简洁、高效的代码变得非常容易。通过合理地使用协程和通道,可以充分发挥多核处理器的能力,构建出健壮、高性能的并发应用程序。

go拼接字符串的方式

在 Go 语言中,有多种方式可以拼接字符串,每种方式在性能方面都有不同的影响。以下是一些常见的字符串拼接方式以及它们的性能对比:

  1. 使用 + 操作符: 使用 + 操作符拼接字符串是最简单的方式之一,但是它在大量字符串拼接时性能可能会较差,因为每次拼接都会创建一个新的字符串,并且要进行内存分配和复制。
str1 := "Hello, "
str2 := "world!"
result := str1 + str2
  1. 使用 strings.Join 函数strings.Join 函数在大量字符串拼接时性能较好,因为它内部使用了一个字符串构建器(strings.Builder)来避免频繁的内存分配和复制。
import "strings"
   stringsToJoin := []string{"Hello", "world!"}
   result := strings.Join(stringsToJoin, " ")
  1. 使用 fmt.Sprintf 函数fmt.Sprintf 函数在格式化字符串时非常方便,但在大量拼接时性能相对较低,因为它每次都会创建一个新的格式化字符串。
import "fmt"
   
   str1 := "Hello"
   str2 := "world!"
   result := fmt.Sprintf("%s, %s", str1, str2)
  1. 使用 strings.Builder 类型strings.Builder 类型适用于需要高性能的大量字符串拼接场景。它使用一个缓冲区来逐步构建字符串,避免了频繁的内存分配和复制。
import "strings"
   
   var builder strings.Builder
   builder.WriteString("Hello, ")
   builder.WriteString("world!")
   result := builder.String()

在性能方面,当涉及大量字符串拼接时,使用 strings.Builderstrings.Join 通常是更好的选择,因为它们可以减少内存分配和复制的开销。然而,对于简单的拼接操作,使用 + 操作符也是可以的。总的来说,根据实际情况选择适当的拼接方式,以平衡代码的简洁性和性能需求。

map判断包含某个key

v, ok := scoreMap["张三"]
	if ok {
		fmt.Println(v)
	} else {
		fmt.Println("查无此人")
	}

go是否支持默认或者可选参数

Go 语言本身并不直接支持默认参数或可选参数的功能,与一些其他编程语言(如Python)不同,它没有提供在函数定义中设置参数的默认值或实现可选参数的内置机制。在 Go 中,函数的参数必须显式地传递。

然而,你可以通过一些技巧来模拟默认参数或者可选参数的行为:

  • 使用结构体参数: 你可以将多个参数组织到一个结构体中,然后将该结构体作为函数的参数,从而实现类似于传递多个参数的效果。结构体中的字段可以有默认值,这样就达到了类似于默认参数的效果。
package main
import "fmt"
type Options struct {
    Param1 string
    Param2 int
}
func MyFunction(options Options) {
    fmt.Println(options.Param1, options.Param2)
}
func main() {
    defaultOptions := Options{Param1: "default", Param2: 42}
    MyFunction(defaultOptions)
}
  • 可变参数: 可以使用可变参数来模拟具有不定数量参数的函数,这可以在一定程度上达到可选参数的效果。可变参数使用 ... 表示,传递进函数后将以切片的形式访问。
package main
import "fmt"
func PrintMessages(messages ...string) {
    for _, message := range messages {
        fmt.Println(message)
    }
}
func main() {
    PrintMessages("Hello", "World")             // 输出:Hello  World
}

tag标签及常见场景

在 Go 语言中,标签(Tag)是结构体字段的元信息,它是一种以键值对的形式存储在结构体字段后的字符串,用于为字段附加额外的信息。标签通常用于在序列化、反序列化、ORM(对象-关系映射)等场景中,为字段提供更多的元数据,以便框架或工具能够更好地理解和操作这些字段。

标签的基本形式是一个字符串,可以使用反引号 ` 或双引号 " 来包裹。标签的内容由键值对构成,键和值之间使用冒号分隔,键值对之间使用空格分隔。

以下是一些常见的使用场景以及标签的应用:

  1. 序列化与反序列化: 在使用 JSON、XML 等格式进行序列化和反序列化时,可以使用标签为字段提供对应的键名、类型转换等信息。
type User struct {
       ID       int    `json:"user_id"`
       Username string `json:"username"`
   }
  1. ORM(对象-关系映射): 在 ORM 库中,标签可以用来指定数据库表的列名、数据类型、索引等信息。
type User struct {
       ID       int    `gorm:"column:user_id;primary_key"`
       Username string `gorm:"column:username;unique_index"`
   }
  1. 表单验证: 在 Web 开发中,标签可以用来进行表单数据的验证,指定字段的校验规则、错误提示等信息。
type LoginForm struct {
       Username string `form:"username" validate:"required"`
       Password string `form:"password" validate:"required"`
   }
  1. 文档生成: 一些自动生成文档的工具,如 Swagger,可以根据标签生成 API 文档,提供更加详细的接口描述。
  type APIResponse struct {
       Data  interface{} `json:"data"`
       Error string      `json:"error"`
   }

标签在 Go 中是一种强大的元信息机制,它能够为字段提供额外的信息,帮助开发者更好地编写通用的代码、框架和工具。需要注意的是,标签的解析需要额外的代码来实现,因此在自己的代码中也需要编写相应的逻辑来处理这些标签信息。

获取结构体所有tag标签方法

要获取 Go 结构体中所有字段的标签,你可以使用反射(reflection)来实现。Go 的反射包 reflect 提供了用于在运行时检查类型信息的功能,通过它可以访问结构体字段的标签。以下是一个示例代码,展示了如何获取结构体中所有字段的标签:

package main

import (
	"fmt"
	"reflect"
)

type User struct {
	ID       int    `json:"user_id"`
	Username string `json:"username"`
	Email    string `json:"email"`
}

func main() {
	user := User{
		ID:       1,
		Username: "john_doe",
		Email:    "john@example.com",
	}

	// 获取 User 结构体的反射类型
	userType := reflect.TypeOf(user)

	// 遍历所有字段
	for i := 0; i < userType.NumField(); i++ {
		field := userType.Field(i)
		tag := field.Tag.Get("json") // 获取字段的 json 标签
		fmt.Printf("Field: %s, Tag: %s\n", field.Name, tag)
	}
}

%v %+v %#v的区别

在 Go 语言中,%v%+v%#v 是格式化输出的格式化占位符,用于将值格式化为字符串。这些占位符在使用 fmt.Printffmt.Sprintffmt.Errorf 等函数中非常有用。

以下是这些格式化占位符的区别:

  1. %v:通用格式占位符,根据值的类型自动选择合适的格式。对于结构体,它将递归地打印字段的值。对于数组和切片,它将打印其中的元素。
type Person struct {
       Name string
       Age  int
   }
   
   p := Person{Name: "Alice", Age: 30}
   fmt.Printf("%v\n", p) // {Alice 30}
  1. %+v:与 %v 类似,但对于结构体,它会打印字段名以及字段的值。对于数组和切片,它也会打印索引。
p := Person{Name: "Alice", Age: 30}
fmt.Printf("%+v\n", p) // {Name:Alice Age:30}
  1. %#v:与 %v 类似,但对于字符串、字符和切片,它会在输出中包含引号,并对特殊字符进行转义。对于复合类型(如结构体),它将递归地打印字段的类型和值。
p := Person{Name: "Alice", Age: 30}
fmt.Printf("%#v\n", p) // main.Person{Name:"Alice", Age:30}

用go表示枚举

Go 语言本身没有像一些其他编程语言(如C++、Java)那样的显式枚举类型。不过,你可以通过使用 const 常量来模拟枚举。以下是一种在 Go 中表示枚举的常用方法:

package main

import "fmt"

const (
    Red   = iota // 0
    Green        // 1
    Blue         // 2
)

func main() {
    color := Green

    switch color {
    case Red:
        fmt.Println("Color is Red")
    case Green:
        fmt.Println("Color is Green")
    case Blue:
        fmt.Println("Color is Blue")
    default:
        fmt.Println("Unknown Color")
    }
}

在这个示例中,我们使用 const 定义了一组整数常量,每个常量对应一个枚举值。使用 iota 来递增枚举值,从0开始自动递增。然后我们可以使用 switch 语句来根据枚举值做不同的操作。

这种方法虽然不是传统的枚举类型,但在 Go 语言中很常见,可以满足大部分的枚举需求。如果你需要更丰富的枚举功能,也可以使用自定义类型和常量组合,但通常使用 constiota 就可以满足大部分场景。

空结构体有什么作用,场景

在 Go 语言中,空结构体(empty struct)是一种不占用内存空间的特殊结构体类型,它没有任何字段。空结构体在某些场景下可以发挥重要的作用,尤其在并发编程、内存优化和映射等方面。

以下是空结构体的一些常见应用场景:

  1. 实现集合和映射: 空结构体可以被用作集合和映射的键,用于表示集合中是否存在某个元素。由于空结构体不占用内存,使用它作为映射的键可以减少内存开销。
// 使用空结构体作为集合元素的存在标记
   type Set map[string]struct{}
   
   func main() {
       mySet := make(Set)
       mySet["apple"] = struct{}{}
       mySet["banana"] = struct{}{}
   
       if _, exists := mySet["apple"]; exists {
           fmt.Println("Apple exists in the set.")
       }
   }
  1. 同步信号: 空结构体可以用于实现同步机制,如通道的发送和接收。通过发送空结构体来表示某个事件的发生。
// 使用空结构体作为同步信号
   var signal chan struct{}
   
   func main() {
       signal = make(chan struct{})
       go doSomething()
       <-signal // 阻塞,直到事件完成
   }
   
   func doSomething() {
       // 做一些操作
       signal <- struct{}{} // 发送空结构体表示事件完成
   }
  1. 内存优化: 在某些情况下,如果你只关心某些类型是否存在,而不需要实际的数据,可以使用空结构体来减少内存占用。

  2. 遍历通道: 在使用通道进行信号传递时,空结构体可以用于遍历通道,以等待多个事件的发生。

// 使用通道传递信号
   var done = make(chan struct{})
   
   func main() {
       go doSomething()
       <-done // 阻塞,等待 doSomething 完成
   }
   
   func doSomething() {
       // 做一些操作
       done <- struct{}{} // 发送空结构体表示完成
   }

总的来说,空结构体在 Go 中被广泛用于表示某些状态或者事件的发生,同时又不需要实际的数据。使用空结构体可以减少内存占用并提高代码的可读性。

int32和int的区别

在 Go 语言中,intint32 都是整数数据类型,但它们之间有一些区别:

  1. int 类型: 在 Go 中,int 是一个平台相关的整数类型,其大小根据当前运行的计算机架构而变化。在 32 位架构上,int 是 4 字节(32 位),在 64 位架构上,int 是 8 字节(64 位)。这意味着在不同的架构上,int 的大小会有所不同,但它始终会根据所运行的计算机架构而自动调整。
  2. int32 类型: int32 是 Go 语言中一个明确指定大小的 32 位整数类型。它始终占据 4 个字节,无论在哪种计算机架构上运行。这使得 int32 在需要确切控制整数大小的情况下非常有用,例如在数据存储或通信协议中。

总之,在 Go 语言中,int 类型的大小取决于计算机的架构,而 int32 类型则始终是 32 位大小。如果您需要确切控制整数大小或需要与特定的数据格式进行交互,使用 int32 可以确保整数大小的一致性。如果不需要显式控制大小,通常可以使用默认的 int 类型。

1 uint32 - 2 uint32 =?

在 Go 语言中,无符号整数类型(例如 uint32)不允许出现负数。因此,在执行 a - b 操作时,如果结果为负数,将会出现溢出并产生一个正整数。

对于您提供的代码:

var a uint32 = 1
var b uint32 = 2
result := a - b

由于 uint32 类型不支持负数,计算结果会发生溢出,得到一个正整数。具体结果取决于计算机体系结构和编译器等因素。

在大多数情况下,如果 a 小于 b,则 a - b 将会产生一个较大的正整数,即使计算结果溢出。这是因为无符号整数类型不支持负数值,因此在计算时会循环回到大的正整数范围内。

两个nil相等吗?

在 Go 语言中,两个 nil 是相等的,不会因为上下文或其他因素而变得不相等。

无论在什么情况下,两个 nil 值都被认为是相等的,这是 Go 语言规范中的定义。无论是比较指针、接口、切片、映射等类型,只要它们的值都是 nil,它们都被视为相等。

例如,以下代码演示了不同类型的 nil 比较,它们都会返回 true

package main

import "fmt"

func main() {
    var ptr *int
    var iface interface{}
    var slice []int
    var m map[int]string

    fmt.Println(ptr == nil)    // true
    fmt.Println(iface == nil)  // true
    fmt.Println(slice == nil)  // true
    fmt.Println(m == nil)      // true
}

GMP有什么状态

在 Go 语言的调度模型中,GMP 表示 Goroutine、M(Thread)、P(Processor)的缩写,它们共同构成了 Go 语言运行时系统的核心部分。每个部分都有不同的状态,下面是它们的状态说明:

  1. Goroutine (G) 状态:
    • Runnable: 当一个 Goroutine 可以运行时,它的状态是 Runnable。这意味着它已经被调度器选中,并准备好在一个线程(M)上执行。
    • Running: 当一个 Goroutine 正在一个线程(M)上执行时,它的状态是 Running。
    • Waiting: 如果一个 Goroutine 正在等待某个事件(如通道操作、互斥锁等),它的状态是 Waiting。这时,它会被从线程上移除,直到等待的事件发生。
    • Dead: 当一个 Goroutine 完成了它的任务,或者由于某种原因终止时,它的状态是 Dead。这时,它的资源会被回收。
  2. Thread (M) 状态:
    • Idle: 当线程没有正在执行的 Goroutine 时,它的状态是 Idle。空闲线程等待调度器分配 Goroutine 给它。
    • Running: 当线程正在执行一个 Goroutine 时,它的状态是 Running。
    • Blocked: 如果线程在等待某个事件(如等待系统调用返回、等待网络 I/O 等),它的状态是 Blocked。这时,该线程会被暂时挂起,直到事件发生。
  3. Processor(p) 状态:
    • Idle: 当处理器没有绑定到任何线程(M)时,它的状态是 Idle。空闲处理器等待调度器将线程绑定到它上面。
    • Running: 当处理器绑定到线程(M)并执行 Goroutine 时,它的状态是 Running。
    • Blocked: 如果线程在等待事件发生(如等待垃圾回收完成等),处理器的状态是 Blocked。此时,处理器会被临时停用。

在 Go 语言的调度模型中,调度器负责根据 Goroutine 的状态和处理器的状态,将 Goroutine 分配到合适的线程上执行。这种 GMP 模型使得 Go 能够有效地在多核系统上并行执行 Goroutine,从而实现高并发和高效率的编程。

类型断言

在Go语言中,类型断言(Type Assertion)是一种检查接口值的实际底层类型的操作。它允许我们在接口值中获取底层类型的值,并判断该值是否是我们期望的类型。类型断言的一般语法如下:

value, ok := x.(T)

其中,x是一个接口值,T是一个具体的类型。这个语法尝试将x转换为类型T。如果类型断言成功,那么变量value将持有x的底层类型值,而ok将为true。如果类型断言失败,那么value将持有T类型的零值,而ok将为false

以下是一个类型断言的示例:

func processValue(x interface{}) {
    if str, ok := x.(string); ok {
        // x是一个字符串类型
        fmt.Println("String:", str)
    } else if num, ok := x.(int); ok {
        // x是一个整数类型
        fmt.Println("Number:", num)
    } else {
        // x既不是字符串也不是整数
        fmt.Println("Unknown type")
    }
}

func main() {
    processValue("Hello")   // String: Hello
    processValue(42)        // Number: 42
    processValue(true)      // Unknown type
}

在上面的例子中,processValue函数接受一个空接口类型参数x,然后使用类型断言来判断x的底层类型是字符串、整数还是其他类型,并执行相应的逻辑。需要注意的是,如果使用错误的类型进行断言,会导致运行时的panic错误。因此,在进行类型断言之前,通常需要使用ok变量来检查类型断言是否成功,以避免程序崩溃。类型断言在需要根据接口值的底层类型执行不同逻辑的场景中非常有用,例如在处理错误时获取更多的错误信息或执行特定类型的操作。

struct

struct能不能比较

  • 不同类型的 struct 之间不能进行比较,编译期就会报错(GoLand 会直接提示)
  • 同类型的 struct 也分为两种情况,
  • struct 的所有成员都是可以比较的,则该 strcut 的不同实例可以比较
  • struct 中含有不可比较的成员(如 Slice),则该 struct 不可以比较

比较方法

  1. 当结构体的字段类型都是可比较的时,可以使用相等运算符(==)进行结构体之间的比较
  2. 使用结构体的方法来进行比较。通过在结构体上定义一个Equals方法
type Person struct {
    Name string
    Age  int
}
func (p Person) Equals(other Person) bool {
    return p.Name == other.Name && p.Age == other.Age
}
func main() {
    person1 := Person{Name: "Alice", Age: 25}
    person2 := Person{Name: "Bob", Age: 30}
    if person1.Equals(person2) {
        fmt.Println("person1 and person2 are equal")
    } else {
        fmt.Println("person1 and person2 are not equal")
    }
}

iota

在常量声明语句中,iota常用于声明连续的整形常量。单个const声明块中从0开始取值,单个const声明块中,每增加一行声明,iota的取自增1,即便声明中没有使用iota也是如此,单行声明语句中,即便出现多个iota,iota取值保持不变。可以用来表示go中的枚举。

IO多路复用

select是go在语言层面提供的多路I/O复用机制,用于检测多个管道是否就绪(即可读或者可写),其特性跟管道息息相关。

实现多路I/O复用(Multiplexing I/O)的常用方式是使用select语句。select语句允许在多个通信操作中等待,直到其中一个操作可以继续进行。以下是使用select语句实现多路I/O复用的基本步骤:

  1. 创建一个select语句,并在其中添加多个case子句。每个case子句对应一个I/O操作,如读取、写入或关闭等。
  2. 在每个case子句中,指定对应的通道操作或I/O操作,以及要执行的代码块。
  3. 使用default子句来处理非阻塞的操作,当没有任何case子句满足条件时,会执行default子句中的代码块。
  4. 使用select语句进行多路I/O复用,它会阻塞等待,直到任何一个case子句中的操作可以继续进行。

select

底层实现:

type scase struct{
	c *hchan // 操作管道 scase.c为当前case语句所操作的channel指针,这也说明了一个case语句只能操作一个channel。
	kind uint16 // case类型
	elem unsafe.Pointer // data elemen
}
  1. scase.kind表示该case的类型,分为读channel、写channel和default,三种类型分别由常量定义:
    • caseRecv:case语句中尝试读取scase.c中的数据;
    • caseSend:case语句中尝试向scase.c中写入数据;
    • caseDefault: default语句
  2. scase.elem表示缓冲区地址,根据scase.kind不同,有不同的用途:
    • scase.kind == caseRecv : scase.elem表示读出channel的数据存放地址;
    • scase.kind == caseSend : scase.elem表示将要写入channel的数据存放地址;
实现逻辑

源码包src/runtime/select.go:selectgo()定义了select选择case的函数:

func selectgo(cas0 *scase, order0 *uint16, ncases int) (int, bool)

函数参数:

  • cas0为scase数组的首地址,selectgo()就是从这些scase中找出一个返回。
  • order0为一个两倍cas0数组长度的buffer,保存scase随机序列pollorder和scase中channel地址序列lockorder
    • pollorder:每次selectgo执行都会把scase序列打乱,以达到随机检测case的目的。
    • lockorder:所有case语句中channel序列,以达到去重防止对channel加锁时重复加锁的目的。
  • ncases表示scase数组的长度

函数返回值:

  1. int: 选中case的编号,这个case编号跟代码一致
  2. bool: 是否成功从channle中读取了数据,如果选中的case是从channel中读数据,则该返回值表示是否读取成功。

selectgo实现伪代码如下:

func selectgo(cas0 *scase, order0 *uint16, ncases int) (int, bool) {
    //1. 锁定scase语句中所有的channel
    //2. 按照随机顺序检测scase中的channel是否ready
    //   2.1 如果case可读,则读取channel中数据,解锁所有的channel,然后返回(case index, true)
    //   2.2 如果case可写,则将数据写入channel,解锁所有的channel,然后返回(case index, false)
    //   2.3 所有case都未ready,则解锁所有的channel,然后返回(default index, false)
    //3. 所有case都未ready,且没有default语句
    //   3.1 将当前协程加入到所有channel的等待队列
    //   3.2 当将协程转入阻塞,等待被唤醒
    //4. 唤醒后返回channel对应的case index
    //   4.1 如果是读操作,解锁所有的channel,然后返回(case index, true)
    //   4.2 如果是写操作,解锁所有的channel,然后返回(case index, false)
}

特别说明:对于读channel的case来说,如case elem, ok := <-chan1:, 如果channel有可能被其他协程关闭的情况下,一定要检测读取是否成功,因为close的channel也有可能返回,此时ok == false。

结论
  • select仅能操作管道
  • 每个case语句仅能处理一个管道,要么读要么写
  • 多个case语句的执行顺序是随机的
  • 存在default语句,select将不会阻塞

I/O多路复用的netpoll模型

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

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

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

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

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

    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是否阻塞。

定时器

一次性定时器

定时器只计时一次,计时结束便停止运行。Timer是一种单一事件的定时器,即经过指定的时间后触发一个事件,这个事件通过其本身提供的channel进行通知。之所以叫单一事件,是因为Timer只执行一次就结束,这也是timer和ticker最重要的区别之一。

源码包src/time/sleep.go:Timer定义了Timer数据结构:

type Timer struct { // Timer代表一次定时,时间到来后仅发生一个事件。
    C <-chan Time
    r runtimeTimer
}

使用场景

  • 设定超时时间
  • 延迟执行某个方法

总结

  • time.NewTimer(d)创建一个Timer;
  • timer.Stop()停掉当前Timer;
  • timer.Reset(d)重置当前Timer;

周期性定时器

定时器周期性地进行计时,除非主动停止,否则将永久运行,通过Ticker本身提供的管道将事件传递出去

type Ticker struct {
    C <-chan Time
    r runtimeTimer
}

Ticker对外仅暴露一个channel,指定的时间到来时就往该channel中写入系统时间,也即一个事件。在创建Ticker时会指定一个时间,作为事件触发的周期。这也是Ticker与Timer的最主要的区别。

使用场景

  • 简单定时任务:如:每隔1s记录一次日志:
  • 定时聚合任务:如:公交车每隔5分钟发一班,不管是否已坐满乘客;已坐满乘客情况下,不足5分钟也发车;

总结

  • 使用time.NewTicker()来创建一个定时器;
  • 使用Stop()来停止一个定时器;
  • 定时器使用完毕要释放,否则会产生资源泄露;

slice

slice和array的区别

  1. 大小固定 vs. 大小可变:
    • 数组是大小固定的,定义时需要指定数组的长度,无法动态增加或减少长度。
    • 切片是基于数组的动态长度的抽象,可以根据需要动态调整长度。
  2. 值传递 vs. 引用传递:
    • 数组在赋值或传递时,会进行值拷贝,即创建一个新的数组副本。
    • 切片在赋值或传递时,只是传递了一个指向底层数组的引用,不会进行拷贝。
  3. 定义方式:
    • 数组的长度是固定的,定义时需要指定长度,例如 var arr [5]int
    • 切片的长度是可变的,可以通过 make 函数或使用切片字面量定义,例如 s := make([]int, 5)s := []int{1, 2, 3}
  4. 内存分配:
    • 数组在定义时会直接分配连续的内存空间,长度固定。
    • 切片在底层依赖数组,会根据实际需要动态分配内存空间。
  5. 操作和功能:
    • 数组具有一些内置的操作和功能,如遍历、排序等。
    • 切片提供了更多的操作和功能,如追加、拼接、截取等。

slice扩容机制

扩容是为切片分配新的内存空间并复制原切片中元素的过程。在 go 语言的切片中,扩容的过程是:估计大致容量 -> 确定容量 -> 覆盖原切片 -> 完成扩容。

  • 首先判断,如果新申请容量大于 2 倍的旧容量,最终容量就是新申请的容 量
  • 否则判断,如果旧切片的长度小于 1024,则最终容量就是旧容量的两倍
  • 否则判断,如果旧切片长度大于等于 1024,则最终容量从旧容量开始循环 增加原来的 1/4, 直到最终容量大于等于新申请的容量
  • 如果最终容量计算值溢出,则最终容量就是新申请容量

slice是否线程安全

Go 的切片(slice)类型本身并不是线程安全的。多个 goroutine 并发地对同一个切片进行读写操作可能会导致数据竞争和不确定的结果。如果需要在并发环境下安全地使用切片,可以采取以下几种方式:

  1. 使用互斥锁(Mutex)或读写锁(RWMutex)来保护对切片的并发访问。在访问切片前获取锁,操作完成后释放锁,以确保同一时间只有一个 goroutine 可以访问切片。
  2. 使用通道(Channel)来进行同步和通信。将切片操作封装为一个独立的 goroutine,通过通道接收和发送操作来保证对切片的顺序访问。
  3. 使用原子操作(Atomic Operations)来进行原子性的读写操作。Go 提供了一些原子操作的函数,如 atomic.AddInt32atomic.LoadPointer 等,可以确保在并发环境下对切片的操作是原子的。

slice分配到栈上还是堆上

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

扩容过程中是否重新写入

切片的扩容, 当在尾部扩容时,追加元素,不需要重新写入;

var a []int
a = append(a, 1)

在头部插入时;会引起内存的重分配,导致已有的元素全部重新写入;

a = append([]int{0}, a...);

在中间插入时,会局部重新写入,如下: 使用链式操作在插入元素,在内层append函数中会创建一个临式切片,然后将a[i:]内容复制到新创建的临式切片中,再将临式切片追加至a[:i]中。

a = append(a[:i], append([]int{x}, a[i:]...)...) 
a = append(a[:i], append([]int{1, 2, 3}, a[i:]...)...)//在第i个位置上插入切片

go深拷贝发生在什么情况下?切片的深拷贝是怎么做的

  • 深拷贝(Deep Copy):

拷贝的是数据本身,创造一个样的新对象,新创建的对象与原对象不共享内存,新创建的对象在内存中开辟一个新的内存地址,新对象值修改时不会影响原对象值。既然内存地址不同,释放内存地址时,可分别释放。

  • 浅拷贝(Shallow Copy):

拷贝的是数据地址,只复制指向的对象的指针,此时新对象和老对象指向的内存地址是一样的,新对象值修改时老对象也会变化。释放内存地址时,同时释放内存地址。参考来源 (opens new window)在go语言中值类型赋值都是深拷贝,引用类型一般都是浅拷贝:

  • 值类型的数据,默认全部都是深拷贝:Array、Int、String、Struct、Float,Bool
  • 引用类型的数据,默认全部都是浅拷贝:Slice,Map

对于引用类型,想实现深拷贝,不能直接 := ,而是要先开辟地址空间(new) ,再进行赋值。可以使用 copy() 函数对slice进行深拷贝,copy 不会进行扩容,当要复制的 slice 比原 slice 要大的时候,只会移除多余的。使用 append() 函数来进行深拷贝,append 会进行扩容

copy和左值进行初始化区别

  1. copy(slice2, slice1)实现的是深拷贝。拷贝的是数据本身,创造一个新对象,新创建的对象与原对象不共享内存,新创建的对象在内存中开辟一个新的内存地址,新对象值修改时不会影响原对象值。 同样的还有:遍历slice进行append赋值
  2. 如slice2 := slice1实现的是浅拷贝。拷贝的是数据地址,只复制指向的对象的指针,此时新对象和老对象指向的内存地址是一样的,新对象值修改时老对象也会变化。默认赋值操作就是浅拷贝。

slice和map的区别

Map 是一种无序的键值对的集合。Map 可以通过 key 来快速检索数据,key 类似于索引,指向数据的值。 而 Slice 是切片,可以改变长度,动态扩容,切片有三个属性,指针,长度,容量。 二者都可以用 make 进行初始化。

map

map介绍

Go中Map是一个KV对集合。底层使用hash table,用链表来解决冲突 ,出现冲突时,不是每一个Key都申请一个结构通过链表串起来,而是以bmap为最小粒度挂载,一个bmap可以放8个kv。每个map的底层结构是hmap,是有若干个结构为bmap的bucket组成的数组。每个bucket底层都采用链表结构。bmap 就是我们常说的“桶”,桶里面会最多装 8 个 key,这些 key之所以会落入同一个桶,是因为它们经过哈希计算后,哈希结果是“一类”的,关于key的定位我们在map的查询和赋值中详细说明。在桶内,又会根据key计算出来的hash值的高8位来决定 key到底落入桶内的哪个位置(一个桶内最多有8个位置)。

map的key的类型

map[key]value,其中key必须是可比较的,也就是可以通过==!=进行比较,所以可以比较的类型才能作为key,其实就是等价问go语言中哪些类型是可以比较的:

什么可以比较:bool、array、numeric(浮点数、整数等)、pointer、string、interface、channel

什么不能比较:function、slice、map

golang中的map,的 key 可以是很多种类型,比如 bool, 数字,string, 指针, channel , 还有 只包含前面几个类型的 interface types, structs, arrays; map是可以进行嵌套的。

map对象如何比较

使用reflect.DeepEqual 这个函数进行比较。使用 reflect.DeepEqual 有一点注意:由于使用了反射,所以有性能的损失。

map的底层原理

  1. map的实现原理
    1. go map是基于hash table(哈希表)来实现的,冲突的解决采用拉链法
  2. map的底层结构
    1. hmap(哈希表):每个hmap内含有多个bmap(buckets(桶)、oldbuckets(旧桶)、overflow(溢出桶))可以这样理解,每个哈希表都是由多个桶组成的
type hmap struct {
    count     int   	  	 //元素的个数
    flags     uint8  	 	 //状态标志
    B         uint8 	 	 //可以最多容纳 6.5 * 2 ^ B 个元素,6.5为装载因子
    noverflow uint16 	 	 //溢出的个数
    hash0     uint32   		 //哈希种子

    buckets    unsafe.Pointer //指向一个桶数组
    oldbuckets unsafe.Pointer //指向一个旧桶数组,用于扩容
    nevacuate  uintptr        //搬迁进度,小于nevacuate的已经搬迁
    overflow *[2]*[]*bmap     //指向溢出桶的指针
}

    • buckets:一个指针,指向一个bmap数组、存储多个桶。
    • oldbuckets: 是一个指针,指向一个bmap数组,存储多个旧桶,用于扩容。
    • overflow:overflow是一个指针,指向一个元素个数为2的数组,数组的类型是一个指针,指向一个slice,slice的元素是桶(bmap)的地址,这些桶都是溢出桶。为什么有两个?因为Go map在哈希冲突过多时,会发生扩容操作。[0]表示当前使用的溢出桶集合,[1]是在发生扩容时,保存了旧的溢出桶集合。overflow存在的意义在于防止溢出桶被gc。
  1. bmap(哈希桶): bmap是一个隶属于hmap的结构体,一个桶(bmap)可以存储8个键值对。如果有第9个键值对被分配到该桶,那就需要再创建一个桶,通过overflow指针将两个桶连接起来。在hmap中,多个bmap桶通过overflow指针相连,组成一个链表。
type bmap struct {
    //元素hash值的高8位代表它在桶中的位置,如果tophash[0] < minTopHash,表示这个桶的搬迁状态
    tophash [bucketCnt]uint8
    //接下来是8个key、8个value,但是我们不能直接看到;为了优化对齐,go采用了key放在一起,value放在一起的存储方式,
    keys     [8]keytype   //key单独存储
	values   [8]valuetype //value单独存储
	pad      uintptr
	overflow uintptr	  //指向溢出桶的指针
}

map 负载因子

负载因子用于衡量一个哈希表冲突情况,公式为:

负载因子 = 键数量/bucket数量

例如,对于一个bucket数量为4,包含4个键值对的哈希表来说,这个哈希表的负载因子为1.哈希表需要将负载因子控制在合适的大小,超过其阀值需要进行rehash,也即键值对重新组织:

  • 哈希因子过小,说明空间利用率低
  • 哈希因子过大,说明冲突严重,存取效率低

每个哈希表的实现对负载因子容忍程度不同,比如Redis实现中负载因子大于1时就会触发rehash,而Go则在在负载因子达到6.5时才会触发rehash,因为Redis的每个bucket只能存1个键值对,而Go的bucket可能存8个键值对,所以Go可以容忍更高的负载因子。

map哈希冲突解决

在Go语言中,普通的map类型在哈希冲突的情况下采用了开链法(链地址法)来解决。当不同的键经过哈希计算后映射到了同一个桶(bucket)时,就会产生哈希冲突。为了解决这些冲突,每个桶会维护一个链表,将哈希值相同的键值对链接在一起。以下是哈希冲突如何在Go中的普通map中解决的简要过程:

  1. 哈希计算:当插入或查找一个键值对时,首先会对键进行哈希计算,得到一个哈希值。
  2. 映射到桶:哈希值会被映射到一个特定的桶。Go中的map底层使用了一个哈希表,这个哈希表由多个桶组成。
  3. 处理冲突:如果两个不同的键的哈希值映射到了同一个桶,就会发生哈希冲突。此时,系统会将新的键值对添加到该桶对应的链表中。
  4. 链表操作:链表中的每个节点代表一个键值对,相同哈希值的键值对会链接在同一个桶的链表上。插入时会在链表头部插入节点,这使得查找和删除操作的时间复杂度相对较低。
  5. 查找和删除:对于查找操作,系统会先计算哈希值并找到对应的桶,然后遍历该桶的链表以找到目标键值对。对于删除操作,会在链表中找到目标键值对并将其从链表中移除

map扩容机制

扩容条件

  1. 负载因子大于6.5时,负载因子 = 键数量/bucket数量
  2. overflow的数量达到2^min(B,15)时

增量扩容

新建一个bucket数组,新的bucket数组的长度是原来的两倍,然后旧bucket数组中的数据搬迁到新的bucket数组中。考虑到如果map存储了数以亿计的key-value,一次性搬迁将会造成比较大的延时,Go采用逐步搬迁策略,即每次访问map时都会触发一次搬迁,每次搬迁2个键值对。

等量扩容

所谓等量扩容,实际上并不是扩大容量,buckets数量不变,重新做一遍类似增量扩容的搬迁动作,把松散的键值对重新排列一次,以使bucket的使用率更高,进而保证更快的存取。

实现线程安全的map

Map默认不是并发安全的,并发读写时程序会panic。map为什么不支持线程安全?和场景有关,官方认为大部分场景不需要多个协程进行并发访问,如果为小部分场景加锁实现并发访问,大部分场景将付出加锁代价(性能降低)。

  • 加读写锁
  • 分片加锁
  • sync.Map

加读写锁、分片加锁,这两种方案都比较常用,后者的性能更好,因为它可以降低锁的粒度,提高访问此 map 对象的吞吐。前者并发性能虽然不如后者, 但是加锁的方式更加简单。sync.Map 是 Go 1.9 增加的一个线程安全的 map ,虽然是官方标准,但反而是不常用的,原因是 map 要解决的场景很难 描述,很多时候程序员在做抉择是否该用它,不过在一些特殊场景会使用 sync.Map,场景一:只会增长的缓存系统,一个 key 值写入一次而被读很多次; 场景二:多个 goroutine 为不相交的键读、写和重写键值对。对它的使用场景介绍,来自官方文档 (opens new window),这里就不展开了。 加读写锁,扩展 map 来实现线程安全,支持并发读写。使用读写锁 RWMutex,是为了读写性能的考虑。 对 map 对象的操作,无非就是常见的增删改查和遍历。我们可以将查询和遍历看作读操作,增加、修改和 删除看作写操作。示例代码链接:https://github.com/guowei-gong/go-demo/blob/main/mutex/demo.go 。通过读写锁提供线程安全的 map,但是大量并发读写的情况下,锁的竞争会很激烈,导致性能降低。如何解决这个问题? 尽量减少锁的粒度和锁的持有时间,减少锁的粒度,常用方法就是分片 Shard,将一把锁分成几把锁,每个锁控制一个分片。

sync.map的实现

sync.map采用读写分离和用空间换时间的策略保证map的读写安全。

  1. 散列桶和片段划分sync.Map的底层使用了一个散列桶数组来存储键值对。这个数组被划分成多个小的片段,每个片段有自己的锁,这样不同的片段可以独立地进行操作,从而减少了竞争。
  2. 读写分离:为了允许高并发读取,sync.Map实现了一种读写分离的机制。在读取时,不需要锁定,多个goroutine可以并发读取。写操作涉及到写入数据,会获取特定散列桶的写锁。
  3. 散列算法和冲突解决sync.Map使用散列算法将键映射到散列桶。每个散列桶中都可能包含多个键值对,因此可能会出现散列冲突。冲突的解决方式通常是通过链表来存储具有相同散列的键值对。
  4. 版本控制sync.Map引入了版本控制的概念。每个散列桶中都包含了一个版本号,用于跟踪对散列桶的修改。这使得在读取时可以检测到同时进行的写入,从而确保读取的数据的一致性。
  5. 内存管理和垃圾回收sync.Map还包含了一些内存管理机制,以避免不再使用的内存积累。当某个散列桶不再被使用时,相应的内存可能会被释放。

基本结构:

type Map struct {
    mu Mutex
    read atomic.Value 	 //包含对并发访问安全的map内容的部分(无论是否持有mu)
    dirty map[ant]*entry //包含map内容中需要保存mu的部分
    misses int 			//计算自从上次读取map更新后,需要锁定mu来确定key是否存在的加载次数
}

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-5jbXC6NA-1691932395866)(C:\Users\hp\AppData\Roaming\Typora\typora-user-images\image-20230813151541034.png)]

read:read 使用 map[any]*entry 存储数据,本身支持无锁的并发读read 可以在无锁的状态下支持 CAS 更新,但如果更新的值是之前已经删除过的 entry 则需要加锁操作由于 read 只负责读取,dirty 负责写入,因此使用 amended 来标记 dirty 中是否包含 read 没有的字段

**dirty:**dirty 本身就是一个原生 map,需要加锁保证并发写入

**entry:**read 和 dirty 都是用到 entry 结构entry 内部只有一个 unsafe.Pointer 指针 p 指向 entry 实际存储的值指针 p 有三种状态

  • p == nil

    在此状态下对应: entry 已经被删除 或 map.dirty == nil 或 map.dirty 中有 key 指向 e 此处不明

  • p == expunged

    在此状态下对应:entry 已经被删除 或 map.dirty != nil 同时该 entry 无法在 dirty 中找到

  • 其他情况

    entry 都是有效状态并被记录在 read 中,如果 dirty 不为空则也可以在 dirty 中找到

场景

  1. 只会增长的缓存系统,一个key只写一次而被读很多次
  2. 多个goroutine为不相交的键集读、写和重写键值对

map和sync.map的区别

  1. 线程安全性:map 是非线程安全的,多个 goroutine 并发地读写 map 可能会导致数据竞争和不确定的结果。而 sync.Map 是线程安全的,可以在多个 goroutine 并发地读写 sync.Map,而不需要额外的同步操作。
  2. 扩容机制:map 的扩容是在插入新元素时自动进行的,按需增加内部哈希表的大小。而 sync.Map 不会自动扩容,它始终使用固定大小的内部哈希表。
  3. 功能和方法:map 提供了常见的读取、插入、更新和删除等操作,如 m[key]m[key] = valuedelete(m, key) 等。而 sync.Map 提供了一组特定的方法,如 LoadStoreDeleteRange,用于读取、存储、删除和遍历键值对。
  4. 性能:由于 sync.Map 是线程安全的,它需要进行额外的同步操作,因此在并发性能方面可能会比普通的 map 稍慢。而普通的 map 在单个 goroutine 下的读取和写入操作性能较高

map查找过程

查找过程如下:

  1. 根据key值算出哈希值
  2. 取哈希值低位与hmap.B取模确定bucket位置
  3. 取哈希值高位在tophash数组中查询
  4. 如果tophash[i]中存储值也哈希值相等,则去找到该bucket中的key值进行比较
  5. 当前bucket没有找到,则继续从下个overflow的bucket中查找。
  6. 如果当前处于搬迁过程,则优先从oldbuckets查找

注:如果查找不到,也不会返回空值,而是返回相应类型的0值。

map插入过程

新元素插入过程如下:

  1. 根据key值算出哈希值
  2. 取哈希值低位与hmap.B取模确定bucket位置
  3. 查找该key是否已经存在,如果存在则直接更新值
  4. 如果没找到将key,将key插入

map没申请空间,取值,会发生什么情况

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

set的原理,Java 的HashMap和 go 的map底层原理

1. Set原理: Set特性: 1. 不包含重复key. 2.无序. 如何去重: 通过查看源码add(E e)方法,底层实现有一个map,map是HashMap,Hash类型是散列,所以是无序的. 如果key值相同,将会覆盖,这就是set为什么能去重的原因(key相同会覆盖). 注意: 如果new出两个对象add到set中,因为两个对象的地址不相同,所以map在计算key的hash值时,将它当成了两个不同的元素。这时要重写equals和hashcode两个方法。 这样才能保证set集合的元素不重复.

2. Java HashMap:

线程不安全 安全的map(CurrentHashMap) HashMap由数组+链表组成,数组是HashMap的主体, 链表则是为了解决哈希冲突而存在的,如果定位到的数组位置不含链表(当前entry的next指向null),那么查找,添加等操作很快,仅需一次寻址即可; 如果定位到的数组包含链表,对于添加操作,其时间复杂度为O(n),首先遍历链表,存在即覆盖,否则新增; 对于查找操作来讲,仍需遍历链表,然后通过key对象的equals方法逐一比对查找。 所以,性能考虑,HashMap中的链表出现越少,性能才会越好。 假如一个数组槽位上链上数据过多(即链表过长的情况)导致性能下降该怎么办? JDK1.8在JDK1.7的基础上针对增加了红黑树来进行优化。 即当链表超过8时,链表就转换为红黑树,利用红黑树快速增删改查的特点提高HashMap的性能,其中会用到红黑树的插入、删除、查找等算法。

3. go map:

线程不安全 安全的map(sync.map) 特性: 1. 无序. 2. 长度不固定. 3. 引用类型. 底层实现: 1.hmap 2.bmap(bucket) hmap中含有n个bmap,是一个数组. 每个bucket又以链表的形式向下连接新的bucket. bucket关注三个字段: 1. 高位哈希值 2. 存储key和value的数组 3. 指向扩容bucket的指针 高位哈希值: 用于寻找bucket中的哪个key. 低位哈希值: 用于寻找当前key属于hmap中的哪个bucket. map的扩容: 当map中的元素增长的时候,Go语言会将bucket数组的数量扩充一倍,产生一个新的bucket数组,并将旧数组的数据迁移至新数组。 加载因子 判断扩充的条件,就是哈希表中的加载因子(即loadFactor)。 加载因子是一个阈值,一般表示为:散列包含的元素数 除以 位置总数。是一种“产生冲突机会”和“空间使用”的平衡与折中:加载因子越小,说明空间空置率高,空间使用率小,但是加载因子越大,说明空间利用率上去了,但是“产生冲突机会”高了。 每种哈希表的都会有一个加载因子,数值超过加载因子就会为哈希表扩容。 Golang的map的加载因子的公式是:map长度 / 2^B(这是代表bmap数组的长度,B是取的低位的位数)阈值是6.5。其中B可以理解为已扩容的次数。 当Go的map长度增长到大于加载因子所需的map长度时,Go语言就会将产生一个新的bucket数组,然后把旧的bucket数组移到一个属性字段oldbucket中。注意:并不是立刻把旧的数组中的元素转义到新的bucket当中,而是,只有当访问到具体的某个bucket的时候,会把bucket中的数据转移到新的bucket中。 map删除: 并不会直接删除旧的bucket,而是把原来的引用去掉,利用GC清除内存。

channel

channel介绍

channel是Golang在语言层面提供的goroutine间的通信方式,channel主要用于进程内各goroutine间的通信。channel分为无缓冲channel和有缓冲channel。

Channel 在 gouroutine 间架起了一条管道,在管道里传输数据,实现 gouroutine 间的通信;在并发编程中它线程安全的,所以用起来非常方便;channel 还提供“先进先出”的特性;它还能影响 goroutine 的阻塞和唤醒。

channel底层实现

背景

  • Go语言提供了一种不同的并发模型–通信顺序进程(communicating sequential processes,CSP)。
  • 设计模式:通过通信的方式共享内存
  • channel收发操作遵循先进先出(FIFO)的设计

底层结构

type hchan struct {
    qcount   uint           // 当前队列中剩余元素个数
    dataqsiz uint           // 环形队列长度,即可以存放的元素个数
    buf      unsafe.Pointer // 环形队列指针
    elemsize uint16         // 每个元素的大小
    closed   uint32            // 标识关闭状态
    elemtype *_type         // 元素类型
    sendx    uint           // 队列下标,指示元素写入时存放到队列中的位置
    recvx    uint           // 队列下标,指示元素从队列的该位置读出
    recvq    waitq          // 等待读消息的goroutine队列
    sendq    waitq          // 等待写消息的goroutine队列
    lock mutex              // 互斥锁,chan不允许并发读写
}

从数据结构可以看出channel由队列、类型信息、goroutine等待队列组成,channel内部数据结构主要包含:

  • 环形队列
  • 等待队列(读队列和写队列)
  • mutex

缓冲区—环形队列

chan内部实现了一个环形队列作为其缓冲区,队列的长度是创建chan时指定的。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ZI1xAfsy-1691932395867)(C:\Users\hp\AppData\Roaming\Typora\typora-user-images\image-20230813164252270.png)]

  • dataqsiz指示了队列长度为6,即可缓存6个元素;
  • buf指向队列的内存,队列中还剩余两个元素;
  • qcount表示队列中还有两个元素;
  • sendx指示后续写入的数据存储的位置,取值[0, 6);
  • recvx指示从该位置读取数据, 取值[0, 6);

等待队列

从channel读数据,如果channel缓冲区为空或者没有缓冲区,当前goroutine会被阻塞。
向channel写数据,如果channel缓冲区已满或者没有缓冲区,当前goroutine会被阻塞。

被阻塞的goroutine将会挂在channel的等待队列中:

  • 因读阻塞的goroutine会被向channel写入数据的goroutine唤醒;
  • 因写阻塞的goroutine会被从channel读数据的goroutine唤醒;

channel 读写

写数据

向一个channel中写数据简单过程如下:

  1. 如果等待接收队列recvq不为空,说明缓冲区中没有数据或者没有缓冲区,此时直接从recvq取出G,并把数据写入,最后把该G唤醒,结束发送过程;
  2. 如果缓冲区中有空余位置,将数据写入缓冲区,结束发送过程;
  3. 如果缓冲区中没有空余位置,将待发送数据写入G,将当前G加入sendq,进入睡眠,等待被读goroutine唤醒;

读数据

  1. 如果等待发送队列sendq不为空,且没有缓冲区,直接从sendq中取出G,把G中数据读出,最后把G唤醒,结束读取过程;
  2. 如果等待发送队列sendq不为空,此时说明缓冲区已满,从缓冲区中首部读出数据,把G中数据写入缓冲区尾部,把G唤醒,结束读取过程;
  3. 如果缓冲区中有数据,则从缓冲区取出数据,结束读取过程;
  4. 将当前goroutine加入recvq,进入睡眠,等待被写goroutine唤醒;

出现panic的场景

关闭channel时会把recvq中的G全部唤醒,本该写入G的数据位置为nil。把sendq中的G全部唤醒,但这些G会panic。

除此之外,panic出现的常见场景还有:

  1. 关闭值为nil的channel
  2. 关闭已经被关闭的channel
  3. 向已经关闭的channel写数据

出现阻塞的场景

  1. 无缓冲区读写数据会阻塞
  2. 缓冲区已满,写入会阻塞;缓冲区为空,读取数据会阻塞
  3. 值为nil读写数据会阻塞

channel和锁对比

并发问题可以用channel解决也可以用Mutex解决,但是它们的擅长解决的问题有一些不同。channel关注的是并发问题的数据流动,适用于数据在多个协程中流动的场景。而mutex关注的是是数据不动,某段时间只给一个协程访问数据的权限,适用于数据位置固定的场景。

channel应用场景

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

  1. 定时任务:超时处理
  2. 解耦生产者和消费者,可以将生产者和消费者解耦出来,生产者只需要往channel发送数据,而消费者只管从channel中获取数据。
  3. 控制并发数:以爬虫为例,比如需要爬取1w条数据,需要并发爬取以提高效率,但并发量又不过过大,可以通过channel来控制并发规模,比如同时支持5个并发任务

有无缓冲在使用上的区别

无缓冲:发送和接收需要同步。 有缓冲:不要求发送和接收同步,缓冲满时发送阻塞。 因此 channel 无缓冲时,发送阻塞直到数据被接收,接收阻塞直到读到数据;channel有缓冲时,当缓冲满时发送阻塞,当缓冲空时接收阻塞。

channel是否线程安全

  • channel为什么设计成线程安全? 不同协程通过channel进行通信,本身的使用场景就是多线程,为了保证数据的一致性,必须实现线程安全。
  • channel如何实现线程安全的? channel的底层实现中, hchan结构体中采用Mutex锁来保证数据读写安全。在对循环数组buf中的数据进行入队和出队操作时,必须先获取互斥锁,才能操作channel数据。

用channel实现分布式锁

分布式锁定义-控制分布式系统有序的去对共享资源进行操作,通过互斥来保持一致性。 通过数据库,redis,zookeeper都可以实现分布式锁。其中,最常见的是用redis的setnx实现。

通过channel作为媒介,利用struct{}{}作为信号,判断struct{}{}是否存在进行加锁、解锁操作。

go channel实现归并排序

func Merge(ch1 <-chan int, ch2 <-chan int) <-chan int {
 
    out := make(chan int)
 
    go func() {
        // 等上游的数据 (这里有阻塞,和常规的阻塞队列并无不同)
        v1, ok1 := <-ch1
        v2, ok2 := <-ch2
        
        // 取数据
        for ok1 || ok2 {
            if !ok2 || (ok1 && v1 <= v2) {
                // 取到最小值, 就推到 out 中
                out <- v1
                v1, ok1 = <-ch1
            } else {
                out <- v2
                v2, ok2 = <-ch2
            }
        }
        // 显式关闭
        close(out)
    }()
 
    // 开完goroutine后, 主线程继续执行, 不会阻塞
    return out

判断channel已关闭

方式1:通过读chennel实现

用 select 和 <-ch 来结合判断,ok的结果和含义: true:读到数据,并且通道 (opens new window)没有关闭。 false:通道关闭,无数据读到。需要注意: 1.case 的代码必须是 _, ok:= <- ch 的形式,如果仅仅是 <- ch 来判断,是错的逻辑,因为主要通过 ok的值来判断; 2.select 必须要有 default 分支,否则会阻塞函数,我们要保证一定能正常返回;

方式2:通过context

通过一个 ctx 变量来指明 close 事件,而不是直接去判断 channel 的一个状态. 当ctx.Done()中有值时,则判断channel已经退出。注意: select 的 case 一定要先判断 ctx.Done() 事件,否则很有可能先执行了 chan 的操作从而导致 panic 问题;

chan和共享内存的优劣势

Go的设计思想就是, 不要通过共享内存来通信,而是通过通信来共享内存,前者就是传统的加锁,后者就是Channel。 共享内存是在操作内存的同时,通过互斥锁、CAS等保证并发安全,而channel虽然底层维护了一个互斥锁,来保证线程安全,但其可以理解为先进先出的队列,通过管道进行通信。 共享内存优势是资源利用率高、系统吞吐量大,劣势是平均周转时间长、无交互能力。 channel优势是降低了并发中的耦合,劣势是会出现死锁。

使用chan不占内存空间实现传递信息

// 空结构体的宽度是0,占用了0字节的内存空间。
// 所以空结构体组成的组合数据类型也不会占用内存空间。
channel := make(chan struct{})
go func() {
    // do something
    channel <- struct{}{}
}()
fmt.Println(<-channel)

go中的syncLock和channel的性能区别

hannel的底层也是用了syns.Mutex,算是对锁的封装,性能应该是有损耗的。根据压测结果来说Mutex 比 channel的性能快了两倍左右

同一个协程里面,对无缓冲channel同时发送和接收数据有什么问题

同一个协程里,不能对无缓冲channel同时发送和接收数据,如果这么做会直接报错死锁。对于一个无缓冲的channel而言,只有不同的协程之间一方发送数据一方接受数据才不会阻塞。channel无缓冲时,发送阻塞直到数据被接收,接收阻塞直到读到数据。

reflect

说一下reflect

recflect是golang用来检测存储在接口变量内部(值value;类型concrete type) pair对的一种机制。它提供了两种类型(或者说两个方法)让我们可以很容易的访问接口变量内容,分别是reflect.ValueOf() 和 reflect.TypeOf()。

  • ValueOf用来获取输入参数接口中的数据的值,如果接口为空则返回0
  • TypeOf用来动态获取输入参数接口中的值的类型,如果接口为空则返回nil

反射是什么

Go语言中的反射是指在运行时动态地检查类型信息和操作对象的能力。通过反射,可以在运行时获取对象的类型信息、访问对象的字段和方法,以及动态地调用对象的方法。,反射的特性与interface紧密相关。

反射的作用是什么?

答:反射在某些情况下非常有用,例如:

  • 动态地获取对象的类型信息,如类型名称、字段和方法等。
  • 在运行时创建对象、赋值和修改对象的字段值。
  • 调用对象的方法。
  • 实现通用的数据处理和代码生成等。

反射的优缺点是什么?

答:反射的优点是它提供了灵活性和动态性,可以在运行时处理不同类型的对象。然而,反射的使用会引入性能上的开销,因为它需要进行类型转换和动态调用。此外,反射也会降低代码的可读性和可维护性,因为它隐藏了一些类型信息和编译时的检查。

interface

interface是一个复合类型,每个interface类型代表一个特定的方法集,方法集中的方法称为接口。任何类型只要实现了interface类型的所有方法,就可以声称该类型实现了这个接口,该类型的变量可以存储到interface变量中。

为什么interface变量可以存储任意实现了该接口类型的变量呢?

因为interface类型的变量在存储某个变量时会同时保存变量类型和变量值。

type iface struct{
	tab *itab // 保存变量类型(以及方法集)
	data unsafe.Pointer // 变量值位于堆栈的指针
}

反射定律

reflect包

reflect包中提供了reflect.Type和reflect.Value两个类型,分别代表interface的value和type。

第一定律

反射可以将interface类型变量转换成反射对象

第二定律

反射可以将反射对象还原成interface对象

第三定律

反射对象可以修改,value值必须是可设置的

defer

defer规则

defer的执行顺序

多个defer出现的时候,它是一个“栈”的关系,也就是先进后出。一个函数中,写在前面的defer会比写在后面的defer调用的晚。

延迟函数的参数在defer语句出现时就已经确定

func a() {
    i := 0
    defer fmt.Println(i)
    i++
    return
}

defer语句中的fmt.Println()参数i值在defer出现时就已经确定下来,实际上是拷贝了一份。后面对变量i的修改不会影响fmt.Println()函数的执行,仍然打印”0”。注意:对于指针类型参数,规则仍然适用,只不过延迟函数的参数是一个地址值,这种情况下,defer后面的语句对变量的修改可能会影响延迟函数。

延迟函数可能操作主函数的具名返回值

定义defer的函数,即主函数可能有返回值,返回值有没有名字没有关系,defer所作用的函数,即延迟函数可能会影响到返回值。若要理解延迟函数是如何影响主函数返回值的,只要明白函数是如何返回的就足够了。

函数返回过程

关键字return不是一个原子操作,实际上return只代理汇编指令ret,即将跳转程序执行。比如语句return i,实际上分两步进行,即将i值存入栈中作为返回值,然后执行跳转,而defer的执行时机正是跳转前,所以说defer执行时还是有机会操作返回值的。

主函数拥有匿名返回值,返回字面值

一个主函数拥有一个匿名的返回值,返回时使用字面值,比如返回”1”、”2”、”Hello”这样的值,这种情况下defer语句是无法操作返回值的

func f() int {
    var i int
    defer func() {
        i++
    }()
    return 2
}
// 上面的return语句,直接把1写入栈中作为返回值,延迟函数无法操作该返回值,所以就无法影响返回值。
主函数拥有匿名返回值,返回变量

一个主函数拥有一个匿名的返回值,返回使用本地或全局变量,这种情况下defer语句可以引用到返回值,但不会改变返回值。

func f() int {
    var i int
    defer func() {
        i++
    }()
    return i
}
// 上面的函数,返回一个局部变量,同时defer函数也会操作这个局部变量。对于匿名返回值来说,可以假定仍然有一个变量存储返回值,假定返回值变量为”anony”,上面的返回语句可以拆分成以下过程:
anony = i
i++
return
// 由于i是整型,会将值拷贝给anony,所以defer语句中修改i值,对函数返回值不造成影响。
主函数拥有具名返回值

主函声明语句中带名字的返回值,会被初始化成一个局部变量,函数内部可以像使用局部变量一样使用该返回值。如果defer语句操作该返回值,可能会改变返回结果。一个影响函返回值的例子:

func foo() (ret int) {
    defer func() {
        ret++
    }()

    return 0
}
// 上面的函数拆解出来,如下所示:
ret = 0
ret++
return
// 函数真正返回前,在defer中对返回值做了+1操作,所以函数最终返回1。

defer与return谁先谁后

return之后的语句先执行,defer后的语句后执行

defer遇见panic

能够触发defer的是遇见return(或函数体到末尾)和遇见panic。defer遇见return情况如下:

在这里插入图片描述

遇到panic时,遍历本协程的defer链表,并执行defer。在执行defer过程中:遇到recover则停止panic,返回recover处继续往下执行。如果没有遇到recover,遍历完本协程的defer链表后,向stderr抛出panic信息。
在这里插入图片描述

defer遇见panic,但是并不捕获异常的情况

package main

import (
    "fmt"
)
func main() {
    deferTest()

    fmt.Println("main 正常结束")
}
func deferTest() {
    defer func() { fmt.Println("defer: panic 之前1") }()
    defer func() { fmt.Println("defer: panic 之前2") }()
    panic("异常内容")  //触发defer出栈
	defer func() { fmt.Println("defer: panic 之后,永远执行不到") }()
}

defer遇见panic,并捕获异常

package main

import (
    "fmt"
)

func main() {
    deferTest()

    fmt.Println("main 正常结束")
}

func deferTest() {

    defer func() {
        fmt.Println("defer: panic 之前1, 捕获异常")
        if err := recover(); err != nil {
            fmt.Println(err)
        }
    }()

    defer func() { fmt.Println("defer: panic 之前2, 不捕获") }()

    panic("异常内容")  //触发defer出栈

	defer func() { fmt.Println("defer: panic 之后, 永远执行不到") }()
}

defer 最大的功能是 panic 后依然有效所以defer可以保证你的一些资源一定会被关闭,从而避免一些异常出现的问题。

defer中包含panic

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")
}
// 输出 defer panic

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

调度模型

golang操作内核线程

在此模型下的用户线程与内核线程一一对应,也就是说完全接管了用户线程,它也属于内核的一部分,统一由调度器来创建、终止和切换。这样就能完全发挥出多核的优势,多个线程可以跑在不同的CPU上,实现真正的并行。但也正由于一切都由内核来调度,这样大大增加了工作量,线程的切换是非常耗时的,而且创建也很用到更多的资源,所以也大大减少能创建线程的数量。由于是一对一的关系所以也叫(1:1)线程实现。

讲一讲GMP模型

  • G(Goroutine):G 就是我们所说的 Go 语言中的协程 Goroutine 的缩写,相当于操作系统中的进程控制块。其中存着 goroutine 的运行时栈信息,CPU 的一些寄存器的值以及执行的函数指令等。
  • M(Machine):代表一个操作系统的主线程,对内核级线程的封装,数量对应真实的 CPU 数。一个 M 直接关联一个 os 内核线程,用于执行 G。M 会优先从关联的 P 的本地队列中直接获取待执行的 G。M 保存了 M 自身使用的栈信息、当前正在 M上执行的 G 信息、与之绑定的 P 信息。
  • P(Processor):Processor 代表了 M 所需的上下文环境,代表 M 运行 G 所需要的资源。是处理用户级代码逻辑的处理器,可以将其看作一个局部调度器使 go 代码在一个线程上跑。当 P 有任务时,就需要创建或者唤醒一个系统线程来执行它队列里的任务,所以 P 和 M 是相互绑定的。总的来说,P 可以根据实际情况开启协程去工作,它包含了运行 goroutine 的资源,如果线程想运行 goroutine,必须先获取 P,P 中还包含了可运行的 G 队列。

能开多少个M由什么决定

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

能开多少个M由什么决定

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

golang调度能不能不要P

第一版

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

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

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

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

第二版

在 Go 语言中,P(Processor)是调度器的一部分,用于管理和执行 goroutine。每个 P 都有一个固定的系统线程(OS thread)关联,用于在该线程上执行 goroutine。P 的存在是为了协调调度器和系统线程之间的关系,它充当了调度器和操作系统之间的中间层。P 的作用包括:

  1. 调度:P 负责将 goroutine 分配给系统线程执行,并在系统线程空闲时重新分配。
  2. Goroutine 栈管理:P 管理 goroutine 的栈空间,包括分配和回收。
  3. 垃圾回收:P 参与垃圾回收过程,协助标记和清理不再使用的内存。

由于 Go 语言的调度器是基于 M:N 模型实现的,即将 M 个 goroutine 关联到 N 个系统线程上执行,因此不能直接在没有 P 的情况下运行 goroutine。

为什么GMP这么快

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

GMP调度过程

  1. 我们通过 go func()来创建一个goroutine;
  2. 有两个存储G的队列,一个是局部调度器P的本地队列、一个是全局G队列。新创建的G会先保存在P的本地队列中,如果P的本地队列已经满了就会保存在全局的队列中;
  3. G只能运行在M中,一个M必须持有一个P,M与P是1:1的关系。M会从P的本地队列弹出一个可执行状态的G来执行,如果P的本地队列为空,会从全局队列拿P,如果全局队列也为空,就会向其他的MP组合偷取一个可执行的G来执行;
  4. 一个M调度G执行的过程是一个循环机制;
  5. 当M执行某一个G时候如果发生了syscall或则其余阻塞操作,M会阻塞,如果当前有一些G在执行,runtime会把这个线程M从P中摘除(detach),然后再创建一个新的操作系统的线程(如果有空闲的线程可用就复用空闲线程)来服务于这个P;
  6. 当M系统调用结束时候,这个G会尝试获取一个空闲的P执行,并放入到这个P的本地队列。如果获取不到P,那么这个线程M变成休眠状态, 加入到空闲线程中,然后这个G会被放入全局队列中。

两种类型的队列

  1. 本地队列:本地的队列是无锁的,没有数据竞争问题,处理速度比较高。
  2. 全局队列:是用来平衡不同的P的任务数量,所有的M共享P的全局队列。
  3. 全局G队列(Global Queue):存放等待运⾏的G。
  4. P的本地G队列:同全局队列类似,存放的也是等待运⾏的G,存的数量有限,不超过256个。 新建G时,G优先加入到P的本地队列,如果队列满了,则会把本地队列中⼀半的G移动到全局队列
  5. P列表:所有的P都在程序启动时创建,并保存在数组中,最多有 GOMAXPROCS(可配置)个。可通过 runtime.GOMAXPROCS() 来进⾏设置,1.5版本之前默认为1,使⽤单核⼼执⾏,之后默认为最⼤逻辑cpu数量,即默认有最⼤逻辑cpu数量个P。、
  6. M列表:当前操作系统分配给golang程序的内核线程数。线程想运⾏任务就得获取P,从P的本地队列获取G,P队列为空时,M会优先尝试从全局队列拿⼀批G放到P的本地队列,或从其他P的本地队列偷⼀半放到⾃⼰P的本地队列。M运⾏G,G执⾏之后,M会从P获取下⼀个G,不断重复下去。 Goroutine调度器和OS调度器是通过M结合起来的,每个M都代表了1个内核线程,OS调度器负责把内核线程分配到CPU的

g阻塞,g,m,p发生什么

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

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

绑定在P上的local queue是顺序执行的,不存在执行状态的G协程抢占,所以可以无锁访问。任务窃取也是窃取其他P上等待状态的G协程,所以也可以不用加锁。

go调度中阻塞的方式
  1. 由于原子、互斥量或通道操作调用导致 Goroutine 阻塞,调度器将把当前阻塞的 Goroutine 切换出去,重新调度 LRQ 上的其他 Goroutine;
  2. 由于网络请求和 IO 操作导致 Goroutine 阻塞。Go 程序提供了网络轮询器(NetPoller)来处理网络请求和 IO 操作的问题,其后台通过 kqueue(MacOS),epoll(Linux)或 iocp(Windows)来实现 IO 多路复用。通过使用 NetPoller 进行网络系统调用,调度器可以防止 Goroutine 在进行这些系统调用时阻塞 M。这可以让 M 执行 P 的 LRQ 中其他的 Goroutines,而不需要创建新的 M。执行网络系统调用不需要额外的 M,网络轮询器使用系统线程,它时刻处理一个有效的事件循环,有助于减少操作系统上的调度负载。用户层眼中看到的 Goroutine 中的“block socket”,实现了 goroutine-per-connection 简单的网络编程模式。实际上是通过 Go runtime 中的 netpoller 通过 Non-block socket + I/O 多路复用机制“模拟”出来的。
  3. 当调用一些系统方法的时候(如文件 I/O),如果系统方法调用的时候发生阻塞,这种情况下,网络轮询器(NetPoller)无法使用,而进行系统调用的 G1 将阻塞当前 M1。调度器引入 其它M 来服务 M1 的P。
  4. 如果在 Goroutine 去执行一个 sleep 操作,导致 M 被阻塞了。Go 程序后台有一个监控线程 sysmon,它监控那些长时间运行的 G 任务然后设置可以强占的标识符,别的 Goroutine 就可以抢先进来执行。

具体的调度策略

Go的调度器内部有三个重要的结构,G(代表一个goroutine,它有自己的栈),M(Machine,代表内核级线程),P(Processor([prɑːsesər]),上下文处理器,它的主要用途就是用来连接执行的goroutine和内核线程的,定义在源码的src/runtime/runtime.h文件中) -G代表一个goroutine对象,每次go调用的时候,都会创建一个G对象 -M代表一个线程,每次创建一个M的时候,都会有一个底层线程创建;所有的G任务,最终还是在M上执行 -P代表一个处理器,每一个运行的M都必须绑定一个P,就像线程必须在每一个CPU核上执行一样 一个M对应一个P,一个P下面挂多个G,但同一时间只有一个G在跑,其余都是放入等待队列(runqueue([kjuː]))。 当一个P的队列消费完了就去全局队列里取,如果全局队列里也消费完了会去其他P的队列里抢任务(所以需要单独存储下一个 g 的地址,而不是从队列里获取)。

同时启动一万个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偷取一部分过来,一般每次偷取一半。

抢占式调度及goroutine泄漏

go的抢占式调度

在1.1 版本中的调度器是不支持抢占式调度的,程序只能依靠 Goroutine 主动让出 CPU 资源才能触发调度。Go 语言的调度器在 1.2 版本中引入基于协作的抢占式调度,解决了以下的问题:

  • 某些 Goroutine 可以长时间占用线程,造成其它 Goroutine 的饥饿;
  • 垃圾回收需要暂停整个程序(Stop-the-world,STW),最长可能需要几分钟的时间,导致整个程序无法工作;

1.2 版本的抢占式调度虽然能够缓解这个问题,但是它实现的抢占式调度是基于协作的,在之后很长的一段时间里 Go 语言的调度器都有一些无法被抢占的边缘情况,例如:for 循环或者垃圾回收长时间占用线程,这些问题中的一部分直到 1.14 才被基于信号的抢占式调度解决。 抢占式分为两种:

  • 协作式的抢占式调度
  • 基于信号的抢占式调度

Goroutine 泄露

Goroutine 作为一种逻辑上理解的轻量级线程,需要维护执行用户代码的上下文信息。在运行过程中也需要消耗一定的内存来保存这类信息,而这些内存在目前版本的 Go 中是不会被释放的。因此,如果一个程序持续不断地产生新的 goroutine、且不结束已经创建的 goroutine 并复用这部分内存,就会造成内存泄漏的现象。造成泄露的大多数原因有以下三种:

  • Goroutine 内正在进行 channel/mutex 等读写操作,但由于逻辑问题,某些情况下会被一直阻塞。
  • Goroutine 内的业务逻辑进入死循环,资源一直无法释放。
  • Goroutine 内的业务逻辑进入长时间等待,有不断新增的 Goroutine 进入等待。

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

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

一个协程挂起换入另外一个协程是什么过程?

对于进程、线程,都是有内核进行调度,有CPU时间片的概念,进行抢占式调度。协程,又称微线程,纤程。英文名Coroutine。协程的调用有点类似子程序,如程序A调用了子程序B,子程序B调用了子程序C,当子程序C结束了返回子程序B继续执行之后的逻辑,当子程序B运行结束了返回程序A,直到程序A运行结束。但是和子程序相比,协程有挂起的概念,协程可以挂起跳转执行其他协程,合适的时机再跳转回来。 本质上goroutine就是协程,但是完全运行在用户态,采用了MPG模型:

M:内核级线程

G:代表一个goroutine

P:Processor,处理器,用来管理和执行goroutine的。

G-M-P三者的关系与特点:

  • P的个数取决于设置的GOMAXPROCS,go新版本默认使用最大内核数,比如你有8核处理器,那么P的数量就是8
  • M的数量和P不一定匹配,可以设置很多M,M和P绑定后才可运行,多余的M处于休眠状态。
  • P包含一个LRQ(Local Run Queue)本地运行队列,这里面保存着P需要执行的协程G的队列
  • 除了每个P自身保存的G的队列外,调度器还拥有一个全局的G队列GRQ(Global Run Queue),这个队列存储的是所有未分配的协程G。

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数量。

第二版

全局队列中的G不会饥饿,P中每执行61次调度,就需要优先从全局队列中获取一个G到当前P中,并执行下一个要执行的G。

P数量问题可以通过 runtime.GOMAXPROCS() 设置数量,默认为当前CPU可执行的最大数量。M数量问题 Go语⾔本身是限定M的最⼤量是10000。 runtime/debug包中的SetMaxThreads函数来设置。 有⼀个M阻塞,会创建⼀个新的M。 如果有M空闲,那么就会回收或者睡眠。

内存管理

make和new的异同点

  1. 用途不同:make 用于创建和初始化引用类型(如 slice、map 和 channel),而 new 用于创建指针类型的值。
  2. 返回类型不同:make 返回的是所创建类型的引用,而 new 返回的是对应类型的指针。
  3. 参数不同:make 接收的参数是类型和一些可选的长度或容量等参数,具体取决于所创建的类型。而 new 只接收一个参数,即所要创建类型的指针。
  4. 初始化不同:make 创建的引用类型会进行初始化,并返回一个可用的、已分配内存的对象。而 new 创建的指针类型只是返回一个对应类型的指针,并不会进行初始化。

内存模型

Go语言运行时依靠细微的对象切割、极致的多级缓存、精准的位图管理实现了对内存的精细化管理。 将对象分为微小对象、小对象、大对象,使用三级管理结构mcache、mcentral、mheap用于管理、缓存加速span对象的访问和分配,使用精准的位图管理已分配的和未分配的对象及对象的大小。 Go语言运行时依靠细微的对象切割、极致的多级缓存、精准的位图管理实现了对内存的精细化管理以及快速的内存访问,同时减少了内存的碎片。

span

Go 将内存分成了67个级别的span,特殊的0级特殊大对象大小是不固定的。

当具体的对象需要分配内存时,并不是直接分配span,而是分配不同级别的span中的元素。因此span的级别不是以每个span的大小为依据,而是以span中元素的大小为依据的。

Span等级元素大小(字节)Span大小(字节)元素个数
1881921024
2168192512
3328192256
4488192170
65648192128
6528672573442
6632768327681

第1级span拥有的元素个数为8192/8=1024。每个span的大小和span中元素的个数都不是固定的,例如第65级span的大小为57344字节,每个元素的大小为28672字节,元素个数为2。span的大小虽然不固定,但其是8KB或更大的连续内存区域。 每个具体的对象在分配时都需要对齐到指定的大小,假如我们分配17字节的对象,会对应分配到比17字节大并最接近它的元素级别,即第3级,这导致最终分配了32字节。因此,这种分配方式会不可避免地带来内存的浪费。

三级对象管理

为了方便对Span进行管理,加速Span对象访问、分配。分别为mcache、mcentral、mheap。 TCMalloc内存分配算法的思想: 每个逻辑处理器P都存储了一个本地span缓存,称作mcache。如果协程需要内存可以直接从mcache中获取,由于在同一时间只有一个协程运行在逻辑处理器P上,所以中间不需要加锁。mcache包含所有大小规格的mspan,但是每种规格大小只包含一个。除class0外,mcache的span都来自mcentral。

mcentral 所有逻辑处理器P共享的。

  • 对象收集所有给定规格大小的span。每个mcentral都包含两个mspan的链表:empty mspanList表示没有空闲对象或span已经被mcache缓存的span链表,nonempty mspanList表示有空闲对象的span链表。(为了的分配Mspan到Mcache中)

    mheap 每个级别的span都会有一个mcentral用于管理span链表(0级除外),其实 都是一个数组,由Mheap管理 作用: 不只是管理central,大对象也会直接通过mheap进行分配。

  • mheap实现了对虚拟内存线性地址空间的精准管理,建立了span与具体线性地址空间的联系,保存了分配的位图信息,是管理内存的最核心单元。堆区的内存被分成了HeapArea大小进行管理。对Heap进行的操作必须全局加锁,而mcache、mcentral可以被看作某种形式的缓存。

四级内存块管理

Go 根据对象大小,将堆内存分成了 HeapArea->chunk->span->page 4种内存块进行管理。不同的内存块用于不同的场景,便于高效地对内存进行管理。

  • HeapArea 内存块最大,其大小与平台相关,在UNIX 64位操作系统中占据64MB。
  • chunk占据了512KB
  • span根据级别大小的不同而不同,但必须是page的倍数
  • 而1个page占据8KB

内存分配的实现

Golang内存分配和TCMalloc差不多,都是把内存提前划分成不同大小的块,其核心思想是把内存分为多级管理,从而降低锁的粒度。先了解下内存管理每一级的概念: mspan mspan跟tcmalloc中的span相似,它是golang内存管理中的基本单位,也是由页组成的,每个页大小为8KB,与tcmalloc中span组成的默认基本内存单位页大小相同。mspan里面按照8*2n大小(8b,16b,32b … ),每一个mspan又分为多个object。

mcache mcache跟tcmalloc中的ThreadCache相似,ThreadCache为每个线程的cache,同理,mcache可以为golang中每个Processor提供内存cache使用,每一个mcache的组成单位也是mspan。

mcentral mcentral跟tcmalloc中的CentralCache相似,当mcache中空间不够用,可以向mcentral申请内存。可以理解为mcentral为mcache的一个“缓存库”,供mcaceh使用。它的内存组成单位也是mspan。mcentral里有两个双向链表,一个链表表示还有空闲的mspan待分配,一个表示链表里的mspan都被分配了。

mheap mheap跟tcmalloc中的PageHeap相似,负责大内存的分配。当mcentral内存不够时,可以向mheap申请。那mheap没有内存资源呢?跟tcmalloc一样,向OS操作系统申请。还有,大于32KB的内存,也是直接向mheap申请。

golang 分配内存具体过程如下:

  1. 程序启动时申请一大块内存,并划分成spans、bitmap、arena区域
  2. arena区域按页划分成一个个小块
  3. span管理一个或多个页
  4. mcentral管理多个span供线程申请使用
  5. mcache作为线程私有资源,资源来源于mcentral

简单介绍一下go的内存分配机制?有mcentral为啥要mcache?

第一版

1.介绍内存分配机制

GO语言内存管理子系统主要由两部分组成:内存分配器和垃圾回收器(gc)。内存分配器主要解决小对象的分配管理和多线程的内存分配问题。什么是小对象呢?小于等于32k的对象就是小对象,其它都是大对象。小对象的内存分配是通过一级一级的缓存来实现的,目的就是为了提升内存分配释放的速度以及避免内存碎片等问题

2.介绍MCentral

所有线程共享的组件,不是独占的,因此需要加锁操作。它其实也是一个缓存,cache的一个上游用户,但缓存的不是小对象内存块,而是一组一组的内存page(一个page4K)。从图2可以看出,在heap结构里,使用了一个0到n的数组来存储了一批central,并不是只有一个central对象。从上面结构定义可以知道这个数组长度位61个元素,也就是说heap里其实是维护了61个central,这61个central对应了cache中的list数组,也就是每一个sizeclass就有一个central。所以,在cache中申请内存时,如果在某个sizeclass的内存链表上找不到空闲内存,那么cache就会向对应的sizeclass的central获取一批内存块。注意,这里central数组的定义里面使用填充字节,这是因为多线程会并发访问不同central避免false sharing。

3.介绍mcache

每个线程都有一个cache,用来存放小对象。由于每个线程都有cache,所以获取空闲内存是不用加锁的。cache层的主要目的就是提高小内存的频繁分配释放速度。 我们在写程序的时候,其实绝大多数的内存申请都是小于32k的,属于小对象,因此这样的内存分配全部走本地cache,不用向操作系统申请显然是非常高效的

4.阐述二者区别

mcentral与mcache有一个明显区别,就是有锁存在,由于mcentral是公共资源,会有多个mcache向它申请mspan,因此必须加锁,另外,mcentral与mcache不同,由于P绑定了很多Goroutine,在P上会处理不同大小的对象,mcache就需要包含各种规格的mspan,但mcentral不同,同一个mcentral只负责一种规格的mspan就够了。

第二版

Go 的内存分配借鉴了 Google 的 TCMalloc 分配算法,其核心思想是内存池 + 多级对象管理。内存池主要是预先分配内存,减少向系统申请的频率;多级对象有:mheap、mspan、arenas、mcentral、mcache。它们以 mspan 作为基本分配单位。具体的分配逻辑如下: 当要分配大于 32K 的对象时,从 mheap 分配。 当要分配的对象小于等于 32K 大于 16B 时,从 P 上的 mcache 分配,如果 mcache 没有内存,则从 mcentral 获取,如果 mcentral 也没有,则向 mheap 申请,如果 mheap 也没有,则从操作系统申请内存。 当要分配的对象小于等于 16B 时,从 mcache 上的微型分配器上分配。

GC触发时机

  • 内存分配量达到阀值触发GC

每次内存分配时都会检查当前内存分配量是否已达到阀值,如果达到阀值则立即启动GC。

阀值 = 上次GC内存分配量 * 内存增长率

内存增长率由环境变量GOGC控制,默认为100,即每当内存扩大一倍时启动GC

  • 定期触发GC

默认情况下,最长2分钟触发一次GC,这个间隔在src/runtime/proc.go:forcegcperiod变量中被声明:

var forcegcperiod int64 = 2 * 60 * 1e9
  • 手动触发

程序代码中也可以使用runtime.GC()来手动触发GC。这主要用于GC性能测试和统计。

go垃圾回收介绍

第一版

三色标记法+混合写屏障

  1. 初始状态下所有对象都是白色的。
  2. 从根节点开始遍历所有对象,把遍历到的对象变成灰色对象
  3. 遍历灰色对象,将灰色对象引用的对象也变成灰色对象,然后将遍历过的灰色对象变成黑色对象。
  4. 循环步骤3,直到灰色对象全部变黑色。
  5. 通过写屏障(write-barrier)检测对象有变化,重复以上操作
  6. 收集所有白色对象(垃圾)。

  1. 标记清除: 此算法主要有两个主要的步骤:

    标记(Mark phase)

    清除(Sweep phase)

    第一步,找出不可达的对象,然后做上标记。 第二步,回收标记好的对象。

    操作非常简单,但是有一点需要额外注意:mark and sweep算法在执行的时候,需要程序暂停!即 stop the world。 也就是说,这段时间程序会卡在哪儿。故中文翻译成 卡顿.

    标记-清扫(Mark And Sweep)算法存在什么问题? 标记-清扫(Mark And Sweep)算法这种算法虽然非常的简单,但是还存在一些问题:

    STW,stop the world;让程序暂停,程序出现卡顿。

    标记需要扫描整个heap

    清除数据会产生heap碎片 这里面最重要的问题就是:mark-and-sweep 算法会暂停整个程序。

  2. 三色并发标记法: 首先:程序创建的对象都标记为白色。 gc开始:扫描所有可到达的对象,标记为灰色 从灰色对象中找到其引用对象标记为灰色,把灰色对象本身标记为黑色 监视对象中的内存修改,并持续上一步的操作,直到灰色标记的对象不存在 此时,gc回收白色对象 最后,将所有黑色对象变为白色,并重复以上所有过程。

  3. 混合写屏障: 注意: 当gc进行中时,新创建一个对象. 按照三色标记法的步骤,对象会被标记为白色,这样新生成的对象最后会被清除掉,这样会影响程序逻辑. golang引入写屏障机制.可以监控对象的内存修改,并对对象进行重新标记. gc一旦开始,无论是创建对象还是对象的引用改变,都会先变为灰色。

第二版

goalng1.8的GC采用三色标记法+混合写屏障

三色标记法:将所有对象分为三类,白色、黑色与灰色。

白色:暂无对象引用的潜在垃圾,其内存可能会被垃圾回收器回收

黑色:表示活跃的对象

灰色:黑色与白色的中间状态

三色标记算法分五步进行。

  1. 将所有的对象标记为白色
  2. 从根节点出发,将第一次遍历到的节点标记为灰色
  3. 遍历节点,将灰色节点遍历到的白色节点标记为灰色,把遍历到的灰色节点标记为黑色
  4. 循环执行该过程
  5. 直到没有灰色节点,回收所有白色节点

屏障机制分为插入屏障和删除屏障,插入屏障实现的是强三色不变式,删除屏障则实现了弱三色不变式。值得注意的是为了保证栈的运行效率,屏障只对堆上的内存对象启用,栈上的内存会在GC结束后启用STW重新扫描。

插入屏障:对象被引用时触发的机制,当白色对象被黑色对象引用时,白色对象被标记为灰色(栈上对象无插入屏障)。

C语言这种较为传统的语言通过mallocfree手动向操作系统申请和释放内存,这种自由管理内存的方式给予程序员极大的自由度,但是也相应地提高了对程序员的要求。C语言的内存分配和回收方式主要包括三种:

  • 函数体内的局部变量:在栈上创建,函数作用域结束后自动释放内存
  • 静态变量:在静态存储区域上分配内存,整个程序运行结束后释放(全局生命周期)
  • 动态分配内存的变量:在堆上分配,通过malloc申请,free释放

CC++Rust等较早的语言采用的是手动垃圾回收,需要程序员通过向操作系统申请和释放内存来手动管理内存,程序员极容易忘记释放自己申请的内存,对于一个长期运行的程序往往是一个致命的缺点。

Java垃圾回收

就是将 对象的内存周期划分为几块,按照每块的情况采取不同的垃圾回收算法。一般是把Java堆分为新生代和老年代。年轻代:年轻代用来存放新近创建的对象,年轻代中存在的对象是死亡非常快的。存在朝生夕死的情况。 老年代:老年代中存放的对象是存活了很久的对象。 垃圾回收算法分为三种,分别为标记-清除算法,复制算法,标记-整理算法。

标记-清除算法:标记无用对象,然后对其进行清除回收。 复制算法:将内存区域划分为大小相等的两部分,每次只使用一部分,当该部分用完后将其存活的对象移至另一部分,并把该部分内存全部清除。 标记-整理算法:标记无用对象,让所有存活的对象都向内存一端移动,然后清除掉存活对象边界外的内存区域。

golang逃逸分析

Golang 的逃逸分析,是指编译器根据代码的特征和生命周期,自动的把变量分配到堆或者是栈上面。Go 在编译阶段确立逃逸,并不是在运行时。可以使用 -gcflags="-m" 参数来查看逃逸分析的详细信息,包括哪些变量逃逸到堆上。

介绍栈堆

栈( stack)是系统自动分配空间的,例如我们定义一个 char a;系统会自动在栈上为其开辟空间。而堆(heap)则是程序员根据需要自己申请的空间,例如 malloc(10);开辟十个字节的空间。栈在内存中是从高地址向下分配的,并且连续的,遵循先进后出原则。系统在分配的时候已经确定好了栈的大小空间。栈上面的空间是自动回收的,所以栈上面的数据的生命周期在函数结束后,就被释放掉了。堆分配是从低地址向高地址分配的,每次分配的内存大小可能不一致,导致了空间是不连续的,这也产生内存碎片的原因。由于是程序分配,所以效率相对慢些。而堆上的数据只要程序员不释放空间,就一直可以访问到,不过缺点是一旦忘记释放会造成内存泄露。

逃逸策略

每当函数中申请新的对象,编译器会根据该对象是否被函数外部引用来决定是否逃逸:

  1. 如果函数外部没有引用,则优先放到栈中;
  2. 如果函数外部存在引用,则必定放到堆中;

注意,对于函数外部没有引用的对象,也有可能放到堆中,比如内存过大超过栈的存储能力。

逃逸分析好处

  1. 内存分配优化:逃逸分析可以帮助编译器确定哪些变量可以在栈上分配,而不是在堆上分配。栈上分配的变量生命周期受限于函数或栈帧的范围,分配和释放内存的开销较小,可以提高程序的性能。
  2. 减少内存压力:通过将变量分配在栈上,可以减少对堆的内存压力。这对于大量临时对象的创建和销毁非常有用,可以减少垃圾回收的频率,提高程序的吞吐量。
  3. 减少垃圾回收压力:逃逸分析可以减少不必要的堆分配,从而减少垃圾回收器的负担。这对于大型和长时间运行的应用程序尤为重要,可以降低垃圾回收的停顿时间。

常见的逃逸现象

func(函数类型)数据类型interface{} 数据类型指针类型

  1. []interface{}数据类型,通过[]赋值必定会出现逃逸。
  2. map[string]interface{}类型尝试通过赋值,必定会出现逃逸。
  3. map[interface{}]interface{}类型尝试通过赋值,会导致key和value的赋值,出现逃逸。
  4. map[string][]string数据类型,赋值会发生[]string发生逃逸。
  5. []*int数据类型,赋值的右值会发生逃逸现象。
  6. func(*int)函数类型,进行函数赋值,会使传递的形参出现逃逸现象。
  7. func([]string): 函数类型,进行[]string{"value"}赋值,会使传递的参数出现逃逸现象。
  8. chan []string数据类型,想当前channel中传输[]string{"value"}会发生逃逸现象。
  9. 发送指针或带有指针的值到channel,因为编译时候无法知道那个goroutine会在channel接受数据,编译器无法知道什么时候释放。
  10. 在一个切片上存储指针或带指针的值。比如[]*string,导致切片内容逃逸,其引用值一直在堆上。
  11. 切片的append导致超出容量,切片重新分配地址,切片背后的存储基于运行时的数据进行扩充,就会在堆上分配。
  12. 调用接口类型时,接口类型的方法调用是动态调度,实际使用的具体实现只能在运行时确定,如一个接口类型为io.Reader的变量r,对r.Read(b)的调用将导致r的值和字节片b的后续转义并因此分配到堆上。
  13. 在方法内把局部变量指针返回,被外部引用,其生命周期大于栈,导致内存溢出。

避免逃逸方法

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

写代码时如何减少对象分配

例如如果需要把数字转换成字符串,使用 strconv.Itoa() 比 fmt.Sprintf() 要快一倍左右。如果需要把数字转换成字符串,使用 strconv.Itoa() 比 fmt.Sprintf() 要快一倍左右。

内存分配和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最大的不同在于,它提供了逃逸分析和垃圾回收机制。

Go 语言内存分配,什么分配在堆上,什么分配在栈上

Go 语言有两部分内存空间:栈内存和堆内存。栈内存由编译器自动分配和释放,函数调用的参数、返回值以及局部变量大都会被分配到栈上。堆内存的生命周期比栈内存要长,如果函数返回的值还会在其他地方使用,那么这个值就会被编译器自动分配到堆上。堆内存相比栈内存来说,不能自动被编译器释放,只能通过垃圾回收器才能释放,所以栈内存效率会很高。

go性能调优的方法

内存优化

  1. 将小对象合并成结构体一次分配,减少内存分配次数 Go runtime底层采用内存池机制,每个span大小为4k,同时维护一个cache。cache有一个0到n的list数组,list数组的每个单元挂载的是一个链表,链表的每个节点就是一块可用的内存块,同一链表中的所有节点内存块都是大小相等的;但是不同链表的内存大小是不等的,即list数组的一个单元存储的是一类固定大小的内存块,不同单元里存储的内存块大小是不等的。cache缓存的是不同类大小的内存对象,申请的内存大小最接近于哪类缓存内存块时,就分配哪类内存块。当cache不够时再向spanalloc中分配。
  2. 缓存区内容一次分配足够大小空间,并适当复用 在协议编解码时,需要频繁地操作[]byte,可以使用bytes.Buffer或其它byte缓存区对象。 bytes.Buffer等通过预先分配足够大的内存,避免当增长时动态申请内存,减少内存分配次数。对于byte缓存区对象需要考虑适当地复用。
  3. slice和map采make创建时,预估大小指定容量 slice和map与数组不一样,不存在固定空间大小,可以根据增加元素来动态扩容。 slice初始会指定一个数组,当对slice进行append等操作时,当容量不够时,会自动扩容: 如果新的大小是当前大小2倍以上,则容量增涨为新的大小; 否则循环以下操作:如果当前容量小于1024,按2倍增加;否则每次按当前容量1/4增涨,直到增涨的容量超过或等新大小。 map的扩容比较复杂,每次扩容会增加到上次容量的2倍。map的结构体中有一个buckets和oldbuckets,用于实现增量扩容: 正常情况下,直接使用buckets,oldbuckets为空; 如果正在扩容,则oldbuckets不为空,buckets是oldbuckets的2倍, 因此,建议初始化时预估大小指定容量
  4. 长调用栈避免申请较多的临时对象 Goroutine的调用栈默认大小是4K(1.7修改为2K),采用连续栈机制,当栈空间不够时,Go runtime会自动扩容: 当栈空间不够时,按2倍增加,原有栈的变量会直接copy到新的栈空间,变量指针指向新的空间地址; 退栈会释放栈空间的占用,GC时发现栈空间占用不到1/4时,则栈空间减少一半。 比如栈的最终大小2M,则极端情况下,就会有10次的扩栈操作,会带来性能下降。 因此,建议控制调用栈和函数的复杂度,不要在一个goroutine做完所有逻辑;如的确需要长调用栈,而考虑goroutine池化,避免频繁创建goroutine带来栈空间的变化。
  5. 避免频繁创建临时对象 Go在GC时会引发stop the world,即整个情况暂停。Go1.8最坏情况下GC为100us。但暂停时间还是取决于临时对象的个数,临时对象数量越多,暂停时间可能越长,并消耗CPU。 因此,建议GC优化方式是尽可能地减少临时对象的个数:尽量使用局部变量;所多个局部变量合并一个大的结构体或数组,减少扫描对象的次数,一次回尽可能多的内存。

并发优化

  1. 高并发的任务处理使用goroutine池 Goroutine虽然轻量,但对于高并发的轻量任务处理,频繁来创建goroutine来执行,执行效率并不会太高,因为:过多的goroutine创建,会影响go runtime对goroutine调度,以及GC消耗;高并发时若出现调用异常阻塞积压,大量的goroutine短时间积压可能导致程序崩溃。
  2. 避免高并发调用同步系统接口 goroutine的实现,是通过同步来模拟异步操作。 网络IO、锁、channel、Time.sleep、基于底层系统异步调用的Syscall操作并不会阻塞go runtime的线程调度。 本地IO调用、基于底层系统同步调用的Syscall、CGo方式调用C语言动态库中的调用IO或其它阻塞会创建新的调度线程。 网络IO可以基于epoll的异步机制(或kqueue等异步机制),但对于一些系统函数并没有提供异步机制。例如常见的posix api中,对文件的操作就是同步操作。虽有开源的fileepoll来模拟异步文件操作。但Go的Syscall还是依赖底层的操作系统的API。系统API没有异步,Go也做不了异步化处理。 因此,建议:把涉及到同步调用的goroutine,隔离到可控的goroutine中,而不是直接高并的goroutine调用。
  3. 高并发时避免共享对象互斥 传统多线程编程时,当并发冲突在4~8线程时,性能可能会出现拐点。Go推荐不通过共享内存来通信,Go创建goroutine非常容易,当大量goroutine共享同一互斥对象时,也会在某一数量的goroutine出在拐点。 因此,建议:goroutine尽量独立,无冲突地执行;若goroutine间存在冲突,则可以采分区来控制goroutine的并发个数,减少同一互斥对象冲突并发数。

其它优化

  1. 避免使用CGO或者减少CGO调用次数 GO可以调用C库函数,但Go带有垃圾收集器且Go的栈动态增涨,无法与C无缝地对接。Go的环境转入C代码执行前,必须为C创建一个新的调用栈,把栈变量赋值给C调用栈,调用结束现拷贝回来。调用开销较大,需要维护Go与C的调用上下文,两者调用栈的映射。相比直接的GO调用栈,单纯的调用栈可能有2个甚至3个数量级以上。 因此,建议:尽量避免使用CGO,无法避免时,要减少跨CGO的调用次数。
  2. 减少[]byte与string之间转换,尽量采用[]byte来字符串处理 GO里面的string类型是一个不可变类型,GO中[]byte与string底层是两个不同的结构,转换存在实实在在的值对象拷贝,所以尽量减少不必要的转化。 因此,建议:存在字符串拼接等处理,尽量采用[]byte。
  3. 字符串的拼接优先考虑bytes.Buffer string类型是一个不可变类型,但拼接会创建新的string。GO中字符串拼接常见有如下几种方式: string + 操作 :导致多次对象的分配与值拷贝 fmt.Sprintf :会动态解析参数,效率好不哪去 strings.Join :内部是[]byte的append bytes.Buffer :可以预先分配大小,减少对象分配与拷贝 因此,建议:对于高性能要求,优先考虑bytes.Buffer,预先分配大小。

虚拟内存有什么作用 (无效,属于操作系统)

虚拟内存就是说,让物理内存扩充成更⼤的逻辑内存,从⽽让程序获得更多的可⽤内存。虚拟内存使⽤部分加载的
技术,让⼀个进程或者资源的某些⻚⾯加载进内存,从⽽能够加载更多的进程,甚⾄能加载⽐内存⼤的进程,这样
看起来好像内存变⼤了,这部分内存其实包含了磁盘或者硬盘,并且就叫做虚拟内存。

并发编程

说一下reflect

recflect是golang用来检测存储在接口变量内部(值value;类型concrete type) pair对的一种机制。它提供了两种类型(或者说两个方法)让我们可以很容易的访问接口变量内容,分别是reflect.ValueOf() 和 reflect.TypeOf()。

  • ValueOf用来获取输入参数接口中的数据的值,如果接口为空则返回0
  • TypeOf用来动态获取输入参数接口中的值的类型,如果接口为空则返回nil

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。
  4. Goexit():退出当前 goroutine(但是defer语句会照常执行)。
  5. NumGoroutine:返回正在执行和排队的任务总数。 runtime.NumGoroutine函数在被调用后,会返回系统中的处于特定状态的 Goroutine 的数量。这里的特定状态是指Grunnable\Gruning\Gsyscall\Gwaition。处于这些状态的Groutine即被看做是活跃的或者说正在被调度。注意:垃圾回收所在Groutine的状态也处于这个范围内的话,也会被纳入该计数器。
  6. GOOS:查看目标操作系统。很多时候,我们会根据平台的不同实现不同的操作,就可以用GOOS来查看自己所在的操作系统。
  7. runtime.GC:会让运行时系统进行一次强制性的垃圾收集。 强制的垃圾回收:不管怎样,都要进行的垃圾回收。非强制的垃圾回收:只会在一定条件下进行的垃圾回收(即运行时,系统自上次垃圾回收之后新申请的堆内存的单元(也成为单元增量)达到指定的数值)。
  8. GOROOT():获取 goroot 目录。
  9. runtime.LockOSThread 和 runtime.UnlockOSThread 函数:前者调用会使调用他的 Goroutine 与当前运行它的M锁定到一起,后者调用会解除这样的锁定。

sync.once 如何实现并发安全

type Once struct {
    done unit32
    m Mutex
}

他们分别为标记是否已经执行过的标志(done),以及执行时所用的互斥锁(m) 除了结构体外,sync.Once还包括了一个公开的方法Do:

func (o *Once) Do(f func()) {
    if atomic.LoadUint32(&o.done) == 0 {
        o.doSlow(f)
    }
}

Once.Do方法的实现非常简单,通过atomic.LoadUint32获取Once实例的done属性值。 若done值为0时,表示函数f未被调用过或正运行中且未结束,则将调用doSlow方法; 若done值为1时,表示函数f已经调用且完成,则直接返回。 这里使用了原子操作方法atomic.LoadUint32而不是直接将o.done进行比较,也是为了避免并发状态下错误地判断执行状态,产生不必要的锁操作带来的时间开销。

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

Once.doSlow方法的实现使用了传统的互斥锁Mutex操作,在执行时即调用o.m.Lock方法获得锁,然后再继续判断是否已经完成并调用f函数。 可以看到,在获得锁后还需要对o.done的值进行一次判断,避免了f函数被重复调用。 最后,在退出doSlow方法时还需要对获取的锁进行释放,若进入到f函数的调用则需要更改o.done属性值。

context数据结构

Context 是一个接口,定义了 4 个方法,它们都是幂等的。也就是说连续多次调用同一个方法,得到的结果都是相同的。

  1. Done() 返回一个 channel,可以表示 context 被取消的信号:当这个 channel 被关闭时,说明 context 被取消了。注意,这是一个只读的channel。 我们又知道,读一个关闭的 channel 会读出相应类型的零值。并且源码里没有地方会向这个 channel 里面塞入值。换句话说,这是一个 receive-only 的 channel。因此在子协程里读这个 channel,除非被关闭,否则读不出来任何东西。也正是利用了这一点,子协程从 channel 里读出了值(零值)后,就可以做一些收尾工作,尽快退出。
  2. Err() 返回一个错误,表示 channel 被关闭的原因。例如是被取消,还是超时。
  3. Deadline() 返回 context 的截止时间,通过此时间,函数就可以决定是否进行接下来的操作,如果时间太短,就可以不往下做了,否则浪费系统资源。当然,也可以用这个 deadline 来设置一个 I/O 操作的超时时间。
  4. Value() 获取之前设置的 key 对应的 value。

go 怎么控制查询timeout (context)

context 监听是否有 IO 操作,开始从当前连接中读取网络请求,每当读取到一个请求则会将该cancelCtx传入,用以传递取消信号,可发送取消信号,取消所有进行中的网络请求。

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

go并发优秀在哪里

Go中天然的支持并发,Go允许使用go语句开启一个新的运行期线程,即 goroutine,以一个不同的、新创建的goroutine来执行一个函数。同一个程序中的所有goroutine共享同一个地址空间。 Goroutine非常轻量,除了为之分配的栈空间,其所占用的内存空间微乎其微。并且其栈空间在开始时非常小,之后随着堆存储空间的按需分配或释放而变化。内部实现上,goroutine会在多个操作系统线程上多路复用。如果一个goroutine阻塞了一个操作系统线程,例如:等待输入,这个线程上的其他goroutine就会迁移到其他线程,这样能继续运行。开发者并不需要关心/担心这些细节。 Go语言的并发机制运用起来非常简便,在启动并发的方式上直接添加了语言级的关键字就可以实现,和其他编程语言相比更加轻量。

高并发特点

  • 用户空间:避免了内核态和用户态的切换导致的成本
  • 可以由语言和框架层进行调度
  • 更小的栈空间允许创建大量的实例 2) channel: 被单独创建并且可以在进程之间传递,它的通信模式类似于 boss-worker 模式的,一个实体通过将消息发送到 channel 中,然后又监听这个 channel 的实体处理,两个实体之间是匿名的,这个就实现实体中间的解耦,在实现原理上其实是一个阻塞的消息队列。 3) 调度器: goroutine 中提供了调度器,在调度器加入了steal working 算法 ,goroutine 是可以被异步抢占,因此没有函数调用的进程不再对调度器造成死锁或造成垃圾回收的大幅变慢。并且 go 对网络IO库进行了封装,屏蔽了复杂的细节,对外提供统一的语法关键字支持,简化了并发程序编写的成本。

golang并发控制

数据安全控制

  • 互斥锁 sync.Mutex
  • 读写锁 sync.RWMutex
  • 原子操作 sync/atomic

并发行为控制

golang控制并发有三种经典的方式,一种是通过channel通知实现并发控制 一种是WaitGroup,另外一种就是Context

  1. 使用最基本通过channel通知实现并发控制 无缓冲通道: 无缓冲的通道指的是通道的大小为0,也就是说,这种类型的通道在接收前没有能力保存任何值,它要求发送 goroutine 和接收 goroutine 同时准备好,才可以完成发送和接收操作。 从上面无缓冲的通道定义来看,发送 goroutine 和接收 gouroutine 必须是同步的,同时准备后,如果没有同时准备好的话,先执行的操作就会阻塞等待,直到另一个相对应的操作准备好为止。这种无缓冲的通道我们也称之为同步通道。
  2. 通过sync包中的WaitGroup实现并发控制 在 sync 包中,提供了 WaitGroup ,它会等待它收集的所有 goroutine 任务全部完成,在主 goroutine 中 Add(delta int) 索要等待goroutine 的数量。 在每一个 goroutine 完成后 Done() 表示这一个goroutine 已经完成,当所有的 goroutine 都完成后,在主 goroutine 中 WaitGroup 返回返回。
  3. 在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包方法: Done() 返回一个只能接受数据的channel类型,当该context关闭或者超时时间到了的时候,该channel就会有一个取消信号 Err() 在Done() 之后,返回context 取消的原因。 Deadline() 设置该context cancel的时间点 Value() 方法允许 Context 对象携带request作用域的数据,该数据必须是线程安全的。

golang支持哪些并发机制

Go语言中实现了两种并发模型,一种是我们熟悉的线程与锁的并发模型,它主要依赖于共享内存实现的。程序的正确运行很大程度依赖于开发人员的能力和技巧,程序在出错时不易排查。另一种就是CSP并发模型,它使用通信的手段来共享内存。CSP中的并发实体是独立的,它们之间没有共享的内存空间,它们之间的数据交换通过通道实现的

CSP并发模型:

Go实现了两种并发模式。第一种:多线程共享内存。第二种:通过通信来共享内存(CSP)

CSP并发模型是Go语言特有的并发模型,也是Go语言官方所推荐的并发模型。

Go的CSP并发模型,是由Go语言中的goroutinechannel共同来实现的。

  • goroutine:Go语言中使用关键字go来创建goroutine。将关键字go放到需要调用的函数前,在相同地址空间调用运行这个函数,该函数在执行的时候会创建一个独立的线程去执行,这个线程就是Go语言中的goroutine。
  • channel:Go语言中goroutine之间的通信机制

线程模型:

  1. 一对一模型(1:1)

    将一个用户级线程映射到一个内核线程,每一个线程由内核调度器独立调度,线程之间互不影响

    优点:在多核处理器的条件下,实现了真正的并行。

    缺点:为每一个用户级线程建立一个内核线程,开销大,浪费资源。

  2. 多对一模型(M:1)

    将多个用户级线程映射到一个内核线程。

    优点:线程上下文切换发生在用户空间。

    缺点:只有一个处理器被应用,在多处理环境下是不可以被接受的,实现了并发,不能解决并行问题。

  3. 多对多模型(M:N)

    多个用户级线程运行在多个内核线程上,这使得大部分的线程上下文切换都发生在用户空间,而多个内核线程又能充分利用处理器资源

golang中Context的使用场景

Go1.7加入到标准库,在于控制goroutine的生命周期。当一个计算任务被goroutine承接了之后,由于某种原因,我们希望中止这个goroutine的计算任务,那么就用得到这个Context了。 包含CancelContext,TimeoutContext,DeadLineContext,ValueContext

场景一:RPC调用 在主goroutine上有4个RPC,RPC2/3/4是并行请求的,我们这里希望在RPC2请求失败之后,直接返回错误,并且让RPC3/4停止继续计算。这个时候,就使用的到Context。

场景二:PipeLine runSimplePipeline的流水线工人有三个,lineListSource负责将参数一个个分割进行传输,lineParser负责将字符串处理成int64,sink根据具体的值判断这个数据是否可用。他们所有的返回值基本上都有两个chan,一个用于传递数据,一个用于传递错误。(<-chan string, <-chan error)输入基本上也都有两个值,一个是Context,用于传声控制的,一个是(in <-chan)输入产品的。

场景三:超时请求 我们发送RPC请求的时候,往往希望对这个请求进行一个超时的限制。当一个RPC请求超过10s的请求,自动断开。当然我们使用CancelContext,也能实现这个功能(开启一个新的goroutine,这个goroutine拿着cancel函数,当时间到了,就调用cancel函数)。鉴于这个需求是非常常见的,context包也实现了这个需求:timerCtx。具体实例化的方法是 WithDeadline 和 WithTimeout。具体的timerCtx里面的逻辑也就是通过time.AfterFunc来调用ctx.cancel的。

场景四:HTTP服务器的request互相传递数据 context还提供了valueCtx的数据结构。这个valueCtx最经常使用的场景就是在一个http服务器中,在request中传递一个特定值,比如有一个中间件,做cookie验证,然后把验证后的用户名存放在request中。我们可以看到,官方的request里面是包含了Context的,并且提供了WithContext的方法进行context的替换。

用共享内存的方式实现并发如何保证安全

Go的设计思想就是, 不要通过共享内存来通信,而是通过通信来共享内存,前者就是传统的加锁,后者就是Channel。也就是说,设计Channel的主要目 的就是在多任务间传递数据的,本身就是安全的。 看源码就知道channel内部维护了一个互斥锁,来保证线程安全,channel底层实现出队入队时也加锁。

从运行速度来讲,go的并发模型channel和goroutine

  1. goroutine 是一种非常轻量级的实现,可在单个进程里执行成千上万的并发任务,它是Go语言并发设计的核心。 说到底 goroutine 其实就是线程,但是它比线程更小,十几个 goroutine 可能体现在底层就是五六个线程,而且Go语言内部也实现了 goroutine 之间的内存共享。 使用 go 关键字就可以创建 goroutine,将 go 声明放到一个需调用的函数之前,在相同地址空间调用运行这个函数,这样该函数执行时便会作为一个独立的并发线程,这种线程在Go语言中则被称为 goroutine。
  2. channel 是Go语言在语言级别提供的 goroutine 间的通信方式。可以使用 channel 在两个或多个 goroutine 之间传递消息

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

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

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

异常处理

error

Go语言没有提供传统的try···catch语句来处理异常,而是使用error来处理错误,用panic和recover来处理异常。

Go语言中的错误的表示

答案:在Go中,错误是通过实现error接口来表示的。error接口只有一个方法Error() string,返回一个描述错误的字符串。

为什么Go选择使用接口来表示错误

答案:Go选择使用接口来表示错误有几个优势。首先,接口提供了一种统一的错误表示方式,使得编写和处理错误更加一致和简单。其次,接口可以被多个不同类型的错误实现,这样就可以根据具体的错误类型执行不同的处理逻辑。最后,通过接口,我们可以轻松地将错误传递给调用者,而不需要暴露底层错误的具体实现细节。

在Go中,如何处理错误

答案:在Go中,错误处理是显式的。通常使用if err != nil来检查错误,并根据需要执行相应的处理逻辑。这可以是返回错误、记录日志、重新尝试操作或采取其他恰当的行动。

进行错误类型断言(Type Assertion)

答案:错误类型断言可用于判断错误的具体类型,并执行相应的处理逻辑。可以使用类型断言表达式err.(具体错误类型)来判断错误的类型,如果断言成功,则可以访问该错误类型的特定方法或属性。

创建自定义的错误类型

答案:在Go中,可以通过创建满足error接口的自定义类型来定义自己的错误类型。可以定义一个新的结构体类型,并为其实现Error() string方法,以便返回适当的错误描述信息。

什么是包装错误(Error Wrapping)

答案:包装错误是指将底层错误包装在更高级别的错误中,以提供更多的上下文信息。这可以通过调用fmt.Errorf函数或使用errors.Wrap函数来实现。包装错误可以形成错误链,可以使用errors.Iserrors.As函数来遍历和检查包装的错误。

defer

defer规则

defer的执行顺序

多个defer出现的时候,它是一个“栈”的关系,也就是先进后出。一个函数中,写在前面的defer会比写在后面的defer调用的晚。

延迟函数的参数在defer语句出现时就已经确定
func a() {
    i := 0
    defer fmt.Println(i)
    i++
    return
}

defer语句中的fmt.Println()参数i值在defer出现时就已经确定下来,实际上是拷贝了一份。后面对变量i的修改不会影响fmt.Println()函数的执行,仍然打印”0”。注意:对于指针类型参数,规则仍然适用,只不过延迟函数的参数是一个地址值,这种情况下,defer后面的语句对变量的修改可能会影响延迟函数。

延迟函数可能操作主函数的具名返回值

定义defer的函数,即主函数可能有返回值,返回值有没有名字没有关系,defer所作用的函数,即延迟函数可能会影响到返回值。若要理解延迟函数是如何影响主函数返回值的,只要明白函数是如何返回的就足够了。

函数返回过程

关键字return不是一个原子操作,实际上return只代理汇编指令ret,即将跳转程序执行。比如语句return i,实际上分两步进行,即将i值存入栈中作为返回值,然后执行跳转,而defer的执行时机正是跳转前,所以说defer执行时还是有机会操作返回值的。

主函数拥有匿名返回值,返回字面值

一个主函数拥有一个匿名的返回值,返回时使用字面值,比如返回”1”、”2”、”Hello”这样的值,这种情况下defer语句是无法操作返回值的

func f() int {
    var i int
    defer func() {
        i++
    }()
    return 2
}
// 上面的return语句,直接把1写入栈中作为返回值,延迟函数无法操作该返回值,所以就无法影响返回值。
主函数拥有匿名返回值,返回变量

一个主函数拥有一个匿名的返回值,返回使用本地或全局变量,这种情况下defer语句可以引用到返回值,但不会改变返回值。

func f() int {
    var i int
    defer func() {
        i++
    }()
    return i
}
// 上面的函数,返回一个局部变量,同时defer函数也会操作这个局部变量。对于匿名返回值来说,可以假定仍然有一个变量存储返回值,假定返回值变量为”anony”,上面的返回语句可以拆分成以下过程:
anony = i
i++
return
// 由于i是整型,会将值拷贝给anony,所以defer语句中修改i值,对函数返回值不造成影响。
主函数拥有具名返回值

主函声明语句中带名字的返回值,会被初始化成一个局部变量,函数内部可以像使用局部变量一样使用该返回值。如果defer语句操作该返回值,可能会改变返回结果。一个影响函返回值的例子:

func foo() (ret int) {
    defer func() {
        ret++
    }()

    return 0
}
// 上面的函数拆解出来,如下所示:
ret = 0
ret++
return
// 函数真正返回前,在defer中对返回值做了+1操作,所以函数最终返回1。

defer与return谁先谁后

return之后的语句先执行,defer后的语句后执行

defer遇见panic

能够触发defer的是遇见return(或函数体到末尾)和遇见panic。defer遇见return情况如下:
在这里插入图片描述

遇到panic时,遍历本协程的defer链表,并执行defer。在执行defer过程中:遇到recover则停止panic,返回recover处继续往下执行。如果没有遇到recover,遍历完本协程的defer链表后,向stderr抛出panic信息。

在这里插入图片描述

defer遇见panic,但是并不捕获异常的情况
package main

import (
    "fmt"
)
func main() {
    deferTest()

    fmt.Println("main 正常结束")
}
func deferTest() {
    defer func() { fmt.Println("defer: panic 之前1") }()
    defer func() { fmt.Println("defer: panic 之前2") }()
    panic("异常内容")  //触发defer出栈
	defer func() { fmt.Println("defer: panic 之后,永远执行不到") }()
}
defer遇见panic,并捕获异常
package main

import (
    "fmt"
)

func main() {
    deferTest()

    fmt.Println("main 正常结束")
}

func deferTest() {

    defer func() {
        fmt.Println("defer: panic 之前1, 捕获异常")
        if err := recover(); err != nil {
            fmt.Println(err)
        }
    }()

    defer func() { fmt.Println("defer: panic 之前2, 不捕获") }()

    panic("异常内容")  //触发defer出栈

	defer func() { fmt.Println("defer: panic 之后, 永远执行不到") }()
}

defer 最大的功能是 panic 后依然有效所以defer可以保证你的一些资源一定会被关闭,从而避免一些异常出现的问题。

defer中包含panic
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")
}
// 输出 defer panic

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

panic

panic()是一个内置函数:

func panic(v interface{})

他接受一个任意类型的参数,参数将在程序崩溃时通过另一个内置函数print(args …Type)打印出来。如果程序中途返回任意一个defer函数执行了recover(),那么参数也是recover()的返回值。panic可由程序员显示得通过该内置函数触发,Go运行时遇到诸如内存越界之类的问题也会触发。

panic执行过程中重要的点

  1. panic会递归执行协程中所有的defer,与函数正常退出时的执行顺序一致
  2. panic不会处理其他协程中的defer
  3. 当前协程中的defer处理完成后,触发程序退出

panic工作机制

每个协程中都维护了一个defer链表,执行过程中每遇到一个defer语句都创建一个defer实例并插入链表,函数退出时取出本函数创建的实例并执行。panic发生时,实际上是把程序流程转向了这个defer链表,程序专注于消费链表中的函数,当链表中的defer函数被消费完,再触发程序退出。

panic触发异常,如果函数没有处理异常,则异常将沿函数函数调用链逐层向上传递,最终导致程序退出。

recover

内置函数recover()用于消除panic并使程序回复正常。

recover函数

recover函数是内置函数

func recover() interface{}

recover()函数的返回值就是panic()函数的返回值,当程序产生panic时,recover()函数就可以用于消除panic,同时返回panic函数的参数,如果程序没有发生panic,则recover函数返回nil。如果panic函数参数为nil,那么仍然是一个有效的panic,此时recover函数仍然可以捕获到panic,但返回值为nil。此外,recover函数必须且直接位于defer函数中才有效。

使用recover函数需要明确

  • recover函数调用必须要位于defer函数中,且不能出现在另一个嵌套函数中
  • recover函数成功处理异常后,无法再次回到本函数发生panic的位置继续执行
  • recover函数可以消除本函数产生或收到的panic,上游函数感知不到panic的发生

recover函数生效条件

  1. 程序中必须真正产生了panic
  2. recover函数在defer函数中
  3. recover函数被defer函数直接调用

recover失效条件

  1. “panic”时指定的参数为nil(一般的panic语句如panic(“xxx failed …”))
  2. 当前协程没有发生panic
  3. recover没有被defer函数直接调用

测试

单元测试

单元测试是指针对软件中的最小可测试单元进行检查和验证,比如对一个函数的测试

  1. 测试文件名必须以_test.go结尾
  2. 测试函数名必须以TestXxx开始
  3. 在命令行下使用go test即可启动测试

性能测试

性能测试也称为基准测试,可以测试一段程序的性能,可以得到时间消耗,内存使用情况的报告

  1. 文件名必须以_test.go结尾
  2. 函数名必须以BenchmarkXxx开始
  3. 使用go test -bench=.命令即可开始性能测试

示例测试

示例测试广泛应用于Go源码和各种开源框架中,用于展示某个包或者某个方法的用法。比如,在Go标准库中,mail包展示了如何从一个字符串中解析出邮件列表的用法。

  1. 示例测试函数名需要以Example开头
  2. 检测单行输出格式为 // Output:<期望字符串>
  3. 检测多行输出格式为 // Output:\n <期望字符串> \n <期望字符串> ,每个期望字符串占一行
  4. 检测无序输出格式为 // Unordered output :\n <期望字符串> \n <期望字符串>,每个期望字符串占一行
  5. 测试字符串时会自动忽略字符串前后的空白字符串
  6. 如果测试函数中没有Output标识,则该测试函数不会被执行
  7. 执行测试可以使用go test,此时该目录下的其他测试文件也会一并执行
  8. 执行测试可以使用go test <xxx_test.go>,此时仅执行特定文件中的测试函数

# Gin

什么是Gin框架?

Gin是一个用于构建Web应用程序的轻量级框架,基于Go语言开发。它提供了快速、灵活和易于使用的特性,使得开发高性能的Web应用程序变得简单。

Gin框架的主要特点

  • 高性能:Gin框架被设计为高性能的框架,它比许多其他Go框架更快。
  • 轻量级:Gin框架具有简洁的API和最小化的内存占用,使其易于学习和使用。
  • 路由和中间件支持:Gin提供了强大的路由和中间件功能,使请求路由和中间件处理变得简单。
  • JSON验证和绑定:Gin框架内置了对JSON请求的验证和绑定功能,使得处理请求数据变得方便。
  • 错误处理:Gin提供了内置的错误处理机制,可以方便地处理和返回错误信息。

如何在Gin中定义路由

在Gin中,可以使用gin包的实例来定义路由,例如:

import "github.com/gin-gonic/gin"

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

    r.GET("/hello", func(c *gin.Context) {
        c.JSON(200, gin.H{"message": "Hello, World!"})
    })

    r.Run()
}
// gin 的每种方法 (POST, GET …) 都有自己的一颗 路由树。
// 当 gin 收到客户端的请求时, 会去 路由树 里根据 URL 找到相关的 处理函数(handler)。

Gin框架中的中间件是什么?如何使用中间件?

中间件是Gin框架的一项重要功能,它允许在请求到达处理程序之前或之后执行一些通用的处理逻辑。Gin中的中间件是一个函数,它接受gin.Context作为参数,并可以在处理程序执行之前、之后或之间执行额外的操作。以下是使用中间件的示例:

func Logger() gin.HandlerFunc {
    return func(c *gin.Context) {
        fmt.Println("Logging middleware")
        c.Next()
    }
}

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

    // 使用中间件
    r.Use(Logger())

    r.GET("/hello", func(c *gin.Context) {
        c.JSON(200, gin.H{"message": "Hello, World!"})
    })

    r.Run()
}

Gin框架如何处理JSON请求和响应?

Gin框架提供了方便的方法来处理JSON请求和响应。可以使用ShouldBindJSON函数将JSON请求绑定到Go结构体,并使用JSON函数将JSON响应发送回客户端。以下是一个示例:

type User struct {
    Name  string `json:"name"`
    Email string `json:"email"`
}

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

    r.POST("/user", func(c *gin.Context) {
        var user User
        if err := c.ShouldBindJSON(&user); err != nil {
            c.JSON(400, gin.H{"error": err.Error()})
            return
        }

        // 处理用户数据
        // ...

        c.JSON(200, gin.H{"message": "User created successfully"})
    })

    r.Run()
}

这些问题涵盖了Gin框架的一些重要概念和用法。当准备面试时,还可以根据需要进一步扩展和调整问题。

gin的底层实现

Gin框架的底层实现是基于Go语言的标准库和一些第三方库的组合。以下是Gin框架的主要组成部分和底层实现:

  1. HTTP服务器:Gin使用Go语言的net/http包提供HTTP服务器的功能。它使用http.ListenAndServe函数启动HTTP服务器,并使用http.Handler接口处理传入的HTTP请求。
  2. 路由:Gin框架的路由功能是通过创建一个路由器来实现的。Gin使用httproutergin自身实现的路由器来处理传入的请求。路由器根据请求的HTTP方法和路径将请求分发给相应的处理函数。
  3. 中间件:Gin的中间件功能是通过使用http.Handler接口实现的。每个中间件都是一个处理函数,它接受http.Handler参数并返回新的http.Handler。Gin框架使用中间件链来按顺序执行中间件函数,从而在请求到达处理函数之前或之后执行额外的操作。
  4. 上下文(Context):Gin使用自定义的上下文结构体(gin.Context)来封装HTTP请求和响应的相关信息。gin.Context提供了许多有用的方法和属性,用于处理请求参数、路由参数、响应内容等。它还提供了一些便捷的方法来发送响应、设置HTTP头部等。
  5. 错误处理:Gin框架使用HTTP状态码和自定义错误处理来处理错误。Gin提供了一些常用的错误处理函数,如c.JSONc.AbortWithError,用于返回JSON格式的错误响应。开发人员也可以根据需要自定义错误处理函数。
  6. 上下文池(Context Pool):为了提高性能,Gin使用上下文池来重用上下文对象。上下文池可以减少内存分配和垃圾回收的压力,提高请求处理的效率。
  7. 其他实用工具:Gin框架还使用了一些其他的第三方库来实现一些功能,如github.com/ugorji/go/codec用于处理响应的编码和解码,github.com/gin-contrib/sse用于实现服务器发送事件(Server-Sent Events)等。

总体而言,Gin框架的底层实现是基于Go语言的标准库和一些第三方库,它们共同提供了路由、中间件、上下文处理、错误处理等功能,使得构建高性能的Web应用程序变得简单和高效。

Gin框架如何处理并发请求

Gin框架默认使用多协程处理并发请求,每个请求都会被分配到一个独立的协程中执行,可以充分利用CPU资源提高并发处理能力。此外,Gin框架还提供了异步HTTP请求处理的功能,可以通过使用context.Context对象来控制异步请求的超时时间和取消操作。

Gin框架的错误处理机制

Gin框架的错误处理机制主要是通过中间件来处理异常,同时还可以通过panic/recover机制处理运行时异常。Gin框架提供了很多钩子函数和回调函数,可以帮助开发者实现自定义的错误处理逻辑。

如何在Gin中使用模板引擎

Gin框架内置了多种模板引擎,包括HTML、JSON、XML等格式,可以通过c.HTML()、c.JSON()、c.XML()等方法来渲染页面。Gin框架还支持自定义模板引擎,可以通过gin.SetHTMLTemplate()方法将自定义的模板引擎注册到Gin框架中。

  • 4
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

终生成长者

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值