一、基础
=和:=的区别
=是赋值,:=是声明并初始化一个新的变量
go异常类型
在 Go 语言中,并没有像一些其他编程语言那样使用传统的异常处理机制,Go 语言使用了一种不同的错误处理模式,通过返回错误值来进行错误处理。Go 的错误处理模式更加简洁和明确,使用了多返回值来传递错误信息。在 Go 中,通常会将函数的最后一个返回值用于传递错误信息。这个错误值通常是一个实现了 error 接口的类型。error 接口只有一个方法 Error() string,该方法返回错误的描述信息。
type error interface {
Error() string
}
协程的介绍
Go 语言(也称为 Golang)在并发编程方面有一个独特的特性叫做 “协程”(Goroutine)。协程是一种轻量级的线程,由 Go 运行时管理。协程可以在相同的地址空间中同时运行,可以在多个协程之间高效地共享内存,但也因此需要开发者自己来确保数据同步和访问的安全性。
以下是关于 Go 协程的一些重要信息:
- 创建协程(Goroutine): 使用关键字 go 可以在 Go 语言中创建一个协程。协程是非常轻量级的,可以在程序中创建成百上千个而不会引起太大的开销。
func main() {
go someFunction() // 启动一个新的协程来执行 someFunction
}
- 协程调度: Go 运行时会自动管理协程的调度,将其分配到可用的系统线程上执行。这样的调度方式称为 M:N 调度,其中 M 是逻辑协程,N 是操作系统线程。
- 通信: Go 协程之间可以通过通道(Channel)来进行通信。通道是一种特殊的数据类型,用于在协程之间传递数据。通道提供了同步和数据传输的机制,可以用于协程之间的协作。
- 并发与并行: Go 协程使得并发编程变得简单,因为它们可以在单个线程内并发运行。这与传统的并行编程方式不同,后者通常需要多个线程在多个处理器上同时执行。
- 协程之间的数据共享: 虽然协程之间可以共享内存,但是在并发编程中需要小心处理数据同步和竞态条件,以避免数据不一致或其他问题。
- 内置并发支持: Go 语言提供了丰富的标准库用于并发编程,包括协程、通道、锁等。这些工具使得编写并发代码更加容易。
协程是 Go 语言并发编程的核心概念之一,它使得在高并发场景下编写简洁、高效的代码变得非常容易。通过合理地使用协程和通道,可以充分发挥多核处理器的能力,构建出健壮、高性能的并发应用程序。
go拼接字符串的方式
在 Go 语言中,有多种方式可以拼接字符串,每种方式在性能方面都有不同的影响。以下是一些常见的字符串拼接方式以及它们的性能对比:
- 使用 + 操作符: 使用 + 操作符拼接字符串是最简单的方式之一,但是它在大量字符串拼接时性能可能会较差,因为每次拼接都会创建一个新的字符串,并且要进行内存分配和复制。
str1 := "Hello, "
str2 := "world!"
result := str1 + str2
- 使用
strings.Join函数:strings.Join函数在大量字符串拼接时性能较好,因为它内部使用了一个字符串构建器(strings.Builder)来避免频繁的内存分配和复制。
import "strings"
stringsToJoin := []string{"Hello", "world!"}
result := strings.Join(stringsToJoin, " ")
- 使用
fmt.Sprintf函数:fmt.Sprintf函数在格式化字符串时非常方便,但在大量拼接时性能相对较低,因为它每次都会创建一个新的格式化字符串。
import "fmt"
str1 := "Hello"
str2 := "world!"
result := fmt.Sprintf("%s, %s", str1, str2)
- 使用 strings.Builder 类型: strings.Builder 类型适用于需要高性能的大量字符串拼接场景。它使用一个缓冲区来逐步构建字符串,避免了频繁的内存分配和复制。
import "strings"
var builder strings.Builder
builder.WriteString("Hello, ")
builder.WriteString("world!")
result := builder.String()
在性能方面,当涉及大量字符串拼接时,使用 strings.Builder 或 strings.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(对象-关系映射)等场景中,为字段提供更多的元数据,以便框架或工具能够更好地理解和操作这些字段。
标签的基本形式是一个字符串,可以使用反引号或双引号来包裹。标签的内容由键值对构成,键和值之间使用冒号分隔,键值对之间使用空格分隔。
以下是一些常见的使用场景以及标签的应用:
- 序列化与反序列化: 在使用 JSON、XML 等格式进行序列化和反序列化时,可以使用标签为字段提供对应的键名、类型转换等信息。
type User struct {
ID int `json:"user_id"`
Username string `json:"username"`
}
- ORM(对象-关系映射) : 在 ORM 库中,标签可以用来指定数据库表的列名、数据类型、索引等信息。
type User struct {
ID int `gorm:"column:user_id;primary_key"`
Username string `gorm:"column:username;unique_index"`
}
- 表单验证: 在 Web 开发中,标签可以用来进行表单数据的验证,指定字段的校验规则、错误提示等信息。
type LoginForm struct {
Username string `form:"username" validate:"required"`
Password string `form:"password" validate:"required"`
}
- 文档生成: 一些自动生成文档的工具,如 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.Printf、fmt.Sprintf、fmt.Errorf 等函数中非常有用。
以下是这些格式化占位符的区别:
- %v:通用格式占位符,根据值的类型自动选择合适的格式。对于结构体,它将递归地打印字段的值。对于数组和切片,它将打印其中的元素。
type Person struct {
Name string
Age int
}
p := Person{Name: "Alice", Age: 30}
fmt.Printf("%v\n", p) // {Alice 30}
%+v:与%v类似,但对于结构体,它会打印字段名以及字段的值。对于数组和切片,它也会打印索引。
p := Person{Name: "Alice", Age: 30}
fmt.Printf("%+v\n", p) // {Name:Alice Age:30}
%#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 语言中很常见,可以满足大部分的枚举需求。如果你需要更丰富的枚举功能,也可以使用自定义类型和常量组合,但通常使用 const 和 iota 就可以满足大部分场景。
空结构体有什么作用,场景
在 Go 语言中,空结构体(empty struct)是一种不占用内存空间的特殊结构体类型,它没有任何字段。空结构体在某些场景下可以发挥重要的作用,尤其在并发编程、内存优化和映射等方面。
以下是空结构体的一些常见应用场景:
- 实现集合和映射: 空结构体可以被用作集合和映射的键,用于表示集合中是否存在某个元素。由于空结构体不占用内存,使用它作为映射的键可以减少内存开销。
// 使用空结构体作为集合元素的存在标记
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.")
}
}
- 同步信号: 空结构体可以用于实现同步机制,如通道的发送和接收。通过发送空结构体来表示某个事件的发生。
// 使用空结构体作为同步信号
var signal chan struct{}
func main() {
signal = make(chan struct{})
go doSomething()
<-signal // 阻塞,直到事件完成
}
func doSomething() {
// 做一些操作
signal <- struct{}{} // 发送空结构体表示事件完成
}
- 内存优化: 在某些情况下,如果你只关心某些类型是否存在,而不需要实际的数据,可以使用空结构体来减少内存占用。
- 遍历通道: 在使用通道进行信号传递时,空结构体可以用于遍历通道,以等待多个事件的发生。
// 使用通道传递信号
var done = make(chan struct{})
func main() {
go doSomething()
<-done // 阻塞,等待 doSomething 完成
}
func doSomething() {
// 做一些操作
done <- struct{}{} // 发送空结构体表示完成
}
总的来说,空结构体在 Go 中被广泛用于表示某些状态或者事件的发生,同时又不需要实际的数据。使用空结构体可以减少内存占用并提高代码的可读性。
int32和int的区别
在 Go 语言中,int 和 int32 都是整数数据类型,但它们之间有一些区别:
- int 类型: 在 Go 中,int 是一个平台相关的整数类型,其大小根据当前运行的计算机架构而变化。在 32 位架构上,int 是 4 字节(32 位),在 64 位架构上,int 是 8 字节(64 位)。这意味着在不同的架构上,int 的大小会有所不同,但它始终会根据所运行的计算机架构而自动调整。
- 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 语言运行时系统的核心部分。每个部分都有不同的状态,下面是它们的状态说明:
- Goroutine (G) 状态:
- Runnable: 当一个 Goroutine 可以运行时,它的状态是 Runnable。这意味着它已经被调度器选中,并准备好在一个线程(M)上执行。
- Running: 当一个 Goroutine 正在一个线程(M)上执行时,它的状态是 Running。
- Waiting: 如果一个 Goroutine 正在等待某个事件(如通道操作、互斥锁等),它的状态是 Waiting。这时,它会被从线程上移除,直到等待的事件发生。
- Dead: 当一个 Goroutine 完成了它的任务,或者由于某种原因终止时,它的状态是 Dead。这时,它的资源会被回收。
- Thread (M) 状态:
- Idle: 当线程没有正在执行的 Goroutine 时,它的状态是 Idle。空闲线程等待调度器分配 Goroutine 给它。
- Running: 当线程正在执行一个 Goroutine 时,它的状态是 Running。
- Blocked: 如果线程在等待某个事件(如等待系统调用返回、等待网络 I/O 等),它的状态是 Blocked。这时,该线程会被暂时挂起,直到事件发生。
- 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 不可以比较。
比较方法
- 当结构体的字段类型都是可比较的时,可以使用相等运算符(==)进行结构体之间的比较;
- 使用结构体的方法来进行比较。通过在结构体上定义一个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复用的基本步骤:
- 创建一个select语句,并在其中添加多个case子句。每个case子句对应一个I/O操作,如读取、写入或关闭等。
- 在每个case子句中,指定对应的通道操作或I/O操作,以及要执行的代码块。
- 使用default子句来处理非阻塞的操作,当没有任何case子句满足条件时,会执行default子句中的代码块。
- 使用select语句进行多路I/O复用,它会阻塞等待,直到任何一个case子句中的操作可以继续进行。
select
底层实现:
type scase struct{
c *hchan // 操作管道 scase.c为当前case语句所操作的channel指针,这也说明了一个case语句只能操作一个channel。
kind uint16 // case类型
elem unsafe.Pointer // data elemen
}
- scase.kind表示该case的类型,分为读channel、写channel和default,三种类型分别由常量定义:
- caseRecv:case语句中尝试读取scase.c中的数据;
- caseSend:case语句中尝试向scase.c中写入数据;
- caseDefault: default语句。
- 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数组的长度
函数返回值:
- int: 选中case的编号,这个case编号跟代码一致
- 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模型
- go语言怎么做的连接复用
go语言中IO多路复用使用netpool模型
netpoll本质上是对 I/O 多路复用技术的封装,所以自然也是和epoll一样脱离不了下面几步:
- netpoll创建及其初始化;
- 向netpoll中加入待监控的任务;
- 从netpoll获取触发的事件;
- 在go中对epoll提供的三个函数进行了封装。
func netpollinit()
func netpollopen(fd uintptr, pd *pollDesc) int32
func netpoll(delay int64) gList
netpollinit函数负责初始化netpoll;
netpollopen负责监听文件描述符上的事件;
netpoll会阻塞等待返回一组已经准备就绪的 Goroutine;
- 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的区别
- 大小固定 vs. 大小可变:
- 数组是大小固定的,定义时需要指定数组的长度,无法动态增加或减少长度。
- 切片是基于数组的动态长度的抽象,可以根据需要动态调整长度。
- 值传递 vs. 引用传递:
- 数组在赋值或传递时,会进行值拷贝,即创建一个新的数组副本。
- 切片在赋值或传递时,只是传递了一个指向底层数组的引用,不会进行拷贝。 3.定义方式:
- 数组的长度是固定的,定义时需要指定长度,例如 var arr [5]int。
- 切片的长度是可变的,可以通过 make 函数或使用切片字面量定义,例如 s := make([]int, 5) 或 s := []int{1, 2, 3}。
- 内存分配:
- 数组在定义时会直接分配连续的内存空间,长度固定。
- 切片在底层依赖数组,会根据实际需要动态分配内存空间。
- 操作和功能:
- 数组具有一些内置的操作和功能,如遍历、排序等。
- 切片提供了更多的操作和功能,如追加、拼接、截取等。 slice扩容机制 扩容是为切片分配新的内存空间并复制原切片中元素的过程。在 go 语言的切片中,扩容的过程是:估计大致容量 -> 确定容量 -> 覆盖原切片 -> 完成扩容。
- 首先判断,如果新申请容量大于 2 倍的旧容量,最终容量就是新申请的容 量;
- 否则判断,如果旧切片的长度小于 1024,则最终容量就是旧容量的两倍;
- 否则判断,如果旧切片长度大于等于 1024,则最终容量从旧容量开始循环 增加原来的 1/4, 直到最终容量大于等于新申请的容量;
- 如果最终容量计算值溢出,则最终容量就是新申请容量。
slice是否线程安全
Go 的切片(slice)类型本身并不是线程安全的。多个 goroutine 并发地对同一个切片进行读写操作可能会导致数据竞争和不确定的结果。如果需要在并发环境下安全地使用切片,可以采取以下几种方式:
- 使用互斥锁(Mutex)或读写锁(RWMutex)来保护对切片的并发访问。在访问切片前获取锁,操作完成后释放锁,以确保同一时间只有一个 goroutine 可以访问切片;
- 使用通道(Channel)来进行同步和通信。将切片操作封装为一个独立的 goroutine,通过通道接收和发送操作来保证对切片的顺序访问;
- 使用原子操作(Atomic Operations)来进行原子性的读写操作。Go 提供了一些原子操作的函数,如 atomic.AddInt32、atomic.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和左值进行初始化区别
- copy(slice2, slice1)实现的是深拷贝。拷贝的是数据本身,创造一个新对象,新创建的对象与原对象不共享内存,新创建的对象在内存中开辟一个新的内存地址,新对象值修改时不会影响原对象值。 同样的还有:遍历slice进行append赋值
- 如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的底层原理
- map的实现原理
go map是基于hash table(哈希表)来实现的,冲突的解决采用拉链法 - map的底层结构
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。
- 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中解决的简要过程:
- 哈希计算:当插入或查找一个键值对时,首先会对键进行哈希计算,得到一个哈希值。
- 映射到桶:哈希值会被映射到一个特定的桶。Go中的map底层使用了一个哈希表,这个哈希表由多个桶组成。
- 处理冲突:如果两个不同的键的哈希值映射到了同一个桶,就会发生哈希冲突。此时,系统会将新的键值对添加到该桶对应的链表中。
- 链表操作:链表中的每个节点代表一个键值对,相同哈希值的键值对会链接在同一个桶的链表上。插入时会在链表头部插入节点,这使得查找和删除操作的时间复杂度相对较低。
- 查找和删除:对于查找操作,系统会先计算哈希值并找到对应的桶,然后遍历该桶的链表以找到目标键值对。对于删除操作,会在链表中找到目标键值对并将其从链表中移除。
map扩容机制
扩容条件
- 负载因子大于6.5时,负载因子 = 键数量/bucket数量
- 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 对象的操作,无非就是常见的增删改查和遍历。我们可以将查询和遍历看作读操作,增加、修改和 删除看作写操作。示例代码链接:github.com/guowei-gong… 。通过读写锁提供线程安全的 map,但是大量并发读写的情况下,锁的竞争会很激烈,导致性能降低。如何解决这个问题? 尽量减少锁的粒度和锁的持有时间,减少锁的粒度,常用方法就是分片 Shard,将一把锁分成几把锁,每个锁控制一个分片。
sync.map的实现
sync.map采用读写分离和用空间换时间的策略保证map的读写安全。
- 散列桶和片段划分:sync.Map的底层使用了一个散列桶数组来存储键值对。这个数组被划分成多个小的片段,每个片段有自己的锁,这样不同的片段可以独立地进行操作,从而减少了竞争。
- 读写分离:为了允许高并发读取,sync.Map实现了一种读写分离的机制。在读取时,不需要锁定,多个goroutine可以并发读取。写操作涉及到写入数据,会获取特定散列桶的写锁。
- 散列算法和冲突解决:sync.Map使用散列算法将键映射到散列桶。每个散列桶中都可能包含多个键值对,因此可能会出现散列冲突。冲突的解决方式通常是通过链表来存储具有相同散列的键值对。
- 版本控制:sync.Map引入了版本控制的概念。每个散列桶中都包含了一个版本号,用于跟踪对散列桶的修改。这使得在读取时可以检测到同时进行的写入,从而确保读取的数据的一致性。
- 内存管理和垃圾回收:sync.Map还包含了一些内存管理机制,以避免不再使用的内存积累。当某个散列桶不再被使用时,相应的内存可能会被释放。
基本结构:
type Map struct {
mu Mutex
read atomic.Value //包含对并发访问安全的map内容的部分(无论是否持有mu)
dirty map[ant]*entry //包含map内容中需要保存mu的部分
misses int //计算自从上次读取map更新后,需要锁定mu来确定key是否存在的加载次数
}
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 中找到
场景
- 只会增长的缓存系统,一个key只写一次而被读很多次;
- 多个goroutine为不相交的键集读、写和重写键值对。
map和sync.map的区别
- 线程安全性:map 是非线程安全的,多个 goroutine 并发地读写 map 可能会导致数据竞争和不确定的结果。而 sync.Map 是线程安全的,可以在多个 goroutine 并发地读写 sync.Map,而不需要额外的同步操作。
- 扩容机制:map 的扩容是在插入新元素时自动进行的,按需增加内部哈希表的大小。而 sync.Map 不会自动扩容,它始终使用固定大小的内部哈希表。
- 功能和方法:map 提供了常见的读取、插入、更新和删除等操作,如 m[key]、m[key] = value、delete(m, key) 等。而 sync.Map 提供了一组特定的方法,如 Load、Store、Delete 和 Range,用于读取、存储、删除和遍历键值对。
- 性能:由于 sync.Map 是线程安全的,它需要进行额外的同步操作,因此在并发性能方面可能会比普通的 map 稍慢。而普通的 map 在单个 goroutine 下的读取和写入操作性能较高。
map查找过程
查找过程如下:
- 根据key值算出哈希值;
- 取哈希值低位与hmap.B取模确定bucket位置;
- 取哈希值高位在tophash数组中查询;
- 如果tophash[i]中存储值也哈希值相等,则去找到该bucket中的key值进行比较;
- 当前bucket没有找到,则继续从下个overflow的bucket中查找;
- 如果当前处于搬迁过程,则优先从oldbuckets查找。
注:如果查找不到,也不会返回空值,而是返回相应类型的0值。
map插入过程
新元素插入过程如下:
- 根据key值算出哈希值;
- 取哈希值低位与hmap.B取模确定bucket位置;
- 查找该key是否已经存在,如果存在则直接更新值;
- 如果没找到将key,将key插入。
map没申请空间,取值,会发生什么情况
在map查询操作中,最多可以给两个变量赋值,第一个为值,第二个为bool类型的变量,用于指示是否存在指定的键,如果键不存在,那么第一个值为相应类型的零值。如果只指定一个变量,那么该变量仅表示改键对应的值,如果键不存在,那么该值同样为相应类型的零值。
set的原理,Java 的HashMap和 go 的map底层原理
- Set原理:
Set特性:
(1)不包含重复key;
(2)无序;
如何去重:
通过查看源码add(E e)方法,底层实现有一个map,map是HashMap,Hash类型是散列,所以是无序的。如果key值相同,将会覆盖,这就是set为什么能去重的原因(key相同会覆盖)。注意: 如果new出两个对象add到set中,因为两个对象的地址不相同,所以map在计算key的hash值时,将它当成了两个不同的元素。这时要重写equals和hashcode两个方法。这样才能保证set集合的元素不重复。 - Java HashMap:
线程不安全 安全的map(CurrentHashMap) HashMap由数组+链表组成,数组是HashMap的主体, 链表则是为了解决哈希冲突而存在的,如果定位到的数组位置不含链表(当前entry的next指向null),那么查找,添加等操作很快,仅需一次寻址即可; 如果定位到的数组包含链表,对于添加操作,其时间复杂度为O(n),首先遍历链表,存在即覆盖,否则新增; 对于查找操作来讲,仍需遍历链表,然后通过key对象的equals方法逐一比对查找。 所以,性能考虑,HashMap中的链表出现越少,性能才会越好。 假如一个数组槽位上链上数据过多(即链表过长的情况)导致性能下降该怎么办? JDK1.8在JDK1.7的基础上针对增加了红黑树来进行优化。 即当链表超过8时,链表就转换为红黑树,利用红黑树快速增删改查的特点提高HashMap的性能,其中会用到红黑树的插入、删除、查找等算法。 - 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时指定的。
- 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中写数据简单过程如下:
- 如果等待接收队列recvq不为空,说明缓冲区中没有数据或者没有缓冲区,此时直接从recvq取出G,并把数据写入,最后把该G唤醒,结束发送过程;
- 如果缓冲区中有空余位置,将数据写入缓冲区,结束发送过程;
- 如果缓冲区中没有空余位置,将待发送数据写入G,将当前G加入sendq,进入睡眠,等待被读goroutine唤醒;
读数据
- 如果等待发送队列sendq不为空,且没有缓冲区,直接从sendq中取出G,把G中数据读出,最后把G唤醒,结束读取过程;
- 如果等待发送队列sendq不为空,此时说明缓冲区已满,从缓冲区中首部读出数据,把G中数据写入缓冲区尾部,把G唤醒,结束读取过程;
- 如果缓冲区中有数据,则从缓冲区取出数据,结束读取过程;
- 将当前goroutine加入recvq,进入睡眠,等待被写goroutine唤醒;
出现panic的场景
关闭channel时会把recvq中的G全部唤醒,本该写入G的数据位置为nil。把sendq中的G全部唤醒,但这些G会panic。
除此之外,panic出现的常见场景还有:
- 关闭值为nil的channel;
- 关闭已经被关闭的channel;
- 向已经关闭的channel写数据;
出现阻塞的场景
- 无缓冲区读写数据会阻塞;
- 缓冲区已满,写入会阻塞;缓冲区为空,读取数据会阻塞;
- 值为nil读写数据会阻塞;
channel和锁对比 并发问题可以用channel解决也可以用Mutex解决,但是它们的擅长解决的问题有一些不同。channel关注的是并发问题的数据流动,适用于数据在多个协程中流动的场景。而mutex关注的是是数据不动,某段时间只给一个协程访问数据的权限,适用于数据位置固定的场景。
channel应用场景
channel适用于数据在多个协程中流动的场景,有很多实际应用:
- 定时任务:超时处理;
- 解耦生产者和消费者,可以将生产者和消费者解耦出来,生产者只需要往channel发送数据,而消费者只管从channel中获取数据;
- 控制并发数:以爬虫为例,比如需要爬取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捕获到。