目标
- 简单介绍一下 GO 语言的诞生背景,使用场景,目前使用方案
- 简单介绍一下 GO的使用,GO的基础语法,简单过一下一些GO的语言例子
- 着重介绍一下GO的特性,为什么大家都使用GO语言,GO的内存结构、为什么都说GO快
- 论述清楚为什么大家都在开始使用GO
1 Go语言概述
1. 1Go语言的诞生背景
超线程技术和CPU多核化的普及为并行计算提供了技术支持和编程需求,程序的并发度有了极大的提升。
但是反观编程语言领域却没有什么大的动作,在多核 CPU 高效安全的协作方面,主流语言(C、C++、Java)能做的并不是很多。
Google工程师为了解决多核 CPU 高效安全地协作的问题,同时提高开发效率,便开始开发了Go语言。
Go语言是Google开发的一种静态强类型、编译型、并发型,并具有垃圾回收功能的编程语言。
- 静态强类型:静态强类型语言是指在编译阶段就确定每个变量的数据类型的编程语言,并且这个数据类型在后续的代码中是不允许改变的。
- 编译型:代码在执行前需要经过一个转换过程,也就是编译。编译器会将源代码(人类可读的高级指令)转换成目标代码(机器可执行的低级指令),并生成一个可执行文件。这个可执行文件可以在没有源代码的情况下运行。Java是一种既编译又解释的语言。
1.2 Go语言的使用场景
Go在诞生之初,就以出色的性能和高效的并发处理能力,被广泛应用在以下的场景:
- 服务器端开发:Go语言非常适合用于构建大型、高性能的服务器端应用程序。它的并发处理能力和高效的I/O操作使其成为处理高并发请求的理想选择。越来越多的公司开始拥抱Go语言。
- 分布式系统:Go语言具有对并发的天然支持,其内置的goroutine和channel使得并发编程变得简单而高效。这使得Go语言成为构建分布式系统的理想选择,可以方便地实现并发的任务分发和数据同步。
- 云计算:Go语言具有快速编译、高效执行和低资源消耗等特点,非常适合用于构建云计算平台、容器编排工具、云存储等。许多知名的云计算项目,如Docker、Kubernetes等,都是使用Go语言开发的。
- 数据库和存储系统、系统编程、DevOps工具等
2 Go语言基础
2.1 Go语言的基础语法
以一个经典的Hello World示例:
// 指定包名
package main// 导包
import "fmt"// 函数定义 函数名(参数) 返回值
func main() {// 使用标准库进行打印fmt.Println("Hello, World!")// 结束分句不需要 ;
}
与Java相比有几点差异:
- 不需要分号:在Go语言中,一行代码的结束不需要分号,这与Java和许多其他C系列语言不同。
- 强制大括号:在Java中,如果if,for等语句的主体只有一行代码,可以省略大括号,但在Go语言 > 中,无论主体部分有多少行代码,都必须使用大括号。
- 缩进风格:Go语言采用的是Tab键进行缩进,而Java则是使用四个空格。
- 错误处理:Go语言没有异常处理,而是通过多值返回和错误接口进行错误处理,这与Java的try-> catch-finally异常处理方式不同。
- 变量声明:Go语言的变量声明方式也与Java不同,Go语言使用 var 关键字声明变量,同时Go语言还支持 “:=” 形式的短变量声明和初始化。
- 公有和私有:Go语言中,首字母大写的函数、变量是公有的,首字母小写的是私有的,而在Java中,公有和私有由关键字public和private表示。
- 类型声明:在Go语言中,类型声明放在变量名之后,而在Java中,类型声明放在变量名之前。
- 资源管理:Go语言使用defer关键字进行资源管理,使得资源的释放操作可以紧跟在资源的获取操作之后,而不需要去关心何时进行资源的释放。
2.2 Go语言的基本数据类型
// 布尔类型
// var : go关键字,用来声明变量,类型可加可不加,会进行类型推导
// 若不需要指定变量类型,也可以指定为:b := true,表示声明变量且赋值
var b bool = true
cBool := true
// 数字类型
var i int = 10
var f float32 = 12.34
// 字符串类型
var s string = "Hello Go"
var arr1 [5]int // 声明了一个int类型的数组
arr2 := [3]int{1,2,3} // 声明了一个长度为3的int数组
arr3 := [...]int{2, 4, 6, 8, 10} // 声明了一个长度为5的int数组,元素是2, 4, 6, 8, 10// 切片会自动扩容
var mySlice []int //声明一个int切片
mySlice = []int{2, 3, 5, 7, 11, 13} // 创建包含6个元素的切片
mySlice = []int{2, 3, 5, 7, 11, 13}[1:4] // 创建包含原切片元素1-4的切片// map类型
var m map[string]intm["key1"] = 1
Go语言提供了一种简单、直接、灵活的方式来处理数据类型,可以根据实际需要来构造出复杂的数据类型。
2.3 Go语言的控制结构
if else 结构
package mainimport"fmt"
func main() {var a int = 100if a < 20 {fmt.Printf("a 小于 20\n" )} else {fmt.Printf("a 不小于 20\n" )}
}
循环结构只有for循环,没有while关键字:
// 标准的for循环语句,初始化变量、循环判断条件,后处理,这三个部分都可以不指定
for i := 0; i < 10; i++ {fmt.Println(i)
}// 类似while循环的使用
i := 0
for i < 10 {fmt.Println(i)i++
}// 无限循环
for {fmt.Println("This loop will run forever.")
}
2.4 Go语言的函数
package mainimport "fmt"// 定义函数 函数名(参数A, 参数B) 返回值
func add(x int, y int) int {return x + y
}func main() {// 调用函数fmt.Println(add(42, 13))
}
2.5 Go语言的错误处理方式
package mainimport ("fmt""errors"
)// 我们定义了一个f1函数,该函数在参数等于42时返回一个错误,否则返回参数加3的结果。
func f1(arg int) (int, error) {if arg == 42 {return -1, errors.New("can't work with 42")}return arg + 3, nil
}func main() {// 对f1函数的返回值进行了错误处理,如果返回错误,打印"f1 failed:“,否则打印"f1 worked:”。for _, i := range []int{7, 42} {if r, e := f1(i); e != nil {fmt.Println("f1 failed:", e)} else {fmt.Println("f1 worked:", r)}}
}
2.7 Go语言的面向对象编程
2.7.1 结构体(Structs)
Go语言没有“类”这个概念,但可以通过结构体实现类似的功能。
在Go语言中,结构体是一种复合的数据类型,可以包含零个或多个任意类型的值。我们可以把结构体看作是类的一种简化形式。
type Person struct {Name stringAge int
}func main() {p := Person{Name: "Alice", Age: 20}fmt.Println(p.Name) // 输出: Alice
}
2.7.2 方法(Methods)
在Go语言中,可以给任意类型(包括结构体)定义方法。方法的定义形式和函数类似,只是在函数名前多了一个接收者参数,接收者可以是任意类型的变量。这样我们就可以在这个变量上调用这个方法。
type Rectangle struct {Width, Height float64
}// 为Rectangle定义Area方法
func(r Rectangle) Area() float64 {return r.Width * r.Height
}func main() {r := Rectangle{Width: 10, Height: 5}fmt.Println(r.Area()) // 输出: 50
}
2.7.3 接口(Interfaces)
在Go语言中,接口是一种类型,它定义了一组方法,但这些方法不包含实现代码。
实现接口的类型(也就是实现了接口中的所有方法的类型),可以被看作是那个接口类型的变量。
这与Java等语言的接口有一定的差异,Go语言的接口更为灵活,实现接口的类型不需要显式声明它实现了哪些接口,只要实现了接口中的所有方法就可以。
type Shape interface {Area() float64
}type Shape2 interface {Area() float64
}type Circle struct {Radius float64
}// Circle实现Shape接口
func (c Circle) Area() float64 {return math.Pi * c.Radius * c.Radius
}func main() {c := Circle{Radius: 5}var s Shape2 = cfmt.Println(s.Area()) // 输出: 78.53981633974483
}
2.7.4 继承与组合
Go语言没有明确的继承机制,但可以通过组合的方式达到类似的效果。
在Go语言中,可以在一个结构体中嵌入其他的结构体或接口,被嵌入的结构体或接口就像是该结构体的一部分,该结构体可以直接访问被嵌入的结构体的字段和方法,从而实现了类似继承的功能。
type Person struct {Name stringAge int
}type Student struct {Person // 嵌入PersonSchool string
}func main() {s := Student{Person: Person{Name: "Alice", Age: 20}, School: "MIT"}fmt.Println(s.Name) // 输出: Alice
}
2.7.5 多态
Go语言通过接口实现多态,使用了一种鸭子类型(Duck Typing)的思想来实现接口。
简单来说,鸭子类型就是“如果它走起路来像鸭子,叫起来也像鸭子,那么它就是鸭子”。
当一个类型实现了某个接口的所有方法,那么这个类型的变量就可以被看作是这个接口类型的变量,可以被赋值给这个接口类型的变量。
这样,我们就可以使用接口类型的变量来调用实现类型的方法,实现多态。
定义一个Walker接口。
type Walker interface {Walk()
}
然后我们定义了一个Duck类型,并为其实现了Walk()方法:
type Duck struct {}func(d Duck) Walk() {fmt.Println("Duck walks")
}
这样,Duck就被认为实现了Walker接口,即使我们并没有显式声明Duck实现了Walker接口。然后我们就可以把Duck类型的对象赋值给Walker类型的变量,通过这个变量调用Walk()方法:
var w Walker = Duck{}
w.Walk() // 输出:Duck walks
如果一个类型需要实现多个接口,并且这些接口中有相同的方法,那么这个类型只能提供一个实现,这个实现会被所有调用这个方法的接口共享。
2.8 Go语言的并发模型和Channel
2.8.1 Go语言的Goroutine
Goroutine的创建和运行
Goroutine是Go语言的轻量级线程实现,通过Go关键字就能创建一个Goroutine,它的启动速度和资源消耗都远小于传统的线程。
程序启动时其主函数即在一个特殊的Goroutine中运行,一般称为为主Goroutine。示例代码如下:
go func() {fmt.Println("This is a goroutine")
}()fmt.Println("This is main goroutine")
代码中,创建了一个Goroutine并在其中打印消息,然后在主Goroutine中打印消息。由于Goroutine的调度是由Go运行时进行的,所以两个打印语句的执行顺序是不确定的。
2.8.2 Go语言的Channel
2.8.3.1 Channel的基本概念和作用
Channel是Go语言中的一个核心类型,可以把它看成一个管道,可以通过它发送类型化的数据,在不同Goroutine之间进行通信,Channel是支持并发的。示例代码如下:
ch := make(chan int)
go func() {ch <- 1 // 向ch中发送数据
}()
fmt.Println(<-ch) // 从ch中接收数据
2.8.3.2 Channel的创建和关闭
创建Channel的语法如下:
ch := make(chanint) // 创建一个整型的Channel
关闭Channel的语法如下:
close(ch) // 关闭Channel
注意,关闭后的Channel不能再发送数据,但是仍可以接收数据。多次关闭同一个Channel会导致panic。
2.8.3.3 通过Channel进行数据传递和通信
通过Channel发送和接收数据的语法如下:
ch <- 1// 向ch发送一个整数x := <-ch // 从ch接收一个整数并赋值给x
如果Channel已经关闭,接收操作将立即完成,接收到的值为元素类型的零值。
2.6 Go语言编程实战
2.6.1 Go语言的Channel
生产者消费者。
3. Go语言特性及优势
为什么说Go提高开发效率?
- 语法简洁:Go语言的语法非常简洁,只有极少数的关键词,没有复杂的类继承,使得写代码变得更直接、更容易理解。这种简洁性使得学习曲线陡峭,新手更容易上手
- 编译速度、启动速度快:Go语言编译的是静态二进制文件,运行时不需要JVM(Java虚拟机)的启动和类加载过程,所以启动速度通常更快。启动速度快意味着在做开发时,调试的速度更快,在Docker等方式部署应用时能够更快run起来。
- 跨平台:Go语言编译后的执行文件可以直接在目标操作系统上运行,无需其他依赖,这使得部署应用变得非常简单,提高了开发效率。
- 运行占用内存小:Go语言使用静态编译,直接编译成机器语言运行,不需要像Java那样需要JVM来运行,因此在内存占用上会较小。
运行Java 服务的内存为598 MB
运行Go的内存为19MB (不严谨对比)
3.1 Go语言的并发模型
3.1.1 GORoutine(协程)
- 线程分为内核线程和用户态线程,协程是一种用户态线程。
用户态线程跟内核态线程主要的2点区别:
- 管理方式:内核线程由操作系统内核管理和调度,而用户态线程由用户程序自己管理和调度。内核线程可以直接使用操作系统提供的调度算法,而用户态线程需要在用户程序中实现调度算法。
- 上下文切换:内核线程的上下文切换涉及到用户态和内核态之间的切换(切换时要校验指令等操作),所以开销比较大。用户态线程的上下文切换只发生在用户态,开销较小。
Go Routine是Go语言中的一种轻量级用户态线程实现,也是Go语言中并发编程的核心。Go Routine与普通的线程或进程不同,它由Go运行时(Go Runtime)管理。
Go Routine的优点:
- 创建和销毁的成本更低:Go Routine的创建和销毁的成本远低于线程和进程,每个GoRoutine的栈初始只有2KB,对比普通的线程栈大小在MB级别。这让我们可以大量地创建Go Routine来处理任务,而不必担心系统资源被耗尽。
- 切换的成本更低:Go Runtime可以自动进行Go Routine之间的切换,与线程上下文切换相比,其成本更低。
- 更简单的同步机制:Go语言内建了Channel(管道)和Select机制,使得Go Routine之间的同步和通信更为简单高效。
使用Go语言的go关键字,可以非常简单地启动一个Go Routine来并发执行任务,如:
go doSomething()
在这行代码中,doSomething函数将会在一个新的Go Routine中并发执行,而不会阻塞当前的程序执行流程。
3.1.2 Go的并发调度模型
Go语言的调度器是Go运行时系统的一部分,负责管理和调度Go Routine的执行。
Go语言的调度器设计采用了三级模型:G、P、M。
- G: Go Routine,代表一个待执行的任务。
- P: Processor,处理器,代表Go语言的调度上下文环境,每个P都有一个本地的Go Routine队列,并且每个P在同一时间只能被一个线程(M)所拥有。
- M: Machine,代表一个操作系统的线程。
Go语言的调度器采用了M:N的调度模型,也就是说,M个Go Routine会被N个操作系统线程所调度和执行。
站在CPU的角度,一个线程可以持久的占用住CPU,Go Routine并发的使用一个线程资源去执行任务,减少线程调度带来的资源消耗。
在Go语言的调度模型中:
-
P的数量由GOMAXPROCS环境变量决定,这个变量默认值是机器的CPU核数。P的数量决定了可以同时运行的Go Routine的数量。
-
M的数量没有上限,当所有的P都被占用,且有新的Go Routine需要运行时,Go运行时会创建一个新的M来运行这个Go Routine。
-
当M在执行G的过程中,如果G发生了阻塞(如等待IO、调用系统调用等),那么M会将自己和G解绑,然后去运行其他的G。当G解除阻塞时,会再次被某个M所绑定并执行。
- 如果当前的P的本地队列已满,那么运行的G会尝试将新的Goroutine放入全局队列或者其他P的本地队列,然后唤醒一个空闲的P和M组合来执行新的Goroutine。
下面是具体的工作流程:
- 如果当前的P的本地队列已满,那么运行的G会尝试将新的Goroutine放入全局队列或者其他P的本地队列,然后唤醒一个空闲的P和M组合来执行新的Goroutine。
-
当一个Go程序启动时,会首先创建一个主G和一个主M,然后创建由GOMAXPROCS指定数量的P。主M会获取一个P,然后开始执行主G。
-
在程序执行过程中,可能会创建新的G。新创建的G会首先被放到当前P的本地G队列中。如果本地G队列已满,就会被放到全局G队列中。
-
当一个M的当前G阻塞时(例如等待IO、系统调用、channel操作等),M会将自己和G解绑,然后去P的本地G队列中获取一个新的G来执行。如果本地G队列为空,M会尝试去全局G队列中获取G,或者从其他P的本地G队列中偷取G。如果都获取不到G,M就会阻塞,直到有新的G可执行。
-
当一个阻塞的G变为可执行状态时(例如等待的IO完成、系统调用返回、channel收到数据等),Go运行时会创建或者唤醒一个M,然后将G和M绑定,然后M会获取一个P,然后开始执行G。
-
Go程序退出时,所有的M和P都会被销毁,所有的G都会被停止。
Go语言的调度器在设计中采取了一些策略,以实现高效的并发性能: -
M:N 调度: Go语言的调度器使用了M:N的调度模型,这意味着M个Go协程(Go Routines)会被N个系统级线程(OS threads)调度和执行。这样的设计使得Go语言能够创建大量的协程而不会消耗大量的系统资源。
-
工作窃取(Work Stealing):当某个处理器(P)的本地Go协程队列为空时,它会尝试从全局Go协程队列或其他处理器的本地Go协程队列中窃取一半的协程。这种策略使得各个处理器之间的工作负载更加平衡,避免了某个处理器闲置而其他处理器的任务过多的情况。
-
抢占式调度(Preemptive Scheduling):从Go 1.14版本开始,Go语言的调度器实现了真正的抢占式调度。这种策略能够防止长时间运行的协程阻塞其他协程的执行,保证了系统的响应性。这是通过插入一种称为"抢占请求"的机制实现的。当调度器决定需要抢占一个正在运行的协程时,它会向这个协程的栈插入一个抢占请求。然后,当这个协程下一次执行函数调用时,它会检查这个抢占请求,并主动让出CPU。
需要注意的是,Go的抢占式调度仍然需要goroutine的协作。也就是说,如果一个goroutine没有进行任何函数调用(例如,它正在执行一个无函数调用的无限循环),那么它仍然可能无法被抢占。不过在实际情况中,大多数goroutine都会进行函数调用,所以这种情况很少发生。
4. 亲和性调度:Go 1.5版本开始,Go语言的调度器尝试将P绑定到固定的操作系统线程上,以提高缓存的利用率。这种策略可以提高程序的执行效率。
5. 延迟调度:Go语言的调度器在G阻塞时,不会立即进行调度,而是会等待一段时间。如果在这段时间内G变为可执行状态,那么可以避免一次无谓的调度。这种策略可以减少调度的开销。
6. 非阻塞网络IO:Go语言的网络库实现了非阻塞的网络IO操作,当G进行网络IO时,不会阻塞M和P,而是会让出P给其他的G执行。这样可以大大提高网络程序的并发性能。
以上这些策略使得Go语言的调度器可以高效地管理和调度大量的Go Routine,从而实现高性能的并发程序。
3.2 Go语言的内存结构
3.2.1 TCMalloc
TCMalloc是Thread Cache Malloc的简称,Google开发的一个内存分配器。它主要用于C和C++编程语言,是Go内存管理的起源.
Go的内存管理是借鉴了TCMalloc,TCMalloc的核心理念就是分级缓存。
- Page
操作系统对内存管理以页为单位,TCMalloc也是这样,只不过TCMalloc里的Page大小与操作系统里的大小并不一定相等,而是倍数关系。 - Span
一组连续的Page被称为Span,比如可以有4个页大小的Span,也可以有8个页大小的Span,Span比Page高一个层级,是为了方便管理一定大小的内存区域,Span是TCMalloc中内存管理的基本单位。 - ThreadCache
每个线程各自的Cache,一个Cache包含多个空闲内存块链表,每个链表连接的都是内存块,同一个链表上内存块的大小是相同的,也可以说按内存块大小,给内存块分了个类,这样可以根据申请的内存大小,快速从合适的链表选择空闲内存块。由于每个线程有自己的ThreadCache,所以ThreadCache访问是无锁的。 - CentralCache
是所有线程共享的缓存,也是保存的空闲内存块链表,链表的数量与ThreadCache中链表数量相同,当ThreadCache内存块不足时,可以从CentralCache取,当ThreadCache内存块多时,可以放回CentralCache。由于CentralCache是共享的,所以它的访问是要加锁的。 - PageHeap
PageHeap是堆内存的抽象,PageHeap存的也是若干链表,链表保存的是Span,当CentralCache没有内存的时,会从PageHeap取,把1个Span拆成若干内存块,添加到对应大小的链表中,当CentralCache内存多的时候,会放回PageHeap。 - TCMalloc对象分配
小对象直接从ThreadCache分配,若ThreadCache不够则从CentralCache中获取内存,CentralCache内存不够时会再从PageHeap获取内存,大对象在PageHeap中选择合适的页组成span用于存储数据。
3.2.2 Go的内存管理
-
mcache
mcache与TCMalloc中的ThreadCache类似,mcache保存的是各种大小的Span,并按Span class分类,小对象直接从mcache分配内存,它起到了缓存的作用,并且可以无锁访问。但mcache与ThreadCache也有不同点,TCMalloc中是每个线程1个ThreadCache,Go中是每个P拥有1个mcach,因为在Go程序中,当前最多有GOMAXPROCS个线程在运行,所以最多需要GOMAXPROCS个mcache就可以保证各线程对mcache的无锁访问。 -
mcentral
mcentral与TCMalloc中的CentralCache类似,是所有线程共享的缓存,需要加锁访问,它按Span class对Span分类,串联成链表,当mcache的某个级别Span的内存被分配光时,它会向mcentral申请1个当前级别的Span。但mcentral与CentralCache也有不同点,CentralCache是每个级别的Span有1个链表,mcache是每个级别的Span有2个链表。
为什么span要分级别?
-
mheap
mheap与TCMalloc中的PageHeap类似,它是堆内存的抽象,把从OS(系统)申请出的内存页组织成Span,并保存起来。当mcentral的Span不够用时会向mheap申请,mheap的Span不够用时会向OS申请,向OS的内存申请是按页来的,然后把申请来的内存页生成Span组织起来,同样也是需要加锁访问的。但mheap与PageHeap也有不同点:mheap把Span组织成了树结构,而不是链表,并且还是2棵树,然后把Span分配到heapArena进行管理,它包含地址映射和span是否包含指针等位图,这样做的主要原因是为了更高效的利用内存:分配、回收和再利用。
在Go的内存管理模型中,scav和free span都使用了treap作为其主要的数据结构。treap是一种特殊的二叉搜索树,它结合了二叉搜索树和堆的特性。
在treap中,每个节点都有一个键(key)和一个优先级(priority)。树按照键来排序,但是节点的布局又遵循堆的性质,即每个节点的优先级都高于或等于其子节点的优先级。这样,treap既能像二叉搜索树一样高效地进行查找操作,又能像堆一样高效地进行插入和删除操作。
在Go的内存管理中,free span和scav span的treap的键是span的起始地址,优先级是随机生成的。这样,可以快速地找到一个具有指定起始地址的span,也可以快速地插入和删除span。
使用treap作为数据结构的一个主要优点是它可以高效地支持合并和分割操作。这在内存管理中是非常重要的,因为经常需要将多个小的空闲区域合并成一个大的空闲区域,或者将一个大的空闲区域分割成多个小的空闲区域。
基于以上数据结构,go当中一个对象的创建流程如下:
- 当一个 goroutine 需要创建一个对象时,首先会尝试从它自己的 mcache 中分配内存。mcache 是每个 goroutine 独享的小型内存缓冲区,用于存储一些小的、经常使用的对象。
- 如果 mcache 中的内存不足,那么 goroutine 会尝试从 mcentral 中获取内存。此操作过程需要进行加锁操作。
- 如果 mcentral 中的内存也不足,那么 mcentral 会从 mheap 中申请一大块内存。mheap 是一个全局的大型内存池,是从操作系统直接申请到的内存。此操作过程需要进行加锁操作。
- 如果 mheap 中的内存也不足,那么 Go 会向操作系统请求更多的内存。如果操作系统无法提供更多的内存,那么 Go 会抛出内存不足(out of memory)的错误。
- 一旦获取到足够的内存,Go 就会在这块内存上创建对象,并返回这个对象的引用。
对于大对象的创建,Go 语言的处理方式与小对象有所不同。在 Go 语言中,被定义为大对象的具体大小阈值可能会根据不同的实现和平台有所不同。但一般来说,如果一个对象的大小超过了一个阈值(比如32KB),那么它就会被视为一个大对象。
对于大对象,Go 语言通常会直接在堆(heap)上进行分配。大对象不会经过 mcache 或 mcentral,而是直接从 mheap 中申请内存。这是因为大对象由于其大小,不适合放在 mcache 或 mcentral 中,否则会占用过多的空间和内存碎片。
4 总结
GO是一个语法简洁,天然支持高并发,编译、运行速度快,能够以较小的资源处理较多的请求的语言。学习曲线平缓、语言生态越来越丰富。
在未来我们的服务也可以可以考虑使用GO来作为一种开发语言的考虑,来减少线上的资源使用,提高开发效率。